Commit Graph

19 Commits

Author SHA1 Message Date
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
Jakub Piasecki abe5ff3c56 refactor(flow): generalize when's block machinery so a second block directive can't miss a site (#748)
`when` is the only directive with a sibling `steps:` list, and four
runner sites asked `kind === "when"` directly to decide whether a step
has authored children to expand as skip lines. A second block directive
would have had to remember all four, and a forgotten one silently drops
a whole block from the report — exactly the invariant
`reportBlockSkipped` exists to hold.

This introduces `blockSteps(step)` as the single runtime predicate and
`BLOCK_DIRECTIVE_KEYS` as its parse-time counterpart, and extracts the
parse-side block plumbing (`assertBlockDepth`, `parseBlockSteps`) that a
second directive must share — notably the depth cap, renamed
`MAX_BLOCK_DEPTH`, which has to be ONE counter across directives or an
alternating nesting chain evades it and reopens the cyclic-YAML-alias
hole it was added to close.

No behavior change, with one deliberate exception: the depth-cap message
now reads `block directives nest deeper than 20 levels` where it said
`when blocks nest deeper than 20 levels` — the cap stopped being
`when`'s the moment it became shared, and a message naming only `when`
would misdirect an author who hit it through a different directive.
Every other error message, their ordering, and the report shapes are
untouched.

559 flow tests pass unchanged.

---

**Stack**

1. **#748 — you are here** · `refactor(flow)`: generalize when's block
machinery → `main`
2. #749 · `feat(flow)`: add `repeat:` blocks → #748
2026-08-13 21:51:23 +02:00
Hubert Gancarczyk afd722924f feat(flow): support concurrent flow recordings (#574)
## Problem

Only one flow could be recorded at a time, anywhere on the machine.
Recording state was three module globals in `flow-utils.ts` — harmless
in a per-client process, but the tool-server is a **host-wide singleton
per install bundle**. Every MCP client, every subagent sharing a
parent's connection, every `argent` CLI call and every project on the
machine attaches to the same process, the same `Registry`, and those
same three globals. HTTP requests are served concurrently with no
serialization.

Three failures followed, all reproduced against the pre-change code:

1. **A second recording clobbered the first.** `startRecordingSession`
just overwrote, so the first agent's next `flow-add-step` silently
appended into the *other* agent's flow file — in a different project.
2. **Any agent could finish any agent's recording.**
`flow-finish-recording`'s schema was literally `z.object({})`.
3. **Replay stomped recording.** `resolveFlowFilePath` called
`setActiveProjectRoot`, so a `flow-execute` in project B rebound the
global root mid-recording of project A. Already worked around, locally
only, in `flow-add-step`.

## Approach

Recording state is now a `Map` keyed by the resolved flow file path,
`<project_root>/.argent/flows/<name>.yaml` — the identity of the
artifact being built, so "same key" means "same output file" and two
sessions on one key are a genuine collision rather than an accident of
scoping. `flow-add-step`, `flow-add-echo` and `flow-finish-recording`
each take `name` + `project_root`, so every call is self-contained;
required rather than optional-with-fallback, since a fallback would
succeed while an agent is alone and fail only once a second recording
exists.

`resolveFlowFilePath` is pure, which fixes defect 3 and retires
`FLOW_PROJECT_ROOT_REQUIRED` (the state it guarded can no longer exist).

Rejected alternatives — keying by device udid (flows are deliberately
device-portable, and `flow-add-echo`/`flow-finish-recording` have no
device at all), an opaque `recordingId` (an agent that loses the token
cannot recover), and per-caller identity from the transport
(`ToolContext` carries no caller identity, and subagents share their
parent's MCP connection, so it would fail the exact scenario being
fixed).

### Locking, and why readers needed more than a lock

Every mutation of one flow file runs under a lock keyed by that path —
appends, `flow-start-recording`'s truncate-and-register, and
`flow-finish-recording`'s read-summarize-clear. Keyed by file rather
than owned by the session, because a restart *replaces* the session: a
session-owned lock cannot exclude the operation that supersedes it. Per
file, not global, so two recordings never queue behind each other. An
append whose session was finished, restarted or evicted meanwhile fails
with `FLOW_NO_ACTIVE_RECORDING` rather than writing into a different
take.

The lock serializes writers only, and no reader of a flow YAML can join
it — `flow-execute`'s own load, its `run:` fragment load,
`flow-read-prerequisite`, `flow-add-step`'s sibling-fragment check, and
the `argent` CLI reading from another process entirely, where an
in-process lock cannot reach. Both writers used a plain `fs.writeFile`,
which opens `O_TRUNC`, so a reader could land between the truncate and
the write. That case is silent: `parseFlow("")` returns `{ steps: [] }`
with no error and `summarize` derives `ok` from "no failures", so a
`flow-execute` racing an append reports a **top-level PASS having
replayed zero steps**. Both writes now go through a sibling temp file
and a rename, so a reader sees either the whole old file or the whole
new one — and unlike the lock, that also holds cross-process.

### `stop-all-simulator-servers` gets a `devices` scope

Every agent is told to call this at session end, and unscoped it walked
the whole registry — so agent A wrapping up tore down agent B's devtools
mid-recording, degrading B's flow to brittle coordinate taps. It now
takes an optional `devices` scope and reports `unmatched` ids, so a
mistyped id doesn't read as a clean machine.

Which namespaces count as "owned by a device" turned out to be the
load-bearing part, because nothing cascades to most of them and
`unmatched` turns any omission into an actively wrong diagnosis — a
correct device id reported as a typo. The set now covers `AXService`
(the in-sim ax daemon, spawned `--timeout 3600`; an iOS session that
only ran boot/launch/describe owns this and nothing else),
`ScreenRecordingSession` (an ffmpeg child plus the touch-visualizer
overlay it enabled on the device), `NativeProfilerSession` (an xctrace
child, or an on-device perfetto process and its trace file) and
`JsRuntimeDebugger` (a bound loopback server, the CDP socket to Metro, a
log handle). The debugger URNs interpose the Metro port —
`<ns>:<port>:<deviceId>` — so the matcher understands both shapes,
consuming only the first colon so a wireless adb serial after the port
still compares whole.

`stop-simulator-server` shared none of this and had drifted: it looked
URNs up with an exact, case-sensitive `services.get()`, so a lower-cased
UDID silently no-op'd there while the scoped stop-all reaped it. Both
tools now share one matcher. Their namespace *sets* stay deliberately
different, documented where they are defined — `stop-simulator-server`
is also the documented recovery for a wedged transport, and widening it
to devtools/AX would make a routine retry drop the native-devtools
connection another agent's recording depends on, which is the hazard the
`devices` scope exists to prevent.

## Verification

**End-to-end, three devices, one shared tool-server** (iPhone 16 Pro,
iPhone 17 Pro, Pixel 3a): three interleaved recordings, plus an
unrelated `flow-execute` in a fourth project while all three were live.
Both iOS devices held their own live native-devtools concurrently (two
per-UDID sockets, `connected: true`). Each YAML contained exactly its
own steps in order, taps captured as selectors rather than coordinates,
and neither picked up another's or the third root's path. All three
replayed green — also through the **released 0.17.0 CLI** against the
modified server, which checks wire compatibility. A device-scoped
teardown of sim 1 left sim 2 and the emulator running.

Re-run against the final build:

- Two interleaved recordings across two project roots on one device,
with a **separate process** polling one of the flow files as fast as it
could for the duration. Zero empty or truncated observations; each YAML
held exactly its own steps; both replayed green (6/6 and 5/5). Against
the pre-change in-place write the same reader observes zero-step reads
interleaved among the real ones — the silent-PASS input.
- A scoped teardown of the iPhone reaped `AXService` and
`NativeDevtools` for that UDID only, reported no `unmatched`, and left
the second sim and the emulator running. Lower-casing the UDID matched;
a genuinely bogus id was still reported.
- With a screen recording live on the Pixel 3a, a scoped teardown reaped
`ScreenRecordingSession` and the **ffmpeg child actually exited** (1 →
0). Before the fix it survived to the 180 s cap while the tool called
the correct serial a typo.

The pre-change repros were re-run afterwards to confirm observed
behavior changed, not just the tests.

`npx vitest run` in `packages/tool-server` (295 files, 3154 tests) and
`packages/argent-cli` (280), `tsc --build`, `tsc --noEmit` on both test
projects, eslint and prettier — all green.

## Review notes

Several rounds of review found real defects in this change; each was
reproduced before fixing and re-verified after. Worth knowing when
reading the diff, since the tests exist to pin them:

- `flow-start-recording` truncated the `.yaml` outside any lock, so a
step from the take being discarded landed in the freshly reset file and
was reported as success (26-29 of 200 runs).
- `flow-finish-recording` never took the lock; its `await fs.readFile`
was a yield during which a concurrent append committed, leaving the
reported summary disagreeing with disk (11-16 of 200).
- A failed finish destroyed the recording. Hand-editing the `.yaml`
mid-recording is a documented workflow, so `parseFlow` can legitimately
throw — and the error told the agent to call `flow-start-recording`,
which truncates the very take it would recover.
- `unmatched` reported a device this session had just stopped, because
`disposeService` returns a node to IDLE and keeps it.
- A bare IP claimed every wireless-adb device at that address, since an
adb serial is itself `ip:port`.
- The eviction backstop stamped with `Date.now()`; ties at millisecond
resolution let it drop the session that was just used.
- `requireRecordingSession` ended with "Call flow-start-recording first"
— reached for a key that was finished, superseded or evicted as well as
one never started, and on those branches the file on disk is fully
populated while that call truncates it and reports no `restarted`.
- The atomic write regressed twice on its own terms: its scratch name
was derived from the flow file's basename, which has no length cap, so a
long flow name that appended fine before failed `ENAMETOOLONG`; and the
cleanup guard began after the temp file was created, leaking a scratch
file into a committed directory on any write that failed after opening.
- Two tests passed against implementations they claimed to reject: the
`stop-simulator-server` narrowness guard used a udid that classifies as
*android*, where the iOS-only services it guards can never appear, so
widening the iOS branch kept it green; and the client-mode finish
asserted only the *shape* of `savedTo`, never its content, though that
directive is the only thing that lands the file in client mode.

## Notes for reviewers

- Client/remote persist mode is the least-exercised path, and now has
its own concurrency tests (overlapping appends, and a restart
superseding an in-flight one) — it previously had none. The
`project_root` key is byte-stable across the file boundary (`kind:
"probe"` passes it through unchanged), which is what keeps a remote
recording addressable from `flow-add-step`, which declares no file
input.
- Two spellings of one flow file still mint two sessions — and two
independent locks — over one file, which bypasses every guarantee here.
Two ways in: the root spelled two ways (a symlink, or a case variant on
APFS), and the likelier one, the *name* cased two ways on a
case-insensitive volume. Neither is normalized away because the correct
normalization is the filesystem's: case-folding the key would wrongly
merge two distinct flows on ext4, and resolving symlinks is impossible
in client mode, where the root does not exist on this host. Documented
at `getFlowPath`.
- No recording tool can detect that another agent took its key.
`flow-add-step`, `flow-add-echo` and `flow-finish-recording` all
re-resolve the key on every call, so after a takeover they act on
whichever take is now live and report success: an append lands in the
other agent's file, and a finish finishes and clears it. The only case
that fails loudly is the narrow one the liveness check covers - an
append already IN FLIGHT when the restart lands. There is no caller
identity to key ownership on (see the rejected alternatives above), so
this is spelled out for the agent under "Pick a name unique to your
task" in `argent-create-flow` rather than mechanized.
- Skew hazard in one direction only: a *new* client against an *older*
tool-server. Zod strips the undeclared `name`/`project_root`, and the
old server falls back to its module globals — so concurrent recordings
silently collapse onto one session. The other direction (released 0.17.0
CLI against this server) was verified end-to-end.
- `packages/argent-private/docs/reference.md` still describes
`flow-add-step` as appending to "the current recording", and names two
tool ids that do not exist (`flow-insert-echo`/`flow-run` are file
names; the ids are `flow-add-echo`/`flow-execute`). It is a submodule,
so the fix cannot ride on this branch.

- A restart reported `discardedSteps` from the superseded session's
in-memory flow while it truncates the *file*. Hand-editing the `.yaml`
mid-recording is documented, so the two diverge: four steps wiped, one
reported. It now counts the file, and reports no number at all when that
file cannot be read or parsed - 0 is the answer a genuinely empty take
gives. Client mode still counts from memory (this host cannot see the
client's file), and the description now says which mode it is promising.
- `ChromiumJsRuntimeDebugger` cascades from `ChromiumCdp` and was
therefore already being torn down - silently, while `NetworkInspector`
and `ReactProfilerSession` cascade the same way and were named.
`stopped` is documented as the services that were live and got shut
down, so it is listed now too.
- The client-mode case named for a superseded in-flight append restarted
*between* calls, so the next append re-resolved the key and succeeded -
the guard was never reached, and neutering it left the whole file green.
It now parks an append in its live sub-tool call across the restart.
- `stop-tools.test.ts`' mock handed the tool the live service map where
the real `getSnapshot` copies, so a cascade rewrote the state the sweep
was still reading and the answer depended on map insertion order. The
mock copies and recurses like `_teardown` now, and the cascade case runs
under both orders.
- Prose the code contradicted, each verified against source before
rewriting: Vega owns `JsRuntimeDebugger`/`NetworkInspector` once the
debugger has run (and can match anything at all through an ERROR node);
"nothing here cascades" was denied twice in its own file; an iOS
boot/launch/describe session owns `NativeDevtools` too; `:tcp` is
mintable but unreached in production; the tool-server is a singleton
*per install bundle*, not per machine; `argent flow list` does enumerate
`.argent/flows`; the stop-tools comments narrated a "before" that only
existed on this branch.

Found while verifying, out of scope, worth their own issues: a `tool:
flow-execute` self-reference recurses past both cycle guards (`run:` is
guarded, a nested `flow-execute` reseeds the stack); react-profiler
artifacts collide host-wide on a second-resolution filename;
`stop-metro` is port-scoped and kills another agent's bundler;
`debugger-reload-metro`'s HTTP fallback broadcasts to every attached
client. A tool's `capability` is enforced only by the HTTP layer, not by
`registry.invokeTool`, so a call that reaches the registry another way -
`flow-add-step` takes `command` as a bare string - can resolve a service
the tool declares it does not support, leaving an ERROR node behind
(e.g. `SimulatorServer:<vega serial>`).

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-08-10 10:54:58 +02:00
Jakub Piasecki 22033e1418 feat(flow): allow to run every flow in a directory from flow run (#576)
`argent flow run` now accepts a directory and runs every flow in it
sequentially:

```
argent flow run .argent/flows --recursive
```

Discovery uses the same acceptance rules as `flow list` (lowercase
`.yaml`, safe stem, readable file), sorted for a deterministic run
order. `-r`/`--recursive` walks subdirectories too, skipping
dot-directories and `node_modules` and never following directory
symlinks. A directory named `foo.yaml` still batches (stat wins over
extension).

Batch output is failures-only: each flow prints a `[i/n] <path>` header,
its failing/errored steps — numbered exactly as a single-mode rerun
would number them — and a per-flow summary; the run ends with a
`PASS/FAIL — N flows: X passed, Y failed, Z skipped` verdict. `--json`
prints one aggregate object with per-flow reports. Exit code reflects
the batch (0 pass, 1 any failure, 2 discovery/config errors).

`--output` exports keep the single-run `<output>/<flow>/` layout, keyed
by subdirectory in recursive runs so same-stem flows in different
directories can't clobber each other's snapshots.

**Failure semantics:** a flow failing its steps — or one the tool-server
rejects as invalid (bad YAML, unparseable step) — fails alone and the
batch continues. An infra error (transport death, unclassified failure)
stops the batch and counts the remaining flows skipped. To make that
distinction possible, tool-error responses now carry the server's
failure signal (`error_code`/`error_kind`) on the wire — on the
streaming terminal error line and on JSON error bodies — and the tools
client surfaces server-answered errors as a typed `ToolInvocationError`.
Against a pre-signal server this degrades to stopping on any throw.

Not breaking: single-file runs behave exactly as before.
2026-08-05 14:51:42 +02:00
Jakub Piasecki cc2f907ce9 feat(flows): resolve run: directives from explicit YAML paths (#567)
A `run:` value is now a YAML path resolved against the directory of the
flow file that contains the step, matching the CLI cutover:

```yaml
- run: login.yaml
- run: ../shared/login.yaml   # cross-directory composition now works
```

Bare names fail at parse with a migration hint (`run: login` → `run:
login.yaml`). The filename stem stays the fragment's report attribution;
reports also carry the as-written path as `target`. Cycle detection
compares realpath-canonical paths (same stem in two dirs ≠ cycle; same
file via symlink/spelling = cycle). Snapshot baselines are unchanged —
keyed by and stored beside the root flow.

An uploaded root flow with `run:` steps is now rejected up front with a
contract error instead of a mid-run temp-dir ENOENT (its referenced
files stayed on the client, and relative resolution in the temp dir
could reach host files); same for a relative chromium app path. The
recorder emits the sibling form (`run: <name>.yaml`).

**Breaking:** existing `run: <name>` directives fail at parse until
renamed to `run: <name>.yaml`. Snapshot baselines for a saved flow whose
YAML is a symlink now anchor beside the real file (the symlink target's
directory) — move any committed `.argent/flows/__baselines__/<flow>/`
set beside the real file once, or the next run fails with a missing
baseline. A `run:` target must also resolve inside the project root or
beside/below the flow file that references it (arbitrary host YAML is no
longer reachable), and an uploaded flow with a `snapshot` step is
rejected up front (its baselines cannot anchor to the per-call temp
dir).
2026-08-05 14:51:42 +02:00
Hubert Gancarczyk 5ac69349f0 feat(flow): CSS combinator scopes for flow selectors (#569)
## What

Adds the CSS combinators to flow selectors, read off frames. Any action
or condition can now scope its matches by where an element sits relative
to another:

```yaml
- tap:    { text: Delete, within: { id: profile-card } }          # CSS "A B" — inside that container
- tap:    { role: Switch, next: { text: Wi-Fi } }                 # CSS "A + B" — the nearest follower
- assert: { visible: { role: Button, after: { text: Danger zone } } }  # CSS "A ~ B" — any follower
- assert: { hidden: { any: true, within: { id: empty-state } } }  # CSS "*" — the universal selector
```

| Scope | CSS | Reads as |
| --- | --- | --- |
| `within: <sel>` | `A B` | the match's frame sits inside the scope
element's frame |
| `after: <sel>` | `A ~ B` | the match follows the scope element in
reading order |
| `next: <sel>` | `A + B` | as `after`, narrowed to the nearest follower
of each anchor |

Plus `any: true` — the CSS `*` universal selector, accepted only
alongside a scope and never beside `text`/`id`/`role`.

The child combinator `>` has deliberately **no analog**: flow trees
flatten to leaves under one synthetic root, so parent/child structure
does not survive to replay.

## How

- **Geometric, not ancestry-based.** Frames are what survives
flattening, so every scope is resolved from them by `findAll` in
`ui-tree-match`. The synthetic root never counts as a scope element, and
every relation requires a *distinct* node (nothing scopes itself).
- **Reading order is row-band aware.** An element follows the anchor
when it starts below the anchor's bottom edge, *or* shares its row band
and sits entirely to its right. That band rule is what makes `{ role:
Switch, next: { text: Wi-Fi } }` resolve the Wi-Fi row's own switch even
though the taller switch's frame starts a couple of pixels *higher* than
the label's — a raw top-y comparison gets that backwards. `followKind`
is the single classification both the follower test and the nearest-pick
grouping read, so they cannot disagree.
- **`next` is the union over anchors**, exactly as CSS `A + B` is: three
rows on screen give three switches, one per label.
- **Scopes compose and nest**, capped at `MAX_SELECTOR_SCOPES` (6)
counted across the whole relation *tree* — a size bound, not a depth
bound, because three relations branch and the runner's loose-alternative
expansion is exponential in the bare-string scopes it finds.
- **Flow-YAML only.** `await-ui-element`'s strict schema rejects
`within`/`after`/`next`/`any`, so a scope can never be silently dropped
on that path.
- Adding a fourth relation is a **compile error**: `RELATION_RESOLVERS`
is a `Record` keyed by the relation union, so a new scope cannot be
wired through parse/serialize/reporting and then ignored by the match
engine.

## Testing

- **Unit:** full tool-server suite green — **3006 passed / 1 skipped**.
New coverage in `ui-tree-match.test.ts`, `flow-utils.test.ts`,
`flow-selector-scopes.test.ts` (renamed from
`flow-within-selector.test.ts`, now covering all scopes) and a new
`flow-skill-docs.test.ts` that parses every YAML snippet in the skill so
the docs cannot rot. Each fix is mutation-checked: reverting it fails at
least one test.
- **Real-device E2E** (Pixel 3a, Bluesky on Metro 8081, via a branch
tool-server): three flows, 20 steps, all green — geometric ordering both
ways, two `text` asserts that pin `next`'s pick *by identity* out of
five candidates, empty-scope `hidden` semantics, and two observable taps
(`next`-scoped and `any: true`-scoped) that navigated to the *adjacent*
topic rather than the anchor's own.
- **iOS not covered.** Native-devtools does not attach on either booted
simulator in this environment, so *every* flow selector fails there — an
unscoped `{ text: "Video" }` fails identically. Verified environmental,
not caused by this branch: the globally-installed argent reports the
same, direct `SIMCTL_CHILD_DYLD_INSERT_LIBRARIES` injection also fails
to connect, and it survives a forced simulator reboot on both iOS 18.3
and 26.5. Worth re-running the iOS leg once that is healthy.

## Notes for reviewers

Two deliberate deviations from CSS, both documented in the skill:

- **`next` is looser than `+`.** Where `A + B` matches nothing unless
the immediate sibling is a `B`, `next` keeps looking and returns the
nearest match further on. That is what makes it survive the wrapper and
spacer nodes a flattened tree is full of — but a row *missing* the
control you asked for resolves to the next row's rather than failing.
Happy to switch to strict `+` semantics if the loud failure is
preferred.
- **"follows" is not transitive.** Against a tall anchor an element can
follow something that follows the anchor without following it directly,
so nesting `after` scopes is not the same as chaining `~`.

Other things worth a look:

- `nearestAfter` and `afterTester` are direct scans, not indexed. An
earlier sorted index was a *second* implementation of the nearest rule
and the two disagreed on frames near the tolerance; it also measured
2-13x slower than the scan on realistic shapes, with a prune that
provably never fired. The quadratic tail is documented with measured
numbers: 1.2 ms for a single-anchor `next` at the 12k-node tree cap,
~555 ms only when the anchor selector matches essentially every node.
- A bare-string *anchor* cannot self-correct the way the loose fallback
usually does — a decoy `testID` matching your anchor text wins the
identifier pass whenever anything sits after it. The skill tells authors
to spell sibling anchors as maps.
- `MAX_SELECTOR_SCOPES = 6` replaces the previous
`MAX_SELECTOR_WITHIN_DEPTH = 10`, so a `within` chain deeper than 6 that
parsed on the earlier commits of this branch now rejects. Nothing
released depends on it.

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-07-27 18:25:27 +02:00
Jakub Piasecki e23f0e285e feat(flow): snapshot cropOn for single-element visual diffs (#516)
`snapshot` gains an optional `cropOn: <selector>` — diff one element's
region instead of the whole screen:

`- snapshot: { name: cart-total, cropOn: "Order summary" }`

- The selector resolves like a directive target: settled tree,
auto-wait, the standard
not-found failure. Selector-only — no point form (a point has no extent
to crop to).
- The frame resolves **before** the capture, and the **cropped** image
is what gets
compared, stored as the baseline, and reported as the `current`
artifact. The pixel
rect is the normalized frame × capture size; the crop is a pngjs
`bitblt` written to
  a swept scratch dir and registered only when a failure returns it.
- The baseline key stays on the **full** capture's WxH (device-class
identity), so the
previously unreachable `dimensionMismatch` guard becomes load-bearing:
an element that
grew or shrank fails with "nothing was compared" plus a
crop-fixed-size-containers
hint. Deliberately strict — a 1px layout drift fails on dimensions, not
content (the
  same tradeoff as Playwright element screenshots).
- A tree-source outage while resolving `cropOn` errors the step instead
of degrading to
  a full-screen compare against a cropped baseline.
- No tool-schema changes; `snapshotKey` and `--output` behavior ride
along unchanged.

Tests: parse/serialize round-trip + rejection cases, and runSnapshot
coverage with real
PNGs (crop dims, key vs. content, artifact retention/sweep, unresolved
selector, abort,
size-drift message, sub-pixel region).
2026-07-27 10:07:49 +02:00
Jakub Piasecki 8d633700d7 feat(flow): coordinate targets for tap and long-press (#504)
## Summary

This adds raw normalized screen coordinates as first-class targets for
both `tap` and `long-press`. Gestures can now target either an element
selector or a point, while keeping the existing selector auto-wait and
off-screen diagnostics.

Supported forms include:

```yaml
- tap: { x: 0.5, y: 0.6 }
- tap: { on: { x: 0.5, y: 0.6 }, times: 2 }
- long-press: { x: 0.5, y: 0.6 }
- long-press: { on: { x: 0.5, y: 0.6 }, duration: 1200 }
```

## What changed

- Introduced a shared gesture-target parser for selector and point
targets.
- Added raw-point execution for `long-press` and shared point resolution
between tap and long-press.
- Validate coordinates as finite normalized `0–1` screen fractions,
rejecting pixels, malformed points, mixed selector/coordinate maps, and
unknown point keys.
- Canonicalize option-bearing coordinate gestures under `on`, keeping
option keys separate from target fields.
- Preserve coordinate targets in recording summaries and flow progress
reports instead of displaying an undefined selector.
- Added serialization validation so recording cannot persist a flow that
would fail when parsed for replay.
- Updated the flow authoring skill and tool description with the new
coordinate forms.

## Behavior notes

- Raw points act immediately and do not resolve or observe the UI tree.
- Selector targets retain settling, auto-wait, and the existing
`scroll-to` hint when an element cannot be found.
- Observation directives such as `type`, `await`, `assert`, and
`scroll-to` remain selector-only.
- The mixed spelling `tap: { x, y, times }` is now intentionally
rejected; use `tap: { on: { x, y }, times }` instead.

## Validation

- Full tool-server suite: 2,618 tests passed across 266 files.
- Added parser, serialization, recording, touch execution, Chromium
execution, reporting, and malformed-input coverage.
- Tool-server build, test typecheck, ESLint, Prettier, and skill
validation passed.
2026-07-17 14:48:29 +02:00
Jakub Piasecki 3d3b82a076 feat(flow): regex matching via matches in assertions and selectors (#503)
## What

- Add `matches` as a case-sensitive JavaScript regex comparator for
`await` and `assert` text conditions.
- Support `{ text: { matches: ... } }` in every flow selector slot,
combined with `id` and `role`.
- Preserve regex matchers through parsing, YAML serialization, recording
summaries, and run reports.

## Correctness

- Prevent empty-matchable patterns from matching unlabeled nodes or
empty assertion text.
- Reject unknown raw `await-ui-element` selector keys instead of
silently dropping constraints.
- Reject selector combinations YAML cannot round-trip without changing
their semantics.
- Document native label aggregation accurately and keep action ranking
consistent.

## Maintenance

- Share selector-field and regex-syntax validation across both matcher
parse paths.
- Compile selector regexes once per tree walk and full-match regexes
once per ranking pass.
- Make comparator serialization exhaustive, extract the duplicated flow
test harness, and scope slow-test timeouts narrowly.

## Verification

- Full tool-server suite: 2,588 tests across 263 files.
- Source and test typechecks, ESLint, Prettier, and skill-copy
validation pass.
2026-07-15 08:07:03 +02:00
Jakub Piasecki 5082b63082 feat(flow): multi-tap via tap times and gesture-tap clickCount (#497)
Flows can now express double-taps declaratively:

```yaml
- tap: { on: Login, times: 2 }          # selector double-tap
- tap: { x: 0.5, y: 0.57, times: 2 }    # coordinate double-tap
```

The selector nests under `on` so option keys never share a map with
selector fields — zod would silently strip them; mixing errors with a
hint instead. `times: 1` normalizes to absent and the serializer emits
the minimal spelling, keeping parse/serialize exact inverses.

The taps dispatch as **one gesture** via `gesture-tap`'s new
`clickCount` (1–10): N Down/Up pairs ~100 ms apart on touch, escalating
CDP `clickCount` per click on Chromium (what makes `dblclick` actually
fire) — never N separate calls whose RPC gaps could miss the OS
double-tap window.

The recorder carries a recorded `clickCount` into `times`, so a recorded
double-tap can't silently replay as a single tap.
2026-07-15 07:55:31 +02:00
Jakub Piasecki 551c781fb0 feat(flow): support running end-to-end flows without agent (#429)
# Flow E2E

This PR turns flows from replayable tool-call recordings into a
declarative, portable e2e test system that runs headlessly via `argent
flow run <name>` — no LLM in the loop, non-zero exit on failure,
suitable for CI.

Agents can still replay flows via `flow-execute` and get the full output
from each step after it finishes.

## Flow format

Flows are YAML files in `.argent/flows/`, now built from **directives**
interpreted by the runner rather than raw tool calls:

- `launch` — terminate + relaunch the app under test (per-platform app
id map supported); its presence marks a flow as **e2e** (controls its
own start state) vs. a **fragment** (runs against current state,
declares an `executionPrerequisite`, composable via `run:` with
cycle/depth guards).
- `tap`, `type` (focus handshake + optional submit), `scroll-to`
(momentum-free, increments by half the container, `within:` for nested
scrollers), `await`/`assert` (`visible`/`exists`/`hidden`/`text`
conditions), `wait`, `snapshot`, `echo`, and raw `tool:` as escape hatch
— still fully supported, so previously recorded flows keep working
unchanged.
- **Selectors** are `{ text, identifier, role }`; a bare string is a
loose selector resolving identifier-first, then visible text. Any
directive failure hard-stops the flow; later steps report `skip`.

## Execution engine

- Flows store no device id — the runner binds a device at replay
(explicit `--device`/`--platform` or the single booted one).
- Selector resolution runs against **platform-unified UI trees**: iOS
native view hierarchy (full testID coverage, gated on native-devtools
connecting), Android full accessibility hierarchy (full resource-id
coverage), Chromium CDP DOM, and the Vega automation toolkit. A shared
flatten/text-hoisting pass lets `text` assertions read what an
identified container visibly shows. Vega is remote-driven, so touch
directives fail with guidance to use `tv-remote`/`keyboard`.
- Steps wait for the tree to **settle** (two consecutive identical
reads) before acting; `launch` additionally gates on platform
tree-source readiness instead of silently degrading.

## Visual snapshots

`snapshot` diffs a full-resolution screenshot against baselines in
`.argent/flows/__baselines__/<flow>/`, keyed by platform + resolution,
with configurable `maxMismatch` and `--update-baselines`. The status bar
is pinned for the whole run (iOS `simctl status_bar`, Android demo mode)
so clocks/battery don't drive diffs. Baseline/current/diff images are
returned as artifacts on failure (and the written baseline on first
capture/update); a clean pass carries none, keeping reports and agent
context lean.

## Recording

The record-live workflow (`flow-start-recording` → `flow-add-step` →
`flow-finish-recording`) now emits directives automatically: coordinate
taps are rewritten to portable selector `tap:` steps (selector captured
from the pre-tap tree), a leading `restart-app` becomes `launch:`,
`flow-execute` of a sibling fragment becomes `run:`, and device ids are
stripped. The skill instructs the agent to polish the saved YAML
afterwards (fold tap+keyboard into `type:`, swipes-to-reach into
`scroll-to:`, awaits into sugar form).

## Also included

- `argent flow run` / `argent flow list` CLI with per-step report
rendering, artifact materialization, and `--json` output.
- New structured failure codes for flow validation, device resolution,
composition, and devtools readiness.
- Extensive unit test coverage (tree adapters, selector matching,
scrolling, composition, type focus, snapshots, status bar).
2026-07-13 17:15:01 +02:00
Hubert Gancarczyk 8483fe23f9 feat: add telemetry (#274)
## Summary

Adds anonymous, opt-out telemetry for Argent installer, update/uninstall
flows, CLI telemetry controls, tool-server lifecycle, and tool
invocation health signals.
## What Changed
- Added a new private `@argent/telemetry` package
- Introduced typed telemetry events with runtime property
allowlisting/sanitization.
- Added lazy anonymous identity creation in `~/.argent/telemetry-id`.
- Added consent handling via: `argent telemetry enable`, `argent
telemetry disable`, `argent telemetry status`
- Added local debug logging with `ARGENT_TELEMETRY_DEBUG=1`.
- Tracks installer funnel events for init, global install/update
decisions, editor selection, allowlist decisions, skill installation,
rules/agents copy, update, and uninstall.
- Tracks tool-server start/stop and tool invocation/complete/fail
events.
- Adds per-tool invocation IDs so request metadata can be correlated
without relying on ordering.
- Records only coarse platform metadata (`ios` / `android`) for device
tools
- Ensures short-lived commands drain telemetry with bounded shutdown
before exit.
- Wires telemetry package into bundling and injects full Argent CLI
version metadata at build time.
- Added central `FAILURE_CODES` and `FailureSignal` helpers in
`@argent/registry`.
- Attached structured, sanitized failure metadata across registry
errors, tool-server tools/services, HTTP validation/dependency gates,
installer flows, and `argent run`.
- Added telemetry allowlisting for safe failure fields only: static
error codes/stages, coarse area/kind/command, bounded exit codes,
allowlisted signals/spawn codes, and coarse network failure classes.
- Split CLI-local `argent run` failures into `cli:run_fail` so they are
not double-counted as tool failures.
- Added installer telemetry for failed package actions, update/uninstall
failures, rules/agents copy results, and skill refresh results.
- Made telemetry consent writes atomic and kept telemetry status
side-effect free via `peekAnonId()`.
- Adds a coarse, anonymous signal to tool telemetry: which AI coding
tool is driving the MCP server (Claude Code, Codex, Cursor, Gemini, VS
Code, Windsurf, Zed, opencode, Copilot, or other).

## Tests Added / Updated

- Telemetry unit coverage for consent precedence, identity creation,
base props, sanitization, PostHog config, debug logging, shutdown,
opt-out, and forget behavior.
- Registry/tool-server tests for invocation IDs, platform metadata
boundaries, startup failure flushing, and tool lifecycle telemetry.
- Installer tests for final telemetry drain, update action
classification, and uninstall identity cleanup ordering.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:22:34 +02:00
Ignacy Łątka 74c2065ca0 fix: multiple minor bugs (#194)
## Human Summary

This PR aims to fix bugs that are benign for now, could become serious
in the future:

- multiple CSRF via CORS misconfiguration on `tool-server`
- multiple SSRF that were possible due to the CSRF
- path traversal via flow creator
- multiple shell injections
- lack of auth on `MCP/CLI <-> tool-server` path (bug details discussed
in person). After the other fixes this is somewhat redundant, but
doesn't hurt and protects against theoretical access attempts from
within other processes running on the user's device.

## AI Summary

<details>
Bundles six independent fixes that together close a CSRF cluster on the
local tool-server. The vulnerabilities chain because the HTTP API has no
auth and serves wildcard CORS, so any web origin (Firefox
unconditionally; Chrome via PNA-disabled or loopback-served) can drive
the tool-server from a cross-origin tab. Each individual fix lives on
its own branch (linked below) and stands alone — this PR exists so the
cluster can be reviewed and shipped as one piece.

### Fixes

1. **`fix(http): require auth token + drop wildcard CORS`** —
per-process 32-byte hex Bearer token, generated by the launcher,
persisted in `~/.argent/tool-server.json` with mode `0600`, required on
every route except `/preview`. Constant-time compare. Wildcard CORS
dropped. Threaded through MCP server + CLI + tools-client.
Backwards-compat: when `ARGENT_AUTH_TOKEN` is unset (e.g. `npm run
dev`), the server logs a one-shot warning and skips the gate.
2. **`fix(http): reject non-loopback Host headers`** — middleware
(before the auth gate) returns 403 for any request whose Host is not
`127.0.0.1`, `localhost`, or `::1`. Closes DNS-rebinding bypass.
3. **`fix(ios-profiler-start): prevent shell injection via device_id`**
— `detectRunningApp(udid)` no longer interpolates `params.device_id`
into `execSync` template strings. Both `xcrun simctl spawn ${udid} …`
and the piped `xcrun simctl listapps ${udid} | plutil …` are rewritten
to `execFileSync` array form, with the pipe to `plutil` happening in JS
(input option) rather than the shell.
4. **`fix(flows): validate name to close path-traversal in
getFlowPath`** — `getFlowPath()` requires `^[A-Za-z0-9_\\-]+$` and
verifies `path.relative(getFlowsDir(), filePath)` does not start with
`..`. Centralizing the check in `getFlowPath` covers every caller
(start-recording, add-step, insert-echo, finish-recording, run,
read-prerequisite) without per-tool churn.
5. **`fix(source-resolver): reject paths outside projectRoot +
non-source extensions`** — `readSourceFragment()` previously accepted
any `file` field on the SourceLocation it was given (the field traces
back to a React fiber's `_debugSource.fileName`, which is
attacker-controllable). Now: containment check via
`path.relative(projectRoot, abs)` plus an extension allowlist
(`.js/.jsx/.ts/.tsx/.mjs/.cjs/.json`).
6. **`fix(source-maps): reject non-loopback URLs (close SSRF)`** —
`doRegister()` validates `sourceMapURL` before fetching: only
`http://`/`https://` URLs whose hostname is loopback. Closes a
blind-SSRF probe of the host network (cloud metadata at
`169.254.169.254`, intranet pages, etc.).

### Individual branches

- `fix/auth-token-drop-cors`
- `fix/host-header-validation`
- `fix/profiler-command-injection`
- `fix/flow-name-validation`
- `fix/source-resolver-traversal`
- `fix/source-map-ssrf`

If you want to land them as separate PRs, the integration branch can be
dropped after each lands.

## Test plan

Unit tests (already in CI):

- [x] `npm run build` passes (workspace tsc clean)
- [x] `npm run test -w @argent/tool-server` — **510 passed (was 473 on
`main`)**, +37 new tests across:
- `test/http-auth.test.ts` — 10 tests (auth, CORS removal, dev-mode
escape hatch)
- `test/http-host-validation.test.ts` — 7 tests (loopback allow,
DNS-rebinding reject)
- `test/flows/flow-utils.test.ts` — +5 tests (path-traversal name
rejection)
- `test/metro/source-resolver.test.ts` — +5 tests (containment +
extension allowlist)
- `test/metro/source-maps-ssrf.test.ts` — 10 tests (loopback-only allow)

Live end-to-end (run locally against this branch's bundled artifacts):

- [x] Launcher spawns tool-server; `~/.argent/tool-server.json` contains
a 64-char hex token and is mode `0600`
- [x] `GET /tools` without `Authorization` → **401**
- [x] `GET /tools` with wrong token → **401**
- [x] `GET /tools` with correct token → **200** (61 tools)
- [x] Request with `Host: attacker.example` → **403**
- [x] No `Access-Control-Allow-Origin` header on responses
- [x] Cross-origin command-injection probe via `device_id=\"x;touch
/tmp/marker;#\"` does NOT create the marker file (Vuln 4 closed)
- [x] `flow-start-recording` with `name=\"../../../tmp/escape\"`
rejected with `Invalid flow name`
- [x] `flow-start-recording` with a valid name still works
- [x] `tools-client.fetchTools()` and
`tools-client.callTool('list-simulators')` succeed via the auth-token
threading (the path the MCP server uses internally)
- [x] MCP-stdio entry point (`startMcpServer`) end-to-end: `initialize`
→ `tools/list` (61 tools) → `tools/call('list-simulators')` returns
content. Confirms the launcher → token → fetch → tool-server flow is
intact for the production `argent mcp` path.

## Notes for reviewers

- `/preview` is intentionally exempt from the auth gate (it was not
gated before; tightening it deserves a separate PR).
- The `ARGENT_AUTH_TOKEN`-unset escape hatch keeps `npm run dev`
workable; production `argent mcp` always passes a token via env, so the
warning never fires there.
- The auth bundle changes the public return shape of `ensureToolsServer`
from `Promise<string>` to `Promise<{ url, token }>`. The two in-tree
callers (`argent-mcp/mcp-server.ts`,
`argent-tools-client/tools-client.ts`) were updated; external consumers
using `ARGENT_TOOLS_URL` should also set `ARGENT_AUTH_TOKEN` to match
the server.

</details>
2026-06-03 15:57:42 +02:00
Filip131311 8c2a313c76 chore: format codebase with Prettier
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:53:58 +02:00
Ignacy Łątka 167b604bb8 chore: fix typos throughout entire repo (#46)
* fix: fix typos in comments, docs, and string literals

- succesful → successful, preffered → preferred (argent.md)
- instructs to starts → instructs to start (ios-profiler/SKILL.md)
- possibilites → possibilities (react-native-profiler/SKILL.md)
- refering → referring (react-native-profiler/SKILL.md)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: fix remaining typos across docs, skills, and source files

- Add missing space in argent.md: \`describe\`and → \`describe\` and
- American English: Analyse → Analyze, recognise → recognize,
  unrecognised → unrecognized, initialised → initialized, behaviour → behavior
- Fix broken step reference in SKILL.md: step &4 → step 4
- Fix gender-specific pronoun: he does not → they do not

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(typo): add missing article 'a' in react-native-profiler SKILL.md

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:24:21 +02:00
Ignacy Łątka f8ff5492dc add tests for edge cases 2026-03-17 15:49:18 +01:00
Ignacy Łątka d871b67a75 add tests 2026-03-17 13:14:05 +01:00
Ignacy Łątka 66dc7546f3 rewrite tests to work with yaml 2026-03-13 09:46:43 +01:00
Ignacy Łątka f3a2a952ee add tests 2026-03-13 08:33:39 +01:00