* fix: honor session metro hints and expo dev-client bundle urls
- metro reload now resolves against the dev server the session's last
metro prepare bound (via a per-session hint file), instead of
silently defaulting to localhost:8081 and reloading an unrelated
project. Explicit --metro-host/--metro-port/--bundle-url still win.
- metro prepare --metro-kind expo (detected or forced) now hints the
virtual-metro-entry bundle URL instead of index.bundle, which 404s
against Expo dev servers in monorepos (live-verified against
react-navigation's example app). Package-manager detection for
--install-deps now walks up from --project-root to the nearest
lockfile so Yarn/pnpm workspace monorepos don't wrongly fall back to
npm and hit EUNSUPPORTEDPROTOCOL on workspace: deps; install
failures now hint at --no-install-deps and the detected PM.
- open now accepts --metro-host/--metro-port/--bundle-url/--launch-url
as session-hint setters (folded into the same runtime object the
daemon already persists for open), so a fresh session doesn't need a
throwaway reload-first call just to seed hints.
Updates help text for metro and open to match.
* fix: make the metro-sessions file the single reload hint store
Review follow-up (PR #1199):
- open's --metro-host/--metro-port/--bundle-url now also record the
session's dev-server binding in the metro-sessions file (the one
local store metro reload resolves against), so a later plain reload
actually reuses what open set. The daemon runtime-hints write stays
for device-native dev-server prefs.
- The binding now carries bundleUrl (prepare persists the local-flow
bundle URL; bridge runtimes are excluded).
- clearMetroSessionHints is wired: session close drops the binding
(teardown intent — even when the daemon call fails), and a hintless
open that creates the session clears a leftover same-name binding.
The open result carries sessionReused so the client can tell fresh
from reused sessions. Regression test pins prepare -> close ->
flagless reload resolving to the default, never the stale port.
- Package-manager detection recognizes bun.lock (text lockfile,
default since Bun 1.2) and bounds the lockfile walk-up at the
nearest .git entry.
- Comment audit per maintainer directive: multi-line acceptability
arguments trimmed to one-line constraint statements; the store's
lifecycle is documented once on MetroSessionHints.
* fix: preserve bundle-url mount prefix and broadcast expo reloads over /message
Maintainer review follow-ups (PR #1199):
- Reload/message endpoint URLs keep the bound bundle URL's mount prefix
(e.g. /tenant-42/index.bundle -> /tenant-42/reload) instead of
collapsing to the host root. The Expo virtual entry
(.expo/.virtual-metro-entry.bundle) is an entry-module path, not a
server mount, so it maps to the server-root endpoints (verified
live: Expo serves /message at the root).
- When the dev server has no HTTP /reload route and answers with the
app page (Expo), metro reload now broadcasts
{"version":2,"method":"reload"} over the server's /message websocket
(the channel dev-server CLIs use for the r key) instead of reporting
the app-page 200 as a successful reload. The result carries a
transport field (http | message-socket). Live-verified: a flagless
metro reload against the running Expo server made the app re-fetch
its JS bundle.
- Endpoint resolution moved to src/metro/metro-reload-endpoints.ts so
the seam tests use is production-imported (keeps the new
production-unused-exports ratchet clean).
- Docs reconciled: help metro and website commands.md describe the
single session binding store, open's hint flags, prefix
preservation, and the message-socket fallback.
* fix: preserve Expo reload mount prefixes
* feat: parse and preserve .ad target-v1 evidence (ADR 0012 migration step 3)
Recording now emits a `# agent-device:target-v1 {...}` comment immediately
before every click/press/longpress/fill action that resolves through the
tree (ref or selector), carrying the identity/ancestry/sibling/scrollRegion/
viewportOrder tuple from decision 3's record-time write algorithm plus a
record-time self-check (verified/unverifiable). The parser accepts known
fields in any order, NFC-normalizes, rejects malformed/oversized annotations
with INVALID_ARGS, binds an annotation only to the immediately-next action
line, and treats unknown future target-vN comments as ordinary comments.
writeReplayScript's read-then-rewrite (heal) path preserves v1 annotations
in canonical form. Nothing consumes the parsed evidence yet beyond
preservation — replay-time enforcement is a later migration step.
The identity tuple's own node/tree only ever lived on the internal
visualization/session-history payload (never the public response); it is
converted to the compact target-v1 evidence and stripped before anything is
persisted or returned.
* fix: bound candidate identity before self-check comparison
The identity-set scan in computeTargetEvidence compared an untruncated
candidate identity against the 256-byte-bounded recorded identity, so a
node whose own id/label exceeds the field cap could fail to match itself,
corrupting the record-time self-check. Compare through the same bounding
on both sides instead.
* fix: address PR #1196 review — contract boundary, formatting, path-6 reasons, worst-case sizing
- native-ref contract test: deliberately updated to assert the new ADR 0012
boundary — the preflight's node/preActionNodes ride the INTERNAL runtime
result and visualization payload only; the public responseData never
carries node/preActionNodes/targetEvidence (asserted directly against
buildInteractionResponseData).
- oxfmt formatting on all touched files.
- classifyTargetBindingMatch path 6 now distinguishes decision 3's two
spec-distinct outcomes: a signal isolating a member that differs from the
winner ('signal-isolated-wrong', the paths-4/5 comparison class, future
identity-mismatch) vs true fall-through ('no-signal-isolation', future
identity-unverifiable), so step 4 can consume the reasons directly.
- writer reduction loop sizes every candidate against the worst-case
verification value ("unverifiable" is 4 bytes longer than "verified"), so
a fail-closed self-check downgrade can never push an accepted payload over
the 4 KiB cap; pinned by a 1-byte-granularity sweep across the boundary
that fails against the old placeholder-sized check.
* fix: reject missing role keys in target-v1 annotations, correct data-flow comment
Second-review nits: the writer emits `role` unconditionally (top level,
ancestry entries, scrollRegion) — possibly as the empty string for typeless
nodes, which stays accepted — so a MISSING role key can only come from a
hand-edited/adversarial annotation and is now rejected with INVALID_ARGS
instead of silently parsing as an implicit empty role, which step-4
enforcement could otherwise match against anonymous wrapper nodes. Also
corrects the interaction-touch-response comment that overstated where the
raw node/tree flows (finalizeTouchInteraction strips it before both session
history and touch overlay telemetry).
* fix: record iOS selector target evidence
* refactor: make the record-time evidence channel structural, drop argument-comments
Maintainer directive: comments that argue a workaround is acceptable mark
code to redesign. Applied to the whole PR diff:
- The record-time node/tree now travel on a typed `recordedTarget` side
channel of InteractionResponsePayloads instead of being smuggled through
the visualization Record and stripped later. The construction site routes
them there exclusively, finalizeTouchInteraction consumes the channel
directly, and extractTargetEvidenceForRecording (the strip helper and its
justifying doc) is deleted — the public/internal split is now enforced by
the type shape, so the contract test asserts it in two lines instead of a
paragraph.
- findNearestScrollableContainer/findNearestAncestor made generic over the
node type, removing a cast plus its safety-argument comment.
- computeLocalIdentity/boundedLocalIdentity collapsed into one always-capped
identity reader — the raw/bounded split was the root of the earlier
self-match bug and existed only to be explained.
- parseReplayScriptDetailed's rejectUnbound takes the pending annotation as
a parameter, removing a non-null assertion and its comment.
- Remaining paragraph-length argument-comments trimmed to one-line
constraint statements (module docs, sizing/floor comments, regex/role
rationales, test comments); spec-mapping docs stay.
* chore: oxfmt the selector-evidence test files
* fix: record get target evidence, fail closed on broken parent linkage
Maintainer blockers on ADR 0012 step 3:
- get text/attrs now records target-v1 evidence: GetCommandResult carries
the resolution tree (preActionNodes, internal — neither the recorded
result nor the public payload copies it), recordIfSession gains the typed
recordedTarget channel, and dispatchGetViaRuntime threads the capture
through. The direct-iOS get query is gated during recording so the
snapshot path supplies the evidence tree, mirroring the tap/fill gating.
find/is stay uncovered: their results are match-set shaped (found flags,
predicate booleans over possibly-many matches), not a single resolved
winner — noted in the PR.
- buildAncestryChain now reports a broken parent walk (dangling parentIndex
or cycle) instead of silently producing a root-like chain; the writer
fails the annotation closed to 'unverifiable' per decision 3's
capture-anomaly rule, and broken candidates cannot prove an ancestry
prefix. Regression tests for both anomaly shapes.
- New ADR 0012 recording tests live in focused files
(interaction-target-evidence.test.ts) instead of growing the
interaction.test.ts aggregation; the earlier additions moved out.
* refactor: extract direct-iOS get selector guards below the complexity threshold
* feat: add derived fail-open check:affected selector
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: simplify selector for complexity gate; add docs
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: fail open on ambiguous non-source fixtures; guard catalog against real package.json/vitest.config
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: use src/utils/exec.ts process helpers in check:affected runner
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(check:affected): SkillGym ownership, honest catalog, working-tree discovery
- Add SkillGym ownership for skills/ and test/skillgym/; stop short-circuiting
their Markdown as docs-only (findings 2 & 4).
- Drop the fabricated GitHub 'SkillGym' job: it is a local-only gate, now
localRunnable with no CI job, guarded by a workflow-existence self-test (3).
- Fold working-tree (staged/unstaged/untracked) state into local discovery and
disable rename detection so both rename paths classify (1).
- Add run.test.ts entrypoint regressions (real diff/status/rename discovery,
--run order/skip/stop-on-failure).
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(check:affected): union staged + unstaged diffs so they cannot cancel
A single `git diff HEAD` nets index against working tree, so a staged add
and an unstaged delete of the same file cancel and hide it. Collect
`--cached` (staged) and unstaged diffs separately and union them; add a
cancellation regression test.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(check:affected): cover required suite gates
* refactor(check:affected): delegate tests to vitest
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: ratchet against test-only exports
Three exported-and-unit-tested-but-unreferenced-in-production incidents
this week (#1166 getNearestCommandNames, #1167 buildSettleTail, #1199
clearMetroSessionHints) — the first two were caught by fallow's dead-code
check because they had zero importers anywhere; #1199 was missed because a
test file imports the export, and fallow's default reachability graph
counts a test import as "used".
Adds a second, stricter pass reusing fallow's own --production mode
(entry.exclude test/story/dev files) via scripts/test-only-exports/check.ts:
an export alive in fallow's default graph but dead in its production graph,
with no other reference anywhere in its own file, has no production call
site — exactly the #1199 shape. Ratchets against a checked-in baseline
(scripts/test-only-exports-baseline.json, 77 entries); new findings fail
`pnpm check:test-only-exports` (wired into CI's Fallow job and
check:tooling). A `// test-seam: <reason>` comment above an export is the
escape hatch for intentional test seams.
Also extends .fallowrc.json's ignoreExports for seven daemon route handlers
(src/daemon/handlers/*.ts) that are genuinely production-reachable through
request-handler-chain.ts's `typeof import()` lazy-load pattern, which
fallow's static import graph can't trace as a named-export consumer —
without this they were false positives in the production-mode pass.
* fix: harden test-only-exports ratchet per review
Addresses the two should-fixes and all five minors from the independent
review of #1202:
- Replace the regex own-file occurrence count with an oxc-parser AST walk
(typescript@7 ships no JS scanner API, so the review's fallback tool
suggestion is the primary): identifiers are counted as AST nodes deduped
by source span, so mentions in JSDoc/block comments, strings, and
template-literal text no longer masquerade as call sites (review finding
1, both constructed cases re-verified fixed), and a `//` inside a string
no longer hides real usages (finding 6). Span dedupe keeps barrel
re-exports (`export { x } from`) counting once. The sharper count
surfaced one organic false negative on main: `selector` in
src/commands/index.ts was previously exempted because the regex matched
"selector" inside the './...selector-read.ts' import path string; it is
now baselined alongside its sibling `ref` (same re-export line).
- Make the baseline shrink-only (finding 2): --update-baseline refuses new
findings with the same wire/delete/annotate message, so the `// test-seam:`
annotation in the reviewed source diff is the only acceptance path;
CONTRIBUTING no longer documents baseline regeneration as an acceptance
option and now describes baseline growth as a deliberate manual edit.
- Stale baseline entries now emit a `::warning` CI annotation (finding 3).
- Commit a re-runnable fixture test (finding 4): check.test.ts mirrors
scripts/layering/model.test.ts, builds a synthetic package with a
clearMetroSessionHints-shaped export (JSDoc self-mention included),
asserts it is flagged, and asserts the annotated twin passes; wired
before the check in pnpm check:test-only-exports.
- Mark the unreadable/unparseable-file fallbacks CONSERVATIVE: per
CONTRIBUTING's convention (finding 5).
- Document the dynamic property access (obj[name]) blind spot in the
script header and CONTRIBUTING (finding 7).
* fix: harden test-only export ratchet
* refactor: use native Fallow export gate
* chore: refresh production export baseline
* test(output-economy): add routine-workflow output-behavior oracle
Add a deterministic routine-workflow measurement (#1180) that pairs
response bytes with follow-up behavior: fallback-observation count,
retry count, and whether an actionable failure preserves the session.
Refs chain across one recorded checkout session and counts derive from
the real formatters, so dropping settled-diff refs, the unchanged-
interactive tail, or a recovery handle fails the suite. Adds a matching
non-gating help-conformance next-command case. Response defaults
unchanged.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(output-economy): make routine-workflow ref-surfacing depend on rendered output
Address review on #1190:
- Drop the raw mutation-confirm/failure MCP data samples that leaked e4/e5
and mislabeled the surface; e4/e5 now surface only from the rendered CLI
settled-diff, so dropping added refs genuinely raises the fallback count.
- Track the failure once as its projection-invariant normalized payload
(workflow.failure.shared.json) instead of duplicate cli/mcp raw copies.
- Reuse the shared REF_TOKEN_PATTERN from economy-metrics.ts.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(output-economy): reuse shared fixtures in routine-workflow oracle
Address review finding #2 on #1190: chain the workflow onto the shared
per-surface fixtures instead of copy-pasting them.
- orient/recheck reuse SNAPSHOT_RESULT + SNAPSHOT_DAEMON_RESULT; the
first mutation reuses SETTLE_ADDED_REF_RESULT, so session identity and
ref generations come from ./fixtures.ts and the two suites cannot drift.
- Only the genuinely workflow-specific pieces remain local: the unchanged
recheck, a tail retargeted onto a settled-diff ref (SETTLE_TAIL_RESULT
taps an unsurfaced @e6 and can't chain), the in-session timeout failure,
and its recovered retry. routine-workflow.ts drops ~110 LOC.
- Rendered-output ref guard preserved: @e5 (settled diff) and @e7 (tail)
surface only from formatter output; recovery semantics unchanged.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(daemon): route perf metrics sampling body through PlatformPlugin facet
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(daemon): cover the shipped perf sampler dispatch path via the facet
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: re-trigger checks (flaky settle-observation integration test)
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(daemon): pin perf sampler selection to the facet tag, not the platform
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* docs: add ADR 0012 for interactive replay, resolution disclosure, and retiring --update healing
Records the decision to retire --update healing as a silent actor (repurposing
its candidate machinery as ranked suggestions), disclose selector
disambiguation in every interaction response, verify replay steps against
record-time identity evidence, and add an interactive replay --from loop with
a structured divergence report for all callers.
* docs(adr-0012): ground in live replay evidence; require step provenance for --from
Adds hands-on evidence from driving replay on the RN playground (silent
text-mode success, app-state divergence heal cannot fix, Maestro step-index
shift from runFlow flattening, per-format hint/code inconsistency, recordings
carrying zero observation steps), makes step provenance (source file + line,
including through Maestro runFlow inlining) a requirement of the divergence
report plus an optional replay --list-steps dry-run, and adds a one-line
text-mode success summary as decision 4d.
* docs: make interactive replay ADR implementable
* docs: tighten interactive replay contracts
* docs(adr-0012): demote geometry to disambiguation signal, fix matchCount, define matching algorithm
Reworks the target-v1 contract per review: identity is recorded id, else
role + normalized label, plus a leaf-anchored ancestry prefix (K=8, nearest
ancestors kept, root-side truncation only); absolute rects are demoted to
never-compared diagnostics with the ±8 tolerance removed rather than tuned;
duplicates disambiguate by recorded sibling order among the matching set,
then viewport-relative order within the recorded scroll region — never
absolute pixels; ties are identity-unverifiable divergences with candidates
listed. matchCount is redefined as the replay-time recorded-selector match
count (0..N, always present), with selector-miss (0) and identity-mismatch
(>=1, no identity candidate) as distinct classes in an explicit six-path
verification classification. Also inlines the quantitative benchmark numbers
(3.67->1.00 snapshots, 14.3 vs 23.3/26.7 commands, 38/38 in 539s) so the
evidence is durable without the external harness directory.
* docs(adr-0012): unify positional-signal candidate domains between record and replay
Fixes the P1 domain mismatch: sibling becomes a genuine same-parent child
index (parent already captured as ancestry[0], no new field; identical by
definition on both sides, non-isolating when the same index recurs under
different parents); viewportOrder gets one region-scoped domain — the
identity set partitioned by scroll region, ordinal within the recorded
partition on both sides, unavailable (never compared cross-region) when the
recorded region no longer exists; document order (pre-order index) is the
canonical total order making every ordering deterministic, including equal
rect centers. Residual ties stay identity-unverifiable with candidates
listed. Record-time write, replay verification, and mandatory validation
updated in lockstep; the six-path classification is unchanged.
* docs(adr-0012): conditional matchCount, dependency-ordered migration, writer invariant, suggestion ranking contract
Last night's help-conformance benchmark showed agents guessing
open-url and close-session with no "did you mean" hint (the
Levenshtein fallback doesn't cover either token — both exceed the
edit-distance threshold for their length). Add curated entries
pointing them at `open <url>` and `close`.
* feat: fix codex runner, add override-doc grading, port skillgym quiz cases
The 2026-07-09 evaluation of scripts/help-conformance-bench.mjs found it
structurally right but broken for the codex runner (two bugs), thin on
coverage (4 cases), sequential, and unable to grade a draft help rewrite
without a rebuild.
- Fix runCodex: (1) codex exec reads stdin until EOF when not attached to
a TTY, and execFile never closes the child's stdin, so every codex call
hung until RUN_TIMEOUT_MS with empty output — close stdin right after
spawn. (2) `-o outFile` writes the same final JSON that codex also
prints to stdout, so concatenating both produced two back-to-back JSON
objects that broke every JSON.parse candidate and silently zeroed
extractCommands() — prefer the clean -o payload, fall back to stdout
only when it's empty.
- Add `--override-doc <topicId>=<path>` (repeatable): loads a topic's
text from a file instead of shelling out to
`node bin/agent-device.mjs help <topic>`, so a draft help rewrite can
be A/B graded with zero rebuild.
- Port three cases from
test/skillgym/suites/agent-device-smoke-suite.ts
(settle-diff-is-observation, sample-output-settled-diff-next-target,
sample-output-not-settled-needs-observe) as self-contained
"next-command quiz" cases, generalizing the scorer to support regex
matchers/forbidden patterns alongside the existing named expectations.
Fixture output text matches the CURRENT settle rendering in
src/commands/interaction/output.ts, including the "unchanged
interactive (N):" tail added by #1167/#1172.
- Parallelize the runner x case matrix with a concurrency cap
(HELP_BENCH_CONCURRENCY, default 4); results still print in the
original matrix order.
- Extend test/skillgym/README.md's existing pointer to this bench with
the new flags.
Validated with real LLM calls (both runners, all 7 cases, 14 calls,
~$0.25 total): 13/14 pass; the one fail (claude-haiku-4-5 on
dogfood-mode) is a genuine model miss (returned an empty command plan
asking for the app name instead of committing to a generic plan), not a
bench bug. `--override-doc` demonstrated live: stripping the dogfood
doc's evidence-command examples regresses codex:gpt-5.4-mini from 3/3 to
2/3 on the same case, showing the flag both loads and changes grading.
* fix: apply live-doc post-processing to --override-doc, fail fast on bad overrides
Review findings on the initial version (all reproduced):
- HIGH: an override for the --help:first30 doc id skipped the live path's
firstLines(text, 30) cap, so a 49-line draft leaked lines 31-49 into the
prompt — grading content a live run never shows, on the doc id every case
uses. loadDoc now splits source (live shell-out vs override file) from
post-processing, and the post-processing applies to both, so an override
differs ONLY in where the text comes from.
- MEDIUM: an --override-doc topic id no selected case uses was silently
ignored (exit 0, real doc graded). Now fails fast listing the valid doc
ids for the selection.
- LOW: a missing override file threw a raw ENOENT stack trace; expected
failures now print one clean Error line. Added --help usage text that
documents last-wins semantics for repeated same-topic overrides and the
post-processing parity.
Guard tests (scripts/__tests__/help-conformance-bench.test.ts, wired into
the unit-core vitest project by explicit path): a 49-line fixture whose
prompt must keep line 30 and drop line 31, unknown-topic fail-fast with
valid ids listed, clean no-stack error for a missing file, and last-wins
for repeated overrides. All spawn the script in --dry-run with every
required doc overridden, so they need no LLM calls and no built CLI.
Live re-validation: a 33-line override of --help:first30 whose lines
31-33 instruct the model to emit a sentinel command; neither
claude-haiku-4-5 nor codex:gpt-5.4-mini emitted it (both scored 4/4,
matching the live-doc baseline), proving the cap applies end-to-end.
* fix: make settle tail list real actionable targets
Post-merge benchmark of #1167 (React Navigation prevent-remove flow, iOS
sim, claude-haiku/sonnet) found the unchanged-interactive tail regressing
to chrome-only noise in exactly the cases it was built for:
- buildSettleTailEntries required `hittable === true`, stricter than what
`snapshot -i` itself shows for the same interactive-only capture. Right
after a dismiss animation, real buttons commonly report `hittable:
false`/undefined while application/window containers pass, so the tail
surfaced two useless chrome lines and dropped the actionable button.
Fixed by dropping the hittable requirement and excluding structural
application/window roles instead.
- withoutKeyboardKeys only stripped `Key` nodes; real keyboard chrome
(shift/Emoji/return/Dictate/Next keyboard) are XCUIElementTypeButton
nodes and leaked through as fresh added-line refs, which suppressed the
tail trigger for exactly the post-fill case it exists for. Fixed by
detecting the whole keyboard subtree structurally (via parentIndex, not
a locale-fragile label list): descendants collapse out of the diff, and
the keyboard container's own ref no longer counts as a "meaningful"
added ref for the trigger decision.
Nobody presses shift via settle diff refs, so collapsing keyboard chrome
does not block a user from explicitly targeting the keyboard itself.
* fix: classify the whole iOS keyboard window as settle chrome
Live-device review of the first Bug B fix found "Next keyboard" and
"Dictate" still leaking into the settled diff as added refs: on a real
iPhone 17 Pro simulator they live in a SIBLING subtree of the [Keyboard]
container (the candidate bar), not under it, so the container-descendant
walk missed them. A raw hierarchy capture shows the software keyboard in
its own dedicated window hosting both the container and the candidate
bar, so the structural rule is now: every node inside a window that has
a [Keyboard] descendant is keyboard chrome. Conservative guard: a window
also hosting an editable text node outside the container (iOS puts
inputAccessoryView composers in the keyboard window) is never
window-classified — those fall back to the container-descendant walk.
The live run also showed the filled field re-labeling itself with its
new value (ancestor wrappers inherit it), which produced added refs that
suppressed the tail even with chrome fixed. The trigger now also ignores
self-echo refs — added lines whose settled node rect contains the action
point — since they re-describe the acted-on element, not a new target.
The fill-keyboard provider fixture is now a trimmed REAL capture from
the benchmark flow (sibling candidate bar, main app window absent from
the interactive settled capture, self-echo relabels) instead of a
hand-built tree that hid the sibling-branch shape.
* fix: guide agents past iOS keyboard dismissal and get-text guesses
Tonight's benchmark leaderboard showed two recurring agent-UX misses:
keyboard dismiss failing after fill (5x, now the #1 failed command) and
get-text guessed as a command name (1x).
- iOS keyboardDismiss now returns a hint explaining the on-screen keyboard
does not block agent-device interactions, so agents should press the next
target directly instead of retrying dismiss, and use keyboard enter only
when submission is actually wanted.
- help manual-qa and help workflow recovery text no longer teach the false
"dismiss to unblock the target" pattern.
- The command-suggestion curated map now maps get-text/gettext/get_text to
the real get text command shape; the generic edit-distance fallback did
not produce any suggestion for these guesses.
* fix: soften keyboard guidance for genuinely covered targets
Review on #1173 flagged the unconditional "does not block" claim as false
for targets visually covered by the keyboard: direct-selector press
resolution is isHittable-gated and falls through to ELEMENT_NOT_FOUND, the
tree path allows non-hittable taps with only a no-visible-effect hint, and
bottom-pinned-button-under-keyboard (#291/#469/#957) is a real case where
dismissal was the remedy.
All three strings (runner hint, help manual-qa, help workflow) now say the
keyboard USUALLY does not block presses and name concrete fallbacks: scroll
the target into view, or keyboard enter when submission is wanted.
* feat: include unchanged interactive refs in settle output
Benchmarks (gpt-5.4-mini + claude-haiku, July 2026) showed 27% of --settle
actions were followed by a fallback snapshot -i because a change-only diff
omits refs for elements that did not change: after a modal dismiss the diff
shows only removals, so the next button to press is invisible.
Add an unchanged-interactive tail to SettleObservation, attached only when
the diff's added lines carry zero refs (the modal-dismiss/toast-only
signature). It lists the settled tree's remaining hittable, uncovered
elements so the response stays actionable without an extra round trip.
Rides the CLI text, MCP digest view, ref pinning, and output schema the same
way the diff's added-line refs already do.
* refactor: address fallow audit findings on the settle tail
- drop the unused export on buildSettleTail (tests exercise the trigger
through the public interaction path and the filter via
buildSettleTailEntries)
- extract the digest tail capping from interactionSettleView into a
module-private helper to stay under the complexity gate
* fix: suggest canonical commands for unknown command names
Agents commonly guess command names that don't exist, e.g. relaunch/launch
instead of `open <app> --relaunch`, burning turns on Unknown command errors
that only say "run --help". Add a curated alias-to-canonical-shape map for
the most common guesses (launch/relaunch/start/restart, touch, input/
settext/entertext, screencap/capture, dismiss), backed by a nearest-name
edit-distance fallback derived from the live command registry so
suggestions can't drift. Also hint that `open` takes the app/bundle id as
a positional when an unknown flag looks like a bundle-id guess (e.g.
--bundle-id), and apply the same suggestion to `help <unknown>`.
Suggestions are display-only; nothing auto-executes and the error code
stays INVALID_ARGS.
* fix: address review — dead export, case-insensitive suggestions, tighter nearest-name matching
- Drop the export on getNearestCommandNames (module-private; only
suggestCommandFor uses it) to satisfy the Fallow unused-export gate.
- Lowercase the input token before both the curated-map lookup and the
nearest-name pass, so RELAUNCH/Relaunch/TAP/Touch get the same hint as
their lowercase forms. Added a curated `tap` entry: lowercase `tap` is
normalized to press before the unknown-command check, so the entry only
catches case variants like TAP.
- Tighten the nearest-name fallback: exact prefix matches win outright
(`clos` now suggests only `close`, not "one of: close, logs"),
otherwise only ties at the minimum edit distance are kept, and 1-2
character tokens never get a suggestion (`ls` no longer suggests `is`).
- Share the "open <app> --relaunch" example string between the curated
map and the unknown-flag hint, and extend the registry-drift tests to
parse each curated example end-to-end (validates open --relaunch as a
registered flag) plus assert keyboard dismiss is a real keyboard action.
* feat: promote launch and relaunch to true open aliases
Follow the tap -> press precedent: `relaunch <app>` now runs
`open <app>` with --relaunch injected, and `launch <app>` runs a plain
`open <app>` (no forced restart — that would silently destroy app
state). Both are normalized in normalizeCommandAlias before parsing, so
command identity stays `open` for daemon requests and telemetry, all
other args/flags pass through to open's normal validation (URL targets
still get the daemon's existing --relaunch guidance), and an explicit
--relaunch stays idempotent. Alias matching is now case-insensitive
(TAP, RELAUNCH, Launch), so the curated tap suggestion entry is dead
and removed along with launch/relaunch; start/restart and the rest of
the map stay suggestion-only since start is genuinely ambiguous.
* fix: make manual-qa help self-sufficient and slim routine help paths
July 2026 benchmarks (gpt-5.4-mini/haiku/sonnet driving the CLI) showed
agents burning 14-41KB of tool output on help before a routine QA flow:
help react-native routed "generic navigation, selectors, refs,
verification" to help workflow (31.7KB), one run read the bare
`agent-device help` mid-task (16.3KB), and several fell back to
per-command help because manual-qa lacked concrete command shapes.
- manual-qa gets a "Command shapes" block covering the full routine QA
loop (open --relaunch, deep-link open, snapshot -i, press/fill --settle,
wait text, close) plus a quoting note for apostrophe/quote labels, so
agents don't need to read help workflow or per-command help for a
normal pass.
- react-native's routing block no longer sends routine QA to help
workflow; it now points routine flows at manual-qa and reserves
workflow for deep exploration/debugging.
- physical-device and the shared topic-footer routing also point at
manual-qa alongside workflow.
- Trimmed the bare `agent-device help` output: cut Agent Quickstart
lines that duplicated topic-specific detail already owned by
react-native/workflow/remote/web/tv, dropped 3 redundant Examples, and
fixed an alignment bug where one long usage string (tv-remote) forced
padding whitespace onto every other Commands: row. The "Default app
loop" line (shown to flip Haiku from 0/3 to 3/3 in earlier benchmarks)
stays intact and early.
* fix: satisfy oxfmt and restore pinned-ref staleness nuance in help workflow
- pnpm format:check failed on the new renderAlignedSection columnWidth
line; reformatted with pnpm format.
- Reviewer noted the only content genuinely lost from the bare-help
Quickstart trim was the "pinned refs get exact staleness warnings"
nuance; restored it as one line in help workflow's Snapshots and refs
section (its natural owner), keeping the bare-help line short.
* fix: reap idle daemons and take over stale runner leases
Each AGENT_DEVICE_STATE_DIR spawns its own daemon that never exits on its
own; deleted codex/claude sandboxes leave orphaned daemons accumulating
(10+ observed). A stale-but-still-running orphan also keeps holding its
iOS runner lease, so a fresh daemon for the same device fails with
COMMAND_FAILED "already owned by another agent-device daemon".
- Daemon self-reaps after an idle window (default 5 minutes, matching the
iOS runner idle-stop default) once it has no open sessions, no
in-flight requests, and no active recording. AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS
overrides the window; 0 disables it.
- A runner lease whose owner PID is dead, or whose owner
AGENT_DEVICE_STATE_DIR no longer exists, is now reclaimed automatically
instead of erroring; a genuinely live owner with an existing state dir
still gets the existing rejection + hint.
* fix: gate lease takeover on proven-dead owners and fail closed on stat errors
Review follow-ups on #1169:
- Replace fs.existsSync (which never throws and swallows EACCES/IO errors
into "gone") with fs.statSync + error-code inspection: only ENOENT/
ENOTDIR count as proof the owner state dir is gone; any other stat
error fails closed and classifies the owner as alive.
- Split stale classification by reason (owner-process-dead vs
owner-state-dir-gone). Adoption (readStaleRunnerLease ->
tryAdoptRunnerSessionFromLease) is now strictly PID-dead-gated:
a dir-gone-but-alive owner may still hold a live runner connection,
so its lease routes through the force-stop path (kill leased runner
processes, rebuild) instead of being silently adopted - no two
masters.
- Tests: EACCES stat error keeps the busy rejection; dir-gone+PID-alive
refuses adoption before probing; dir-gone force-stop asserts a fresh
runner launch instead of adopting the old runner pid.
* test: make idle reap tests deterministic
* fix: point unknown selector keys at role=/label= forms
press 'button="Push Article"' errored with a nonsense suggestion
(text="button=\"Push Article\"") because isSelectorToken rejects
`button` as a key, splitSelectorFromArgs returns null, and the point
fallback wraps the whole raw token in text=.
Add detectUnknownSelectorKeyToken to spot a key=value token whose key
isn't a recognized selector key, and use it in readPointTarget to
throw a targeted error before the numeric parse: role=<key>
label=<quoted value> when the key looks like an accessibility role
word (isRoleHintWord, mirroring ROLE_LABELS), otherwise label=<quoted
value>. wait-positionals.ts has no analogous point-fallback path, so
it needs no change.
* fix: fold unquoted multi-word values into the unknown-key suggestion
Review follow-up: readPointTarget only inspected positionals[0], so an
unquoted multi-word value split across positionals (press 'button=Push'
'Article') dropped the trailing tokens and confidently suggested the
wrong completion (label="Push"). Fold trailing positionals into the
suggested value like mergeRestIntoSelectorValue does, unless the value
was fully quoted (button="Push Article") and therefore complete — that
distinction keeps fill's trailing text argument out of the suggestion.
Also: drop the dead `text` entry from ROLE_HINT_WORDS (valid selector
key, short-circuits in ALL_KEYS first) and note the set is a superset
of ROLE_LABELS rather than a mirror; reject whitespace-only values in
detectUnknownSelectorKeyToken.
* fix: recover completed Android recording from a pending-only manifest
When the daemon crashes in the brief window between writing the pending recovery
manifest (before screenrecord starts) and upgrading it to a `current` manifest, the
screenrecord process can still finish and leave a complete MP4 on the device. record
stop previously discarded it as stale because the pending-only recovery path never
checked for an on-device file, unlike the `current` path which already recovers a
finished recording. Extend the pending-only path to recover the completed file with the
same finished-recording warning, and skip the stop signal when the recovered recording
has no tracked pid (a pending chunk never records one, and probing an empty pid is
unsafe).
* fix: treat JSON arrays as invalid Android recovery manifests
isRecord accepted arrays (typeof [] === 'object'), so a stray `[]` recovery manifest
was classified as blocked rather than deleted, wedging every subsequent record stop.
Reject arrays and null so a non-object manifest is cleaned up like other malformed
metadata.