Skip to content

feat(keyboard): clear the focused field - #976

Draft
hubgan wants to merge 141 commits into
mainfrom
feat/keyboard-clear-burst
Draft

feat(keyboard): clear the focused field#976
hubgan wants to merge 141 commits into
mainfrom
feat/keyboard-clear-burst

Conversation

@hubgan

@hubgan hubgan commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Empties the focused text field in one call:

keyboard { udid, clear: true }

Exactly one of text / key / clear per call; combinations go through run-sequence. Works for native, React Native and Flutter apps on Android, iOS and Chromium.

Supersedes #580 -> #581 -> #831 / #821 (and argent-private #31), which together came to ~6.1k production lines, ~11.6k test lines, 0 docs and ~15 tuning constants. That size was a symptom of the mechanism, not of sloppiness: every layer there existed to defend a select-all chord that a widget may silently ignore, and once the primitive can no-op you need read-backs, length measurement, budgets, fallbacks and outcome taxonomies. This replaces the primitive instead.

Why the chord cannot be the primitive

  • Flutter on Android swallows Ctrl+A from input keycombination deterministically - the trailing DEL then removes one character.
  • React Native on Android misses it intermittently (fix(keyboard): read the field back, instead of trusting a select-all that exits 0 #821 measured 15/85 corrupt on API 34).
  • On API 31/32 keycombination sends no metaState, so TextView.onKeyShortcut never sees a shortcut at all.
  • On iOS the chord holds Left-GUI across awaits, so a concurrent { text: "w" } becomes Cmd+W and a throw in between latches Command.
  • On Chromium a modifier-only Meta+A / Ctrl+A selects nothing on macOS builds.

What it does instead

iOS and Android send 100 backspaces interleaved with 100 forward-deletes to whatever holds keyboard focus - one bounded burst, no caret move, no selection, no chord, no read of the field, no per-framework branch. Backspace at a line start joins lines and forward-delete at a line end does too, so a multi-line field empties from wherever the tap left the caret; pressing either key on an empty side is a no-op, so over-sending is harmless. Android sends the whole burst as ONE adb shell input keyevent 67 112 ... (multi-code keyevent has been accepted since API 19, so the API-31/32 metaState bug and the API-30 no-keycombination fallback simply do not arise). iOS writes it over simulator-server HID at a 2 ms cadence with a 300 ms settle, holding no modifier at any point.

Chromium gets the DOM equivalent - one Runtime.evaluate that finds the focused editable (descending through shadow roots), then execCommand('selectAll'); execCommand('delete'). It is one round trip, it delivers no keydown at all so a page shortcut cannot cancel it, and the DOM tells us up front whether anything editable has focus - so a clear aimed at nothing 400s with KEYBOARD_CLEAR_NO_EDITABLE_FOCUS instead of deleting from whatever the page focuses by default.

It also reads what execCommand('delete') answers, which is the one place this could still have reported a failure as a success. Chromium's five date/time input types (date, datetime-local, month, week, time) pass every editability signal the script can read - not in the denylist, not readonly or disabled, plain <input> - and keep their value anyway, because it is structured rather than text. delete returns false for exactly those five and true for everything that ends up empty, including a field that was already empty (where selectAll returns false). A refused delete therefore raises its own code, KEYBOARD_CLEAR_UNSUPPORTED_FIELD, with its own repair - one key: "backspace" on the already-focused field empties it - rather than the "tap the field first" advice, which would loop an agent forever here.

TV targets and Vega reject clear before touching the device.

Documented limit: a field holding more than 100 characters on either side of the caret keeps the remainder; call clear again. That is the same contract Maestro ships (50 by default), and it is stated in the parameter description, the tool description, the skill and the docs.

Deliberately not here

  • No select-all chord on any platform.
  • No field read-back or verification loop - the result reports what was sent, not what the field holds (a cleared field may have held a secret). Flows that need proof assert the field or its consequence, as they already do for type.
  • No length measurement: uiautomator dump races the Android helper for the single UiAutomation connection.
  • No Android helper setText RPC (argent-private fix: reconnect tool-server after idle timeout #31) - the only exact/atomic option, but it costs a cross-repo protocol + versionCode bump, a spawn/idle-respawn window, and a fallback for devices where the helper cannot install. It stays parked as the future exact-clear path; nothing here depends on it.
  • No { clear, text } in one call - that shape is what forced feat(tool-server): clear on the keyboard tool #580 to pre-validate the whole request and detect focus loss between the halves. Replace-a-value is a run-sequence.
  • No flow type: { clear } - follow-up.

API

  • clear?: boolean. Only true acts; false reads as absent, like {}.
  • More than one of text / key / clear -> InvalidToolInputError before anything is sent, keeping the KEYBOARD_TEXT_AND_KEY_COMBINED code and naming which two the request carried.
  • Result { typed: "", keys: <key events sent>, cleared: true } - no field value, no length.
  • Interaction messages: "Clearing the field" / "Cleared the field".
  • delayMs does not apply to the burst (it has its own cadence).
  • Two new failure codes, both Chromium-only and both with distinct repairs: KEYBOARD_CLEAR_NO_EDITABLE_FOCUS (tap the field) and KEYBOARD_CLEAR_UNSUPPORTED_FIELD (the focused field kept its value).

Verified on device

Driven through a branch build of the tool-server over HTTP.

target app result
Pixel 3a API 36 native EditText cleared from the end and with the caret mid-text (26 chars, both sides); password field cleared; the unfocused field untouched
Pixel 3a API 36 Flutter clearlab single-line, multi-line (caret mid-block, all three lines gone) and password all cleared
Pixel 3a API 36 RN Bluesky search 5/5 type -> clear clean; run-sequence clear -> text -> enter left exactly argent, not spliced
Pixel 3a API 36 native EditText 191-char field -> one clear leaves 91 -> second clear empties it (the documented 100-per-side bound, exactly)
iPhone 16 Pro 18.3 Settings search (UIKit) screenshot: "Wifi settings" -> placeholder back
iPhone 16 Pro 18.3 Expo dev-launcher field (RN) screenshot: value gone, Connect button disabled
iPhone 17 Pro 26.4 Settings search (UIKit) screenshot: "Bluetooth options" -> placeholder back
iPhone SE 18.3 Flutter clearlab single, multi-line and password cleared
Chrome 151 fixture page plain / textarea / contenteditable / password / number / controlled input all cleared, each firing one input with inputType: deleteContentBackward (React's value tracker); readonly and disabled refused and untouched; nothing-focused refused; an already-empty field still reports success; an <input> inside a shadow root cleared
Chrome 151 date input refused with KEYBOARD_CLEAR_UNSUPPORTED_FIELD, value left at 2020-01-02; the message's own repair (key: "backspace") then emptied it, driven through the tool
Chrome 151 bsky.app (RN-web) screenshot: controlled field cleared; run-sequence clear -> text left exactly argent.example
Apple TV tvOS 26.5 - clear rejected with TOOL_CAPABILITY_UNSUPPORTED_OPERATION; text still works

Not re-driven on hardware here: Android TV and Vega (no AVD / VVD available on this host) - Android TV goes through the same typeTv rejection that was verified live on Apple TV, and both are unit-tested; and the API 24 / API 30 emulators, which the measurement pass covered (a multi-code input keyevent is accepted on API 24, and a 60-80 key burst clears a native EditText and a Flutter single- and multi-line TextField on API 30 - the level where keycombination Ctrl+A is swallowed). The burst uses no keycombination at all, which keyboard-android.test.ts pins on the exact argv, so the API-31/32 missing-metaState bug and the API-30 fallback cannot arise.

Suites: npm test -w @argent/tool-server (4883 passed, 1 skipped), -w @argent/registry (120), -w @argent/mcp (80), npm run lint, npm run build, npm run typecheck:tests -w @argent/tool-server, npm run typecheck:scripts, npm run test:scripts (92), npm run knip, node scripts/grade-skills.mjs (16/16 at 10.0), npx docusaurus build, prettier --write ..

Docs and skills

reference/tools.mdx keyboard row; features/interacting-with-apps.mdx (a paragraph under "Gestures and text", an example instruction, two limits). argent-device-interact gains a "Clearing a field" section, a capability row, and loses the triple-tap-to-select-all advice; argent-tv-interact, argent-test-ui-flow (login example clears before typing), argent-create-flow live-authoring + flow-yaml updated.

E2E harness

scripts/e2e-full: the Chromium tier gets before/after describe evidence around a clear on a dedicated fixture field, the nothing-focused refusal, the exclusivity rejection and a clear -> text run-sequence; the Android tier gets the contract cases (a headless tier has no focused text field to observe, so the per-framework evidence lives in the device matrix above).

Follow-ups

hubgan added 10 commits August 28, 2026 13:11
`keyboard { udid, clear: true }` empties whatever holds keyboard focus.
Exactly one of `text` / `key` / `clear` per call; combinations go through
`run-sequence`.

iOS and Android send 100 backspaces interleaved with 100 forward-deletes
to the focused field - one bounded burst, no caret move, no selection, no
chord, no read-back, no per-framework branch. Both keys join lines at a
boundary, so a multi-line field empties from wherever the tap left the
caret, and pressing either on an empty side is a no-op. Android sends the
whole burst as one `adb shell input keyevent`; iOS writes it over
simulator-server HID at a 2ms cadence with a 300ms settle, holding no
modifier at any point.

That replaces the select-all chord the earlier attempts were built
around, which a widget can silently ignore: Flutter on Android swallows
`Ctrl+A` from `input keycombination` outright, React Native misses it
intermittently, API 31/32 sends no metaState at all, and on iOS the held
Left-GUI latches if anything throws mid-chord. Once the primitive can
no-op you need read-backs, length measurement, budgets and fallbacks;
this one cannot.

Chromium gets the DOM equivalent - one `Runtime.evaluate` that finds the
focused editable (descending through shadow roots) and runs
`execCommand('selectAll'); execCommand('delete')`. It delivers no keydown
at all, and a clear aimed at nothing fails with
`KEYBOARD_CLEAR_NO_EDITABLE_FOCUS` instead of deleting from whatever the
page focuses by default.

TV targets and Vega reject `clear` before touching the device.

Documented limit: a field holding more than 100 characters on either side
of the caret keeps the remainder; call `clear` again.

Supersedes #580, #581, #831, #821.
`execCommand("delete")` answers false for exactly the five date/time input
types - date, datetime-local, month, week, time - which hold a structured
value it cannot remove while passing every editability signal the script
can read. Discarding that return value reported `cleared: true` for a
field that still held its date, and the caller's next step then typed the
replacement into the retained value: the exact data bug clearing exists
to prevent.

Measured on Chrome 151: `delete` is true for every element that ends up
empty - including one that was already empty, where `selectAll` is false
- and false for those five alone. So the script now reads it, and a
refused delete raises its own code, `KEYBOARD_CLEAR_UNSUPPORTED_FIELD`,
with its own repair: one `key: "backspace"` on the already-focused field
empties it (verified end to end). The focus refusal keeps
`KEYBOARD_CLEAR_NO_EDITABLE_FOCUS` and its "tap the field first" remedy,
which is the wrong advice for this case.

The refusals now name the input's type, so `<input type=checkbox>` and
`<input type=date>` are distinguishable in the message.
`execCommand("delete")` answers true and fires no `beforeinput`, which is
exactly the hook an editor with its own document model reconciles on: Lexical
and CKEditor 5 accept the delete and then restore every character from that
model. The tool answered `cleared: true` for a field that still held its value,
and the next `keyboard { text }` typed into it.

Verified on Chrome 151 against Lexical 0.21.0: `{ clear: true }` returned
`{ typed: "", keys: 0, cleared: true }` with the editor still reading
"HELLO WORLD", and typing "Z" produced "HELLO WORLDZ".

The clear now reads the focused field back in a SECOND evaluate and refuses when
the value survived. Two evaluates rather than one: an editor restores at the
microtask checkpoint that ends the first script, so a read-back folded into the
clear script sees the emptied field and is fooled — measured against CKEditor's
shape, where a same-script read answers "" and the next evaluate answers
"HELLO".
`injectAndroidClear` ran under `ADB_INPUT_TIMEOUT_MS`, a 15s budget whose own
comment sizes it for "a single injection". The burst is 200 injections in one
command, and `input` injects with INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH, so
the adb child blocks on the app once per event.

Measured on a Pixel 7 AVD (API 36) driving a debug Flutter field: 14.9s on an
idle host — already at the cap — and 16.3s with four busy loops on the guest,
where adb was SIGKILLed and the field went from 300 characters to 200 while the
caller was told only `ANDROID_ADB_COMMAND_FAILED`. The same run now completes in
22.0s and reports `cleared: true`.

The burst gets `ADB_CLEAR_TIMEOUT_MS` (90s), and a failure that still happens is
re-stated as `KEYBOARD_CLEAR_UNCONFIRMED`, naming the clear and the partially
emptied field — the one thing an agent has to know before typing again. The 200
keycodes are dropped from that message, which `formatSubprocessFailure` and
Node's nested `Command failed:` each repeated into agent context (1.5KB down to
375 characters).
`document.designMode = "on"` and `<body contenteditable>` make the document its
own editing host, and `document.activeElement` defaults to `<body>` — so the
editability test passed, `selectAll` + `delete` ran against an unbounded editing
host, and the entire page was destroyed and reported as `cleared: true`. No
prior interaction was needed: a clear issued as the very first action did it.

Verified on Chrome 151 against a `designMode` page: `{ clear: true }` used to
answer `{ cleared: true }` and take the body from 288 characters and 7 ids down
to 85 and 1. It now refuses, the page is byte-identical afterwards, and the
message says the whole document is the editing host rather than repeating the
generic "nothing editable has focus".

Refused by identity rather than by editability, so it stays a refusal even for a
page that means `<body contenteditable>` as its editor. A real field on the same
`designMode` page still clears — checked on the same fixture.
…as a dead runtime

`clear` is the only `keyboard` operation that waits on the renderer main thread
— `text` and `key` go through `Input.dispatchKeyEvent`, which the browser
process acknowledges in ~50ms whatever the renderer is doing. So it is the only
one that meets the CDP client's 10s wait, and that wait does not cancel the
request: the delete still runs once the renderer is free.

The caller was told `DEBUGGER_CDP_REQUEST_TIMEOUT`, from the debugger taxonomy,
advising "restart the app, then reconnect and retry once" — wrong twice over,
since the app is fine and a retry lands a second delete on a field the first one
may already have emptied.

Verified on Chrome 151 with the renderer busy for 13s: the call used to fail at
10s with `DEBUGGER_CDP_REQUEST_TIMEOUT` and the field was empty 9s later. It now
fails with `KEYBOARD_CLEAR_UNCONFIRMED`, whose message says the delete is not
cancelled and to read the field back before clearing or typing again. The
read-back evaluate gets the same treatment under its own failure_stage.
`pressKey` is a bare `proc.stdin?.write(...)` with no callback, and the
blueprint registered no `error` listener on that socket — unlike the repo's two
other stdin-writing spawn sites. A write racing the child's death emits EPIPE
with nothing listening, which `index.ts`'s `uncaughtException` handler turns
into a `crashShutdown` of the whole tool-server. One tool-server is shared by
every agent session on the machine, so all of them lose their devices at once.

Latent before `clear`, which is what makes it ordinary: the burst writes 400
lines at a fixed 2ms cadence with `delayMs` deliberately not applicable, while
typing only reaches that density if a caller passes an unusually small
`delayMs`. The trigger on the other side is routine too — every session is told
to call `stop-simulator-server` when it ends.

Reproduced against a booted iPhone SE (18.3) by racing `{ clear: true }` with
`stop-simulator-server` on the same udid: the tool-server process died in 2 of 5
attempts with `Uncaught exception: Error: write EPIPE`, while the clear itself
still answered `200 {"cleared":true}`. It now survives 8 of 8. The child's death
already reaches callers through the `terminated` event, so the listener only has
to stop the crash.
Chromium classification (all verified on Chrome 151):

- A focused `readonly` / `disabled` field, a non-text `<input>` and a `<select>`
  were all reported as `KEYBOARD_CLEAR_NO_EDITABLE_FOCUS`, whose message says to
  tap the field — which is already focused, so an agent loops. Each now carries
  its own reason and lands in `KEYBOARD_CLEAR_UNSUPPORTED_FIELD`, the code the
  follow-up commit added for exactly this trap on date inputs, with a repair
  that is not "tap it again". The field kind is read before `readonly`, so a
  `<input type=checkbox readonly>` is blamed on the thing that matters.
- A field inside a CLOSED shadow root was refused although the browser's own
  editing commands reach it: `el.shadowRoot` is null there, so the descent
  stopped on the host. The host is now tried, and `delete`'s answer is the
  verdict — measured: true and the inner field empties, false and the page is
  untouched. It cannot be read back through the closed root, so that one success
  is reported unverified rather than pretending otherwise.
- A page that replaces `document.execCommand` made the evaluate throw, leaving
  `result.value` undefined, which read as "no element has keyboard focus". The
  script now catches and reports the page's own error.
- A refused `delete` left the selection `selectAll` had made — and on a field
  Chrome then refuses, `selectAll` selects the WHOLE DOCUMENT, not the field, so
  the whole page stayed highlighted into the next screenshot-diff. Dropped
  before returning.

Elsewhere:

- `makeIosRemoteImpl` had no tvOS probe, so a remote Apple TV took the 400-event
  burst instead of the documented refusal. `key` and `clear` now route to
  `typeTv`; `text` keeps its existing MoQ HID path.
- `{ key: "", clear: true }` is still rejected on shape, but the message no
  longer prescribes a `{ key: "enter" }` the caller never asked for and whose
  retry fails.
- The three exclusivity rejections shared one `failure_stage`, so clear-misuse
  could not be measured apart from the older text+key case. One stage each; the
  shared error CODE stays, as disclosed.
- The secret note appended to that error explained the type-then-submit split
  even for `{ clear, text }`, where there is no later Enter to screenshot.
- `{ clear: false }` — a shape this PR newly makes legal — was announced and
  logged as "Pressing a key". It and `{}` now get their own no-op wording.
- Two new comments asserted the opposite of the code: `key-codes.ts` said
  `delete` maps to usage 42 "on every platform" (it is the FORWARD delete on
  chromium and Vega), and `android-input.ts` said neither burst keycode is
  reachable through `ANDROID_NAMED_KEYCODES` (`backspace` is 67). The burst now
  reads its backspace from that table instead of redeclaring it.
- `platforms/tv.ts` justified the refusal with "a TV has no hardware keyboard
  focus", which Android TV contradicts — its `api.type` IS `adb shell input`.
  The real reason is that `TvControlApi` has no delete verb and neither backend
  is measured.
- `vega-vvd-e2e.yml` watched only `keyboard/index.ts`, so the deferred Vega
  follow-up — which edits `platforms/vega.ts` alone — would not have run the job
  asserting today's refusal.
- The `clear` prose now matches the code: Chromium reads the field back, the
  mobile backends do not, `keys` counts key presses, and the focus tap needs
  ~500ms because `run-sequence` waits 100 and no backend checks focus.
Each gap below was proved by mutating the source and re-running: every mutation
used to leave the suite green.

Unit:

- The ios-remote clear path had no test at all. Reverting `makeIosRemoteImpl` to
  `typeSimulatorServer` — deleting the clear routing for every remote simulator
  — broke nothing; it now fails two cases, and the remote tvOS refusal is pinned
  with it.
- `{ clear: false }` was pinned only on Android and TV, and only through the
  guard in index.ts, which sends every shape to one android udid. Widening
  `params.clear === true` to `!== undefined` in chromium.ts, vega.ts or ios.ts
  was green; each now goes red.
- `CLEAR_KEY_PAIRS = 100` was never pinned, only the symbol: setting it to 3 was
  green, while "100 backspaces… 100 forward-deletes" and "`keys` is 200" are
  caller-facing contract. The literal 200 is now stated on both key backends.
- The timing contract was unpinned. Deleting `await sleep(CLEAR_SETTLE_MS)` — a
  settle that exists solely to stop the auto-screenshot racing the deletions —
  was green, and `CLEAR_KEY_CADENCE_MS` was order-asserted only. One timed lower
  bound now covers both.
- The two failure codes were pinned only at the platform-impl boundary. They now
  also travel through `createKeyboardTool(...).execute()`, and through
  failure-classification.test.ts alongside the two older keyboard codes.
- The exclusivity message was asserted for one of its three combinations; all
  three, the three-way join, the per-combination `failure_stage` and both
  appended notes are now covered.
- `clear`'s own `.describe()` was asserted nowhere, and nothing checked that
  `clear` appears in the advertised schema at all.
- Android TV routing for a clear was never run as a route — only `typeTv`'s own
  rejection was, called directly — so hoisting the clear early-return above the
  `isAndroidTv` probe would have fired 200 keyevents at a TV with nothing red.
- `injectAndroidClear`'s `timeoutMs` was not asserted, which after the burst got
  its own budget is the one adb budget worth pinning.
- The Chromium script mock could not represent designMode, a closed shadow root,
  a page that breaks `execCommand`, or the leftover selection. It can now, and
  the two comments about `HTMLInputElement.type` say what the real DOM does.
- Nothing covered the flow layer: a `tool: keyboard {clear:true}` step now runs,
  a refused one fails the run with its reason in the report, and a tap→clear→type
  recording keeps its order.

E2E (`scripts/e2e-full`):

- New `35-ios.sh` tier. No CI path sent a real HID delete to a real simulator —
  the iOS burst was pinned exclusively against an in-memory recorder array.
  Verified green 11/11 against a booted iPhone SE (18.3), using Settings' own
  search field so nothing has to be installed.
- The chromium tier's two refusal cases both used bare `assert_reject`, which
  passes on any non-zero exit — so if the tap at the date input drifted, that
  case silently became a duplicate of the unfocused one. New
  `assert_reject_matching` pins each to a phrase only its own branch produces
  (the CLI prints the message, not the code, so the code is not available here).
  Two more refusals join them, the fixture gains a readonly field and a
  model-restoring rich-text editor, the post-clear assertion now requires the
  node to still exist with an empty label, and `await_ui` replaces the unwaited
  `open-url`, which resolves on commit rather than on load. Verified green 12/12
  against Chrome 151 with the tier's own fixture.
- `argent-tv-interact` gave one reason for a refusal that has two, and
  contradicted itself in the same bullet: it said a TV has no hardware-keyboard
  focus while asserting that named `key` presses work on Vega. Vega refuses
  because nothing has measured a delete on a VVD yet, and `keyboard
  {key:"backspace"}` IS supported there — far cheaper than the on-screen
  keyboard the bullet sent the reader to. Both `references/live-authoring.md`
  and the two `flow-yaml` references repeated that gap; they also named only
  Vega where Apple TV and Android TV refuse too, which matters most for Android
  TV, whose serial is indistinguishable from a phone's.
- The polish table's "focus tap + `tool: keyboard` folds into `type:`" was never
  scoped to an ADJACENT pair, while the recording rule two sections earlier puts
  a clear between them. Folding anyway leaves the clear first with nothing
  focused: a hard refusal on Chromium, a 200-key burst into whatever is focused
  on iOS and Android.
- `reliability-and-recovery.md` put the clear exactly where the 500ms focus
  delay used to be and dropped the delay, in the one flow form the same section
  says takes no settle. Its "this form cannot read the field back" was also
  ambiguous between the injection-free form and the clear itself.
- `argent-device-interact` paired two true clauses into a false one: "it does not
  matter where the tap left the cursor" next to "up to 100 characters on each
  side" reads as "any field up to 200 characters clears in one call". That holds
  only for a centred caret, and a tap into a filled field lands at the end —
  measured on device, a 250-character field keeps 150. Its "pasting with no
  focused field is a silent no-op, as with `keyboard`" is also now only true of
  `text` and `key`.
- `argent-test-ui-flow`'s rewritten login example clears the email field because
  the app remembers it, then types into the password field with no clear.
- The `paste` tool description never mentioned the hazard this PR spends four
  documents on, though `paste` inserts at the caret exactly as typing does.
- `docs/features/interacting-with-apps.mdx` stated the mobile mechanism
  unconditionally, one paragraph above the Limits bullet that correctly scopes
  it, and "A longer field" there had no referent now that the bound is per side.
- `scripts/ci/vega-vvd-test.sh` matched only
  `TOOL_CAPABILITY_UNSUPPORTED_OPERATION`, which every capability refusal
  returns — a regression making `keyboard` wholly unsupported on Vega would have
  passed it.

`npx docusaurus build` exits 0 and `node scripts/grade-skills.mjs` reports 16/16
skills at 10.0.
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

hubgan added 19 commits August 31, 2026 13:40
…ocused node

`designMode = "on"` and `<body contenteditable>` make every element inside the
document report `isContentEditable === true`, so the identity test on
`document.activeElement` only fired when focus happened to be on <body>/<html>.
An `autofocus` <button> — or any page script moving focus to a descendant —
sailed past it and past the `editable` test, and `selectAll` + `delete` then
emptied the whole page and reported `cleared: true`.

Walk up to the outermost editable ancestor and test THAT identity. <input> and
<textarea> inherit the flag too but hold their own value, so they stay exempt
and a real field on such a page is still clearable.

The refusal now names the focused element as well: the host swallows every
descendant, so this fires for a focused <button> exactly as it does for <body>.
The opaque-host path ran selectAll + delete on any hyphenated tag with no open
shadow root and took `delete === true` as proof, skipping the read-back
entirely. Both halves of that are false on Chrome 151:

  * `execCommand` acts on the document SELECTION, not on the focused element.
    With the standard rich-text toolbar shape (mousedown + preventDefault +
    focus()) holding focus while the selection stayed in a neighbouring editor,
    the clear emptied THAT editor and reported `cleared: true`.
  * `delete` answers true whether or not it removed anything, and an opaque host
    cannot be read back — so the success was unfalsifiable.

The hyphen also admitted every plain light-DOM custom element, not only closed
shadow hosts. Refuse instead, before anything is selected, and extend the same
reason to a non-hyphenated host whose empty light subtree marks a closed shadow
root — that one used to get "tap the field first", a loop for an element that
already has focus. A closed-shadow field keeps the documented repair: select
with `gesture-drag` and type over.

`verifiable` goes with it: every accepted clear is now read back.
…sent

The MCP adapter caps each non-longRunning fetch at 30s, and that timeout is not
a cancellation: `fetchWithReconnect` retries on any error, its own AbortError
included, up to five attempts. Nothing on the device side consults the abort, so
one slow `keyboard` call became up to five CONCURRENT invocations at the same
device, and the caller saw "This operation was aborted" rather than any of the
tool's own wording.

Measured through the real stdio adapter against a 40s call: five overlapping
`toolInvoked keyboard` entries, an error after 154s, and a field holding 492
characters instead of the 100 asked for. With the declaration: one invocation,
success after 41s, 100 characters.

Both slow shapes belong to this tool — an Android `clear` under its 90s adb
budget, and any `text` paced with `delayMs`.
The read-back re-derived the focused element and compared it with the clear's
own result by a coarse label ("input type=text"), so two fields of one kind
were indistinguishable. On an auto-advancing OTP / PIN / card-segment form the
page moves focus in its own `input` handler, and the NEXT field's contents were
attributed to the one that had just been cleared: the tool returned 400
KEYBOARD_CLEAR_UNSUPPORTED_FIELD, "still holds 1 character ... nothing was
cleared", against a field that was in fact empty — every statement false, the
prescribed repair wrong, and no auto-screenshot to see it by.

The clear now stashes the element it ran against on the page's own main world,
which is what survives between two `returnByValue` evaluates, and the read-back
compares identity and drops it. Nothing reaches the DOM, CSS or a screenshot.
The read-back looked at whatever held focus when it ran, so the two commonest
restoring-editor shapes escaped it entirely: an editor that hands focus to a
hidden IME buffer on every edit (ProseMirror / Slate / Quill) and a field that
blurs on change both leave focus with nothing to read, and `remaining: null` —
documented in the script as "not evidence of anything" — was then reported as
affirmative evidence. Measured on Chrome 151: both returned `cleared: true`
with the value byte-identical.

It now reads the element the clear ran against, and only while that element is
still connected — a page that REPLACED the field leaves a detached node holding
the old value, which is no evidence about what is on screen.

Content with no text of its own escaped too: an inline image, attachment chip,
embed or table has `textContent.length` 0 before AND after, so a restored one
read as an emptied field. Those are counted, and the message names what
survived instead of quoting "0 characters".
…cument's

A text control keeps a selection separate from the document's, and
`execCommand("selectAll")` acts on the document's. With a page selection
anchored elsewhere — the everyday copy-to-clipboard button that highlights a
code block while keeping focus in the field — it selected the whole DOCUMENT,
`delete` refused, and an ordinary `<input type=text>` came back as
KEYBOARD_CLEAR_UNSUPPORTED_FIELD telling the caller it was a date input. Both
prescribed repairs were measured no-ops on it; the one action that worked was
the one the classification steers away from.

`.select()` selects that control's own value and nothing else. Measured on
Chrome 151: it empties the field the document-wide selection made unclearable,
it throws for no input type, and it still leaves `delete` answering false for
exactly the five date/time types — the distinction the refusal reads. A
contenteditable has no separate selection to hijack and `selectAll` is what
reaches into an open shadow root, so it keeps that path.
…used it

A `contenteditable` holding a `contenteditable="false"` block — a locked
header, an embed, a node view, a mention chip — cannot be cleared, and got one
of two wrong answers depending only on where the block sat. With the block
first the delete is refused and the field was told it is a date input, with a
prescribed `backspace` that is a measured no-op on it. With the block last the
delete is accepted, the content survives, and the read-back asserted that a
page listener restored it — on a page with no listeners at all (zero `input`
events counted, `innerHTML` byte-identical).

The date/time wording is now gated on the type the script already read, and
the restored-value message states the observation and both shapes that produce
it rather than asserting one.
…ts value

The read-back tested only whether the field was empty, so a page that rewrites
the value on `input` — a currency, phone or card mask — was reported as
"nothing was cleared ... the value the field still holds". Both halves are
false: the caller's value is already destroyed, and what is quoted back is the
mask's own seed. Measured: "1,234.56" -> "0.00" and "+1 (555) 010-9999" ->
"+1 ", each reported as a field that kept its value.

The clear now captures a content signature before it deletes and the read-back
compares, so the two cases are separable. Only the boolean crosses back — the
value itself never leaves the renderer, because a cleared field may have held a
credential. The reformatted case gets its own stage and its own wording; the
restored case now says outright that the value is the one it held before.
`pressKey` writes to the simulator-server's stdin with no callback, and the
EPIPE listener that keeps a racing write from crash-shutting the shared
tool-server also left nothing to tell the caller the burst went nowhere:
`terminated` reaches the next `resolveService`, not the call already holding
the resolved api — which is the one making the `cleared: true` claim.

Measured on a booted simulator with a 250-character field: `kill -9` of the
simulator-server 50ms into the clear delivered 9 of 200 keys and the tool
answered `{ keys: 200, cleared: true }`. Now the death is recorded on the pipe
and `pressKey` refuses afterwards, and the clear re-states a cut-short burst as
KEYBOARD_CLEAR_UNCONFIRMED with the same "may be PARTIALLY emptied — read the
field back" wording the Android burst already had. The tool-server still
survives the EPIPE.
Nothing on the clear path consulted the abort signal `dispatchByPlatform`
already forwards, so a cancelled or disconnected clear kept driving the device
for the rest of its 200 keys — the same shape `gesture-swipe` checks
`ctx.signal` for, and for the reason quoted there: the deletions land in
whatever is sent to that device next.

Measured on a booted simulator with a 250-character field: a client gone at
150ms left the full 100 deletions running (250 -> 150), and now leaves 34.

An abandoned burst reports the keys it actually sent and drops `cleared` — the
field is emptied by however many got through, which is exactly the state that
claim must not be made for.
`injectAndroidClear` runs one `adb shell input keyevent <200 codes>` under a 90s
budget and `adbShell`/`runAdb` had no way to carry an abort, so an abandoned
call blocked for that whole budget and nothing killed the adb child. Both
options objects take an optional `signal` now, threaded to `execFile`, which
already kills the child on it; every existing call site is unchanged.

What it does and does not stop, measured on an API 36 emulator against a
100-character native EditText: with the client gone at 150ms the field is
byte-identical afterwards, where the same call used to empty it. With the client
gone at 1s the guest completed all 100 deletions — the abort kills the host-side
client, not an injection already running on the device. That limit is now stated
where the burst is issued, since the whole burst is one command by design.
`paste` wraps its device work in a per-device queue and its comment says why:
two concurrent calls on one device let the second land inside the first's
unserialized steps while both report success. `keyboard` had no such guard, and
`clear` turns a per-keystroke window into 700ms on iOS and 2-90s on Android.

Measured on a booted simulator with a 250-character field: `{ clear: true }`
and, 200ms later, `{ text: "HELLO" }` left the field at `…aaaaaaaaaaLO` — "HEL"
eaten by backspaces still in flight — with both calls reporting success. Now the
same pair leaves `…aaaaaaaHELLO`.

The queue moves to a shared util and both tools use one map per device, because
the hazard is the device's single focused field rather than any one tool's
steps: a paste racing a clear corrupts the value exactly as two clears would.
The request-shape guards stay ahead of it, so a caller's own mistake is not made
to wait out another session's burst.
…r refuses

Two mirror-image findings, one mechanism. The delete-refused branch dropped the
page selection rather than restoring it, so a call that reported "nothing was
cleared" still took the page from a highlighted code block to no selection at
all — visible state the next screenshot and every screenshot-diff registers.
The script-error branch did not even drop it, leaving the select-all highlight
on screen: the exact hazard its sibling's comment names.

The page's ranges are now cloned before anything is selected and restored on
both refusals, per range, so a range whose nodes the delete removed does not
take the rest with it.
`el.disabled === true` was tested on any focused element, so a component library
that exposes `disabled` as a plain property on a non-form host — `ce.disabled =
true` on a `<div contenteditable>` — made a perfectly clearable field refuse
with "nothing can be until the app enables it", a repair the caller cannot act
on. It fails closed, so only the diagnosis was wrong.

Gated on `<input>` / `<textarea>` / `<select>`, where the two are IDL attributes
rather than an ordinary JS property. Measured on Chrome 151: that editor now
clears, and a readonly input still reports readonly.
A `disabled` control cannot become `document.activeElement` at all — measured on
Chrome 151, a real `gesture-tap` on one leaves focus on <body> — so the clear
answers KEYBOARD_CLEAR_NO_EDITABLE_FOCUS, "Tap the field first", the agent taps
the same field, and the identical error comes back. The `disabled` diagnosis
that exists to say "tapping it again will not help" is unreachable for every
standard form control, so this refusal is the only message that can carry it.
Only a timeout was re-stated, so a connection close rejected with
DEBUGGER_CDP_CONNECTION_CLOSED and was rethrown raw, carrying the debugger
taxonomy's "restart the app, then reconnect and retry once" — a retry that
lands a SECOND delete on a field the first may already have emptied. The
cdp-client's own comment at that rejection site is the argument against it: a
request rejected there was already delivered and may have taken effect.

Reproduced by killing the browser while the clear's read-back was pending: the
call answered DEBUGGER_CDP_CONNECTION_CLOSED / "CDP connection closed", and now
answers KEYBOARD_CLEAR_UNCONFIRMED with "read the field back first".

Narrow on purpose: DEBUGGER_CDP_NOT_CONNECTED means the socket was already down
when the send was attempted, so nothing was delivered and it keeps its own code.
…andatory

`keyboard`'s own parameter description says the focus tap needs at least 500ms
before a clear, because `run-sequence` waits only 100ms between steps and no
backend checks focus — "a burst that arrives early deletes from the PREVIOUSLY
focused element and still reports success". Both canonical copy-paste recipes
showed the tap with no `delayMs` at all, and they are the ones an agent follows
onto a slow app.

Both now carry `delayMs: 500` and say why. The run-sequence example was run
verbatim against a Chromium target: 3/3 steps, field replaced.
The message prescribes splitting a combined call into `{ clear: true }` then
`{ text }`, and its caveat named only Apple TV / Android TV — so on a Vega VVD a
caller was sent into a first step `platforms/vega.ts` rejects outright. `clear`'s
own `.describe()` already says "or Vega"; this message now says what to do
instead, and that `key` does work there.
…r names

The message told an agent that a separate `{ clear: true }` call's "screenshot
is taken before the replacement is typed, with the field still showing whatever
it held". The MCP auto-screenshot fires only after a SUCCESSFUL call, and a
clear returns after its burst plus the settle, so that capture shows the emptied
field. The advice (keep both steps in one `run-sequence`) is right; the reason
was not.

The real exposure is narrower and stated instead: the skip is decided per call
from the whole request, so a clear-only call is captured, and on iOS and Android
the burst is bounded at 100 characters per side — a field that held a longer
secret is captured with the remainder still in it.
…like

The map keyed on the caller's raw string while every other consumer PARSES
it, so two spellings of one target got two queues and the serialization
silently did nothing. `parseChromiumCdpPort` reads `chromium-cdp-09333`
and `chromium-cdp-9333` as the same port, and an iOS UDID is
case-insensitive to the same degree.

A/B on the Chrome fixture, two concurrent `keyboard` calls addressed as
`chromium-cdp-9444` and `chromium-cdp-09444`:

  raw-string key   ABABABABABABBAAB
  normalised key   AAAAAAAABBBBBBBB

The key is normalised in one place and used by all three entry points, so
a hold taken under one spelling is seen by a call made under the other.
Genuinely different devices stay apart — pinned by a case, since the
cheapest wrong fix is a key that collapses them.
`serializedPerDevice` deletes its map entry only when the finished task is
still the tail. Delete it unconditionally and the second task's own entry
goes with it, so a third call finds an empty map and runs BESIDE the
second instead of after it — and every case in this file queued exactly
two tasks, one short of the shape that can observe it.

Mutation-checked: with `deviceQueues.get(key) === next` removed the new
case fails (`a b c` becomes `a`, `b`, `c` overlapping) and the rest of the
file stays green.
…e buys

No case ever had two `holdDeviceQueue` calls in flight at once, so nothing
observed the property the store exists for. The one re-entrancy case only
proves the store survives a single chain, which a module-level variable
does too.

The new case runs two sequences holding different devices, and asserts
that the earlier one's reach for the other device WAITS rather than
reading as re-entrant.

Mutation-checked against the module-level variable the source comment
rejects: the new case fails (`a:uses-b` jumps inside sequence B's hold).
No case anywhere put a `paste` step in a `run-sequence`, so removing
`"paste"` from `DEVICE_QUEUE_TOOLS` — the membership that makes a paste
sequence take the device's keyboard hold at all — stayed green. Every
other queue case reaches it through a `keyboard` step.

Mutation-checked: with the set narrowed to `["keyboard"]` the new case
fails.
`clear` is the only boolean any allowed tool takes, and every
pass-through case carried only numbers and strings. Commit 5d90da2
deleted the one case that read a `{ clear: true }` step's args, on the
grounds that a `{ text: "hi" }` case covers it — it does not: a runner
that dropped boolean args while injecting the udid turns `{ clear: true }`
into a no-op and left the ENTIRE 5095-test suite green.

The existing two-step queue case now asserts the args the `keyboard` step
actually received.

Mutation-checked with the runner filtering boolean entries out of
`toolArgs`: the whole suite is green except this case (the lens load-flake
aside).
… count

`expect(new Set(stages).size).toBe(4)` pins distinctness only. Three of
the four values occur exactly once in the repo — at their production site
— and `failure_stage` is an unconstrained string, so each name was free
and, worse, so was each shape-to-stage MAPPING: swapping the ternary's two
two-field branches changes which bucket a client's telemetry lands in with
nothing going red, and separability is that code's entire purpose.

Mutation-checked with those two branches swapped: the case fails.
`failure_exit_code` is set only for `typeof err.code === "number"`, and
the one case that claims this ground builds its fixture with `code: null`
— a SIGKILL — so the spread that forwards it could be deleted with all 297
tests green. The two spread branches need two fixtures.

Mutation-checked: with the spread removed the new exit-status case fails
and the SIGKILL case still passes.
`keyboard_clear_android_burst` and `keyboard_android_runtime_kind` each
occur once in the repo — at their production site — and `failure_stage` is
an unconstrained string, so either could be reassigned to a Chromium or
iOS value with the suite green. The iOS burst stage and all four Chromium
stages are pinned; these two were not.

Mutation-checked twice: reassigning the burst stage to
`keyboard_clear_chromium_restored`, and the runtime-kind stage to
`keyboard_ios_runtime_kind`, each fails exactly one of the new cases.
All four clear wrappers carry `getFailureSignal(err)?.error_kind ??
"subprocess"`, and nothing asserted it: every iOS and Vega fixture threw a
plain Error or already said "subprocess", so the fallback and the read
were indistinguishable and hardcoding the literal passed everywhere. On
iOS that meant the blueprint's EPIPE guard was never composed with the
burst wrapper at all.

- iOS: a `FailureError` from the pipe guard whose kind is "timeout" is
  carried through, its code stays reachable on the cause chain, and a
  plain Error still falls back to "subprocess".
- Android: the existing SIGKILL fixture now pins "timeout" — `runAdb`'s
  own verdict for the 90s cap.
- Vega: a wrapped adb timeout is carried.
- Apple TV: `sendLine`/`sendJson` reject with plain Errors on every path,
  so the read half is dead code there; the reachable answer is pinned
  instead, and the comment says why.

Mutation-checked with `error_kind: "subprocess"` hardcoded in all three
live wrappers: three cases fail.
Both new iOS refusals rest on the probe answering `undefined`, and that
answer only ever came from a `vi.fn`: every keyboard test mocks the
module, and the probe's own cases only ask about UDIDs the mocked listing
carries. Held apart, both halves pass a probe that falls back to "mobile"
— which is the exact silent-corruption the refusal exists to prevent.

Three cases close it: the probe answers `undefined` for a UDID the listing
does not carry and for an `xcrun` that fails, and the iOS impl refuses a
clear on that UDID with `KEYBOARD_TARGET_KIND_UNKNOWN` while resolving no
service at all.

Mutation-checked with `return kind ?? "mobile"` in the probe: all three
fail, and `keyboard-backend-fidelity` / `keyboard-text-key-exclusive` stay
green — which is the gap.
Both script tests build their expected signature by eval-ing the exported
`CONTENT_SIGNATURE_JS`, and every case compares a node against ITSELF — so
the oracle agrees with whatever that source says. Deleting the end-trim
and the zero-width strip from it left them all green, and those two decide
the production shape: an editor that reseeds a zero-width space, or leaves
trailing whitespace, into a field whose value is otherwise the one the
clear was aimed at reads as "the page rewrote the value" — that the
caller's value is already destroyed — for a field nothing rewrote.

An equivalence class pins it: two textually different nodes that must hash
alike, with an interior edit as the control so a constant signature cannot
pass.

Mutation-checked with the trim and the strip removed: the new case fails.
Every `deleteAnswer: false` case was an `<input>`, which takes
`el.select()` and never reaches the select-all — so on the one shape where
the restore has something to undo (a contenteditable whose delete is
refused after the select-all replaced the page's range) there was no case
at all, and the restore was only ever observed as a call count rather than
as the range it put back.

That also leaves the clone's ORDER unpinned: the page's ranges are cloned
before the select-all replaces them, and cloning after would restore the
select-all's own page-wide highlight — the exact thing the restore exists
to remove.

Mutation-checked with the clone moved below the select-all: the new case
fails, and so does the throws-between-the-two case.
`run()` installs a fresh mutable `window` unconditionally, so the frozen
object the case set never reached the script — and the script is
sloppy-mode (indirect eval, as `Runtime.evaluate` is), where assigning
onto a frozen object fails SILENTLY rather than throwing. The case
asserted the same thing as the plain text-input success row, and deleting
the try/catch it guards kept it green.

`run` now takes the page's own `window`, and the case supplies one with a
throwing setter — the hardened-global / Proxy shape, which is what
actually reaches the catch. It also asserts the record was NOT left
behind, so "the stash failed and the clear still succeeded" is observed
rather than assumed.

Mutation-checked with the try/catch removed: the case fails.
`typeTv` touches `device` exactly once on the clear path — the service
lookup — and the case never observed it, so the Apple TV and Android TV
rows were byte-equivalent and the second could not fail unless the first
did. The comment describing the difference between the two backends
described something the case structurally could not see.

Both rows now assert the lookup was made with their own device id.

Mutation-checked with the lookup's argument replaced by a constant: the
Android TV row fails.
Two in `keyboard-backend-fidelity.test.ts`:

- The aborted-burst case armed its abort with a wall-clock wait and then
  asserted `result.keys === events.length / 2`. Both operands are outputs
  of the same run, and that identity holds at EVERY exit of the loop — so
  it could not tell an abort that fired far too early from one that fired
  far too late. The abort is now driven from inside `pressKey` at a chosen
  key, and the count is a literal.
- `expect(ios.clearVerified).toBeUndefined()` passed with the whole
  feature deleted, since no key backend sets the property at all — and it
  would pass for an explicit `clearVerified: undefined`, a different
  claim. `Object.hasOwn` on both sides instead.

Mutation-checked: with `clearVerified` never set, the flag case fails;
with the burst's top-of-loop abort check removed, the two pass-down cases
fail.
The case titled "so the two cannot drift apart" compared the burst's first
code with `String(ANDROID_NAMED_KEYCODES.backspace)` — `String(X)` against
`String(X)` for the same live X — so it could not detect the drift it
names. Set the map entry to 4 and it stayed green.

Both sides are literals now (67 = KEYCODE_DEL, 112 = KEYCODE_FORWARD_DEL),
with the map's own value pinned beside them.

Mutation-checked with the map entry changed to 4: this case fails along
with the four that already pinned literals — which is what it was supposed
to do all along.
…ed it

Chromium does check focus, and fails with
`KEYBOARD_CLEAR_NO_EDITABLE_FOCUS`. The `clear` parameter's own
`.describe()` says so correctly; commit cf1ff3b fixed the flow doc and
the skill and left this third copy — the tool description itself — saying
the opposite.
The row said the clear "was delivered and the outcome is unknown … never
retry blind". The same code is raised for states where NOTHING was
delivered and the field is provably unchanged — an abort before the burst
was sent, an adb refusal before delivery, a first key the iOS or tvOS
transport refused, and a Vega injection of 0 of 200 — where the repair is
the opposite: fix the device and send the clear again, with nothing to
read back.

The row now splits the two and tells the reader the message says which.
The row promised "the clear then works on either kind". On the remote arm
the code says the opposite: `makeIosRemoteImpl` raises the same
`KEYBOARD_TARGET_KIND_UNKNOWN`, and once the kind resolves to `tv` it
refuses `clear` and `key` outright. The row never mentioned `ios-remote`
at all, so following it there is a second failure.
The `KEYBOARD_CLEAR_UNSUPPORTED_FIELD` row sent every non-date/time cause
to `gesture-drag` and type over the selection. The source messages
deliberately do not: a `readonly` field ignores every edit including that
one, and a `disabled` control cannot take keyboard focus at all, so both
name the app's own control instead. `gesture-drag` is prescribed there
only for an `<iframe>`, an opaque host, a restoring editor and a
page-wide editing host.

The row now splits the repairs the way the messages do.
`live-authoring.md` said the recorded clear works on Vega, Apple TV and
Android TV. Three documents in this same PR say the opposite —
`references/flow-yaml.md` ("Apple TV and Android TV are not flow targets
… do not author them"), `argent-qa-flows/SKILL.md` ("out of scope") and
`rules/argent.md` ("no saved-flow support; report that limitation").
Vega is a flow target and keeps the sentence.
`live-authoring.md` said `delayMs: 500` "is the only form the settle can
take here", while `reliability-and-recovery.md` prescribes a standalone
`wait: 500` between the tap and the clear. Both pages are reachable from
the same authoring path, and the code says both forms are valid at
replay: `wait` is a first-class directive the runner sleeps on, and
`delayMs` is a legal sibling of `tool:` that sleeps before its step.

The real constraint is narrower: `wait` cannot be RECORDED —
`flow-add-step` refuses it as "a flow directive, not a tool" — so
`delayMs` is the only settle the recorder can emit, and a hand-added
`wait` is the form for polish. Reworded to say that, and to point at the
other page rather than contradict it.
…roken

The login example drove tap → `{ clear: true }` → `{ text }` as bare
calls with no settle. `argent-device-interact/SKILL.md`, added in this
same PR, names that exact shape as the failure mode: bare calls wait 0ms
between them and hold no keyboard, so the clear can land before focus
moves and then empties the previously focused element while reporting
success.

Each tap-clear-type triple is now one `run-sequence` with `delayMs: 500`
on the tap, which is the shape the other skill prescribes.
"the agent reads the field again after the clear, so it knows that the
field is empty" is only true when that read answers. The tool withholds
`clearVerified` whenever it cannot be taken — a page that replaced the
field, one that sealed `window`, or one that stopped exposing a value to
read — and reports the accepted delete alone.
`packages/docs/CLAUDE.md` reserves "tool names, parameters, values,
limits and exact behaviour" for `reference/` and keeps `features/`
conceptual. The clear's per-target limits, its `keys` / `cleared` /
`clearVerified` shape and its five failure codes landed in
`features/interacting-with-apps.mdx` instead, and `reference/tools.mdx` —
a one-line-per-tool index — said only "Type text, press a special key, or
clear the focused field". The codes appear nowhere in `docs/` at all.

Adds a `Clearing a text field` section to `reference/tools.mdx` with the
per-target table, the codes and their repairs, and the per-device
serialization; the `features/` bullets keep the concept and link to it.

`npx docusaurus build` passes, so the new anchor resolves.
… taught

§ 8 "Action Sequencing with `run-sequence`" was unchanged, while this PR
gives a sequence containing `keyboard` or `paste` a device-wide keyboard
hold — which is the whole reason the tap-clear-type recipe belongs in one
call. It was documented only inside the keyboard section, where a reader
looking up `run-sequence` would not find it.

Says what the hold covers (first step through the last keyboard/paste
step), what waits on it (the other session's keyboard, paste, and anything
of theirs that can move focus), and what does not take one at all.
"It fails there with `KEYBOARD_CLEAR_NO_EDITABLE_FOCUS`" followed a
sentence listing the five key-injecting backends, so "there" read as
those five — while the table twenty lines below marks the code Chromium
only. An earlier commit had the referent explicit; reordering the clauses
lost it.
The PR added an `ios` phase, `--ios-udid` and `E2E_IOS_UDID`, and updated
`run-e2e.sh`'s own `-h` text — and left every part of the README that
describes the harness: the phase table, the "iOS / tvOS / Vega tiers are
intentionally out of scope" line, the "Providing a device" section and the
`--phase` list.

Also says the thing that matters for a release gate: `ios` is in the
darwin default and a skip does not fail the run, so a default run is green
with zero coverage from that tier unless a UDID is named. The skip itself
stays green — every other tier here skips the same way, and the file opens
by promising that a partial run still produces a meaningful report — but
the operator is now told to read the skip count.
`holdDeviceQueue` still described the behaviour it had before the hold was
narrowed and before focus movers learned to wait: "across a WHOLE batch",
"holds the device's keyboard for its whole duration", and "a
`gesture-tap` never waits". All three are now false, and this is the
comment a reader reaches first.

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 4.8]: Reviewed the keyboard clear feature and the new per-device serialization layer across claims-vs-code, nearest-twin / symmetry, non-happy paths (abort / partial burst / transport-death), inputs, reachability, and call lifetime, plus a mutation-minded look at the new constants and an absence sweep of the awaitDeviceHold focus-mover contract. Individual notes are inline.

* Wait out another session's hold on this device, if there is one.
*
* Called by the tools that move keyboard focus (the gestures, `button`,
* `tv-remote`, `launch-app`, `restart-app`, `open-url`). A call made INSIDE the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 4.8]: This caller list (and the actual wiring — awaitDeviceHold is imported into exactly these twelve tools and no others) omits two tools that also move keyboard focus:

  • chromium-tabs re-points the shared per-device CDP that a keyboard { clear } evaluates against. Its own description says selecting a tab makes it "the active one … every other tool then acts on it," and chromium-server re-points cdp in place on a tab switch "so every subsystem that captured cdp follows the new page automatically" — which includes server.evaluate, the path clearChromium runs on. chromium-tabs.execute calls no awaitDeviceHold, so a second session's chromium-tabs { action: "select", tab: "t2" } landing inside a first session's run-sequence [tap t1's field, keyboard { clear }] hold switches the active tab before the clear evaluates: the clear then runs against t2 and, when t2 has a focused editable, empties it and returns cleared: true / clearVerified: true while the field the sequence tapped keeps its value. Its nearest twin open-url (a strictly smaller active-tab operation) does wait the hold out.
  • reinstall-app uninstalls the running app first (dropping keyboard focus) — the same terminate/focus-move class as restart-app and launch-app, both listed here — but calls no awaitDeviceHold, so it can tear the focused app down inside another session's keyboard hold and leave that session's clear running against the home screen.

Traced at 63bc63e: chromium-tabs/index.ts execute → chromium-server/index.ts (cdp re-point + evaluate), grep -rl awaitDeviceHold (twelve tools, neither of these), against open-url / restart-app.

// editor and answered true.
// * \`delete\` answers true whether or not it removed anything, so its
// return value is not evidence, and an opaque host cannot be read back
// to get any. \`cleared\` on this backend means the field was SEEN empty;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 4.8]: This says that on the Chromium backend cleared "means the field was SEEN empty," but in the shipped result that is clearVerified's meaning, not cleared's. types.ts documents Chromium cleared as "the delete was ACCEPTED," and clearChromium's unverified return emits { cleared: true } with no clearVerified in the three read-back-declined cases (field replaced, window sealed, target went read-only → remaining: null) — cleared: true while nothing saw the field empty. The parallel wording further down ("cleared alone means … 'read back empty' here") reads the same way and is contradicted a few lines later by "the delete was still accepted, so cleared stands — but nothing saw the field empty." Compared against the cleared / clearVerified docs in types.ts and the verified / unverified returns in this file.

// same undefined, and no probe timed out there. What both causes share is
// that the device is not in the listing.
error_kind: "not_found",
failure_command: "xcrun_simctl",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 4.8]: failure_command: "xcrun_simctl" is stamped unconditionally, but refuseUnknownKind is also called from makeIosRemoteImpl (with remote = true), whose probe is getRemoteSimulatorRuntimeKindsimctlListDevicesexecFileAsync("sim-remote", …) — the sim-remote binary, not xcrun / CoreSimulator. So keyboard { clear } on an ios-remote UDID whose sim-remote listing omits it (or misses its budget) raises a KEYBOARD_TARGET_KIND_UNKNOWN failure whose telemetry attributes it to an xcrun command that never ran; the human-readable message is correct (it interpolates the probe arg) but the structured field ignores the remote parameter. FAILURE_COMMANDS has no sim-remote entry but does carry "unknown". Checked against sim-remote.ts and registry/src/errors.ts.


A remote Apple TV (an `ios-remote` tvOS id) refuses `clear` and `key`. The tvOS daemons drive a simulator in the Argent host's own CoreSimulator set, and they do not reach a device behind sim-remote. Use a local Apple TV simulator instead.

A failed clear carries one of five codes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 4.8]: "A failed clear carries one of five codes" and the table lists five, but a clear can fail with a sixth. keyboard's execute routes the whole clear dispatch through serializedPerDevice, whose guarded() throws KEYBOARD_DEVICE_BUSY when the queue wait exceeds DEVICE_QUEUE_MAX_WAIT_MS (30s — reachable, since the same file budgets an Android clear up to 90s) and when the request aborts while queued. With one shared tool-server and two sessions (the documented default), session B's keyboard { clear } behind session A's >30s hold fails with KEYBOARD_DEVICE_BUSY — a code this table, and the docs generally, never mention. Traced keyboard/index.tsdevice-serial.ts (guarded / deviceBusyError); grep of packages/docs shows the code is absent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants