Commit Graph

1222 Commits

Author SHA1 Message Date
Hubert Gancarczyk aa9e35fb49 Merge branch 'fix/flow-positive-check-second-read' into fix/flow-type-requires-focus 2026-08-31 14:54:33 +02:00
Hubert Gancarczyk c20dfd4be0 Merge branch 'fix/flow-unfalsifiable-checks' into fix/flow-positive-check-second-read 2026-08-31 14:54:33 +02:00
Hubert Gancarczyk efd7855aa3 Merge branch 'fix/recorder-ios-instrumentation-wait' into fix/flow-unfalsifiable-checks 2026-08-31 14:54:33 +02:00
Hubert Gancarczyk 5ad14b9eb9 Merge branch 'fix/recorder-tap-replay-fidelity' into fix/recorder-ios-instrumentation-wait 2026-08-31 14:54:33 +02:00
Hubert Gancarczyk 2eac0efe38 Merge branch 'fix/ios-flow-tree-depth-and-targeting' into fix/recorder-tap-replay-fidelity 2026-08-31 14:54:33 +02:00
Hubert Gancarczyk 8cc2123819 Merge branch 'fix/ui-text-invisible-characters' into fix/ios-flow-tree-depth-and-targeting 2026-08-31 14:54:33 +02:00
Hubert Gancarczyk 65f435bbfe Merge branch 'fix/failed-nested-step-not-recorded' into fix/ui-text-invisible-characters 2026-08-31 14:54:33 +02:00
Hubert Gancarczyk 5b31406219 Merge remote-tracking branch 'origin/main' into fix/failed-nested-step-not-recorded 2026-08-31 14:54:33 +02:00
filip131311 b835de2326 fix(packaging): ship one shared copy of the Android screen-sharing agent (#983)
## Summary

Fixes the biggest chunk of #980: the published package shipped **four
byte-identical copies** of the Android screen-sharing agent
(`resources/android/`: jar + per-ABI `.so`, ~12.7 MiB each) — one next
to each platform's simulator-server binary, because the binary resolves
the agent relative to its working directory and the spawn set cwd to the
per-platform dir.

This ships **one shared copy** at `bin/resources/android/` and points
the spawn cwd at the bin root instead.

Package size (built from this branch, same artifact set as 0.22.1):

|  | before | after |
|---|---|---|
| unpacked | 115.5 MB | **76.4 MB** |
| packed | 37.6 MB | 25.4 MB |

76.4 MB is under npmmirror's 83886080-byte unpacked-size cap (the limit
#980 trips — note it applies to `dist.unpackedSize`, not the tarball).
Already-published oversized versions are immutable, so the mirror also
needs the `cnpm/unpkg-white-list` entry to sync the backlog; this PR
keeps future versions under the cap.

## Changes

- `scripts/download-simulator-server.sh` extracts the agent tarball
once, to `bin/resources/android/`, instead of next to each binary.
- `bundle-tools.cjs` copies that single shared directory into the
package.
- `@argent/native-devtools-ios` replaces `simulatorServerBinaryDir()`
with `simulatorServerRunDir()`: the bin root when the shared
`resources/android/` exists there, falling back to the per-platform dir
so an `ARGENT_SIMULATOR_SERVER_DIR` override pointing at a pre-dedup
layout keeps working.
- The blueprint spawns simulator-server with `cwd: RUN_DIR` (binary path
stays absolute; the agent lookup is the only cwd-relative path in the
simulator-server crate — audited: every other fs path there is a
tempdir, `dirs::cache_dir()`, or caller-supplied).

No symlinks, no postinstall scripts — plain files plus a different cwd,
so nothing changes for npm/pnpm/yarn/bun or Windows.

## Testing

- Unit: resolver tests updated + new shared-layout case; blueprint test
asserts the spawn cwd.
- Packaging: ran the modified download script + `npm run pack`
end-to-end; tarball contains exactly one `bin/resources/android/` (94
files vs 112).
- Live: branch tool-server with `ARGENT_SIMULATOR_SERVER_DIR` pointed at
the new layout — iOS simulator (screenshot) and Android emulator
(boot-device, screenshot, gesture-tap) green; `lsof` confirms both
spawned simulator-servers run with cwd = bin root, where
`resources/android/<jar>` and each per-ABI `.so` resolve (verified
present at those exact relative paths).
- Physical Android phone (Galaxy S24, the one path that actually pushes
the agent): live-tested — the `android_device` controller resolved the
agent from the shared layout, pushed it, and connected ("All agent
connections established successfully"); screenshot and tap through the
on-phone agent both green, spawn cwd confirmed as the shared bin root.

No docs update needed: internal packaging/layout only — no MCP tool,
CLI, config, or flow-file surface changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01UGAsAvYBJ3Jy9DKLaX9aoZ


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved simulator-server resource handling by using one shared
screen-sharing resource location across host platforms.
  * Added a warning when required shared resources are unavailable.
* Ensured simulator-server processes start from the correct runtime
directory.

* **Documentation**
  * Clarified simulator-server runtime and shared resource behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-31 14:49:11 +02:00
Hubert Gancarczyk 0331d88f7c Merge branch 'fix/flow-positive-check-second-read' into fix/flow-type-requires-focus
# Conflicts:
#	packages/docs/docs/reference/flow-yaml.mdx
#	packages/skills/skills/argent-qa-flows/SKILL.md
#	packages/tool-server/src/tools/flows/flow-actions.ts
2026-08-31 14:37:08 +02:00
Hubert Gancarczyk 6940b377bb Merge branch 'fix/flow-unfalsifiable-checks' into fix/flow-positive-check-second-read 2026-08-31 14:33:29 +02:00
Hubert Gancarczyk 3e8719dc48 Merge branch 'fix/recorder-ios-instrumentation-wait' into fix/flow-unfalsifiable-checks
# Conflicts:
#	packages/tool-server/src/tools/flows/flow-run.ts
2026-08-31 14:31:42 +02:00
Hubert Gancarczyk a65c615408 Merge branch 'fix/recorder-tap-replay-fidelity' into fix/recorder-ios-instrumentation-wait 2026-08-31 14:28:52 +02:00
Hubert Gancarczyk 5b97ad7143 Merge branch 'fix/ios-flow-tree-depth-and-targeting' into fix/recorder-tap-replay-fidelity 2026-08-31 14:27:05 +02:00
Hubert Gancarczyk 6f1a1c479c Merge branch 'fix/ui-text-invisible-characters' into fix/ios-flow-tree-depth-and-targeting 2026-08-31 14:25:11 +02:00
Hubert Gancarczyk d22f0559e4 Merge branch 'fix/failed-nested-step-not-recorded' into fix/ui-text-invisible-characters 2026-08-31 14:23:25 +02:00
Hubert Gancarczyk b9d0531150 fix(flow): mark an abort-cancelled raw tool step as having reached the device
The swipe directive's merge brought a new abort arm into `execLeafStep`'s
raw-tool `catch`: a gesture tool that consults the signal rejects when the
run is cancelled mid-dispatch, and that is reported as a skip rather than
the tool's own "aborted after N frames" failure.

It returned that skip unmarked. `reachedAStep` reads an unmarked skip as
"nothing was dispatched", so a composed or nested run cancelled inside a
raw `tool: gesture-swipe` step lost its partial-mutation warning - the
recorder told the agent the device was untouched by a gesture that had
already gone out.

Mark it, like the directive and launch arms alongside it: the tool was
invoked, so an unknown resolves to reached.
2026-08-31 14:23:17 +02:00
Hubert Gancarczyk a855ed8090 Merge branch 'fix/failed-nested-step-not-recorded' into fix/ui-text-invisible-characters
# Conflicts:
#	packages/docs/docs/reference/flow-yaml.mdx
2026-08-31 14:16:53 +02:00
Hubert Gancarczyk 06c7a1885f Merge remote-tracking branch 'origin/main' into fix/failed-nested-step-not-recorded 2026-08-31 14:12:19 +02:00
Ignacy Łątka 2cc1f5281c test(android-profiler): drop an assertion that cannot fail (#933)
Audit of every assertion in the repo that pins shipped agent-facing
text, looking for more of the `/restart/i` shape that reddened main in
#871 — an assertion satisfiable only by making the product string
restate itself. **The class is essentially absent. One instance found.**

## The one

`tool-server/test/android-perfetto/hang-stacks-offcpu.test.ts`

```ts
expect(out).toMatch(/No usable on-CPU stack samples/);   // L156
// The thread was on-CPU/executing — this is genuine CPU work, not a wait.
expect(out).toMatch(/on-CPU/);                           // L158  ← removed
expect(out).toMatch(/executing/);
```

`/on-CPU/` is a substring of the literal asserted two lines above, so
`L156 ∧ L158 ≡ L156`. It cannot fail while L156 passes.

Mutation-checked rather than argued: changing the pipeline's sentence
from `_No usable on-CPU stack samples were captured…` to `_No usable
stack samples were captured…` still fails the test —

```
× explains an empty sample set as un-unwound CPU work when the thread was executing
AssertionError: expected ... to match /No usable on-CPU stack samples/
```

— so nothing this line was catching goes uncaught. `/executing/` is
**not** implied and stays; the comment above it still reads correctly.

## What the audit covered, and what it found instead

| stage | count |
|---|---|
| assertions pinning shipped agent-facing text | 707 |
| …in the `/restart/i` shape (short generic literal beside stronger
siblings on the same value) | 44 |
| …surviving hand-verification | **1** |

The 43 rejected are doing real work. A sample, so the reasoning is
auditable:

- `"844x390"` / `"390x844"` — dimension-mismatch test; the point is
argument order.
- `"(2×)"` / `"(1×)"` — different dedup counts.
- `/wait/` beside `/No on-CPU stack samples/` — the sibling literal
contains no "wait"; it pins the wait-vs-CPU explanation.
- `"localhost:8081"` / `"127.0.0.1:8081"` — two hosts the interceptor
must filter.
- metro `discovery.test.ts` `"Do not retry in a loop"` — a *different*
test from the one asserting the full sentence.

More importantly, the 707 is not a pile of redundancy. It is mostly the
only way to test a function whose output is a string —
`formatTraceFreshness(NOW - 5*60*MIN, NOW)` can only be checked by
asserting `/5 hours ago/` — plus assertions that are load-bearing in
their own right, e.g. `expect(description).not.toContain("hunter2")` in
`configuration-core/tests/secrets.test.ts`, the sole guarantee a secret
value never reaches a source description. Stripping the category
wholesale would empty 74 tests into unconditional passes.

## Two adjacent findings, not changed here

- `tool-server/test/native-profiler-missing-trace.test.ts:90` —
`expect(result.report).toMatch(/missing|not found|unreadable/i)` is
meant (per its comment) to check the wording tells the user the file is
missing rather than the export empty. It passes vacuously: the fixture
is named `missing_cpu.xml`, so `/missing/i` matches the filename the
assertion two lines up already pins. It enforces nothing. The fix is a
discriminating regex or a renamed fixture, not deletion.
- `tool-server/test/capability.test.ts:58-59` — `toContain("demo-tool")`
and `toContain("android")` are both substrings of
`"tools/demo-tool/platforms/android.ts"` asserted at L60, so they are
provably implied. Left in place deliberately: the test is named
*"composes a uniform message with toolId, platform, and the file"*, and
deleting them would make the name over-claim relative to what the body
checks.

## Verification

Branched off `366f17c61`.

| gate | result |
|---|---|
| `vitest run` (tool-server) | **358 files, 4401 passed, 1 skipped**,
exit 0 |
| `tsc --build` | clean |
| `typecheck:tests --workspaces` | clean |
| `eslint . --max-warnings 0` | exit 0 (with `npm ci` in
`packages/docs`, as CI does) |
| `prettier --check` | clean |
2026-08-31 12:51:26 +02:00
Juliusz Wajgelt bf38e9069b fix(sim-remote): pass the udid before -b in simctl bootstatus (#982)
## What

`simctlBootstatus` in `packages/tool-server/src/utils/sim-remote.ts`
built the command as `simctl bootstatus -b <udid>`, placing the flag
before the device. simctl's grammar puts the device first — `bootstatus
<udid> -b`.

## Why

Newer Argent Cloud deployments are more faithful to how the local
`simctl` handles its arguments, so they now reject `-b` in that position
as an unknown flag. The result is that **every remote boot fails**, on
every device:

```
[tool: mcp__argent__boot-device]  ->  invalid option -- b
```

The local `xcrun` path already used the correct order
(`boot-device.ts:461`), so this only affected sim-remote / Argent Cloud.

## Notes for the reviewer

- I checked the other `sim-remote` wrappers for the same
flag-before-udid shape. `injectDylib` is the only other one with an
optional flag and it already appends after the udid, so `bootstatus` was
the sole offender.
- `packages/tool-server/test/boot-device.test.ts` passes (23/23), but it
only exercises the local `xcrun` path — there is no existing test for
the remote wrapper, so **this fix is not covered by tests**. It's worth
a manual check against a Cloud device before merging.
- `tsc --noEmit` reports pre-existing errors in this package from a
stale `@argent/registry` build; `sim-remote.ts` itself is clean and none
of them are related to this change.
- No docs update needed: this is an internal argument-construction bug
with no user-facing surface change.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed simulator boot status checks by correcting the order of
command-line arguments.
* Ensured the simulator identifier is processed correctly when the
optional boot flag is used.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-31 11:52:20 +02:00
filip131311 23b9a07760 feat(mcp): auto-describe — element tree after interaction tools + relaxed tapping rule (#958)
## Why

The rule's `<tapping_rule>` ("Before **every** tap, you MUST call a
discovery tool") makes agents loop `tap → describe → tap → describe`:
over 35 baseline runs, `describe` was called 90 times against 86 taps —
one round-trip per tap, each a full model turn. The screen after an
action is already captured by the auto-screenshot; the element tree is
the only thing the agent still had to fetch.

## What

- **MCP** (`packages/argent-mcp`): after the action settles, call
`describe` and append `--- Elements after action (describe) ---` + the
tree to the tool result. Follows the same tool allow-list as the
auto-screenshot, skips `describe` itself, and sits inside the non-secret
branch so a `{{secret:…}}` value never re-enters context as text.
Spy-logged as `auto_describe` (duration, chars). MCP `instructions`
reworded.
- **Flag** `disable-auto-describe` (opt-out, mirrors
`disable-auto-screenshot`). Auto-describe has its own tool list
(`AUTO_DESCRIBE_TOOLS`) and its own branch, so the two features are
independent; the secret-placeholder skip and the screen-idle wait cover
both.
- **Rule + skill**: `tapping_rule` now says take coordinates from the
returned tree, call a discovery tool only when there is no fresh tree
for the screen (first action on a screen, after a wait, target not
listed); the two-failed-taps rule and the "never from pixels"
prohibition stay. `argent-device-interact` best-practice #6 and the
general-rules line updated to match.
- **Docs**: `reference/configuration.mdx` flag row,
`features/interacting-with-apps.mdx` discovery section.
- **Tests**: `autoDescribeEnabled` (global / project scope) and
`shouldAutoDescribe`.

## Evidence

A/B with `claude -p --model sonnet`, one build (variant env-gated during
the experiment), two sandbox projects differing only in rule/skill text
and the toggle; 7 tasks × 2 arms × 5 reps = 70 runs on the iPhone 17 Pro
simulator (iOS Settings ×3, Expo `my-app` ×2, Contacts create verified
in sqlite) and the Electron testbed.

| | main | this PR | Δ |
|---|---|---|---|
| Task success | 35/35 | 35/35 | = |
| Turns / run | 10.4 | 8.2 | −21% |
| Wall / run | 36.5 s | 28.0 s | −23% (new won 28/34 pairs) |
| Cost / run | $0.28 | $0.23 | −17% (27/34) |
| `describe` calls / run | 2.2 | 0.24 | −89% |
| Cache-read tokens / run | 622k | 421k | −32% |

(34 clean pairs; one pair excluded for external interference on the
simulator — with it included: wall −5%, cost −10%, turns −16%.) Every
iOS task moved 15–30% in favour of the new arm; Electron was neutral
(both arms describe once after `boot-device`, then a single
`run-sequence`). The appended tree costs ≈2.4k chars (~700 tokens) and a
median 73 ms per action; per-turn latency was unchanged, the saving is
the removed model round-trip.

Full report:
https://claude.ai/code/artifact/279528cf-ea92-4f52-9276-4f0475ba3e3c

## Caveats

- Sonnet only, short tasks, small screens. On feed-heavy apps with
10–20k-char trees the per-action overhead grows; a follow-up could cap
or filter the appended tree.
- Flags are read once at MCP start (same as `disable-auto-screenshot`),
so toggling needs an MCP restart.

## Checks

`vitest` argent-mcp (85) + configuration-core (122) green; `eslint
--max-warnings 0` on changed packages; `prettier --check .`; `npx
docusaurus build` green.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Interaction tools now return an accessibility element tree alongside
the latest screenshot.
- Agents can use fresh element data for follow-up actions without an
additional discovery step.

- **Configuration**
- Added the `disable-auto-describe` feature flag to turn off automatic
element trees.

- **Documentation**
- Updated interaction guidance and configuration references to explain
the new behavior and flag.

- **Bug Fixes**
- Secret-placeholder handling now correctly accounts for both
screenshots and element trees.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-28 17:20:51 +02:00
filip131311 e7c6f3324c fix(tool-server): frame device sockets on \n only — U+2028 in a label stalled describe for 15 s (#975)
## Problem

`describe` returned an empty tree after ~15 s whenever the screen had an
accessibility label containing U+2028 (LINE SEPARATOR) — e.g. any
Element/Matrix reply preview, or rich text pasted from the web. Nothing
was wrong on the device: ax-service answered in ~170 ms with a full,
valid tree.

The tool-server framed the ax-service socket with
`readline.createInterface`, which splits on the four ECMAScript line
terminators (`\n`, `\r`, **U+2028, U+2029**). JSON permits U+2028
unescaped inside a string and `NSJSONSerialization` emits it raw, so one
valid frame became three fragments, none parsed, each was dropped by
`catch { return }`, and the pending RPC waited out its 10 s timeout —
then the native-devtools fallback waited out its 5 s. On top of that,
the timeout was reported as *"this simulator was not booted through
argent … call boot-device with force=true"*, sending the agent to reboot
a healthy simulator.

Minimal repro (no device): `JSON.stringify({label:"a
b"})+"\n"` through
`readline` → 2 lines, 0 parsed.

## Fix

- **`utils/ndjson-socket.ts`** (new): `attachNdjsonReader` frames on the
`\n` byte only (`0x0A` can never occur inside a multi-byte UTF-8
sequence, so this is exact) and reports every unparseable frame to
stderr instead of swallowing it.
- Wired into the three device-facing JSON sockets: `ax-service.ts`,
`native-devtools.ts`, `android-devtools-client.ts` (the Android helper's
`org.json` and the iOS dylib's `NSJSONSerialization` both emit U+2028
raw, so all three were exposed). The two remaining `readline` uses read
process **stdout** for readiness markers — no JSON, no user text — and
are left alone.
- **`describe/platforms/ios/index.ts`**: a throw from `axApi.describe()`
(timeout, RPC error) now carries the error message in the hint and uses
the resolved api's real `degraded` flag, instead of being reported as
the boot caveat. Separate commit so it can be reverted independently.

## Verification

- 6 new unit tests for the reader (U+2028/U+2029 in a string, chunk
split mid-multibyte char, several frames per chunk, CRLF/empty lines,
dropped-frame reporting, trailing frame on `end`) and 3 for the hint
(`describe-tool.test.ts`). Full tool-server suite: 4816 passed.
- Live A/B on iPhone 16 / iOS 18.5, Safari page with `<meta
charset=utf-8>` and a U+2028 inside a `<p>`:
- argent 0.22.1 (`main`): `describe` **12.6 s**, `source:
native-devtools`, web text missing, false "not booted through argent"
hint.
- This branch (`dist` against installed binaries): **50 ms**, `source:
ax-service`, label `"In reply to Dave
Loving it so far"` present, no
hint, 0 dropped frames logged.

## Docs

No docs update needed — no tool, CLI, config or flow surface changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01VwqqHRhfuHXbKr6LYskxhJ
2026-08-28 17:07:15 +02:00
Ignacy Łątka 43b7d79a2a fix(boot-device): stop passing -crash-report-mode to emulators that reject it (#557)
## Problem

`boot-device` cannot launch an Android emulator on builds that do not
accept
`-crash-report-mode`. The emulator rejects the unknown option and exits
before
the device ever comes up:

```
$ argent run boot-device --args '{"avdName":"Pixel_6_API_34"}'
emulator binary exited with code 1 before the device booted
```

## Cause

`-crash-report-mode` is undocumented and missing from many emulator
builds, so
#306 added a feature-detect gate:

```ts
const crashReportArgs = (await emulatorSupportsFlag("-crash-report-mode"))
  ? ["-crash-report-mode", "never"]
  : [];
```

That commit was a pure addition. It never removed the flag from the
shared
`LAUNCH_HARDENING_ARGS` constant, which still reads:

```ts
const LAUNCH_HARDENING_ARGS = [
  "-no-boot-anim", "-netfast",
  "-crash-report-mode", "never",
  "-no-metrics",
] as const;
```

So the gate is dead code. Every launch gets the flag unconditionally
through
the constant, and builds that do support it are handed it twice.

## Proof

On Android emulator 36.1.9.0, whose `-help` does not list the flag:

```
$ emulator -version
Android emulator version 36.1.9.0 (build_id 13823996)

$ emulator -help | grep -c -- -crash-report-mode
0
$ emulator -help | grep -c -- -no-metrics
1
```

`-list-avds` and an unknown `-avd` both short-circuit before option
validation, so the rejection only surfaces once a real AVD resolves.
Against
one, with everything else held equal:

```
$ emulator -avd Pixel_6_API_34 -check-snapshot-loadable default_boot \
      -crash-report-mode never
INFO    | Found systemPath .../android-34/google_apis/arm64-v8a/
unknown option: -crash-report-mode
please use -help for a list of valid options       # exits, never boots

$ emulator -avd Pixel_6_API_34 -check-snapshot-loadable default_boot
INFO    |      Ok: System requirements to run avd: `Pixel_6_API_34` are met
```

The launch dies at option parsing, after the AVD resolves and before any
device work. `hardeningArgs` reaches the snapshot probe, the hot boot
and the
cold boot alike, so on such a build all three spawns abort.

## Fix

- Drop `-crash-report-mode never` from `LAUNCH_HARDENING_ARGS`, leaving
the
  `crashReportArgs` gate as the only source of the flag.
- Spread `crashReportArgs` into the `-check-snapshot-loadable` probe's
`extraArgs`. The probe is itself an emulator spawn, so it gets the same
crash-dialog suppression as the two boots; once the flag left the
constant
it would otherwise have been the only site missing it. This one is not a
  snapshot-parity requirement - unlike `-noaudio` and `-netfast`,
`-crash-report-mode` selects crash reporting rather than qemu devices,
so a
  mismatch would not invalidate the saved snapshot.
- Leave the gate where it is, after the already-running reuse fast path,
so
the `emulator -help` probe is still skipped when nothing will be
spawned.
- Fix the prose that had drifted: the comment above
`LAUNCH_HARDENING_ARGS`
still listed `-crash-report-mode` among its flags and claimed they all
need
no detection, and both it and the `emulatorSupportsFlag` docstring said
the
flag is "only present in newer emulator releases (~36.x and late 35.x)"
-
  which 36.1.9.0 not having it contradicts.

`bootAndroidImpl` is the only emulator launch path in the repo, and all
three
of its spread sites are covered.

Making the gate load-bearing put weight on a probe that was only ever
advisory, so `emulator -help` detection is hardened alongside it:

- `emulatorSupportsFlag` memoized its verdict for the process lifetime
even
when the probe learned nothing - a transient spawn failure, a timeout,
or a
silent zero-exit run pinned "unsupported" until restart. An empty
capture is
  now reported to stderr and left uncached, so the next boot retries.
- A killed probe is no verdict at all: the flag may sit in the part of
the
listing the child never transmitted, so partial output from a timeout is
  discarded rather than read as absence.
- The three emulator probe spawns (`-help`, `-list-avds`,
`-check-snapshot-loadable`) pass `killSignal: "SIGKILL"`, matching
`runAdb`.
  `execFile`'s default SIGTERM can be ignored by an emulator wedged in
Qt/graphics init, which leaves the promise unsettled past every caller's
  deadline.

## Tests

A new `-crash-report-mode feature gate` block in
`packages/tool-server/test/boot-device-hotboot.test.ts` asserts the real
spawned argv and the probe's `extraArgs`:

- gate false: zero occurrences at the probe, hot-boot and cold-boot
sites;
- gate true: exactly one occurrence at each, always followed by `never`.
The
count matters - asserting mere presence passes on the double-pass code
and
  would guard nothing;
- plus a test that `-help` detection is skipped entirely on the
already-running
  reuse fast path, pinning why the computation sits where it does.

`emulatorSupportsFlag` is stubbed in that file's existing
`vi.mock("../src/utils/adb")` factory, because the real one memoizes its
`-help` result per binary for the process lifetime and both branches
could not
otherwise be exercised in one file. The two pre-existing
launch-hardening
assertions were updated to the four remaining flags.

`packages/tool-server/test/emulator-supports-flag.test.ts` is new and
covers
the detection hardening: which outcomes are memoized (a verdict read
from a
non-zero exit's captured listing, and a normal zero-exit run) and which
are
not (spawn failure, timeout with empty streams, zero exit with no
listing,
timeout with partial output), the stderr warning, and `SIGKILL` on all
three
spawns.

Discrimination check - both source changes reverted (flag back in
`LAUNCH_HARDENING_ARGS`, `crashReportArgs` dropped from the probe's
`extraArgs`), tests kept:

```
Tests  5 failed | 26 passed (31)
  x omits the flag from the snapshot probe and the hot-boot spawn when the emulator rejects it
  x omits the flag from the cold-boot spawn when the emulator rejects it
  x passes the flag exactly once to the snapshot probe and the hot-boot spawn when supported
  x passes the flag exactly once to the cold-boot spawn when supported
  x keeps exactly one flag occurrence on the probe-reject cold fallback when supported

  AssertionError: expected 1 to be +0   (x2 - the flag reaches a site that must not see it)
  AssertionError: expected 2 to be 1    (x3 - the flag arrives twice)
```

Fix restored: `Tests 31 passed (31)`.

## Verification

- `npm run build` (`tsc --build`): clean.
- `npx vitest run` in `packages/tool-server`: clean.
- `npm run typecheck:tests --workspaces --if-present`: clean. This is
the CI
  gate that plain `tsc` + vitest does not cover.
- `npx prettier --check .` and `npm run lint` (`eslint . --max-warnings
0`):
  clean.
- End to end on a real emulator (36.1.9.0, the build that rejects the
flag).
  The gate reads the binary correctly:

  ```
  emulatorSupportsFlag(-crash-report-mode) = false
  emulatorSupportsFlag(-no-metrics)        = true
  emulatorSupportsFlag(-no-boot-anim)      = true
  ```

  and `boot-device` against `Pixel_6_API_34` returns
`{"platform":"android","serial":"emulator-5554","booted":true}` in 4.8
s.
  The live process argv shows the flag dropped and the four remaining
  hardening flags intact:

  ```
qemu-system-aarch64 -avd Pixel_6_API_34 -force-snapshot-load
-no-snapshot-save \
    -gpu auto -noaudio -no-boot-anim -netfast -no-metrics
  ```

`-force-snapshot-load` means the hot-boot path ran, so the snapshot
probe
  accepted its flag-free argv too.

## Docs

No docs update needed: `-crash-report-mode` is an internal launch
argument
that no reference page, config key or tool parameter exposes.
2026-08-28 12:49:10 +02:00
Jakub Piasecki d4a712c401 refactor(gesture): rename the momentum-free flag to momentum (#732)
`settle: true` collided with the runner's other `settle` — waiting for
the tree to quiesce — which is the very thing a swipe step does after
every dispatch, whichever way the flag is set. One sentence of the flow
skill used both senses at once.

This names the physics instead: `momentum: false` is the momentum-free
gesture, and the natural fling is the default the prose already
described.

### Renamed

| surface | before | after |
| --- | --- | --- |
| `swipe` flow directive | `settle: true` | `momentum: false` |
| `gesture-swipe` param | `settle` | `momentum` |
| `gesture-drag` param | `settle` | `momentum` |

The directive passes the flag straight through to the tools, so all
three move together. `momentum: true` is the default and normalizes to
absent, mirroring what `settle: false` did — parse/serialize stay exact
inverses.

### Also

- Recording summary label `(settle)` → `(momentum-free)`
- Internal constants → `MOMENTUM_FREE_EASE_EXPONENT`,
`MOMENTUM_FREE_MIN_STEPS`
- `flow-run` tool description and `argent-create-flow` skill updated

### Testing

`tsc -b`, eslint (`--max-warnings 0`) and prettier clean; `test/flows` +
`test/tools` pass 671/671, including a new case covering the
legacy-spelling rejection.
2026-08-28 09:55:47 +02:00
Jakub Piasecki e314b3ef4a feat(flow): swipe directive (#520)
Add one semantic finger flick for dismissing cards, paging carousels,
opening drawers, and other gesture-driven actions distinct from
goal-seeking scroll-to. Support bare directions plus from with exactly
one of direction, to, or by, optional settle, and positive finite
duration.

Resolve selector or normalized-point anchors through the shared target
path. Direction uses the Maestro geometry presets and now rejects
anchored gestures that would collapse or travel opposite to the
requested direction. Relative by deltas saturate supplied axes to [0, 1]
and reject any effective zero or sign reversal. Resolved starts must
remain on-screen, while explicit endpoints stay author-controlled.

Keep parse and serialization invariants aligned for programmatic steps,
improve unknown-key hints and selector diagnostics, surface swipe
targets in run reports and recording summaries, share
positive-millisecond validation, and reuse the common flow test harness.

Dispatch gesture-swipe on touch platforms and gesture-drag on Chromium,
reject touch directives on Vega, and document when to prefer scroll-to
or a raw system-edge gesture.
2026-08-28 09:55:47 +02:00
Hubert Gancarczyk 1e562cee8c perf(flow): resolve the terminate command only where the reason quotes it
`explainTargetingFailure` built the `xcrun simctl terminate` string before
dispatching to a branch, so all four paid for it - including the three that
never interpolate it. The cost is not free: with `ios.additionalDeviceSets`
configured, `deviceSetForUdid` probes the default set and each configured one
with a sequential `simctl list devices --json`, and a udid found in none
caches nothing, so the sweep re-runs per call. This sits in the catch of
every failed read, and a failing `await:` rebuilds its reason once per poll.

Move the call into the ambiguous branch, and into the probe-failure branch's
"clear the others" clause, which only appears above one connection.

Measured against a booted simulator, one additional set configured, each
branch driven cold through the real `queryFullHierarchyTree`:

  branch                        before  after   quotes it
  ambiguous (2 apps)             112ms  142ms   yes
  single app not foreground       95ms    0ms   no
  probe failed, 1 connection      98ms    0ms   no
  probe failed, 2 connections     97ms   94ms   yes
  no connected app               103ms    0ms   no

Every reason comes back byte-identical. The new case pins the coupling
across both connection counts; hoisting the call back fails it.
2026-08-27 13:20:10 +02:00
Hubert Gancarczyk 3803bb2d45 docs(flow): cut the depth-cap comment to what the constant cannot say
Three review threads landed on this one docblock. Two found claims in it
that do not hold:

- "nothing truncates it" - the recorder's cross-tree re-probe quotes a
  determinate reason through `cappedReason`, bounded at
  MAX_PROBE_REASON_CHARS (1250). Only the run/replay surfaces are unbounded.
- the `compatibilityMissNote` mapping - it branches on whether the locator
  matched, not on the condition, so a `text` condition whose selector missed
  reaches the not-located arm, where `selectorMissNote` does quote tree text.

Both paragraphs restated behaviour that already lives beside the code that
produces it: the hoisting fallback at `evaluateCondition`, the quote bounds
at `CODEPOINT_DUMP_MAX` and `typographicVariantNote`. What is left is the
pair of facts the constant cannot state on its own - why 40 was too low, and
why the headroom above a real tree costs nothing.
2026-08-27 13:18:11 +02:00
Hubert Gancarczyk ff6d67a17b docs: separate the two fates of an invisible-only selector field
"A field of only invisible characters matches nothing rather than everything"
is true of `role`, `identifier` and `expectedText`, which reach the runtime
gate, and false of `text`, which the schema refuses in both the tool and the
flow parser:

  text must contain at least one visible character (icon-font/private-use and
  zero-width characters render as nothing) — select by identifier or role instead

An author told their selector matches nothing goes looking for a wrong match
instead of reading the validation error they actually got. Checked on a
Chromium target through both surfaces.
2026-08-27 12:30:17 +02:00
Hubert Gancarczyk 1c57f3adf5 docs(flow): say that any: true needs a scope where an author reads it
The sentence an author follows while authoring presented `any: true` as usable
on its own. The parser refuses it:

  assert.visible: `any: true` matches every element on screen — pair it with a
  scope (within/after/next) so it selects something specific

Both the tool description and the selectors section of the docs page already
stated the requirement; these two sentences did not.
2026-08-27 12:30:17 +02:00
Hubert Gancarczyk 8366764227 docs(flow): state what a wrapper half in a needle really does
The reference said a needle carrying half a bidi wrapper "puts the closing half
mid-label and matches nothing". That is false at the label's edges: a needle of
just the opening wrapper and the start of the label matches, and so does one
ending exactly at the closing wrapper. An author reading the mechanism rather
than the advice predicts wrong results there.

The half has to meet the same half in the label at the same place, which is
what makes a fragment copied out of the middle fail. Pinned in the comparator
tests, and checked on a Chromium target against a bidi label.
2026-08-27 12:30:17 +02:00
Hubert Gancarczyk 72b776924b test(await-ui-element): pin the zero-area filter its flow twin already pins
The miss note keeps only visible candidates for a `visible` wait and all of
them for `exists`, which accepts zero-area nodes. The flow runner's copy of
that rule is covered; this one was not, in either direction - dropping the
filter let a `visible` timeout point at a node that is off screen, and applying
it unconditionally took the explanation away from `exists`, and neither turned
a test red. Both mutations now fail.
2026-08-27 12:30:17 +02:00
Hubert Gancarczyk 564adf8342 test(ui-tree-match): pin the order selectorMissNote asks its two questions in
The rationale promises the codepoint answer first, because it names exact
characters, but nothing enforced it: swapping the two loops left the whole
selector suite green. With one candidate on screen carrying a blocking
invisible and another that is a typographic variant of the same needle, the
swap hands the author the vaguer answer about the wrong element.

The case runs both tree orders, so the pin is about the loops rather than about
which node comes first.
2026-08-27 12:30:17 +02:00
Hubert Gancarczyk c6c58bf6cb test(ui-tree-match): pin the literal grade against promotion, not only demotion
Every existing grade test catches a demotion. Raising EXACT_LITERAL to 3
survived all of them, and it moves what a tap targets: a selector whose text
matches a large container literally and a small leaf only through the fold ties
at 2 today, so the smallest-frame rule takes the leaf, while at 3 the container
wins outright and the tap centre moves with it.

The new case sits exactly on that boundary - one literal field against two
folded ones - and fails under the promotion.
2026-08-27 12:30:17 +02:00
Hubert Gancarczyk 353ca92bdf fix(ui-tree-match): stop the fold cache throwing away every warm entry per poll
The cache counted entries, not characters, and cleared itself wholesale. Both
hurt a long-running server:

- A key retains the ORIGINAL string, and one card's label has reached 11,532
  characters, so an entry cap alone let the map hold megabytes of dead
  originals for the life of the process.
- A screen emitting more distinct strings per poll than the cap - timestamped
  rows, a ticking relative time - wiped every stable label beside them on every
  pass, so the next pass refolded the whole tree cold, indefinitely.

The cache is now two generations: a lookup reads both and promotes a hit out of
the older one, so a string read once per cap-worth of new keys is never
evicted, and a rotation drops only what nothing has read since. Both the entry
count and the character count trigger the rotation. A worst-case tree of
ordinary labels still fits one generation - measured warm re-fold of 24k
strings: 5.18 ms min against 5.19 ms before, 0 refolds.
2026-08-27 12:30:17 +02:00
Hubert Gancarczyk 6cf409b0bc fix(ui-tree-match): diagnose and defuse the control characters that never fold
Cc is not Default_Ignorable_Code_Point, so U+0000, U+001C-U+001F and DEL fell
outside every question the notes ask, and quoteScreenText passed them into the
tool's JSON reply as raw bytes:

  element matched but its text was "Sign<raw DEL> in" (wanted to equal "sign in")

with no explanation of the miss. The chromium walker concatenates DOM text-node
values with no Cc filter, DEL is legal in HTML text, and a copy-paste out of a
log or a PDF carries the rest.

The C0 controls the fold does not already absorb as whitespace join the inert
ignorables the notes strip, so a codepoint note now names them, and the quote
prints them as <U+007F> rather than as the byte.
2026-08-27 12:30:17 +02:00
Hubert Gancarczyk 7e5368d3e0 fix(ui-tree-match): explain a miss that carries an invisible AND a typographic variant
The two diagnostic gates partitioned the misses rather than composing. The
invisible gate needs the ignorable-stripped remainders to compare equal, which
a rendered ellipsis defeats; the typographic gate needs NFKC equality on the
originals, which a never-folded invisible defeats. A label carrying both -
hyphenation inserting a soft hyphen, truncation inserting an ellipsis - got no
explanation from either, on the very failure this work exists to explain:

  element matched but its text was "Load<SHY>ing…" (wanted to contain "Loading...")

Both notes now accept a compatibility variant beside the invisible characters
and name the two causes, code points first. A miss the typographic question
already answers on its own keeps its single note, so a stray zero-width space
next to a ligature still adds no code point the reader has no use for.
2026-08-27 12:01:01 +02:00
Hubert Gancarczyk dc8664945e fix(ui-tree-match): stop claiming ONLY invisible characters differ when case does too
The note's gate accepts a pair the comparator equates, so a case, spacing or
composition difference can stand beside the invisible one - and the codepoint
dumps in the same sentence print it. confusableTextNote("Home<CGJ>", "home")
led with "differ only in invisible characters" above [U+0048 ...] vs
[U+0068 ...].

The word ONLY is now conditional on the rest of the two strings being identical
once the inert ignorables are gone, and the note names what else differs when
it is not. Each caller asks that question through its own relation: equality
for the whole-string note, "appears in" for the substring one.
2026-08-27 11:55:16 +02:00
Hubert Gancarczyk 4849bf5a82 docs(ui-tree-match): describe the default-ignorable set as it is
The comment claimed the regex excluded the sequence builders, which lives in
the helpers instead, and called every member a character whose removal leaves
the same appearance - which the RENDERING_AFFECTING comment beside it denies of
U+00AD and U+180E.
2026-08-27 11:51:48 +02:00
Hubert Gancarczyk 6df80a7aa2 refactor(flow): export frameWithin again, now that the focus gate reads it 2026-08-27 11:05:24 +02:00
Hubert Gancarczyk 2a5715f985 Merge branch 'fix/flow-unfalsifiable-checks' into sm/725 2026-08-27 11:05:15 +02:00
Hubert Gancarczyk 0dd8f52644 Merge branch 'fix/flow-positive-check-second-read' into fix/flow-type-requires-focus 2026-08-27 11:05:15 +02:00
Hubert Gancarczyk 9a4d0bec0f Merge branch 'fix/recorder-ios-instrumentation-wait' into sm/724 2026-08-27 11:05:14 +02:00
Hubert Gancarczyk 57f86e1119 Merge branch 'fix/recorder-tap-replay-fidelity' into sm/723 2026-08-27 11:05:14 +02:00
Hubert Gancarczyk a79fd5c6ca refactor(flow): keep frameWithin module-private until a caller needs it
The knip gate now runs at --max-issues 0, and this export has no reader
outside ui-tree-match.ts until the focus work adds one.
2026-08-27 11:05:05 +02:00
Hubert Gancarczyk b5e3526001 Merge branch 'fix/flow-positive-check-second-read' into fix/flow-type-requires-focus 2026-08-27 10:22:17 +02:00
Hubert Gancarczyk 37184c14f6 Merge branch 'fix/flow-unfalsifiable-checks' into fix/flow-positive-check-second-read 2026-08-27 10:19:32 +02:00
Hubert Gancarczyk 33c99cbb3a Merge branch 'fix/recorder-ios-instrumentation-wait' into fix/flow-unfalsifiable-checks 2026-08-27 10:09:41 +02:00
Hubert Gancarczyk 394b936b4e Merge branch 'fix/recorder-tap-replay-fidelity' into fix/recorder-ios-instrumentation-wait 2026-08-27 10:05:16 +02:00
Hubert Gancarczyk faf2452bbd Merge branch 'fix/ios-flow-tree-depth-and-targeting' into fix/recorder-tap-replay-fidelity 2026-08-27 10:03:05 +02:00