* refactor(ios): snapshot capture plans with a structured quality verdict
Implements ADR 0004's explicit-strategies decision as architecture
(candidates 1+2 of the snapshot pipeline review):
- Snapshot backend seam: three adapters (recursive tree, query sweep,
private AX) behind one captureWithBackend dispatch. Each strategy
declares its chain as data (regular: tree→queries→private-ax,
compact: queries→private-ax, raw: tree→private-ax) and one plan
runner walks it under a 20s umbrella budget so chained recovery can
never stack past the 30s main-thread watchdog. Terminal policy is
per-plan: raw rethrows AX failures (diagnostics preserve errors),
interactive fails closed with runnerFatal invalidation.
- Single quality classifier: one sparsePayloadReason predicate (with
reason codes), one collapsed-leaf detector, replacing the three
divergent sparse detectors (Swift structural, daemon count==1, CLI
count<=3) that each patched a different failure shape.
- Structured snapshot quality verdict on the wire (state, backend,
reason, reasonCode, effectiveDepth, collapsedLeafIndexes): the daemon
and CLI render warnings from it instead of re-deriving degradation
from node shapes; budget starvation is no longer blamed on the app's
accessibility. Legacy runner messages and daemon-side detectors stay
behind a verdict-absent gate for mixed-version compat.
- The verdict surfaces in --json (snapshotQuality) for agents; the
generic sparse CLI hint is suppressed when a verdict explains it.
Threading the verdict exposed two more hand-copy field drops
(captureInteractionOutcomeAwareSnapshot, serializeSnapshotResult,
client response mapping) - now carried alongside warnings everywhere.
Verified live: Settings healthy (tree, no warnings), Settings compact
under load (recovered/private-ax/budget), production login (sparse
best-effort with honest warning), collapse fixture (healthy +
collapsedLeafIndexes -> @ref warning), Bluesky Home (recovered/
private-ax, 24 nodes in 2s). Full unit suite 2327 passed, fallow clean,
runner builds.
* fix(ios): correct recovered-snapshot viewport and private-AX scope semantics
Review follow-ups on the capture-plan refactor:
- The query-sweep synthetic root doubles as the daemon's viewport
(find.ts prefers on-screen matches inside nodes[0].rect), but it was
built from candidate bounds, so off-screen controls below the screen
could inflate it and win duplicate-label resolution. The root now
uses the real finite viewport, falling back to candidate bounds only
when viewport capture failed.
- The private-AX backend applied --scope as a per-node text filter,
hiding the matched container's children — diverging from regular
snapshot scope semantics and contradicting the depth-cap hint that
recommends scoped re-runs. Scope now selects the matched subtree:
descendants inherit the match and only the normal option filters
apply to them (in-bundle test covers a non-matching descendant).
Verified live on Bluesky Home: scope homeScreen returns the 52-node
subtree including non-matching descendants; compact root rect equals
the screen (0,0,402,874).
* fix(ios): fail closed on interactive AX failure, stamp fatal verdict, validate parser
Three review findings on the capture-plan terminal path:
- P1: the fail-closed guard required `best == nil`, but the query-sweep
tier always returns a synthetic-root sparse payload that sets `best` —
so an interactive recursive-tree AX serialization failure that no
backend recovered returned a sparse snapshot instead of invalidating
the cached target. Reaching the terminal already means no backend
produced a usable tree, so the sparse `best` must not suppress the
fail-closed path. Extracted the decision into a pure, unit-tested
`resolveSnapshotPlanTerminal` (closes the terminal-ordering testability
gap the architecture review flagged).
- P2: `snapshotAccessibilityUnavailable` returned a payload with no
`snapshotQuality`, leaving one planned sparse result on the
legacy-message path. It now carries a sparse/ax-rejected verdict like
every other planned snapshot, so downstream sparse handling keys off
the verdict.
- P2: `readSnapshotQualityVerdict` cast any string state/backend into the
union, so a malformed object suppressed the legacy node-shape
detectors. State and backend are now validated against their unions
(unknown → verdict-absent → legacy detectors run); an unknown
reasonCode is dropped rather than rejecting the whole verdict, so a
forward-version runner still yields a usable verdict.
Unit-covered: Swift resolveSnapshotPlanTerminal matrix + fatal-verdict
assertion; TS parser accept/reject/forward-compat. Full suite 249 files
/ 2449 tests, fallow, lint, runner build green.
* fix: add iOS private AX snapshot fallback
* fix: add public iOS snapshot query recovery
* fix(ios): make private AX snapshot fallback recover deep React Native trees
Four fixes that turn the #758 private AX fallback from
works-on-one-tree-shape into reliable on Bluesky Home:
- Depth ladder: the AX server rejects bulk snapshot requests outright
(kAXErrorIllegalArgument) once requested depth crosses a
tree-size-dependent limit that moves with live content. Retry at
56/40/24/12 instead of giving up after one attempt at 64.
- Real attribute identifiers: the server silently ignored the raw
keypath strings the bridge passed, so every node came back with a
zero frame (breaking ref taps and the interactive/compact filters,
which is why 'snapshot -i -c' stayed sparse). Map keypaths through
XCElementSnapshot.axAttributesForElementSnapshotKeyPaths (it returns
an NSSet) and drop the mapper's expensive extras (automation type,
window display id, base type) that pushed deep requests past the 30s
main-thread watchdog.
- Viewport from the private root frame when the public windows query
degrades to an infinite viewport, so off-screen drawer content stops
passing the visibility filter.
- Runner source fingerprint now includes .m/.h, so bridge edits stop
reusing stale cached runner builds.
Also hardens the bridge per review: UInt(exactly:) for untrusted
element types, pid_t-sized objc_msgSend for process id matching, and
objCType-checked NSValue frame decoding.
* fix(ios): recover deadline-truncated near-empty compact snapshots
The all-structural sparse detector misses the common large-RN-tree case
where the typed-query sweep resolves one or two stray controls before
its 1s deadline: the payload has 'content', so recovery never fires,
yet 2 nodes is useless in practice. Treat deadline-truncated payloads
with <= 8 nodes as needing recovery, and only replace the original
payload when the recovered tree actually carries more nodes. Completed
sweeps on legitimately minimal screens stay untouched (not truncated).
* chore: fix CI for the AX snapshot fallback branch
- Sync the setup metadata script's fingerprint extension list with the
runtime (.m/.h were added for the ObjC bridge), fixing the cache
metadata parity test.
- Reduce find.ts complexity flagged by fallow: hoist the node fetcher
into createFindNodeFetcher with a recoverSparseInteractiveSnapshot
helper, split match disambiguation and resolution scoring into
narrowMultipleMatches/resolvedTouchScore, extract rectsMatch.
* feat(ios): make accessibility fallbacks and collapsed containers visible in snapshot output
Two transparency gaps from #701's 'no silent fallback' requirement:
- Runner-attached snapshot messages now surface as snapshot warnings
(readAppleSnapshotResult previously dropped them), so every recovery
through the fallback accessibility backend or query tier is announced,
states what it usually means (the app publishes an unhealthy
accessibility tree - fixing the app is the real cure), and points to
screenshot as visual truth.
- A leaf whose label merges many comma-joined segments is flagged as a
collapsed accessible container: the app marks a container accessible,
hiding every descendant from assistive tech and automation alike.
Nothing can be recovered below it (VoiceOver sees the same merged
element), so the warning names the node, estimates the merged label
count, and gives the app-side fix plus the screenshot/coordinate-tap
workaround.
Validated live on the lab stress fixture (adlab://stress?accessible=1):
the 6-node tree now carries '@e5 [Other] merges ~126 labels...'.
* fix(ios): detect sparse trees with labeled roots and surface warnings through the daemon
Validated against a real-world repro (a production React Native app's
login screen, simulator build provided privately by the reporter): a
full-screen accessibilityViewIsModal overlay leaves the public snapshot
with just Application+Window. Two gaps kept recovery off:
- The sparse detector counted the Application label (the app's display
name) as content and the full-screen root as hittable, so the app
name alone defeated recovery. Application/Window labels and root
hittability say nothing about tree health and no longer count.
- Interactor-level snapshot warnings were dropped by the daemon capture
chain (only the runtime/commands layer kept them); they now thread
through CaptureSnapshotResult into BackendSnapshotResult.
With both fixes that login screen recovers through the public query
tier: 16 nodes with every control addressable (fill @ref + read-back
verified), and the output carries the recovery warning. Bluesky-class
trees still ladder into the private fallback unchanged.
Every ok runner response now carries a transport-stamped currentUptimeMs
captured just before the HTTP write. Simulator recording start anchors
gesture overlay timing from the warm snapshot response it already makes,
skipping the standalone uptime request. The standalone uptime path stays
as fallback for older runner builds, and journal-stored responses remain
unstamped so recovered results never pair a stale uptime with a late
receipt time.
Closes#670
* refactor(ios): consolidate series batching onto the sequence runner command
Closes#767
Routes every Apple multi-press variant (plain, double-tap, hold, jitter)
and swipe series through budget-chunked sequence requests, retiring the
daemon-side tapSeries and dragSeries senders:
- Add a doubleTap step kind to the sequence allowlist on both ends,
mirroring the retired tapSeries doubleTapAt branch.
- The single doubleTap interactor sends a one-step sequence and parses
the result, surfacing step failures as errors.
- Swipe series unroll ping-pong daemon-side into per-step endpoints;
the runner's coordinate-drag path ignores durationMs exactly as the
daemon-sent (non-synthesized) dragSeries did.
- Extract runIosSequenceChunks so press and swipe share the chunking,
aggregation, and global step-index rebasing.
- Keep tapSeries/dragSeries runner handlers for wire compatibility with
older daemons, annotated like interactionFrame; remove both from the
preflight-skip allowlist (daemon never sends them) and update ADR
0005 / protocol-optimizations docs.
This also closes the latent watchdog exposure where press --count N
--interval-ms M routed to tapSeries and executed all pauses inside one
30s-watchdog main-thread block with no chunking.
Behavior note: plain tap series now use the synthesized HID tap path on
iOS non-tv (with runner-side tapAt fallback), matching the individual
tap command instead of the retired tapSeries' XCUICoordinate taps.
https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo
* refactor(ios): drop dead series wire surface from the daemon
- Remove chunkRunnerSequenceSteps: superseded by the budget-aware
chunker; no production callers remained.
- Remove tapSeries/dragSeries from the RunnerCommand union along with
their orphaned fields (count, intervalMs, doubleTap, pauseMs,
pattern) and protocol fixtures: this type is the send surface of the
current daemon, which no longer sends either command. The Swift
runner keeps serving both for wire compatibility with older daemons.
- Retarget the ready-mutation preflight test from tapSeries to
sequence.
https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo
* refactor(ios): remove retired series and frame wire commands entirely
Drops the runner-side wire compatibility for tapSeries, dragSeries, and
interactionFrame now that no daemon path sends them (series fuse into
sequence since this branch; interactionFrame was fused into scroll in
#760):
- Swift: delete the three handler cases, performDragSeries, runSeries
(no remaining callers), the CommandType enum cases, journal-retention
and traits entries, and the Command fields (count, intervalMs,
doubleTap, pauseMs, pattern) that existed only for them. The
never-sent synthesized dragSeries branch goes with it.
- TS: drop interactionFrame from the RunnerCommand union and
isReadOnlyRunnerCommand, and its protocol fixture.
- Update stale perf scenario labels referencing the retired commands.
Verified dead before removal: no dynamic command construction anywhere
(runner-command-recovery only echoes in-flight command ids), no
raw-string references in Swift, no docs references. Helpers shared with
live paths (synthesizedDragAt, doubleTapAt, keyboardAvoidingDragPoints,
sleepFor) all retain callers.
Compat: an old daemon paired with a runner built from these sources
gets a CommandType decode rejection; the source-fingerprint check
rebuilds a matching runner on the next session.
https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo
---------
Co-authored-by: Claude <noreply@anthropic.com>
* perf(ios): add lifecycle-safe runner sequence command for hot press series
Adds a narrow 'sequence' runner command that batches an explicit
allowlist of coordinate steps (tap, longPress, drag) into one
lifecycle-tracked request with stop-on-first-failure and small bounded
per-step results. iOS press series with hold/jitter now issue one
sequence request per ~20-step chunk (also budgeted to stay under the
runner's 30s main-thread watchdog) instead of one request per press.
Sequence responses are journaled and retained, so lost-response recovery
returns observed results without replaying the gesture sequence.
Closes#669
* fix: perform every press in direct press series
runDirectPressSeries guarded the awaited interaction itself with ??=,
so presses 2..N were silently skipped once the first result was kept
(affects Android series and doubleTap series; introduced in #512).
The kept-first-result shape is preserved.
* chore: unexport internal sequence chunk budget constant
* perf(ios): make sequence eligible for readiness preflight skip
Rebased onto main with #763 (healthy-mutation preflight skip) and #760
(fused scroll). Per the merge-order note, add 'sequence' to
PREFLIGHT_SKIP_ELIGIBLE_RUNNER_COMMANDS so a successful sequence earns
the next hot-command skip instead of always taking the
conservative_command path. Extend the per-family skip tests and the
allowlist enumeration in ADR 0005 and the protocol-optimizations doc.
https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo
---------
Co-authored-by: Claude <noreply@anthropic.com>
* perf(ios): fuse scroll frame resolution and drag into one runner command
Non-tvOS scroll now sends a single mutating 'scroll' runner command. The
Swift runner resolves the interaction frame and executes the same
non-synthesized drag path, eliminating the separate read-only
interactionFrame request per scroll. The command is lifecycle-journaled
with retained response JSON so lost-response recovery returns the result
without replaying the gesture.
Closes#668
* perf(ios): make fused scroll eligible for readiness preflight skip
#763 landed the healthy-mutation preflight skip with a note that the
fused scroll command should join the allowlist once it exists. Add
'scroll' to PREFLIGHT_SKIP_ELIGIBLE_RUNNER_COMMANDS, drop the
now-resolved code note, extend the per-family skip tests, and update
the allowlist enumeration in ADR 0005 and the protocol-optimizations
doc.
https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo
* test: complete scroll plan parity vector mirror
Address review on the cross-language parity vectors:
- mirror the Swift pixels-plan vector (down, 120px @ 300x600) in the
vitest suite so every vector exists in both languages
- add amount > 1 clamp and tiny-frame (2x2) vectors to both suites;
the tiny frame engages every max(1, ...) floor and the .5 rounding
cases where JS half-up and Swift half-away-from-zero must agree
https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo
---------
Co-authored-by: Claude <noreply@anthropic.com>
Reintroduces the #662 adaptive readiness-preflight skip with guardrails
for the #702 failure modes. Recency is recorded only from healthy
(non-runnerFatal) responses to an explicit mutating-interaction allowlist
(tap, tapSeries, longPress, drag, dragSeries, swipe), scoped to the same
appBundleId, capped at a 5s freshness window, and lives on the session
object so it dies with every invalidation. Startup, no-recent-success,
stale, app-switch, and non-allowlisted commands still preflight. A
transport failure after a skip clears recency, carries the skip context
through status recovery, and never routes into restart-and-replay.
Closes#667
Coordinate presses during recording resolved their touch reference
frame from an interactive-only snapshot that was also captured in
compact mode. Compact pruning drops Application/Window containers, so
viewport inference fell back to a bounding box of leaf elements (e.g.
386x208 instead of the 440x956 screen) and the overlay burn-in scaled
tap markers with that garbage frame: x landed nearly right by
coincidence, y blew past the canvas and clamped to the bottom edge.
Capture the reference-frame snapshot with compact disabled so the
Application node survives and the frame is the real screen point size.
Verified on an iPhone 17 simulator (402x874pt screen, 220x480 video):
gesture telemetry now carries 402x874 and the burned-in marker lands at
(121, 329) vs expected (120.4, 329.5); previously it rendered clamped
at the bottom edge.
* chore(daemon): takeover notice, dev state-dir pruning, session state-dir command surface
Implements the three follow-ups from #737:
1. Print a one-line stderr notice when the client replaces a running
daemon, stating identity and reason (version mismatch, code-signature
mismatch, or unreachable). Best effort; never fails the command.
2. Add 'pnpm clean:daemon --prune-dev' to remove worktree-scoped state
dirs under ~/.agent-device/dev/ that no live daemon owns (same
pid/start-time liveness check as server-lifecycle) and that have been
idle for 14+ days. Scoped dirs only; one line printed per removal.
3. Fold 'session state-dir' into the regular command surface: the
session contract resolves it locally via the new
client.sessions.stateDir(), the cli.ts pre-dispatch special case is
removed, and the MCP session tool now exposes the state-dir action.
Closes#737https://claude.ai/code/session_013WBrUjQ4WRxRkfVruALKX3
* docs: surface clean:daemon --prune-dev in AGENTS.md
Local agents discover daemon state-dir hygiene through AGENTS.md, not
the website docs, so document the prune flag next to the existing
worktree-scoped state-dir guidance.
https://claude.ai/code/session_013WBrUjQ4WRxRkfVruALKX3
---------
Co-authored-by: Claude <noreply@anthropic.com>
* test: cover least-tested CLI grammar and config parsing modules
Add focused unit tests for the modules the coverage report flagged as
least covered, exercising real behavior rather than padding metrics:
- utils/source-value: env/config value parsing (booleans, enums, enum
flags with setValue, int bounds, multiple) — 42% -> 100% lines
- commands/cli-grammar/gesture: CLI<->daemon gesture argument
translation for every gesture kind incl. error paths — 41% -> 100%
- commands/cli-grammar/system: back/rotate/keyboard/clipboard/
react-native readers and writers incl. validation — 57% -> 100%
- core/device-rotation: orientation parsing with aliases/errors — 100%
- core/dispatch-payload: push payload loading from inline JSON and
files, with temp-file I/O and JSON/shape error handling
https://claude.ai/code/session_018i1mhcSe6sqM4mKLUKgYXe
* chore: gitignore generated coverage report directory
https://claude.ai/code/session_018i1mhcSe6sqM4mKLUKgYXe
* test: verify public exports reach the npm build; drop dead daemon barrels
Strengthen the package-exports test so it verifies the real
publish-time invariant: every package.json "exports" subpath maps to a
configured rslib build entry that points at an existing source module
which actually exposes named exports. This catches a subpath being added
to package.json without a matching build entry (which would ship a
broken import), and keeps import/types targets in lockstep.
Remove four internal re-export barrels under src/daemon that were pure
1:1 forwarders to their src/utils source, and repoint all importers at
the real module so it is obvious where the code lives:
- daemon/is-predicates.ts -> utils/selector-is-predicates.ts
- daemon/selectors-build.ts -> utils/selector-build.ts
- daemon/snapshot-diff.ts -> utils/snapshot-diff.ts
- daemon/snapshot-processing.ts -> utils/snapshot-processing.ts
The curated daemon/selectors.ts facade is kept; only blind forwarders
were removed. No behavior change.
https://claude.ai/code/session_018i1mhcSe6sqM4mKLUKgYXe
---------
Co-authored-by: Claude <noreply@anthropic.com>
* perf(daemon): offload PNG decode/encode and screenshot pixel diff to a worker thread
PNG decode (inflateSync) and per-pixel screenshot diffing previously ran
synchronously on the daemon event loop, stalling all concurrent sessions
while multi-MB screenshots were processed.
- add src/utils/png-worker.ts worker_threads entry (rslib internal/png-worker)
handling one decode, encode, or diff-pixels job per message
- add src/utils/png-worker-client.ts async wrappers (decodePngAsync,
encodePngAsync, computeScreenshotDiffPixelsAsync) that lazily spawn the
worker, resolve it next to the current module in dev (.ts) and dist (.js)
like the companion tunnel entry, and fall back to the in-process
synchronous path when the worker is unavailable
- extract the unchanged pixel-compare loop into
src/utils/screenshot-diff-pixels.ts so both paths share identical logic
- route daemon call sites (screenshot-overlay annotate, compareScreenshots)
through the async wrappers; results stay byte-identical
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
* refactor(daemon): apply review findings to PNG worker offload
- guard postMessage failures: a job-specific send error (e.g. DataCloneError)
now cleans up its pending entry and falls back to sync for that call only
- resolve the worker entry via a shared src/utils/internal-entry.ts helper
that returns null on any resolution failure (non-file import.meta.url)
- match repo precedent: spawn the worker with --experimental-strip-types
when the resolved entry is a .ts module
- report permanent worker degradation once (scoped diagnostic + process
warning with the failure reason) instead of silently going sync-only
- daemon lifecycle: pre-warm the worker at startup and terminate it during
shutdown with a 1s best-effort timeout (daemon-only)
- collapse the three async wrappers onto one kind-typed job runner with a
single unavailability channel (rejection, no null path) and drop the dead
mismatched-result guards
- derive the diff-pixels contract types from screenshot-diff-pixels.ts and
share toBuffer via the contract module
- serialize worker errors with normalizeError and reconstruct AppError
(code/message/details) client-side; the worker reuses decodePng so decode
failures are identical on both paths
- transfer result buffers back to the client when a view fully owns its
ArrayBuffer; clone pooled buffers to protect Node's shared buffer pool
- decode baseline/current screenshots concurrently in compareScreenshots
- move resizePngFileToMaxSize to src/utils/png-resize.ts and route its
decode/encode through the worker (daemon screenshot --max-size path)
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
* fix(fallow): declare png worker entry and simplify resultTransferList
The Fallow audit flagged src/utils/png-worker.ts as unreachable from any
entry point and resultTransferList as over the complexity threshold.
- Declare src/utils/png-worker.ts in .fallowrc.json's entry list: it is a
worker_threads entry point loaded at runtime (mirrors src/daemon.ts /
src/companion-tunnel.ts and the internal/png-worker rslib entry).
- Flatten resultTransferList into filter/map over an extracted
ownsEntireArrayBuffer predicate, preserving the exact transfer rule
(byteOffset === 0, byteLength === owner.byteLength, real ArrayBuffer).
- Add direct unit coverage asserting fully-owned buffers are transferred
while pooled/offset views are not.
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(daemon): timing-safe token comparison and daemon.json permission hardening
Use crypto.timingSafeEqual (via SHA-256 digests, length-independent) for the
three daemon token checks, and chmod daemon.json to 0600 after writes since
writeFileSync only applies mode on creation.
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
* fix(deps): clear CVE-2026-9277 by overriding shell-quote to >=1.8.4 in test-app
Override added to examples/test-app/pnpm-workspace.yaml (package.json-level
overrides are silently ignored for this nested app, see the comment there).
Lockfile change is limited to shell-quote 1.8.3 -> 1.8.4; pnpm audit is clean.
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
---------
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(types): rename NetworkLogBackend -> LogBackend
The union is reused for both network-log and app-log backends, so the
network-specific name was misleading. Also renames the resolveNetworkLogBackend
helper to resolveLogBackend. No behavior change.
* refactor(types): add string-enum helpers (isStringMember/parseStringMember)
Small utils/string-enum.ts helpers for the now-common 'as const tuple' string
enums: a membership guard and a strict exact-match parser. Adopt at the strict
sites (parseSessionSurface, parseScrollDirection, parseSwipePreset, isPerfArea,
isPerfAction), replacing hand-rolled switch/includes boilerplate. Alias-accepting
parsers (device rotation) keep their custom logic. Error messages unchanged.
Chose focused helpers over a defineStringEnum factory: the modules are already
concise with tuple-as-source, and a factory would force export-name churn for no
gain.
* refactor(types): dedupe runner/interaction tail shapes
- RunnerXctestrunCacheKind / RunnerXctestrunArtifactState shared between
RunnerXctestrunArtifact and AppleRunnerPrepareResult.
- Annotate buildInteractionSurfaceSignature with its existing
InteractionSurfaceSignature alias.
- RunnerOpts / runnerOptionsFromContext use Pick<RunnerContext, ...>.
(Skipped daemon-invoke-fn: maestro already has its own MaestroRuntimeInvoke
alias, so forcing a shared DaemonInvoke creates naming inconsistency for ~2 lines.)
* refactor(types): dedupe remaining tail shapes
- ElementSelectorTarget (core/interactor-types.ts) shared by
DirectIosSelectorTarget and ElementSelectorTapOptions (= Omit<...,'raw'>).
- Collapse client-types ClientCommandBaseOptions into the identical exported
DeviceCommandBaseOptions; merge the two byte-identical snapshot-pick aliases.
- File-local SelectorRuntimeError and LineWriter for repeated inline shapes.
- Export mcp ToolResult and use it for the router's textToolResult return.
Pure refactor, type-only.
* address review: adopt helpers, fix alias direction, tidy tail dedupes
- Adopt parseStringMember/isStringMember at parseGestureDirection and the
swipe-pattern guard in dispatch-interactions.ts (same module the PR touched;
byte-identical error/behavior).
- Extract RunnerCallOptions = Pick<RunnerContext,...> in interactor-types.ts;
reuse in dispatch-interactions + ios/interactions (was written twice).
- Invert the ElementSelector alias direction: ElementSelectorTapOptions stays the
plain core type; DirectIosSelectorTarget = ElementSelectorTapOptions & { raw }
(keeps the daemon-only raw field in the daemon module).
- Drop redundant cleanStaleBundles in AppleRunnerLifecycleOptions (already in
AppleRunnerCommandOptions); drop the now-unnecessary cast in isStringMember.
- callTool returns Promise<ToolResult>; rename SelectorRuntimeError ->
DirectIosSelectorErrorResult (matches the DirectIosSelector* family).
- Dedupe the log-backend device mapping: export resolveLogBackend and reuse it
in resolveSessionLogBackendLabel.
Pure refactor; typecheck/lint green, affected tests pass.
* chore(fallow): fit config to repo profile so baselines stay near-empty
- Raise health thresholds in .fallowrc.json to the smallest values that
pass on a clean tree (maxCyclomatic 58, maxCognitive 77, maxCrap 591)
instead of grandfathering ~180 findings in fallow-baselines/health.json.
- Raise duplicates.minTokens to 66, the smallest value covering the four
tolerated clone groups (largest is 65 tokens).
- Regenerate baselines: health.json shrinks from ~18.6 KB of grandfathered
finding counts to refactoring-target metadata only; dead-code.json is
empty.
- Upgrade fallow 2.52.0 -> 2.91.0: 2.87.0 made ignorePatterns silence the
"examples/test-app is not declared as a workspace" warning, which 2.52.0
emitted regardless of config.
- Remove the unused ensureAdb export (and its now-unused imports) from
src/platforms/android/adb.ts; it is not re-exported by any public entry
and has no references anywhere in the repo.
- Document local (pnpm fallow) vs CI (fallow audit) usage in
CONTRIBUTING.md.
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
* chore(fallow): keep default thresholds, gate locally via diff-based audit
Revise the previous commit after review: pinning global thresholds at the
repo's historical maxima (cyclomatic 58, cognitive 77, CRAP 591, minTokens
66) weakened the gate for brand-new code and left zero headroom on the
worst existing functions. Restore the original design — fallow default
thresholds with legacy findings grandfathered per-file in
fallow-baselines/health.json — and fix the local-DX problem at the script
level instead:
- .fallowrc.json: drop the health/duplicates overrides so fallow defaults
(cyclomatic 20, cognitive 15, CRAP 30, minTokens 50) apply to new code.
- fallow-baselines/health.json: regenerate at default thresholds under
fallow 2.91 (201 grandfathered findings across 108 files); dead-code
baseline stays empty.
- package.json: `pnpm fallow` now runs `fallow audit --base origin/main`,
the same diff-based gate CI uses, so it passes on a clean tree. The old
full-tree summary moves to `pnpm fallow:all` (expected to report legacy
findings). `check:fallow` is unchanged (CI passes an explicit --base).
- CONTRIBUTING.md: correct the fallow docs accordingly.
Verified: clean tree passes; a new unused export fails the audit; a new
cyclomatic-25 function fails the audit; +1 branch growth in an already-
grandfathered function (classifyBootFailure) is absorbed by the baseline.
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
---------
Co-authored-by: Claude <noreply@anthropic.com>
* test(maestro): cover runtime target resolution and point parsing behaviors
Pin previously untested behaviors of the Maestro compat target-resolution
layer: selector platform mapping, visible-text query extraction, fuzzy text
exact/partial ordering, index selection and out-of-range errors, childOf
failure messages, ancestor rect inheritance, zero-size rect visibility
errors, regex selector handling (including invalid regexes), malformed
selector INVALID_ARGS errors, visible match counts, and absolute/percentage
point coordinate parsing with malformed-input rejections.
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
* test(maestro): tighten assertions per review
- Reduce the malformed-selector test to a single propagation case using a
tolerant /Unknown selector key/i regex; exact parser wording stays owned
by src/daemon/__tests__/selectors.test.ts.
- Make the blank fuzzy-query whitespace explicit via a hoisted
' '.repeat(3) query interpolated into the expected message.
- Add readMaestroSelectorPlatform boundary cases pinning the
case-sensitive === 'android' contract ('Android' and 'tvos' -> 'ios').
- Introduce a resolveIosNode helper to collapse the repeated
resolveMaestroNodeFromSnapshot(..., 'ios', IOS_TAB_FRAME) call sites in
the new tests.
- Add '50,75%' and '100.5,200' to the parseMaestroPoint rejection loop
(percent on one coordinate only; integer-only absolute regex).
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
---------
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(types): documentary NormalizedRect/NormalizedPoint aliases
Add NormalizedRect/NormalizedPoint aliases of Rect/Point (utils/snapshot.ts)
to convey coordinate space at the two normalized-percentage sites that were
typed as absolute Rect: ScreenshotDiffRegion.normalizedRect and the OCR
block normalizedRect (+ the OCR center-distance path). Documentary only — the
aliases are structurally Rect/Point, so this is intent-conveying, not enforced.
Full nominal branding was scoped out as disproportionate: the normalized
surface is 2 fields with no current cross-space mixing, and rectCenter/
squaredDistance are intentionally space-agnostic.
* address review: relocate NormalizedRect/Point to screenshot-geometry + fix doc
- Move the documentary aliases out of the general snapshot.ts into
screenshot-geometry.ts (the screenshot-diff geometry module), where their
only consumers live (better collocation).
- Correct the doc: coords are normalized to the screenshot IMAGE's dimensions,
not 'viewport'.