* fix(apple): switch to manual code signing when a provisioning profile is set
CODE_SIGN_STYLE was hardcoded to Automatic even when
AGENT_DEVICE_IOS_PROVISIONING_PROFILE was configured, so xcodebuild rejected
the resulting PROVISIONING_PROFILE_SPECIFIER + CODE_SIGN_STYLE=Automatic
combination with "conflicting provisioning settings" on physical-device runs.
Fixes#2153
* fix: satisfy formatting and the test-file size ratchet
- oxfmt: wrap the long array literal in the new manual-signing test.
- runner-client.test.ts was already pinned at the 1000-line tripwire
(1577 lines); adding a test grew it past the pin, which the ratchet
test rejects by design ("extract instead of adding to a file over
the tripwire"). Extract the pure runner-cache-metadata.ts build-
settings tests (signing, bundle, performance, sandbox args) into a
new runner-cache-metadata.test.ts, shrinking runner-client.test.ts
to 1441 lines and lowering its pin to match.
* docs+ux: make device ownership discoverable end to end
Complete the #1320 agent experience so 'busy? -> inspect -> choose or
release' is discoverable from every surface an agent actually reads:
- devices now projects the blocking claim owner per row (claimedBy with
session and workspace, observe-policy projection; provably dead owners are
excluded because the next open replaces them automatically), so an agent
told a device is busy can pick a free one from the same listing.
- help debugging gains a 'Device busy and ownership' section separating the
two DEVICE_IN_USE flavors and their exact recoveries.
- AGENTS.md documents both flavors; docs/agents/device-verification.md
retires the last ps/kill recovery guidance in favor of device status,
daemon stop --state-dir, and device release --stale (Stage 5 of #1320).
- ADR-0010 no longer calls DEVICE_IN_USE 'the only retriable code' without
naming the claim path's non-retriable override.
- The rendered cross-worktree claim error gains a help-conformance quiz case
binding (sample-output-device-claim-inspects-owner).
- README points at device status / device release --stale.
Part of #1320.
* fix: key ownership projection by canonical device identity end to end
Review findings on #2165:
- blockingClaimOwnersByDevice keyed claims and inventory rows by bare
device.id, so a live Android claim could project claimedBy onto an
unrelated same-id Apple/Harmony/Vega row, with scan order picking the
displayed owner. Both sides now use the canonical local device key
(claim.deviceKey against canonicalLocalDeviceKey of the row's claim
identity). The cross-family same-id regression was observed red against
the bare-id keying.
- The projection is now asserted across every hop the PR promises: client
normalization preserves well-formed claimedBy and drops malformed ones,
and the devices CLI formatter carries it through JSON data and renders
the text line (MCP shares the same serialization).
* feat(apple): reclaim retained runners under device-claim authority
Implement the #1320 retained-runner rule: a daemon holding the host-global
device claim may stop and replace a warm XCTest runner whose owner no longer
holds that claim. Previously worktree B's open failed with an unstructured
COMMAND_FAILED (IOS_RUNNER_OWNED_BY_OTHER_DAEMON) for up to five minutes
after worktree A closed, until A's daemon idled out.
- Runner leases now record deviceClaimProtocol: 1; takeover is gated on the
lease declaring claim arbitration, so owners from pre-claims builds are
never preempted.
- The claim-authority probe is daemon-bound through the existing runner-owner
seam and answers from the claim store by process identity; unbound embedders
answer false and keep today's refusal.
- Disposal now skips device-wide runner container-app termination when the
on-disk lease is owned by someone else, so the losing daemon's idle stop or
shutdown cannot kill the successor's runner on the shared simulator.
- Help topics updated: the live-owner runner rejection now names claim
arbitration instead of being unconditional.
Live-validated with two daemons sharing one claim store against a throwaway
simulator: live-owner open still rejects with structured DEVICE_IN_USE;
after close, the contender opens in ~8s with the lease re-owned while the
loser daemon is still alive; stopping the loser afterwards leaves the
winner's runner healthy.
* fix(apple): make disposal ownership check and device-wide teardown one lease-locked operation
Review P1 on #2160: cleanupRunnerSessionResources read the on-disk lease
token and then terminated the device-global runner container bundles without
holding the runner-lease lock, so a loser could pass its check while a
successor's reclaim was in flight and then kill the successor's runner.
The ownership check, the container-app termination, and the lease release now
run as one operation under the runner-lease lock — the same lock a successor
holds for its entire reclaim-and-publish window — on every disposal path.
Callers already inside the lock (startup abort, lease-publish failure,
stopIosRunnerSession) declare leaseLockHeld instead of re-acquiring the
non-reentrant lock; if the lock cannot be acquired, all device-wide teardown
is skipped and an own unreleased lease turns stale on process exit.
The two-owner regression starts loser disposal inside a held successor
window, completes the takeover, and proves the loser neither terminates the
successor's runner nor touches its lease. Observed red against the pre-fix
code. Live-revalidated twice with overlapping daemon stop and takeover open
on a real simulator.
* fix(apple): match runner takeover authority by canonical device identity
Review P1 on #2160: the authority probe matched any active claim with the
same raw device id, while claims are canonically scoped by platform family +
Apple OS + id — an Android claim whose serial equals an Apple runner id could
authorize destructive takeover of a live Apple runner (and the prior probe
regression used an Android fixture, blessing exactly that).
The probe now receives the full DeviceInfo through the runner-owner seam and
the daemon answers with an exact canonical-local-device-key lookup: family,
Apple OS, and id must all match the held claim, which also replaces the store
scan with a direct keyed read. prepareRunnerLeaseForStartup takes the device
rather than a bare id so the lease seam can never degrade the match.
Adversarial regressions: a same-id Android claim grants no authority for the
Apple device (and the true Apple claim does) at the probe, and the lease-seam
test pins that the probe receives full family/OS identity, never a bare id.
* refactor: sink package-closed src modules into existing packages
Move closed modules into contracts, kernel, capture-kit, and ad-script,
and declare DaemonCommandDescriptor in core so R6/R9 can pin the remaining
provider-webdriver type cycle.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: keep contracts and capture-kit off generic sinks
Move interaction-outcome, snapshot warning rendering, and inventory ALS
behind focused owners, and plant R18/R70 domain-shape gates so they
cannot return as package export-map growth.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: drop moved implementation comments from owner modules
Names, types, and tests already carry those invariants; the relocated
files should not keep review-history or control-flow narration.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: drop the empty snapshot-quality layering zone
W1 moved the verdict into capture-kit and this PR moved warning rendering
into snapshot-presentation, so the ranked zone no longer has production files.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(platforms): sink the shared src/platforms root files into their substrate homes (#2082 W3)
The shared files left directly under src/platforms move onto a declared
owner: provisioning mechanics (install-source family, toolchain probing,
boot-failure classification, app-resolution caching) form
@agent-device/provision-kit above capture-kit; host mechanics resolve to
host-kit's seams; kernel takes the pure numeric helpers; contracts keeps
vocabulary only.
Settings parsing, command-attempt rendering, and the unsupported-interactor
factory stay with their families rather than pooling in a substrate
package: android and apple settings each own their parsing, and the
unsupported-interactor factory lives in root core with a vega-local copy.
A platforms-root-shape rule rejects any new shared file or directory
appearing directly under src/platforms, and the provision-kit direction
gates (no platform imports in, no capture-kit importer) are planted red.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH
* test: cover the family-owned parsers and the unsupported-interactor factories
The settings parsers and the unsupported-interactor factory arrived without
owning tests, so their branches rode on callers. Each now has one: the
appearance/state parsers over every accepted spelling and their rejections,
the attempt summarizer over its arg join and stderr budget, and both
interactor factories over the whole operation surface and the per-instance
label.
Also drops the duplicate ./snapshot-desktop-projection export key that a
rebase left in capture-kit's manifest, where JSON silently keeps the last,
and the root-shape docblock the violation message already states.
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
---------
Co-authored-by: Claude <noreply@anthropic.com>
* 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>
* 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>
* refactor: move runtime resource mechanics out of daemon
* refactor: move Apple resource access out of daemon
* chore: enforce the terminal daemon platform boundary
* docs(batch): name the step shape and the accepted commands in help and refusals
`batch` accepts one step shape and `help batch` documented none of it: the
usage line, one sentence, and the flags. Every refusal named only what was
wrong. A caller reaching for `press` through `batch` therefore saw
"Invalid batch step 1." for `["press @e12"]` and "unknown field(s): args" for
`{"command":"press","args":[...]}`, and reasonably concluded the mutating verbs
were excluded (#2062).
They are not, and never were: `press`, `click`, `fill`, `longpress`, `scroll`
and `back` all carry `batchable: true` in the command-descriptor registry,
including at 0.20.10. The exclusions are `batch`/`replay` (which never nest) and
the session/daemon/connection/host-tooling commands. Nothing about the
allowlist changes here; what changes is that it is stated.
- `help batch` documents the step shape, serial semantics, and RENDERS the
accepted commands from the registry's `batchable` trait, so the listing cannot
drift from the runtime allowlist.
- The step-shape refusals (non-object step, unknown field, non-object input)
share one hint naming `{"command":"<name>","input":{...}}`, owned by
`batch-contract.ts` next to the checks that raise them.
- The non-batchable-command refusal points at that listing and says which
families are excluded and why.
- `assertAllowedKeys` takes an optional hint so the batch call sites can attach
theirs without a second unknown-key check.
Closes#2062
* docs(batch): ground the step-shape guidance in the structured schema and keep contracts surface-neutral
`help batch` now prints runnable snapshot/press/fill steps carrying the real
structured field names (`target: {kind, ref}`, `text`, `interactiveOnly`), which
no `help <command>` text states, and says so instead of pointing at command help
for them. `cli-help-examples.test.ts` reads those steps back out of the rendered
help and runs each `input` through its own command's `readInput`, so a renamed
field fails there rather than shipping guidance nobody can run.
Fixes the stale batch guidance the audit missed: `help workflow` named
`batch ./steps.json`, which positional input rejects, and `help scripting` still
weighed the removed positionals/flags shape against the accepted one.
`BATCH_STEP_SHAPE_HINT` in `@agent-device/contracts` describes the shape only;
`readBatchStepRecord`/`readBatchStepInputObject` take the hint as a parameter so
the CLI attaches its own `agent-device help batch` recovery step while the Node
client and MCP tools keep the surface-neutral one.
* test(batch): pin every advertised step key, and hint the removed-shape refusal
The example-validation test accepted a step whose optional key the
reader silently dropped — readInput ignores unknown keys, so a renamed
settle or interactiveOnly kept the test green while help advertised a
step that does less than it claims. Every printed key must now survive
into the parsed input.
The removed positionals/flags refusal carries the CLI shape hint like
its three sibling refusals.
* fix(batch): keep the availability refusal surface-neutral; CLI attaches its recovery
readStructuredBatchCommandName emitted 'Run agent-device help batch'
unconditionally, and the same reader backs the MCP/Node batch metadata
— an MCP caller got a terminal-only, unrunnable recovery step. The
shared default now states the exclusion boundary itself with no
terminal vocabulary (MCP/Node read the accepted commands off the step
schema's command enum), and the CLI admission appends the help pointer
via the same optional-hint parameter the shape hint uses.
Found while fixing it: hint strings are redaction-capped at 400
characters, so enumerating the derived roster inline truncates the
hint — recovery pointer and all. The regression pins neutrality AND
that the hint survives the cap whole.
Addresses the P2 review on #2067.
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
* fix(cli): name --udid when a device identity is passed to --device
`--device <udid>` failed with "No device named <udid>" and the generic
booted/connected hint, and `help commands` listed no device selector at all, so
`--udid` — the only flag that pins one simulator among several sharing a name —
was reachable only from `help device`'s usage line.
Two changes, both discoverability:
- `resolveDeviceByName` now answers the flag mistake instead of the literal
lookup. When the `--device` value is the id of a listed device it names that
device and the flag the value belongs to (`--udid` for Apple, `--serial` for
serial-addressable platforms); when it merely has UDID shape it names `--udid`.
An ordinary unknown name keeps the generic hint. This mirrors the answer
`assertSelectorFlagMatchesPlatform` already gives for a mismatched identity flag.
- `help commands` renders a `Device Selection` section for the selectors every
command accepts (`--platform`, `--device`, `--udid`, `--serial`, `--session`).
They stay in `COMMON_COMMAND_SUPPORTED_FLAG_KEYS`, not `GLOBAL_FLAG_KEYS`, so
the option schema and `explain` output are unchanged.
Closes#2064
* fix(kernel): ground the wrong-flag hint in observed device identity only
The UDID-shape fallback reconstructed Apple identity syntax with a
UUID-only regex: it missed modern physical-device UDIDs
(00008150-001849640CF8401C), could misclassify a UUID-shaped device
name, and justified itself with an inventory state (a shut-down device)
the exact-candidate match already covers wherever inventory lists it.
Only a listed candidate's actual id now earns the hint; an unlisted
value keeps the generic device-not-found answer.
Also corrects help physical-device, which claimed --device accepts a
UDID (it never did): it now names --udid/--serial for name collisions.
Addresses the P1 review on #2065.
* fix(kernel,cli): answer the adversarial review on the wrong-flag hint
- The hint is emitted only for platforms that HAVE an identity flag:
web and linux device ids got 'Did you mean --udid <id>?' even though
--udid resolves only Apple devices — a specific, provably dead
instruction where the generic hint at least did no harm.
- The section header no longer claims every device command accepts all
five selectors (connect has no --udid/--serial, device no --session);
it points at help <command> for each command's own flags.
- The MCP/JSON input schema carries the same discoverability fix the CLI
help got: 'Device name selector (a UDID belongs in udid, a serial in
serial)' and the sharpened udid description, instead of the bare
'iOS device UDID selector.' the issue was filed about.
- --serial's description names HarmonyOS, which the resolver has always
accepted alongside Android and Vega.
* fix(commands): align serial's schema description with the resolver; pin the selector descriptions
The structured/MCP input schema still described serial as Android or
Vega only while the resolver and the CLI guidance accept HarmonyOS.
A metadata test now pins the three device-selector descriptions on
every emitted command schema, so a cross-surface drift fails instead of
shipping.
Addresses the P2 review on #2065.
* fix(commands): annotate the pinned selector property lookup for tsc
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
* fix(daemon): address cwd-scoped sessions by their store key, not their public name
An implicitly cwd-scoped session is NAMED `default` and STORED under
`cwd:<hash>:default`. Three surfaces built caller-facing text from the name, so
each pointed at something that does not exist:
- `DEVICE_IN_USE` reported `session "default"` and its recovery hint said
`agent-device close --session default`. `--session` marks the session
explicit, which disables cwd scoping, so following the hint addressed a
different, absent session: `SESSION_NOT_FOUND`, and the device stayed held —
the residual half of #2031 that #2057's superseded-daemon reconciliation does
not cover.
- `session list` reported `sessionStateDir` and `runnerLogPath` under
`<state>/sessions/default`, a directory that is never created; the session's
real artifacts sit in `<state>/sessions/cwd_<hash>_default`, which is what
`open` already answers with.
`SessionStore.entries()` exposes the key alongside the record, and
`buildSessionRecoveryHint` takes the session's address explicitly. Call sites
that only hold the session the current request named pass its name — the address
there — so their text is unchanged, as are explicitly named sessions everywhere.
Live before/after on an iOS simulator: `open` (cwd-scoped default), then
`open --session qa` on the same device. Before, the hint's own `close --session
default` answered `SESSION_NOT_FOUND` and the retry failed again; after, the
hint names `cwd:8bea844ab16aa9b3:default`, that close releases the device, and
the retry opens.
Refs #2031, #1394
* fix(daemon): thread the resolved session address through every recovery producer
The store key a request resolves — `cwd:<hash>:default` for an implicit
session — was known upstream but dropped before the selector-conflict and
lock-conflict producers, which then named `SessionState.name`. Both emitted
`close --session default`, the unreachable recovery this PR fixes for
DEVICE_IN_USE: `--session` marks the session explicit, so that command
addresses a different session.
`SessionRef` ({ address, session }) now carries the pair, so a recovery
producer cannot be handed a record whose address was never resolved.
`buildSessionRecoveryHint`, `assertSessionSelectorMatches` and
`applyRequestLockPolicy` take it; `prepareLockedRequestBinding` and the
Maestro replay route build it from the store key they already hold.
Replaces the broad `SessionStore.entries()` with the store's own narrow
lookups — `lookup`, `findByDevice`, `listRefs` — so callers receive the
session with its address instead of enumerating raw key/record tuples.
`session list` reports `address` alongside `name`, through the client
contract and serializer, so its discovery output names a value `--session`
accepts.
Regressions run the production routes, not the helpers: router-level
device-in-use, selector-conflict, lock-policy-conflict and `session list`
cases open an implicit session and read the address back off the store, plus
a typed-Maestro selector-conflict case. Each was observed red against the
pre-fix behavior it pins.
Refs #2031, #1394.
* fix(daemon): doctor names same-device sessions by address; green the fallow gate
- sessionChecks enumerated same-device sessions by SessionState.name:
the printed close --session default was the exact unreachable
recovery this PR removes elsewhere, the name-based dedupe hid two
cwd-scoped default sessions from each other, and the evidence
sessionStateDir pointed at <state>/sessions/default, which never
exists. Candidates now come from listRefs() and report addresses.
- findByDevice joins values/delete in .fallowrc.json usedClassMembers
(same resolution false positive on this class; the call site is
session-open-execution.ts).
- the scoped-paths test narrows its response once instead of nine
optional-chaining hops, which tripped the CRAP gate.
* style: format .fallowrc.json
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
* feat(interaction): accept fill <target> "" as the clear-field primitive
Emptying an input was not expressible: `fill` refused the empty string
("Expected text to be a non-empty string"), `type` only appends, and `keyboard`
has no delete verb. Clearing a field before typing is a routine QA step, so the
only route was the app's own clear button or N locale-dependent keyboard delete
presses read out of a snapshot.
`fill <target> ""` now means "replace with nothing". Both platforms already own
the clear half of replace, so this is the validation and reporting that stood in
front of it, not a new interaction:
- `stringField` takes an opt-in `allowEmpty`, used only by `fill`'s `text`.
`requiredField` still refuses a MISSING text, so `fill @e57` stays an error
rather than silently erasing the field — `readFillTargetFromPositionals` now
reports `undefined` for "no text argument" instead of collapsing it to `''`.
`type` keeps refusing an empty text: appending nothing is not a clear.
- The Apple runner's empty-text early return skipped the clear while reporting
"typed". For a replacement it now runs `clearTextInput` and verifies the field
came back empty (secure fields stay unverifiable, as elsewhere).
- Android already clears before typing and skips an empty shell/IME write, but
its verifier read a cleared field's absent `text` attribute as a mismatch
against `''`. An empty expectation now accepts null or "".
Whitespace-only text keeps its established per-shape rules; only `''` is new.
Closes#2063
* fix(interaction): fail the empty-fill clear closed on every backend
Addresses the P1 review on #2066, then closes the same fail-open class
on the backends the PR did not reach:
- Android: an empty expectation no longer matches when the verification
scan observed NO input node at all — actual is null both for a cleared
field and for a wrong point/lost focus, and three empty samples of
nothing were a stable success for a clear that never touched a field.
- Apple runner: when the empty-replacement path cannot resolve a clear
target (including the synthesized first-responder route, whose target
carries no element), it returns the typed TEXT_INPUT_NOT_FOCUSED
failure instead of falling through to the vacuous-typing
verified-success return. Regression runs in the ios.yml XCTest lane.
- webdriver: fill is tap + sendKeys and owns no clear mechanism, so an
empty fill refuses as UNSUPPORTED_OPERATION before touching the
device, instead of reporting a clear it cannot perform.
- linux + web coordinate fill: typing zero characters over the
select-all selection left the old value intact; the empty fill now
deletes the selection.
- recording: an empty --record-as literal matches inside every string;
it now parameterizes only the fill's own text field instead of
rewriting every empty field and empty evidence label in the entry.
(The session-wide echo registry already excluded empty literals.)
- help: the text-entry topic taught agents that fill "" is not a
clear-field command; it now states the new contract.
Each new test was observed red against the pre-fix code.
* fix(android): read hint-showing from the helper so a cleared field verifies
Live Pixel 9 emulator, adb-shell channel: clearing the Settings search
field succeeded on the device but reported 'Android fill verification
failed', because a cleared EditText dumps its HINT as text — getText()
returns the hint for an empty field on modern Android, so 'Search
settings' read back as a residual value. This is the same
placeholder-as-value trap the Apple runner already handles with
treatingPlaceholderAsEmpty.
The helper now emits hint-showing (isShowingHintText, API 26+), the
hierarchy parser carries it, and fill verification matches against the
field's VALUE — hint-only text is an empty value, for empty and
non-empty expectations alike. A field whose real value equals its hint
string keeps failing the clear check: only the authoritative flag, never
the text, says it is a hint. Raw uiautomator dumps carry no such fact
and keep the fail-closed behavior.
Live evidence, both admission channels, after this fix: test-ime and
adb-shell clears both report Filled 0 chars with the field back on its
placeholder; the pre-fix adb-shell run failed closed (never a false
success).
* fix(interaction): close the adversarial-review findings on the empty-fill clear
- android adb-shell: the delete burst is sized from the value being
REMOVED (pre-mutation read; the attempt's cap when unreadable), not
from the empty incoming text, which sent the 12/24-delete minimums and
could never empty a field longer than 36 characters.
- android: the unconfirmed soft-success no longer applies to an empty
expectation — nothing app-formats the empty value, so residue after a
clear is a failed clear, and the soft-success also skipped the second,
bigger delete burst.
- android masked fields: an empty expectation accepts an observed masked
node with no dump text (a masked field WITH content dumps its bullet
run), so clearing a password field no longer fails after the clear
worked — matching iOS, where a secure-field clear succeeds unverified.
- find: 'find <q> fill ""' now reaches the fill leaf as the clear
request on both the CLI reader and the daemon positional parse; a
MISSING value keeps its refusal at each producer, so the typed
value: string contract is unchanged.
- maestro export: a recorded clear exports as tapOn + eraseText instead
of a vacuous inputText: "" (with the 50-character-default warning).
- the missing-text refusals teach the clear form: (use "" to clear
the field).
Full unit suite green (1061 files); each behavioral fix carries a test
observed red against the prior code.
* refactor(interaction,android): extract the fill parse and shell-attempt branches
The review commits pushed parseFillTarget and fillAndroid over the
complexity gate (13 cyclomatic each). Each fill target shape parses in
its own function sharing one missing-text response, and the adb-shell
attempt (clear sizing + clear + type + verify) moves out of the fill
loop. Behavior-preserving; the existing tests cover every branch.
* refactor(interaction,android): one owner per empty-fill fact
Design pass after review: the missing-vs-empty rule and the observed-
value rule each had several owners; now each has one.
- parseFillTarget decodes ONCE through readFillTargetFromPositionals —
which already owns shape detection and documents the undefined-vs-''
contract on DecodedFillTarget — and keeps only what the wire owns:
versioned-ref admission, the selector whitespace rule, and the daemon
responses. This deletes the point branch's duplicated slicing, the
hasFillText guard, and the three per-shape parse functions.
- observedAndroidValue() is the single statement of Android's value
rule (absent attribute and hint-only text are the empty value); the
text branch, the match rule, and the masked branch all consume it.
The masked branch thereby gains the hint-showing collapse it was
missing, and isAcceptableAndroidFillMatch narrows to plain strings.
- The empty-text-is-clear contract is stated once, on Interactor.fill
in contracts, instead of implied per backend.
Behavior-preserving except the masked+hint gain; the existing tests
cover every branch (494 Android, 15 fill-target).
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
* fix(ci): spawn the differential's agent-device CLI as argv, not one option
The Conformance Differential nightly has been red since 2026-08-25: every
scenario reported `infrastructure-failed ... agent-device=fail` under
node: bad option: --experimental-strip-types src/bin.ts
`runAgentDeviceEngine` took the CLI as a single `cliPath` string and spawned
`[cliPath, ...args]`, but AGENT_DEVICE_CLI — which every device workflow sets,
and which is also this runner's default — is a command *line*: a node flag plus
the entry script. Node aborted on the combined token before the CLI loaded, so
the oracle compared nothing on all six scenarios.
Tokenize the variable once, in `resolveAgentDeviceCliArgv` beside the spawn it
feeds, and hand `runAgentDeviceEngine` an argv array. A path containing spaces
stays expressible as a single array element (the property the string signature
was protecting), while a flag plus a script can no longer collapse into one
option.
The fixture the old tests used was a bare `.mjs` path, which cannot tell an argv
from a command line, so the regression test runs the workflow's own shape end to
end — flag plus script — and fails with the exact CI signature without the fix.
* fix(ci): split the differential CLI env into entry path and node flags
Addresses the P1 review on #2069. Tokenizing AGENT_DEVICE_CLI on whitespace
fixed the flags-plus-script shape but broke the other one: an override like
`/tmp/agent device.mjs`, which main passes through intact, became two arguments.
One variable cannot encode both — any delimiter that separates flags from the
entry can also occur inside a path.
So the two concerns become two variables that cannot be confused:
AGENT_DEVICE_CLI the entry script — ONE path, never split
AGENT_DEVICE_CLI_NODE_FLAGS node flags — split on whitespace, which is exact
because a node flag cannot contain a space
Defaults reproduce today's behavior, and the empty string runs an entry that
needs no flags.
The regression now runs through the production route the review asked for —
environment, parseRunnerArgs, runScenario, spawn — rather than calling
runAgentDeviceEngine with a hand-built argv, which cannot see the environment
contract at all. Each direction is pinned by its own case, verified against both
broken implementations: main's unsplit string fails "node flags stay separate
arguments", and the whitespace split fails "a CLI path containing spaces reaches
the spawn unsplit".
The maestro stub stays out of the spaced directory on purpose: runMaestroEngine
still splits its command on spaces, and a spaced stub path would fail these
tests for the other engine's reason.
* perf(apple): start stale simulator runner bundle uninstalls concurrently
cleanupStaleSimulatorRunnerBundles awaited each simctl uninstall
sequentially while discarding the results (best-effort cleanup). Run the
per-bundle uninstalls under Promise.allSettled like the sibling disposal
paths, and pin the concurrent start with a deferred-promise test.
* test(apple): split stale-bundle cleanup coverage out of the pinned runner-session suite
runner-session.test.ts is over the test-size tripwire and its pin may only
shrink. Move the three stale-bundle cleanup tests (boot availability,
best-effort stall, concurrent start) into a sibling file named for the
domain question, carrying the same seam scaffolding.
* test(apple): lower runner-session suite pin to bank the stale-bundle extraction
* test(apple): format stale bundle coverage
* refactor(android): one injectable host-adb transport
* refactor(android): enforce host-adb transport failure contract
* style(android): format relocated limrun transport test
* refactor(android): colocate host adb transport
* refactor(android): route host adb through composition
* fix(android): keep unbound adb host loud