Commit Graph

637 Commits

Author SHA1 Message Date
Ignacy Łątka 8e238598d7 fix(map): keep the status-bar clock out of screen titles
A system alert leaves the app foregrounded, so the crawler maps that screen
- and SpringBoard's status bar is in the AX tree alongside it. The clock is
the one status-bar item shaped like a title row (0.117 wide against the
0.07-and-under carrier and battery readouts) and it sits above the nav bar,
so it anchored the title band on any screen whose own title carries no
header-ish role. A crawl of Settings titled a screen "16:41".
2026-07-31 16:49:10 +02:00
Ignacy Łątka 888c32702b fix(map): give map-app interaction formatters and update the tool count
Merging main in surfaced a convention the new tool did not join: main's
interaction-messages test requires startedMsg/completedMsg/failedMsg on every
registered tool and pins the catalog size. `map-app` defined none of them, and
a flag-gated tool is still counted, so the merged tree failed on the count
assertion before it could report the missing formatters.

This is CI-only visible: the test does not exist on a branch cut before it
landed, so the branch's own vitest run and both typechecks pass while the
merge ref fails.
2026-07-30 16:40:45 +02:00
Ignacy Łątka 1b12f83697 Merge remote-tracking branch 'origin/main' into feat/argent-map 2026-07-30 16:39:14 +02:00
Ignacy Łątka 3ce2d5c13f fix(map): review-pass fixes across action enumeration, crawl and layout
Action enumeration
- MIN_TAP_AREA was an area floor, which is the wrong shape for what it
  documented: a 44x44pt HIG-minimum button is 0.1 x 0.046 = 0.0046 of an
  iPhone 16 Pro Max and was dropped, while a full-width 6pt divider
  (1.0 x 0.006) was kept. Replaced with a per-axis minimum.
- The destructive-action guard ignored `identifier`, so an icon-only control
  whose only tell is com.app:id/logout_button was tapped. Include it, and
  split identity separators first ("_" is a word char, so \blogout\b does not
  match logout_button).
- deriveMapSelector skipped the `value` fallback its twin deriveSelector
  documents, so a value-only element replayed by frame.
- isTappableIosRole matched cell/menuitem/tab and guarded `table`, none of
  which the iOS adapters can emit. Removed the dead branches, corrected the
  doc, and recorded the real consequence (a trait-less row arrives as
  AXGroup and is not enumerated) as a deliberate v1 trade-off.

Crawler
- The deep-link re-root was strictly weaker than its tap-path twin: gated on
  the landed screen's owed actions and never re-propagating depth, so a deep
  link onto a fully-explored hub left depth-capped descendants dead. Re-root
  and re-propagate regardless of the hub's own state.
- Forward taps used the frame recorded at first enumeration instead of the
  selector stored beside it. A reordered list re-keys identically (screenKey
  ignores labels, buckets frames to 5%), so the tap hit whatever now occupied
  that rectangle and the edge got one row's label with another's destination.
- isTargetForeground and screenshot were awaited unguarded although the
  CrawlDriver contract says every method may reject.
- A failed launch no longer proceeds on an unverified tree: iOS describe reads
  whichever app is frontmost, so a mistyped bundle id crawled a stranger's app
  and reported completed, while the error naming that exact cause could never
  fire.

Tool and contract
- Reject tvOS / Android TV up front (both pass the platform capability gate)
  via UnsupportedOperationError, as sibling screen-driving tools do.
- Correct the MapCrawlState tool-result-shape doc, MapSelector.label's
  "(exact)" claim, and the stats.restarts definition.
- State the maxActionsPerScreen / maxDepth caps; measure maxDepth from each
  screen's own entry; reject empty deepLinks entries.

UI
- De-overlap convergence is tested against a sub-pixel tolerance rather than
  exact zero. Resolving a pair drives it to exact contact, where float residue
  leaves penetration marginally positive, so the early break could never fire:
  re-solving a settled 200-node layout spent 1200 of 1200 passes, now 1, with
  0 card-footprint intersections either way.
- releaseAllInput() on tab switch: the key gate drops keyup too, so switching
  tabs with a key held stranded it down on the device.
- Escape on the Map tab also reached Lens's handler and tore down an
  in-progress comment; handle in capture phase and confine to Map.
- mapEdgePathD emitted NaN control points for coincident node centres, so the
  browser discarded the path and the edge silently vanished.
- Correct the determinism comment: positions are deterministic per revision
  sequence, not per graph.

Docs
- Add `argent map` to the CLI Reference; give the MAP_* failure codes the
  group comment every neighbouring group has.
2026-07-30 16:34:17 +02:00
Stanisław Chmiela 7189333ac4 feat(tool-server): add human-readable log message (#582)
## Summary

Adds tool-owned, human-readable interaction messages for every tool
definition.

Each tool defines its own started, completed, and failed wording (as
implemented in https://github.com/software-mansion/argent/pull/367.
Messages can use validated parameters, successful results, and safe
failure codes. They are to avoid sensitive input such as typed text,
cookie values, JavaScript expressions, and URL credentials.

Bridges the gap between current Argent

<img width="954" height="493" alt="Screenshot%202026-07-28%20at%2015 48
37"
src="https://github.com/user-attachments/assets/2c689a93-8ba8-4208-b35e-66eb53c96671"
/>

and current agent-device

<img width="1188" height="476" alt="Zrzut ekranu 2026-07-28 o 20 14 42"
src="https://github.com/user-attachments/assets/6aed0f38-625b-4748-83e5-d384e79266c2"
/>


## Test

Selected fields from the generated JSONL:

```jsonl
{"level":30,"type":"tool.invoked","toolId":"list-devices","toolInvocationId":"decb864f-51c1-41ee-91fc-15bc2494e268","msg":"Listing devices","time":"2026-07-29T10:37:19.896Z"}
{"level":30,"type":"tool.completed","toolId":"list-devices","toolInvocationId":"decb864f-51c1-41ee-91fc-15bc2494e268","durationMs":96.77333299999918,"msg":"Listed 22 devices and 0 AVDs","time":"2026-07-29T10:37:19.993Z"}
{"level":30,"type":"tool.invoked","toolId":"screenshot","toolInvocationId":"8504abb8-83ed-4490-8210-3355d6c638c9","msg":"Capturing screenshot","time":"2026-07-29T10:37:24.503Z"}
{"level":50,"type":"tool.failed","toolId":"screenshot","toolInvocationId":"8504abb8-83ed-4490-8210-3355d6c638c9","failureSignal":{"error_code":"SIMULATOR_SERVER_READY_EXITED","failure_stage":"simulator_server_spawn_ready","failure_area":"tool_server","error_kind":"subprocess","failure_command":"simulator_server","failure_exit_code":1},"durationMs":750.4639999999999,"msg":"Failed to capture screenshot: SIMULATOR_SERVER_READY_EXITED","time":"2026-07-29T10:37:25.254Z"}
{"level":30,"type":"tool.invoked","toolId":"screenshot","toolInvocationId":"63b938fc-3d86-4eb5-9972-a008474ffb01","msg":"Capturing screenshot","time":"2026-07-29T10:38:01.107Z"}
{"level":30,"type":"tool.completed","toolId":"screenshot","toolInvocationId":"63b938fc-3d86-4eb5-9972-a008474ffb01","durationMs":25.509208000003127,"msg":"Captured screenshot argent-screenshot-chromium-cdp-9222-1785321481132-81477.png","time":"2026-07-29T10:38:01.133Z"}
```
2026-07-29 13:07:49 +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
filip131311 68ceb314d6 ci: cut over npm publishing to the unified trusted-publishing workflow (#571)
## Why

Cutover step 3+4 of the plan from #565. `publish-npm.yml` is now
registered on npmjs.com as the **trusted publisher** for
`@swmansion/argent` (2026-07-24) and the OIDC publish path is verified
live: `v0.17.1-next.1` published with a SLSA provenance attestation via
a workflow_dispatch of the unified workflow.

Extra urgency: token-based publishing died on 2026-07-24 (~11:20–15:20
UTC window; npm's 2FA-bypass token restriction) — **`publish-next.yml`'s
canary now fails on every main push** (identical E404 on the registry
PUT, e.g. run 30104847558). The old workflows aren't just redundant,
they're broken; only the trusted-publisher workflow can publish.

## What

- Delete `publish.yml` and `publish-next.yml` — `publish-npm.yml` is the
only publish path.
- `sync-next-tag.yml`: drop the two retired workflow names from the
`workflow_run` trigger.
- `publish-npm.yml`: remove `NODE_AUTH_TOKEN` from both publish steps
(OIDC authenticates; the rest of the job already ran token-less) and
update the header — the cutover plan is replaced by a note that this
file's name is load-bearing for the npmjs.com registration.
- `NPM_TOKEN` stays as a secret: `sync-next-tag.yml` still needs it for
dist-tag mutation, which OIDC doesn't cover. Note the token may
currently be dead for dist-tag writes too — the post-publish sync run
succeeded but had no mutation to make; if a real re-point fails, a fresh
granular token is needed.

## After merging

Set the repo variable to enable canary-on-push (kept as the canary
kill-switch):

```
gh variable set PUBLISH_NPM_UNIFIED --repo software-mansion/argent --body "1"
```

Ordering is safe: until the variable is set, a main push publishes
nothing (the old canary workflow is gone, the new one skips) — no
double-publish window exists.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-24 17:54:00 +02:00
filip131311 2546788a8d ci: unified publish-npm workflow (provenance-ready, trusted-publishing prerequisite) (#565)
## Why

npm provenance / trusted publishing for `@swmansion/argent` requires
registering **one** workflow file as the package's trusted publisher on
npmjs.com — but we currently publish from two (`publish.yml` for stable,
`publish-next.yml` for canaries/prereleases). This PR adds their unified
successor, **`publish-npm.yml`**, without touching the old workflows:
they keep running unchanged until cutover.

## Design

One file, mode derived from the trigger and the version — nothing is
spelled twice:

| Trigger | Behavior |
|---|---|
| `push` to `main` | Canary: version derived + stamped
(`<base>-next.<k>`), published under `next` with the same registry-race
retry loop as before. **Dormant until the `PUBLISH_NPM_UNIFIED` repo
variable is set to `1`** — otherwise every main push would publish two
canaries while `publish-next.yml` still exists. |
| `workflow_dispatch` | Publishes the ref's `package.json` version
verbatim. Dist-tag derived from the version itself: prerelease → `next`,
stable → `latest`. The optional `channel` input is an assertion (fails
on mismatch), not a selector — you can't accidentally ship a prerelease
to `latest` or vice versa. `dry_run` runs the full build + bundle
verification + pack without publishing. |

Provenance is on from day one: the job has `id-token: write` and every
publish path uses `--provenance` (repo is public, `repository.url`
matches — the two things the registry verifies). Token auth
(`NPM_TOKEN`) is kept for now; npm ≥ 11.5.1 is ensured in the job so the
OIDC exchange takes over transparently once the trusted publisher is
registered.

`sync-next-tag.yml` now also triggers on the new workflow, so `next`
keeps getting re-pointed after publishes from either generation. (Until
cutover a main push may trigger the sync twice — once per publish
workflow — which is harmless: the script is idempotent.)

## Cutover checklist (follow-up, also documented in the workflow header)

1. Test: `workflow_dispatch` with `dry_run`, then a real prerelease
dispatch (`v*-next.N` ref).
2. On npmjs.com, register `publish-npm.yml` as the trusted publisher for
`@swmansion/argent` (org `software-mansion`, repo `argent`).
3. Set repo variable `PUBLISH_NPM_UNIFIED=1` and delete `publish.yml` +
`publish-next.yml` in the same PR (also dropping their names from
`sync-next-tag.yml`'s `workflow_run` trigger).
4. Remove `NODE_AUTH_TOKEN` from the publish steps (`NPM_TOKEN` stays
for `sync-next-tag.yml` — dist-tag mutation isn't covered by OIDC);
optionally restrict the package's publishing access to the trusted
publisher only.

## Behavior differences vs the old pair

- Stable dispatch now publishes with an explicit `--tag latest` (same
effect as the old default) and `--provenance`.
- The old `publish-next.yml` dispatch guard ("refuse non-prerelease
versions") is generalized: a stable-version ref dispatched here goes to
`latest` instead of erroring. Set `channel: next` if you want the old
refusal behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-24 17:20:18 +02:00
Jakub Piasecki 6a53e66a96 feat(flow): rotate directive (#546)
# feat(flow): rotate directive

Adds a selector-targeted two-finger rotation directive to flows:
`rotate: { on?, by }`. `on` resolves like `tap` (settled tree +
auto-wait); omitted, the rotation centers on the screen. `by` is
degrees, positive = clockwise. Distinct from `tool: rotate`, which
changes device orientation — the docs and step labels call this out.

## Design

**One continuous gesture, fixed pace, no duration option.** Fingers
orbit at ~90°/300 ms; duration derives from the angle. Unlike pinch
there is no sub-gesture chaining — any angle dispatches as one sweep, so
the angular delta is exact with no settle delays. `by` is parse-bounded
to ±3000°: the largest sweep the 10 s single-gesture envelope delivers
at that pace, derived from the constants rather than picked as policy —
rejected at parse, never silently clamped. A consequence embraced here:
frame count always scales with the angle, so a speed-sensitive recording
stays a raw `tool:` step (the create-flow skill's polish list covers the
conversion).

**Fixed centroid, constant physical radius.** Drifting the centroid
would couple pan into the turn; growing the radius would couple pinch.
Both stay fixed — the trade is a smaller orbit near edges and small
targets, in exchange for a pure rotation.

**Physical circle, not a normalized-space ellipse.** A single normalized
radius on a non-square screen traces a physical ellipse, coupling an
unintended pinch into every turn. `gesture-rotate` grows optional
per-axis `radiusX`/`radiusY` (fractions of width/height with `radiusX·W
= radiusY·H`), and the flow tree adapters surface screen dimensions via
an optional `screen` field on `DescribeTreeData` so the directive can
compute the aspect. Missing dimensions fail soft to the legacy
single-radius call rather than erroring.

**Placement reuses pinch's edge model.** The initial finger axis
(horizontal or vertical) is scored against the same per-platform
system-edge guards, preferring a start parallel to the nearest violated
edge and staying inside the target's axis-matching span. Guards only
score placement — they never shrink the radius, and a violated guard is
attempted, never rejected. The only geometry failure is no positive
on-screen orbit radius.

**Abort correctness.** `gesture-rotate` now honors `ctx.signal`: each
frame checks it, lifts the synthetic fingers with a terminal Up (so a
cancelled run doesn't leave them held on the glass), then throws with
name `AbortError`; the directive maps a mid-gesture abort to the run's
aborted skip rather than a step failure.

**Explicit `inputSchema`.** The radius requirement is a zod `.refine()`
cross-field rule, which `z.toJSONSchema` drops — so the tool ships a
derived schema plus an explicit `anyOf` (`radius` or
`radiusX`+`radiusY`) to keep invalid calls rejected at the schema layer.

**Platform rejections.** Vega is remote-driven (no touch) and Chromium
has no rotate-gesture idiom at all — both fail upfront with guidance to
drive the app's rotate controls directly.

## Tests

Unit tests cover the geometry module (duration derivation, candidate
construction, aspect handling, edge-guard ranking), the directive
end-to-end against a fake device, and `gesture-rotate`'s per-axis radii
and abort path.
2026-07-24 13:18:23 +02:00
Ignacy Łątka a0460eeefe chore: bump workspace packages to 0.17.0 (#554)
## What

Lockstep-bump all 16 `packages/*` workspace packages (and the matching
`package-lock.json` entries) from `0.16.1` → **0.17.0**.

Diff is exactly 32 version-field swaps across 17 files — no formatting
or
dependency churn.

## Verification

- **Lockfile CI gate reproduced locally on the exact CI toolchain**
(Node
24.14.1 / npm 11.11.0, matching `lockfile.yml`'s `node-version: "24"`):
`npm install --package-lock-only --ignore-scripts` on top of the commit,
then `git diff --exit-code -- package-lock.json` → **clean**. npm's own
  regeneration reproduced the hand-edit byte-for-byte, so none of the
  `"peer": true` churn that #513/#539 had to keep out reappears here.
- `node scripts/check-workspace-versions.mjs` → `All workspace packages
are
  at 0.17.0.`
- Remaining `repo-hygiene.yml` checks pass: no conflict markers, no
focused
  tests.
- `prettier --check` on all 17 changed files → `All matched files use
  Prettier code style!`
- **Runtime version actually reports 0.17.0**: `getInstalledVersion()`
(`argent-installer/src/utils.ts:384`) and
`getGloballyInstalledVersion()`
(`topology.ts:94`) both read `version` straight out of the resolved
package
root's `package.json` at runtime, so the bump is the whole mechanism —
no
generated constant or second version file to keep in sync. Exercised
that
  resolution against the bumped tree: `PACKAGE_ROOT` → `packages/argent`
  (`@swmansion/argent`) → `0.17.0`.
- Verified all 16 lockfile hits belong to workspace packages (parsed the
  lockfile's `packages{}` and matched every `0.16.1` entry to a
`packages/*` path) — no unrelated dependency happened to sit at 0.16.1.
- No test asserts a release version string; the only `0.1x.x` matches
under
  `*.test.ts` are historical references inside comments.

## Note

`packages/argent-private` needs no pin change here. Its release version
is
carried purely as a git tag — the repo has no version field on the
release
line (its only `package.json` is a `private: true` research CLI on an
independent `0.1.0` line) — and `v0.17.0` is now tagged at `2d6c292`,
which
is **already** the commit this superproject pins. `v0.16.0` and
`v0.16.1`
sit on that same commit, so the submodule entry is correct as-is and
stays
out of this diff.
v0.17.0
2026-07-23 23:33:49 +02:00
Ignacy Łątka 98ae93145b feat(screen-recording): save recordings to the project's .argent/recordings (#551)
Stacked on #549 (touch visualizer).

## What

Screen recordings now land durably under **`.argent/recordings/`** on
the client, instead of the disposable temp-artifact cache — and for a
remote `argent link` tool-server they land on the **client** host, not
the server.

## Why

Two rough edges in the recording flow:
1. The finished video was only ever written to the OS temp-artifact
cache, so it was easy to lose and not obviously part of the project.
2. Over `argent link`, the video was downloaded into that same temp
cache; there was no project-scoped home for it.

## How

- New optional `saveDir` hint on the artifact wire contract
(`@argent/registry` + `@argent/tools-client`).
- `screen-recording-stop` tags its video with `saveDir:
".argent/recordings"` and drops the internal `argent-` temp prefix from
the filename.
- The client materializer honours `saveDir`: **copies** the file when
the tool-server is co-located, **downloads** it over `/artifacts/:id`
when it is remote. Either way the bytes end up in the durable directory.
- `saveDir` resolves against the client's **project root** (nearest
ancestor with `.git`/`package.json`/`.argent`), falling back to the
global **`~/.argent`** when run outside a project — matching how the
rest of argent scopes per-project config. Adds `findProjectRoot` to
`configuration-core` (a null-returning variant of `resolveProjectRoot`)
to drive that choice.
- Hardened: absolute paths and `..` escapes are rejected, directory
bundles excluded — a tool-server can't steer a write outside the base
directory.

## Verification

- Unit: 28 cases in `argent-tools-client/test/artifacts.test.ts`
(co-located copy, remote download, project-root anchoring incl. from a
subdirectory, global fallback, path-traversal rejection, integrity).
configuration-core suite green (55).
- E2E against a booted iOS 18.5 sim + a worktree tool-server, driving
the real client materializer path:
- in a project, co-located server →
`<projectRoot>/.argent/recordings/…mp4` (valid h264)
- in a project, forced remote download (`GET /artifacts/:id`) → same
durable dir, temp cache left empty
  - outside a project (HOME redirected) → `~/.argent/recordings/…mp4`
- Root `tsc --build`, prettier, and eslint all clean.

MoQ / `sim-remote` recording support lands in a separate PR stacked on
this one.

---------

Co-authored-by: Ignacy Łątka <claude-hubert.gancarczyk@swmansion.com>
2026-07-23 22:43:01 +02:00
Ignacy Łątka f85d3843aa feat(screen-recording): draw touch gestures into the recording (#549)
## What

Draws the touch gestures argent drives directly into the screen
recording, so a captured video shows **where** each interaction landed
instead of a UI reacting to an invisible finger:

- a pulse marks each **tap**
- a fading comet trail follows a **swipe / drag**
- paired markers show the two contact points of a **pinch / rotate**

On by default; pass `showTouches: false` to `screen-recording-start` for
a raw, overlay-free capture.

## How

simulator-server already has a touch visualizer behind its `pointer`
cargo feature (the binary argent bundles ships with it). It renders the
markers into the very MJPEG stream this recording pipeline already
consumes — nothing is composited host-side, and it's driven by the touch
events argent already sends. This PR just turns it on for the life of a
recording:

- `screen-recording-start` builds a small `PointerControl` from the
resolved simulator-server handle and hands it to `startCapture`.
- The overlay is enabled right after the encoder goes live (so the first
interaction is already drawn) and **restored to off** when the capture
ends — stop, time-limit cap, encoder crash, or session teardown all run
the same disable path, so it never outlives the recording it was enabled
for.
- Two thin client helpers (`setPointerVisible`, `setPointerTrail`) POST
to simulator-server's `/api/pointer`. They're best-effort: a build
without the feature (or a remote sim) just yields a `warning` at stop
and a normal overlay-free video, never a failed recording.

## Verification

- **Unit** — `screen-recording.test.ts`: 49 pass, incl. 10 new covering
enable-at-start / disable at stop, cap, unexpected exit, and dispose;
the `pointerFailed` warning; the no-control path; and the `/api/pointer`
wire format (`{show}` / `{trail}`, ok/error/throw handling).
- **Static** — `tsc` (src + `tsconfig.test.json`), eslint, prettier,
full workspace build all clean.
- **E2E** — drove the real tool-server → the bundled (production)
simulator-server on an iOS 18.5 sim:
- `showTouches: true` + a slow swipe → a translucent pointer bubble is
rendered onto the screen at the touch point in the recording.
- identical swipe with `showTouches: false` → paging still happens, **no
bubble** — proving both the toggle and that stop restored the overlay to
off (the control clip never re-enables it, yet draws nothing).

---

Stacked on #548 (static-frame trimming) → base branch
`feat/screen-recording-trim`. Review after that merges.

---------

Co-authored-by: Ignacy Łątka <claude-hubert.gancarczyk@swmansion.com>
2026-07-23 22:32:51 +02:00
Ignacy Łątka 82f6eb16b0 feat(screen-recording): trim static stretches out of the recording (#548)
> Stacked on #517 (`feat/screen-recording`). Review/merge that first;
this PR's diff is against that branch.

## What

Trims stretches where the screen doesn't change out of the recorded
video. A recording left running across waits, slow navigation, or agent
thinking time used to come back padded with dead air; now a long session
with only brief activity comes back as a short clip.

## How

The frame pump already re-emits the last frame on a wall-clock schedule
to keep the timeline honest. This adds a change detector on top:

- Each frame is compared to the last one **byte-exact** (a reference
check short-circuits the common "no new frame arrived" case, so only
genuine arrivals pay the compare). Exact byte equality is stronger than
a hash — no collisions — and flags a change down to a single pixel,
matching "something moved, even by a couple of pixels."
- The first second of every still stretch is kept (pauses read
naturally), then unchanged frames are dropped until the screen moves
again.
- Active stretches still play at real time: the wall-clock pacer is
re-anchored whenever a dead stretch is skipped, so the gap yields no
output frames rather than a catch-up burst of the next frame.

On by default. `trimStatic: false` on `screen-recording-start` keeps a
faithful real-time recording (e.g. to measure how long something took on
screen). `screen-recording-stop` now returns the trimmed length as
`durationMs`, plus `wallClockMs` and `trimmedMs` when trimming removed
anything.

## Verification

- **Unit**: 39 tests in `screen-recording.test.ts` (7 new trim cases:
default-on, grace-window kept, no-trim control,
resume-the-moment-the-screen-changes,
byte-identical-new-frame-stays-trimmed, duration reporting,
fields-omitted-when-off). Full tool-server suite green (2817). `tsc
--build`, `typecheck:tests`, eslint, prettier clean.
- **E2E on a live tool-server (iOS 18.6 sim)**:
- 23s idle-heavy session (motion → 15s idle → motion) → **1.47s** clip,
`trimmedMs` 21930, with the real transitions intact (44 distinct frames
— above the 30-frame grace floor, proving post-grace motion is kept). A
`trimStatic:false` control of the same pattern returned the full 23s /
691 frames.
- 8s continuous burst-motion session → **2.1s** across multiple motion
bursts (not collapsed to the 1s floor).
- **Cross-check**: ran ffmpeg's own `mpdecimate` (duplicate-frame
detector) over the same untrimmed footage — it kept ≤ what this trim
keeps, confirming the trim is faithful, never over-aggressive
(byte-exact is deliberately more conservative than mpdecimate's
near-duplicate heuristic, so smooth animation is preserved while only
frozen stretches are dropped).
2026-07-23 21:45:42 +02:00
Ignacy Łątka f46b11b6e1 Merge origin/main into feat/argent-map
Resolve failure-codes.ts conflict: keep both the map crawler codes
(MAP_CRAWL_ALREADY_RUNNING, MAP_APP_NOT_VISIBLE) and main's
SCREEN_RECORDING_* codes.
2026-07-23 21:23:36 +02:00
Ignacy Łątka 2251c7cf46 feat(screen-recording): screen-recording-start/stop tools with per-call stop reminders (#517)
## What

Adds a `screen-recording-start` / `screen-recording-stop` tool pair
(naming follows `native-profiler-start/stop`) that records the device
screen to an mp4 and returns it as a downloadable artifact.

Capture runs through **simulator-server** — the same backend
`screenshot` and every interaction tool already drive. simulator-server
publishes the device screen as an MJPEG stream (`/stream.mjpeg`), so
recording needs no platform-specific recorder: the tool-server
subscribes to that stream, paces the frames onto a fixed 30 fps
timeline, and pipes them into a single ffmpeg process that encodes — and
watermarks — straight to the final mp4. The stream is multi-subscriber,
so a preview window can stay open on the same device while recording.

- **One path for every device simulator-server drives** — iOS
simulators, Android emulators, and physical Android devices.
- **Native resolution, constant 30 fps** — h264, `+faststart`, no
post-processing pass.
- Session state lives in a per-device `ScreenRecordingSession` blueprint
(mirrors the native-profiler session): time-limit cap with
auto-finalize, unexpected-exit recovery (a capture that ended on its own
is still retrievable via stop), always-startable state reset on stop,
and dispose-on-shutdown cleanup through the registry.

## Why the frames are paced host-side

A device only emits a frame when the screen **changes**. Pointing ffmpeg
straight at the stream therefore produces a video whose length is the
sum of the moments something moved — a still screen collapses to a
fraction of a second — and an ffmpeg blocked on a silent socket does not
answer a stop signal promptly either (measured: 12 s late). Re-emitting
the last frame on a wall-clock schedule fixes both: **video duration
always equals real elapsed time**, and stop is a stdin EOF that
finalizes in ~60 ms. Duplicate frames cost almost nothing once encoded
(a 10 s recording of a static screen is ~1 MB).

## Finishing pass folded into capture

The Argent watermark (logo + `By @swmansion`, bottom-left, sized
relative to the frame) is stamped by the same ffmpeg invocation that
encodes the capture, so there is no second encode and no temp
intermediate. Its colour is chosen **per pixel** from the background
luma underneath it — the white logo over dark areas, a runtime-derived
near-black copy over light areas — so it keeps maximum contrast against
whatever is behind it. It sits at 20 % opacity and is opt-out via
`argent disable video-watermark` (read when the recording starts). Frame
geometry comes from the first JPEG's SOF header, so no `ffprobe` is
needed.

ffmpeg is now the recorder rather than an optional finisher, so a
missing ffmpeg fails `screen-recording-start` up front with an install
hint instead of silently degrading the output at stop time.

## Reminder plumbing

While a recording is running — or ended on its own but was never
retrieved — **every** tool result carries a `note` telling the agent the
recording is still going and how to stop it. It rides the same `note`
field as the update reminder but is per-call and unsuppressed; both
notes compose when they coincide. The note text switches to "already
ended, retrieve the file" once the cap fires or the capture dies.

## Skill

New `argent-screen-recording` skill (+ routing stanza in
`rules/argent.md`): documents the start → interact → stop workflow and
instructs the agent to schedule its own reminder/wakeup for the expected
end of the recording immediately after starting one (harness reminder
tool if available, otherwise a background `sleep` whose completion
notification pulls it back). Scores 10/10 on `scripts/grade-skills.mjs`.

## Verification

- Unit: 45 tests across `screen-recording.test.ts`,
`mjpeg-stream.test.ts`, `watermark.test.ts`,
`http-recording-note.test.ts`; full tool-server suite **2808 tests / 280
files green**; `tsc --noEmit -p tsconfig.test.json`, root `tsc --build`,
eslint, prettier all clean.
- E2E on a live tool-server (`POST /tools/...`), driving real
interactions between start and stop:
- **iOS sim** (iPhone 16 Pro Max, iOS 18.5): 1320×2868 h264,
`r_frame_rate=30/1`, 338 frames, container duration 11.27 s vs reported
`durationMs` 11314 — **within 50 ms of wall clock**. Extracted frames
show real screen content (mid app-switcher animation) with the watermark
rendered light-over-dark.
- **Android emulator** (Pixel 3a, API 34): 1080×2220 h264, 30 fps, 353
frames, duration 11.77 s vs `durationMs` 11793 — within 30 ms. 512 KB
for a mostly-static home screen.
- **Static screen** (no interaction at all, 10 s): 300 frames, duration
exactly 10.000 s — the case the previous `recordVideo` implementation
reduced to ~0.03 s.
- **Long silence then activity** (15 s untouched, then an app launch):
stream survived, no warning, frames after the silence show the new
screen. This caught a real bug during review — the connect timeout was
also acting as a socket *inactivity* timeout and tore the stream down at
10 s of stillness; fixed and covered by a regression test that fails
without the fix.
- **Time-limit cap**: `timeLimitSeconds: 5` self-finalized at 4.97 s /
149 frames, the per-call note flipped to "already ended (it hit its 5s
time limit)", and a stop issued 11 s later still returned the video with
the matching warning.

## Not supported (capability-gated with clear errors)

tvOS simulators, physical iPhones, Chromium apps, Vega, and remote
(`remote:`-prefixed) simulators — none of them expose a readable frame
stream.

---------

Co-authored-by: Ignacy Łątka <claude-hubert.gancarczyk@swmansion.com>
2026-07-23 21:14:37 +02:00
Ignacy Łątka 19995985d3 docs(scripts): stop citing flow-add-step's since-reworded description in a fixture comment (#527)
Follow-up to #436. The escape-cooking fixture's comment cited
flow-add-step.ts as a currently-shipping example of a `\n` description;
main's flow rewrites reworded that description, so the citation is
stale. The comment now leans on the fixture alone instead of any live
tool's wording. No code change - `npm run test:scripts` still 59/59.
2026-07-23 19:40:07 +02:00
Aayush Gautam 3795cb84fb fix(ios-profiler): stop native-profiler-stop being aborted mid-packaging (#529)
## Problem

`native-profiler-stop` fails with `No active native profiling session
found. Call native-profiler-start first.` while xctrace is still
recording — #248, #250, #330. Those were closed on the 0.12.1
`--all-processes` workaround (#380), which fixed trace finalization but
not this.

The tool isn't declared `longRunning`, so the MCP adapter applies its
per-request timeout:

```js
fetchTimeoutMs: meta?.longRunning ? null : FETCH_TIMEOUT_MS,   // mcp-server.ts:222
```

A stop that runs past 30s is aborted and retried (`MAX_RETRIES = 4`).
The retry runs against the session state the first call has already
cleared, hits the `!api.profilingActive` branch and throws. The message
points at session tracking; the actual failure is the timeout.

Every failed stop I have logs for lands at 30–34s: `durationMs: 33639`
in [#330](https://github.com/software-mansion/argent/issues/330), and
30276 / 30605 / 31019 / 31914 locally.

Stop can't fit in 30s on Xcode 26.4+. Since #380 the capture is
host-wide rather than app-scoped, so the bundle is much larger — ~55MB
per 10s of recording here, 385MB for 90s — and xctrace needs ~22s to
package even the 10s case before the four export passes start.

`STOP_GRACE_MS` has the same problem from the other side: at 30s xctrace
is SIGTERM'd mid-package, and the bundle it leaves behind fails to
export.

## Fix

Declare the tool `longRunning`, and raise `STOP_GRACE_MS` to 300s. The
grace is a ceiling rather than a fixed wait — a fast package still
returns as soon as xctrace exits — so this only changes captures that
were previously killed.

With both applied a 90s capture stops and exports cleanly in ~2–4 min,
and a 40s capture returns inline.

## Note

The abort-and-retry sequence is inferred rather than captured directly.
What's observed: `MAX_RETRIES = 4`, the uniform ~30s durations, and
`tool-server.log` showing the `did not respond to SIGINT` warning —
written after the throw site — logged 184ms *before* the failure was
returned. One execution can't produce that ordering.

## Testing

- `npm run build` and eslint clean on changed files
- `npm test -w @argent/tool-server` — 2727 tests / 273 files pass
- Verified on Xcode 26.5 / iOS 26.5 simulator against a bridgeless RN
app: start → interact → stop → analyze now returns a populated report
with `errors: {}`
2026-07-23 18:32:44 +02:00
Jakub Piasecki e006e68c81 fix(tool-server): tag gesture-pinch/gesture-rotate as ios/android in run-sequence (#541)
Both gestures appear to be supported on Android:


https://github.com/software-mansion/argent/blob/5d3f7bc62b278c03f3e63b45387cb41b937185c9/packages/tool-server/src/tools/gesture-pinch/index.ts#L54


https://github.com/software-mansion/argent/blob/5d3f7bc62b278c03f3e63b45387cb41b937185c9/packages/tool-server/src/tools/gesture-rotate/index.ts#L44
2026-07-23 08:02:00 +02:00
Jakub Piasecki f32659df31 feat(flow): pinch directive (#545)
Adds a selector-targeted pinch-zoom directive to flows: `pinch: { on?,
scale }`. `on` resolves like `tap` (settled tree + auto-wait); omitted,
the pinch centers on the screen. `scale > 1` zooms in, `< 1` zooms out.

## Design

**Options-map only, no bare form.** `tap: "Map"` is unambiguous; `pinch:
"Map"` isn't (in or out?), so the parser requires `{ on?, scale }` and
rejects a bare selector with authoring guidance.

**Validity checks, not policy caps.** `scale` must be finite, > 0, and ≠
1 (a no-op scale is almost certainly a mistake) — but there is
deliberately no magnitude cap. An extreme scale decomposes
logarithmically into equal-ratio sub-gestures of ≤4× each, chained with
a 250 ms recognizer-reset delay (`scale: 1e6` is just 10 gestures).

**Pure geometry module.** All placement math lives in
`flow-pinch-geometry.ts` as deterministic data-in/data-out functions (no
device access), so tests drive it with synthetic centers, spans, and
edge guards. Per sub-gesture, a horizontal and a vertical candidate are
built from the axis-matching target dimension (normalized X and Y are
different units, so never `min(width, height)`), and the better one is
dispatched.

**Edge safety scores, it never rejects.** Per-platform system-edge
guards (wider on Android's back-gesture zones) demote candidates in
ranking but never fail a step: viable travel beats edge safety (a
risky-but-perceptible gesture beats a safe no-op the recognizer
ignores), then fully-safe with the most travel, then motion parallel to
the violated edge (edge swipes need motion *away* from the edge). Guards
are resolved once per directive and passed as data — the seam for a
future per-device query. The only geometry failure is literally zero
on-screen finger travel; a tiny target is still attempted.

**Centroid drift near edges.** When expanding fingers would leave the
screen, the centroid drifts inward — only on the moving axis, and only
as far as needed. `gesture-pinch` grows optional
`endCenterX`/`endCenterY` to interpolate the centroid alongside
separation; omitted, behavior is unchanged.

**Open-loop by design.** There is no "current zoom" to read back, so the
directive doesn't try to verify the multiplier — flows assert on the
resulting UI instead.

**Platform rejections.** Vega is remote-driven (no touch input) and
Chromium has no uniform pinch-zoom mapping for a desktop app (ctrl+wheel
or app-specific controls) — both fail upfront with guidance to drive the
app's zoom UI directly.

## Tests

Unit tests cover the geometry module (decomposition, candidate
construction, edge-guard ranking), the directive end-to-end against a
fake device, and the `gesture-pinch` centroid-drift interpolation.
2026-07-23 07:44:26 +02:00
Ignacy Łątka 87a7e77c1f fix(tool-server): remove self-referential node_modules symlink committed in #476 (#537)
## What

`packages/tool-server/node_modules` is tracked in git as a
**self-referential symlink** — mode `120000`, target
`/Users/.../packages/tool-server/node_modules` (it points at itself). It
was introduced in `300bc30e` (#476) and is the only tracked
`node_modules` in the repo; sibling packages have plain empty dirs.

Every clone/pull of `main` now materializes a circular **ELOOP** link
that breaks any recursive directory walker (`find`, globbers, bundlers,
backup tools). Runtime is unaffected — deps hoist to the root
`node_modules`, so the loop is silently bypassed.

## Why it slipped past `.gitignore`

The `node_modules/` and `packages/*/node_modules/` rules have a
**trailing slash → directories only**. Git treats a symlink as a file
(blob), so neither rule matched and the path was addable → it rode into
#476. `git check-ignore` confirmed it was NOT ignored.

## Fix

- Remove the tracked self-symlink.
- Drop the trailing slash on both ignore rules so a `node_modules` of
**any** type (dir, file, or symlink) is ignored — verified with `git
check-ignore` that a symlink at `packages/*/node_modules` is now
refused.

No runtime code touched.
2026-07-22 19:59:25 +02:00
filip131311 7f85f996aa fix(tool-server): defer to an existing server on EADDRINUSE instead of crash-looping (#533)
## Summary

Fixes **bucket B** of the 0.16.0 crash telemetry — the `toolserver_bind`
/ `EADDRINUSE` restart loop. It's the largest crash **volume** (~817
events, one machine alone = 785) even though it's a narrow user set
(10). Companion to #531 (which fixed the widespread multi-user bucket
A).

## Root cause (reproduced live)

The main HTTP listener's `server.on("error")` handler routed **every**
bind error through `crashShutdown` (exit 1). When a second tool-server
starts on an already-owned port, that exit just feeds a supervisor
respawn → re-bind → crash, forever.

The telemetry signature was puzzling: all these crashes are
`crash_phase=serving` with ~1s uptime and **0 tool calls** — i.e. the
listener *bound successfully* and then died. I reproduced it: on macOS,
a **second tool-server process's** `listen()` momentarily **succeeds**
(the `listening` callback runs → `listening=true` → phase `serving`),
and **then** the server emits an asynchronous `EADDRINUSE`. So the
redundant instance binds, immediately errors, crashes, and the
supervisor loops it (~1s each, 785× on one machine).

Live A/B on the compiled `dist` (isolated port, telemetry disabled),
server A up then a second server B on the same port:

```
buggy (current 0.16.0):  B: "Tools server listening ..."  → "Failed to bind ... EADDRINUSE"  → exit 1  (→ respawn loop)
fixed:                   B: "Tools server listening ..."  → "deferring to it (redundant instance)" → exit 0
```

A survives in both.

## Fix

Handle `EADDRINUSE` specifically instead of crash-looping:

- Probe the port for a healthy argent tool-server (`GET /tools` → 200,
or 401 when a token is configured).
- **If a healthy peer answers**, this instance is redundant → shut down
**cleanly** (`shutdown(0)`) and defer to it. This removes it from the
`reason:"crash"` restart-loop population entirely.
- **If nothing healthy owns the port** (a foreign holder, or a wedged
peer that doesn't answer within the probe timeout) → still crash, so a
genuinely stuck port stays visible.
- `EACCES` and all other server errors crash exactly as before.

It also corrects the now-false comment claiming these crashes are phased
`startup` ("the listen callback never ran"). Telemetry and the live
repro prove the bind succeeds first, so `listening=true` and the phase
is genuinely `serving`.

## Testing

- **New unit tests** for `probeArgentToolServer`: peer answering 200 or
401 → `true`; foreign 500, connection-refused, and wedged/timeout →
`false`.
- Full tool-server suite **2731 green**; `tsc` + `eslint` clean.
- **Live A/B** above, on the built dist.

## Note on scope

This is a robustness fix that breaks the loop for the redundant-instance
case (the dominant one). The precise reason macOS lets a second
process's bind succeed-then-error asynchronously (vs. failing
immediately, as same-process contention does) is a kernel/loopback
timing detail; the fix does not depend on it — any `EADDRINUSE`,
immediate or deferred, now defers-or-crashes deterministically rather
than hot-looping.
2026-07-22 16:56:54 +02:00
Ignacy Łątka 7400179b5b Windows support: Android + Chromium host control plane (#421)
## What

Makes Argent run on Windows. iOS Simulator is macOS-only (Apple), so on
Windows that means the **Android + Chromium** host control plane. The
blocker was twofold: (1) no Windows `simulator-server` binary, and (2)
POSIX-only assumptions in the tool-server.

Companion radon PR builds the binary and runs the Android emulator E2E
on Windows: software-mansion/radon#134.

## Changes

**Windows-aware tool-server / resolver / bundling**
- New shared `commandOnPath()` helper — `where` on Windows, `command -v`
via `/bin/sh` on POSIX. `android-binary`, `check-deps`, and `vega-cli`
previously hardcoded `/bin/sh`, which never matches on Windows.
- `android-binary`: append `.exe` to `adb`/`emulator` in the
`$ANDROID_HOME` fallback; probe `%LOCALAPPDATA%\Android\Sdk` (Android
Studio's Windows default).
- simulator-server resolver + dispatcher + `bundle-tools` +
`download-simulator-server.sh`: ship and resolve `simulator-server.exe`
on `win32` (new `simulatorServerBinaryName()`), add `win32` to the
bundled host keys, add the `simulator-server-argent-windows.exe`
download target with PE arch validation.
- `stop-metro`: resolve listening PIDs via `netstat -ano` on Windows
(`lsof` is POSIX-only and threw ENOENT).
- `adb.ts`: AVD root resolution used `$HOME` + `startsWith("/")`
(rejects `C:\…`); now `os.homedir()` + `path.isAbsolute()`.
- **`boot-device`**: the emulator was spawned `detached` with
`stdio:"ignore"`; on Windows a detached emulator with NUL stdout/stderr
never reaches `sys.boot_completed` (verified on `windows-latest`: the
identical flag set boots fine with real handles, hangs with NUL).
Redirect the detached emulator's output to a temp log file on win32.
POSIX keeps `"ignore"`.

**Tests** — cross-platform unit coverage for `commandOnPath` (both
branches), win32 `.exe` Android resolution, win32 `.exe`
simulator-server resolution, and a win32 AVD drive-path case; dep-gate
tests now mock at the `commandOnPath` boundary so they're
platform-agnostic.

**CI** — `windows-e2e.yml` (`windows-chromium` on `windows-latest`):
runs the resolver/dep unit tests natively on Windows, confirms `adb.exe`
resolution via the SDK root, then drives a **real headless Chrome over
CDP** through the tool-server (discover → screenshot → describe → tap →
observe the DOM mutation). Flow in `scripts/ci/windows-chromium-e2e.mjs`
(also runnable locally / in Docker). The `simulator-server.exe` build +
the full **Android-emulator E2E on Windows** live in radon CI (the
binary's source is the private radon repo).

## Verification (all green)
- **Chromium control plane on real Windows** (`windows-latest`): `✓
discovered chromium-cdp-9222 → ✓ screenshot → ✓ describe → ✓ gesture-tap
→ ✓ DOM mutation observed`. Resolver/dep unit tests run natively;
`adb.exe` resolves via the SDK root (it's *not* on PATH on the runner —
the gap the resolver closes).
- **Android emulator on real Windows** (radon#134, `windows-latest` /
WHPX): the real Argent path — `boot-device` → `{ booted: true }` →
screenshot via `simulator-server.exe` → gesture-tap — all green. (The
boot-device fix above is what makes the detached emulator reach
`boot_completed`.)
- **Local clean-room E2E**: the Chromium flow runs green in a headless
Linux Docker container (fresh `npm ci`, no host pollution).
- **No regressions**: full tool-server suite (1624) +
native-devtools-ios (16); ESLint/Prettier/typecheck clean; **Wayland
E2E** (Linux AVD boot) and **Vega VVD E2E** still pass (they exercise
the changed `adb.ts` / `boot-device.ts` / `vega-cli.ts` paths).

## Notes
Android-emulator E2E needs WHPX nested virtualization — confirmed
available on the current hosted `windows-latest` image (Server 2025);
the emulator boots hardware-accelerated in ~3 min.

---------

Co-authored-by: Ignacy Łątka <claude-hubert.gancarczyk@swmansion.com>
Co-authored-by: Ignacy Łątka <latekvo@users.noreply.github.com>
2026-07-22 13:53:32 +02:00
filip131311 8d2a499eaf Project-scope configuration with schema-driven merge + argent config (#534)
## Summary

Adds a general, **scoped** configuration system on top of the shared
`config.json`, plus a universal `argent config` command to manage it.
Motivated by the discussion on #401 (iOS device set): config that needs
to work naturally with the MCP server should be read off disk at runtime
like telemetry consent is — not threaded through CLI flags/env and a
kill-and-respawn dance. This lands the reusable machinery; the
device-set becomes a one-line schema entry + a runtime reader on top of
it.

Four pieces, matching the requirements:

### 1. Project-scope config files
`config.json` now resolves at two scopes:
- `~/.argent/config.json` (**global**, default)
- `<project-root>/.argent/config.json` (**project**, resolved by the
same `.argent`/`.git`/`package.json` marker walk as feature flags)

`configFilePath` / `readConfigObject` / `updateConfig` take an optional
`scope` (default `global`), so **every existing caller is unchanged**
(telemetry, notices, lens). Per-scope lock + atomic write are preserved.

### 2. Schema-driven merged reads
`getConfigValue(def)` reads each scope the value's schema entry allows,
merges them under the entry's policy, and falls back to the entry's
default. This is the reader consumers should call instead of poking at a
single global file. The existing **lens** getters (`getRememberedAgent`
etc.) are migrated onto it as the first live consumer — a repo can now
pin its lens agent, falling back to the user's global choice.

### 3. A registration file for configurations (`config-schema.ts`)
Adding a configuration is a one-liner: `key`, `description`, allowed
`scopes`, a `parse` validator, and a `merge` policy. Merge presets:
- `prioritize-local` (project wins) · `prioritize-global` (global wins)
- `prioritize-restrictive` (booleans/numbers — opt-out/stricter wins)
- `union` / `intersection` (arrays)
- …plus an escape hatch: pass your **own** `(inputs) => value` function
for a bespoke rule.

### 4. `argent config` — one universal command
`argent config <list | get | set | unset>` with `--scope global|project`
and `--json`. New configs are added as **schema entries**, not new
top-level `argent` commands, to avoid bloat.

**Telemetry stays backward-compatible** on its dedicated `argent
telemetry` command (which also drains/resets the live client). It's
surfaced *read-only* under `argent config` via a `manageCommand` guard —
so `argent config set telemetry.enabled` refuses and points at `argent
telemetry`, and a committed project file can never re-enable a global
opt-out (`prioritize-restrictive`, global-only).

## Testing

- **Unit / integration**: 71 configuration-core tests (merge presets +
custom fn, dotted-path + prototype-pollution guards, scope merge,
scope/validation/manage-command errors, `listConfig`, `coerceCliValue`)
and 11 real-filesystem CLI tests driving the actual `config()` entry
point across sandboxed HOME + project cwd. Full suites green:
configuration-core 71, argent-cli 268, telemetry 281, argent 60.
- **Live, on the built binary** (`node packages/argent/dist/cli.js …`):
set global + project (from a deep subdir) → effective get returns the
project value; per-scope gets return each file; both files land in the
right place; `argent telemetry disable/enable` writes `config.json` and
`argent config get telemetry.enabled` reflects it read-only; unknown-key
/ invalid-value / bad-scope errors exit non-zero with hints.
- `tsc --build` across the workspace, prettier, and eslint
(`--max-warnings 0`) all clean.

## Notes / follow-ups
- Config-file reads are not yet fingerprint-cached the way telemetry's
consent read is; a long-lived tool-server re-reads on each
`getConfigValue`. Fine for CLI/low-frequency use; a hot runtime consumer
(e.g. a future device-set reader) may want the same mtime/inode cache.
2026-07-22 12:47:42 +02:00
filip131311 1f5b155229 chore: bump workspace packages to 0.16.1 (#539)
## What

Lockstep-bump all 16 `packages/*` workspace packages (and the matching
`package-lock.json` entries) from `0.16.0` → **0.16.1**.

## Verification

- `node scripts/check-workspace-versions.mjs` → `All workspace packages
are at 0.16.1.`
- Lockfile diff is exactly 16 version-field swaps — no unrelated churn
(the `"peer": true` regeneration noise from a newer npm was again kept
out, same as #513). The one remaining `0.16.0` in the lockfile is the
unrelated `@humanfs/types` dev dependency.
- `packages/argent-private` submodule left pinned at `v0.16.0` — no
`v0.16.1` tag exists there yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
v0.16.1
2026-07-22 11:03:13 +02:00
filip131311 5d3f7bc62b fix(ci): check tcp ax-service at its platform-neutral bin/tcp path (#540)
## Problem

Every **Publish prerelease to npm (next)** run on main has failed since
#536 landed (e.g. [run
29897926322](https://github.com/software-mansion/argent/actions/runs/29897926322/job/88851967242)),
blocking canaries — including the canary that would ship the ios-remote
ax-service fix itself.

The failing step is the sanity check:

```
test -x packages/argent/bin/darwin/tcp/ax-service
```

But the binary is fine — the job log right above the failure shows `✓
Copied ax-service (tcp) binary → bin/tcp/ax-service`. The whole pipeline
deliberately uses the **platform-neutral** `bin/tcp/` slot, not
`bin/darwin/tcp/`:

- `scripts/download-native-binaries.sh` writes to `bin/tcp/ax-service`
- `bundle-tools.cjs` copies from/to `bin/tcp/ax-service`
- `axServiceBinaryPathTcp()` / `tcpBinDir()` in `native-devtools-ios`
resolve `bin/tcp` on every host

The neutrality is intentional: the tcp ax-service is a darwin/iOS-sim
binary uploaded to and `simctl spawn`'d on the *remote* macOS
orchestrator, so a Linux host must still resolve it — `bin/linux/tcp`
would never match. Only the sanity checks (and their comments) got the
path wrong.

## Fix

- Point the check in `publish-next.yml` and `publish.yml` at
`packages/argent/bin/tcp/ax-service` (the stable-release workflow had
the identical wrong path and would have failed the same way on the next
release).
- Correct the comment claiming `axServiceBinaryPathTcp()` resolves
`bin/darwin/tcp`.

Note:
`packages/argent-private/.github/workflows/build-native-binaries.yml:549`
has the same stale comment ("download-native-binaries.sh writes it to
bin/darwin/tcp/ax-service") — that lives in the private submodule repo,
so it isn't part of this PR.

## Testing

Verified against the failing run's log (binary lands at
`bin/tcp/ax-service`, check looks in `bin/darwin/tcp/`) and
cross-checked all three path producers/consumers listed above. `grep -rn
'darwin/tcp'` now only matches comments explaining why the path is *not*
used.
2026-07-22 10:21:14 +02:00
Juliusz Wajgelt 013d5b60c5 fix: get ax-service working on ios-remote (sim-remote --json spawn + TCP artifact diagnostics) (#536)
## Summary

Three fixes that together get `describe` (and the other ax-service /
native-devtools tools) working against **ios-remote** (`sim-remote`)
devices — including from a **Linux** host. Reproduced end-to-end against
a live remote iPhone 17 simulator over the QUIC relay.

Before: `argent run describe --udid remote:<UDID>` returned an empty
`ROOT AXGroup` with a misleading "reboot the simulator" hint.

### 1. `sim-remote spawn` output was never valid JSON
(`fix(tool-server)`)

`simctlSpawn` parses the CLI's stdout as one `{exit_code,stdout,stderr}`
(or `{pid}`) JSON object, but `sim-remote spawn` only emits that when
passed `--json`. Without it a **non-detached** spawn streams the child's
raw output live, so `JSON.parse` throws `Unexpected end of JSON input`.

This broke ios-remote describe at the first step:
`remoteIosHost.bootstrapAx` runs `defaults write com.apple.Accessibility
…` through a non-detached `simctlSpawn`, which failed before the
ax-service daemon ever started — surfacing only as an empty AX tree.
Fix: pass `--json` unconditionally (no-op for the already-JSON
`--detach` path).

### 2. The tcp ax-service was resolved/packaged under a host-platform
dir (`fix(native-devtools-ios)`)

The tcp `ax-service` is a **darwin/iOS-simulator** binary that is
uploaded to and `simctl spawn`d on the **remote** macOS orchestrator, so
it's needed on any *host* platform. But it was resolved (and packaged)
under `bin/<hostPlatform>/tcp` via `platformBinDir()`, so a **Linux host
looked at `bin/linux/tcp` and never found the darwin-built binary** —
the ios-remote path was effectively macOS-host-only. (The tcp dylibs
were already neutral at `dylibs/tcp`.)

Moved it to a platform-neutral `bin/tcp`, mirroring `dylibs/tcp`, and
rewired every producer/consumer:
- resolver: `platformTcpBinDir()` → `tcpBinDir()` = `BIN_DIR/tcp`
- `native-devtools-ios/scripts/build.sh`: `bin/darwin/tcp` → `bin/tcp`
- `scripts/download-native-binaries.sh`: `TCP_BIN_DIR` → `bin/tcp`
- `packages/argent/scripts/bundle-tools.cjs`: copy from/to `bin/tcp`
- tests assert the neutral location on both linux and darwin

### 3. Missing tcp artifacts were undiagnosable
(`fix(native-devtools-ios)`)

When a tcp artifact is absent (local/dev build, older package), the bare
`... not found` error was thrown deep in the ax-service factory, where
`describeIos` swallowed it and returned an empty tree plus
`DEGRADED_HINT` — telling the agent to `boot-device --force`, a dead
end. The only escape was discovering `ARGENT_SIMULATOR_SERVER_TCP_DIR`
by reading source. Fix: `requireTcpArtifact` throws an actionable error
naming the exact path, the override env var, and how to build it;
`describeIos` surfaces that instead of the reboot hint.

## Testing

- New `sim-remote-spawn-json.test.ts` — asserts `--json` on both plain
and `--bin/--detach` argv, before the `--` separator.
- `native-devtools-ios/test/index.test.ts` — the tcp binary resolves
from the neutral `bin/tcp` on **both** linux and darwin; the not-found
errors name their override env var.
- `native-devtools-ios` (16) + ios/describe tool-server suites pass;
typecheck, eslint, prettier clean.
- Manual end-to-end against `remote:<UDID>` (darwin host): binary at
`bin/tcp` → full tree from `source: "ax-service"`, no hint; binary
removed → actionable `ARGENT_SIMULATOR_SERVER_TCP_DIR` hint.
2026-07-22 08:47:52 +02:00
Ignacy Łątka 300bc30e3d fix(open-url): flag when a web URL may open the browser instead of an app (#476)
## Problem

An agent calling `open-url` to deep-link into an app gets Safari
instead:

```
open-url { "url": "https://bsky.app/profile/tvpworld.bsky.social" }  →  opens Safari
```

### Root cause (not a wrapper bug — an iOS Simulator limitation)

`open-url` on iOS is a thin wrapper over `xcrun simctl openurl`. An
`https://`
URL is a **Universal Link**, which only reaches a native app when that
app is
installed and verified for the link's domain. Two things bite here:

1. On the iOS **Simulator**, `simctl openurl` routes Universal Links to
Safari
   even when the owning app *is* installed — a long-standing, documented
limitation. Custom URL schemes (`scheme://…`) route reliably; Universal
   Links do not.
2. In the reported case the Bluesky app isn't installed at all, so
Safari is
the only valid handler — identical to tapping the link on a real iPhone
with
   Bluesky absent.

Reproduced on a booted iOS 18.5 sim: `simctl openurl <bsky https link>`
launches `com.apple.mobilesafari`; the sim has zero third-party apps
installed;
`bluesky://…` fails with `-10814` (no registered handler), confirming
absence.

The real defect on our side: the result was **always** `{ opened: true
}`, so a
web URL that silently fell back to Safari was indistinguishable from a
successful deep-link — the caller (an agent) believed it had reached the
native
app. There is no reliable way to observe which app actually handled the
URL
(Safari and other apps are invisible to the native-devtools socket), so
the fix
makes the ambiguity explicit rather than guessing.

## Fix

- Add an optional `note` to the `open-url` result whenever a web URL
(`http`/
`https`) is opened on a native device (iOS + iOS-remote). It states the
URL
may have opened in the browser and points to the reliable path: the
app's
**custom scheme** (`scheme://path`) or **`launch-app`** with the bundle
id.
- Update the tool description with the same deep-linking caveat.
- Custom schemes, non-web schemes (`tel:`, `maps://`, `geo:`), and
Chromium
navigations are unaffected (no note). No URL rewriting — the URL is
still
  handed to `simctl` unchanged.

Scoped to iOS deliberately: Android App Links go through `am start` with
a
different verification/chooser path, and the reported symptom is
iOS-specific.

## Verification

- New unit tests (`open-url-deep-link-note.test.ts`, 4 cases): the
helper
returns the caveat for `http`/`https` (case-insensitive) and `undefined`
for
custom/non-web schemes; the iOS handler attaches `note` for the bsky
https
link (URL passed to `simctl` unchanged) and omits it for a custom
scheme.
- End-to-end against a live sim through the real `openUrlTool.execute`:
  - `https://bsky.app/profile/…` → `{ opened: true, url, note }`
- `maps://?q=cupertino` (installed native handler) → `{ opened: true }`,
no note
- `bluesky://…` (not installed) → correct `FailureError` (existing
behavior)
- `tsc --noEmit`, `tsc --noEmit -p tsconfig.test.json`, and `prettier
--check`
  all pass on the changed files.
2026-07-21 16:56:31 +02:00
Ignacy Łątka 89ad80180c fix(map): strip DEL/C1 control bytes from the --json artifact
Device-sourced screen titles and element labels come from an untrusted
app. The stdout path already strips control bytes so a crafted
accessibility label can't smuggle an ANSI/OSC escape sequence into the
terminal, but the --json artifact relied on a false premise — 'JSON.stringify
escapes these'. It escapes only C0 (U+0000-U+001F); DEL and the C1 range
(U+007F-U+009F, the CSI/OSC introducers) pass through LITERAL, so a title
like "Settings<CSI>2J" wrote raw control bytes into argent-map.json that
fire a screen-clear / clipboard-write when the file is viewed with cat/less.

writeMapJson now recursively strips exactly the bytes JSON.stringify leaves
literal (DEL + C1) from every string in the state, leaving C0 to
JSON.stringify. Adds a test asserting no raw DEL/C1 byte reaches the file
while the visible text is preserved.
2026-07-21 11:23:24 +02:00
Ignacy Łątka 00af383ece fix(map): rank fetchStableTree samples by the fingerprint's node set
screenKey excludes scroll-decoration overlays (a scroll bar flashes on a
scrollable screen's appear, then fades), but fetchStableTree ranked its
'fullest wins' samples with a RAW node count that includes them. So a
sample of 30 content rows + the scroll bar and a sample of 31 rows with
the bar gone count the same, and 'fullest' by raw count picks the sparser
CONTENT sample on some visits — keying the same screen two different ways
and minting phantom duplicate nodes, exactly what the sampler exists to
prevent.

Add a screenNodeCount that counts the nodes the key actually uses (scroll
decorations excluded) and have the driver pass it as fetchStableTree's
sizeOf, so fullness and identity are measured over the same nodes.
2026-07-21 10:35:01 +02:00
filip131311 f0026ce6c8 fix(tool-server): stop native-devtools unix socket bind crashing the server (#531)
## Problem

The per-UDID native-devtools unix listener binds with a bare
`server.listen(socketPath)` and **no `error` listener**
(`native-devtools.ts`, the `else` branch of the transport check). A bind
failure — `EADDRINUSE` from a live/concurrent server on the same UDID,
or `EEXIST` from a socket re-created in the window after the pre-unlink
— fires an unhandled `'error'` event, which Node throws as an **uncaught
exception that crashes the whole tool-server at startup**.

The TCP branch right above it already guards its bind with
`server.once("error", reject)`; `ax-service.startListener` does too. The
unix branch was the one that didn't.

## Why it matters — 0.16.0 crash telemetry

The crash-diagnostics shipped in 0.16.0 (`crash_fingerprint` /
`crash_phase` / `error_syscall`) make this the clearly-dominant crash
source. Scoped to `toolserver:stop`, `reason=crash`,
`cli_version=0.16.0`:

- **100% of crashes are socket/fs E-codes**, none are logic bugs.
EADDRINUSE (95%), EEXIST (4%), ENOSPC/EPIPE tail.
- Fingerprint **`dc063fe2`** — the native-devtools socket bind site —
throws both **EADDRINUSE (65)** and **EEXIST (30)** at
**`crash_phase=startup`**, across **~14 distinct users**. That's the
widest-reach source by user count. (A separate single-user
`toolserver_bind` restart loop accounts for most raw *volume*, but only
one machine.)

EEXIST at this site is the known concurrent-agent contention on
`/tmp/argent-nd-<UDID>.sock` — a stale/racing socket file left by
another server.

## Fix

Extract the unix bind into `bindNativeDevtoolsUnixSocket(server,
socketPath)`, mirroring `ax-service.startListener`:

- Attaches an `error` listener so a bind failure **rejects** instead of
throwing uncaught → routes into the factory's existing retry +
failure-telemetry path, failing only this one service.
- On `EADDRINUSE`/`EEXIST`, **unlink the stale entry and retry the bind
once** — self-heals the unlink→listen race with a concurrent same-UDID
server.
- On any other failure (or a second failure), rejects with a coded
`FailureError` (`NATIVE_DEVTOOLS_SOCKET_BIND_FAILED`,
`failure_stage=native_devtools_socket_bind`) so it's cleanly
classifiable in telemetry going forward.

## Testing

- **Unit** (`native-devtools-socket-bind.test.ts`, 4 cases): free-path
bind, stale-file self-heal, coded-FailureError on unbindable path,
no-uncaught-throw when contending a live-held path.
- **Full telemetry + registry suites**: 368 pass (new failure code
accepted by the sanitizer allowlist).
- **Live A/B against built output** — a live server holds the socket,
then two child processes contend for it:
- OLD (pre-fix `server.listen(path)`): **exit 1, uncaught EADDRINUSE
crash** ← bug reproduced
- NEW (compiled `bindNativeDevtoolsUnixSocket`): **exit 0, self-healed
and bound** ← fix works
- **Bundled-artifact check**: ran the esbuild `bundle-tools` step; the
fix's retry logic and coded failure survive bundling intact in
`dist/tool-server.cjs` (no dead-code-elimination surprise).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-21 10:28:14 +02:00
Ignacy Łątka 7779902ef3 fix(map): stop fetchStableTree locking onto a leading sparse phase
The early-exit guard `count === maxCount` was meant to keep an
oscillating screen's sparse phase from winning over its fuller phase, but
maxCount is only the fullest seen SO FAR — so when the sparse phase leads
for the whole settle floor (e.g. the Settings root's 41 <-> 30 flip caught
sparse-first), the guard is vacuously true and the capture commits to the
sparse look at read 3, never sampling the fuller phase that was still
within the 5-sample budget. The same screen then keys differently
depending on which phase the visit began in, minting phantom duplicate
graph nodes and wasted restart-replays — exactly what the sampler exists
to prevent.

Require an observed strictly-sparser sample (minCount < maxCount) before
the early exit may fire, so maxCount is a peak the tree grew INTO, not
just the first phase sampled. A leading sparse phase now keeps sampling
until the fuller phase appears; the common incremental-fill early-exit is
unchanged. Adds a sparse-leads-the-floor regression test.
2026-07-21 10:05:38 +02:00
Ignacy Łątka 71f4e74550 fix(map): write the --json artifact on the crawl-error path too
The three CLI exit branches were inconsistent: cancel (Ctrl-C) and
normal-finish both persist the partial graph to --json, but the
crawl-error branch only printed the partial-map URL and silently dropped
the artifact — even though its own `seen.screens > 0` guard shows a
partial graph can exist there. A crawl that threw after mapping screens
(e.g. a screenshot that rejected mid-crawl, which isn't degraded like a
tap) left the user's explicit --json request unwritten.

Honour --json on the error path too: fetch the finalized partial state
and write it, mirroring the cancel branch (same tested writeMapJson
helper), with the same 'couldn't fetch' fallback message.
2026-07-21 09:36:15 +02:00
Ignacy Łątka 4151cb9562 fix(map): re-propagate a shorter depth through the explored graph on revisit
The plain crawl could silently drop reachable screens. When a hub was
discovered the long way first, its only action consumed to record a
depth-capped child, the hub was left spent (owes nothing). A later tap
reaching that hub by a shorter path adopted no shorter depth: both revival
branches required the revisited screen to still owe actions, so a spent
hub fell straight through. Its depth-capped descendant kept its stale-deep
depth, stayed exhausted, and its owed subtree — in fact within budget via
the short route — was never explored. depth is a traversal artifact, so
reachability must not depend on discovery order.

On any strictly-shorter revisit, adopt the shorter route and push it down
the already-explored graph: re-derive each descendant's depth/path/entry
and revive any that was capped at the budget but now fits. It walks
recorded edges with a strict-decrease guard, so cycles terminate. This is
the tap-path twin of the deep-link re-root and makes the in-code 'a
shorter tap path is handled' guarantee true. Adds a reproduction test
(a spent hub drops Leaf without the fix) and a cycle/termination test.
2026-07-21 09:13:08 +02:00
Ignacy Łątka 72ac3aaa4e fix(map): scale card de-overlap budget with graph size
The de-overlap pass ran a fixed 24 sweeps. That clears the incremental
live layout (each revision adds one node to an already-separated
skeleton), but a single-shot cold-load — a fresh page render of an
already-completed large graph, where every node is movable at once —
piles N opaque cards and needs ~O(N) Gauss-Seidel sweeps to fully
un-stack, so a ~100-node graph froze with ~47 overlapping pairs.

Scale the pass budget with node count (floored at the existing token).
The loop already early-breaks the instant nothing moves, so the higher
ceiling is free for the common already-separated case and only spent on
the cold pile. A single-shot 100-node hub graph now settles to zero
overlaps (was 47).
2026-07-21 08:36:23 +02:00
Ignacy Łątka 8f85bebffa fix(map): guarantee screen cards never occlude each other
The force-directed layout had only soft, range-capped repulsion and no
hard minimum-separation, so on realistic graphs (and on any dense pile)
opaque screen cards settled inside each other's footprint and hid one
another. Streaming the graph in one node per structural revision made it
worse: two edge-connected cards could freeze while still stacked, and a
movable-only de-overlap could never part them afterward.

Add a tokenized min-separation constraint to mapRelax: an in-loop
collision force plus a bounded, fixed-order Gauss-Seidel de-overlap pass
that resolves every penetrating pair (frozen ones included) along the
axis of least penetration. It stays deterministic and per-structural-
revision, so an unchanged graph re-solves to the same positions with no
live per-frame jitter.
2026-07-21 07:42:31 +02:00
Jakub Piasecki a0d1bbb1e7 feat(flow): indent nested steps in reports by block depth (#524)
Flow reports rendered every step flat, hiding which steps ran inside a
`when:` block or a `run:` fragment. The tool-server now stamps
`StepReport.depth` (+1 per block directive, omitted at top level) and
the CLI/MCP renderers indent step labels and under-step lines by it:

```
  ✓  1 launch
  ✓  2 when visible "What's new" — condition met
  ✓  3   tap "Skip"
  ✓  4   run dismiss [dismiss]
  ✓  5     tap "OK" [dismiss]
```

Invariants:

- **Wire-compatible both ways** — a flow with no block directives
reports byte-identical to the pre-depth shape; a pre-depth server
renders flat as before.
- **Skipped ≡ entered** — skipped/errored/aborted blocks report the same
depths as entered ones, keeping reports comparable run-to-run.
- **Untrusted wire data** — renderers clamp depth (display cap, not a
producer bound): hostile values render flat or at the cap, never throw
or allocate huge strings.

Also fixes a pre-existing bug: under-step warning/artifact lines now
stay aligned past 99 steps.

Tests pin depth on every producer path (entered/skipped/errored/aborted
blocks, `run:` success/failure markers, `run:`-in-`when:`) and renderer
alignment incl. the clamp boundary.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:28:48 +02:00
Ignacy Łątka 368ba2fe58 fix(map): keep disconnected deep-link entry cards from collapsing into overlap
The force layout seeds a disconnected entry (a deep link is a jump, not a tap, so
its screen has no incoming edge) on a widening golden-angle spiral so entries fan
out. But mapRelax's centerPull toward the all-node centroid — applied every
iteration and uncapped by distance — overpowered the range-capped inverse-square
repulsion and dragged each far-seeded entry back into a tight band around the
centroid, where repulsion could no longer separate them; the node then froze
after its one movable revision, leaving the entry cards permanently overlapping.

Exempt edgeless nodes from centerPull: a disconnected entry has no spring to hold
the spread the spiral seeding gave it, and repulsion is range-capped so it can't
fling the node away. Connected nodes still get centered as before.

Verified with a headless-render harness (8 disconnected entries): overlapping
card pairs 3 -> 0 and min entry-to-entry centre distance 146px -> 457px (the
golden-angle spread is preserved); the s2<>s7 overlap the review cited is gone.
2026-07-21 06:58:56 +02:00
Ignacy Łątka d9cba5ddac fix(map): record a tap-discovered screen's parent edge atomically with the node
createNode added the screen node, then awaited a device screenshot (~100-400ms),
and only the caller added the parent edge afterward. A preview poll (every
1200ms, served on the same event loop) landing in that screenshot window saw the
child node without its parent edge, so the force layout mis-seeded it into the
disconnected entry-point ring and — frozen after its revision — left it there as
a detached island with a stretched edge, also inflating the entry seed counter.

Move the parent edge into createNode, recorded in the same synchronous store
tick as the node (before the screenshot await), so node and edge always surface
to a poll together. The screenshot is a late decorative enrichment; a poll during
it now sees the connected node with a pending thumbnail the UI fills in next poll.

Discriminating test asserts the parent edge already exists when the node's
screenshot is captured (verified failing with the edge added post-createNode).
2026-07-21 06:23:05 +02:00
Ignacy Łątka c34dc8bb02 docs(map): record two accepted limitations of the screen-graph crawler
Two narrow edge cases surfaced by adversarial review are deliberately left for a
follow-up on this first-cut, flag-gated feature, and are now documented in code:

- fetchStableTree's "fullest sample wins" rule (built for iOS AX content-drop
  oscillation) can also capture an additive transient overlay (snackbar / toast /
  tooltip / banner) shown during the sampling window, letting the overlay enter
  the screenKey so a screen may key differently across visits. There is no
  structural way to tell an additive transient from AX content-drop or from a
  real modal sheet that should be mapped, so returning the fullest sample stays
  the trade-off; the crawler degrades gracefully (a duplicate node + recovery
  restart-replays).

- A deep link that lands on a fully-explored ancestor of a depth-capped screen
  (the landed screen owes no actions; the capped subtree is reachable only via an
  already-consumed action) is not re-walked from the new depth-0 origin, so that
  subtree stays dropped. The common recovery cases (landing on the capped screen
  itself, an ancestor that still owes actions, or a shorter tap path) are handled.
2026-07-21 06:00:31 +02:00
Ignacy Łątka ceb30422b6 fix(map): don't persist a cancelled crawl's --json artifact as status "running"
On Ctrl-C the CLI aborts the streaming call and immediately GETs /preview/map for
the partial graph. That read can win the race against the server's between-steps
finalize (the crawler is often mid-await in a non-abortable describe/settle/gap),
so the snapshot still reports status "running" — and writeMapJson persisted it
verbatim, producing an argent-map.json that looks like an in-progress crawl for
one the user actually cancelled (the console summary and exit code were already
correct). The CLI is authoritative that the user cancelled, so coerce the written
artifact's status to "cancelled" via a new asCancelledState helper.

Discriminating test verified failing when the helper is a no-op.
2026-07-21 05:29:53 +02:00
Ignacy Łątka 4382da2da0 fix(map): revive a replay-abandoned screen when a later tap lands back on it
A screen is marked exhausted-with-owed-actions not only at the depth cap but
also when a replay can't get back to it (a leaving-the-app relaunch resumes
elsewhere, or returnToCurrent's replay diverges) — its recorded depth is then a
normal, in-budget depth, not the cap. The revival branch only fired when a
strictly shorter path re-reached the screen, so landing back on such a node at
equal or deeper depth stranded its owed actions and dropped the subtree only
reachable through them — even though the crawler was standing on the screen and
needed no navigation to finish it.

Generalize the revival: an exhausted screen that still owes actions is revived
whenever this tap lands us on it and its owed actions yield in-budget children
(min(recorded depth, revisit depth) < maxDepth). A strictly shorter path still
lowers the recorded depth; an equal/deeper re-encounter keeps it but resumes.
This subsumes the depth-cap re-root and also recovers replay-abandoned screens.

Discriminating test verified failing under the old depth-cap-only gate.
2026-07-21 05:09:19 +02:00
Ignacy Łątka 624b6cde17 fix(map): adopt the shorter depth on any known-screen revisit; sanitize the device picker
Two defects from a further adversarial review:

- Lower a known screen's depth when a shorter tap path revisits it, not only
  when it was depth-capped. The non-exhausted revisit branch adopted the screen
  (`current = known`) but kept its original, deeper traversal depth, so its
  still-owed actions minted children at that stale depth and hit the depth cap
  early — dropping reachable screens on ordinary cross-linked apps (a screen
  reached both deep via a cross-link and shallow via its own tab/direct link).
  The revisit now adopts the shorter depth/path/entry, matching the depth-cap
  and deep-link re-roots. Depth is a traversal artifact, not a screen property.

- Scrub the multi-device picker's labels. When several devices are booted the
  `argent map` picker printed each device's label — which includes an Android
  model / iOS name read from the device — straight to the terminal without the
  control-byte scrub the crawl output uses, so a crafted `ro.product.model`
  could inject ANSI/OSC escapes. Routed through a new `formatDevicePicker`
  helper that sanitizes each label (mirrors `formatProgressLine`).

Each fix has a discriminating test (verified failing when the fix is reverted).
2026-07-21 04:47:27 +02:00
Ignacy Łątka eaaae1238b fix(map): revive depth-capped screens on a shorter path; harden tree sampling and dir sweep
Four defects from an adversarial review of the crawler:

- Re-root a depth-capped screen reached again by a shorter tap path.
  A screen first discovered at exactly the depth cap is recorded with its
  actions still owed; a later, shorter tap path to it was ignored because the
  node was already flagged exhausted, so a within-budget subtree was dropped
  purely because of button-enumeration order. Mirror the deep-link re-root:
  when a shorter path brings the screen back inside maxDepth, lower its depth,
  reset its replay path/entry, clear exhausted and descend. Depth is a
  traversal artifact, not a screen property.

- fetchStableTree no longer locks onto an oscillating screen's sparse phase.
  The early exit could fire on the first agreeing pair before the fuller phase
  was ever sampled, minting a different screenKey than a visit that began in
  the full phase (phantom duplicate nodes). It now stops only once settled on
  the fullest look seen (count === running max) and past a settle-sample floor.

- fetchStableTree rides out a transient describe failure. A throw on any
  non-first sample discarded the good tree already captured and aborted the
  whole crawl at launch (or forced a spurious relaunch mid-crawl). Per-sample
  failures are now kept-and-skipped; the error only surfaces if every sample
  fails.

- The stale screenshot-dir sweep spares dirs a live tool-server still owns.
  A finished map keeps being served after its dir mtime freezes, so a
  concurrent server's age-only sweep could delete another live process's
  thumbnails. Dirs are named <ownerPid>-<crawlId>; a dir whose owner is still
  running is spared, and only exited processes' leftovers are reaped by age.

Each fix has a discriminating test (verified failing when the fix is reverted).
2026-07-21 04:20:15 +02:00
Ignacy Łątka c14e2cdf44 fix(map): skip the restart-replay back to an already-spent screen
When a tap lands on an already-mapped screen and `current` has itself
just consumed its last action, `returnToCurrent`'s back tap can still
be worth trying — it may land on a different known screen that still
has work and adopt it for free. But if the back tap lands nowhere
useful, the fallback restart-replay was aimed back at `current`, a
screen that owes no further actions: the next loop iteration marks it
exhausted and immediately backtracks to the real frontier, so the
replay maps nothing and costs a full restart.

Guard only that fallback: when the back tap fails and `current` is
spent, return null so the caller drops straight to the frontier
backtrack. The opportunistic back-tap adoption (the path the
depth-cap/sheet-dismissal tests rely on) is untouched.
2026-07-21 03:43:29 +02:00
Ignacy Łątka 80b9b63330 chore(map): remove a stray scratch test accidentally committed 2026-07-21 03:31:49 +02:00
Ignacy Łątka 03479cf6e3 fix(map): deep link onto a depth-capped screen explores its dropped subtree
When the launch crawl bottomed out at maxDepth on a screen (recorded but not
descended, its actions still owed) and a deep link later landed back on that
known screen, the seeding loop only flagged it an entry and moved on — silently
dropping the very subtree deep links exist to reach. Re-root such a known screen
as a fresh depth-0 origin (reset depth/path/entryUrl, un-exhaust) and explore it,
so its in-budget subtree is mapped. A fully-explored known screen is still just
flagged, as before.
2026-07-21 03:31:14 +02:00
Ignacy Łątka cf8ce9c454 fix(map): reset the layout seed counter when the graph is cleared
Third sweep round caught a regression from the persistent-seed-counter fix: a
second crawl in the same preview window inherited the prior graph's
mv.unanchoredSeeded, so its root no longer landed at world centre and the layout
diverged from a fresh crawl. mapClearGraph now zeroes the seed counter and the
world extents alongside the node/edge/position maps.
2026-07-21 03:22:48 +02:00
Ignacy Łątka 0a9f748069 fix(map): trust a confident in-app foreground; spread disconnected entries
Second adversarial sweep round on the graph reframe confirmed two more defects:

- readTree honored only a confident isTargetForeground()===false; a ===true fell
  through to the resource-id heuristic, which on Android can only false-positive
  when our own task is already on top — dropping an in-app screen whose only
  qualified ids belong to a third-party SDK (embedded player / maps view) as
  "outside". Make a confident true authoritative: return the tree, skip the
  fallback. The heuristic now runs only when foreground is unknown (null).
- Disconnected deep-link entry points seeded into one narrow wedge and overlapped
  permanently: the neighbourless-seed counter reset to 0 each layout revision, so
  every entry (deep links arrive one per revision) took golden-angle slot 0 and,
  since nodes freeze after one revision, never separated. Persist the counter
  across revisions so each disconnected seed takes the next slot.
2026-07-21 03:10:47 +02:00
Ignacy Łątka 63c7ea720d fix(map): detect leaving the app on iOS, uncap edge viewBox, cap --deep-link
Adversarial review of the graph reframe surfaced four defects:

- iOS "left the app" detection was inert: describe reads whichever app is
  frontmost, so a tap that opened Safari/Settings/the share sheet returned that
  foreign app's tree looking in-app, and the crawler mapped and DFS-descended
  into it. Add driver.isTargetForeground() (native-devtools app state on iOS,
  resumed-activity on Android); readTree treats a confident "not foreground" as
  leaving the app, before the resource-id heuristic can seed a foreign package.
- Android outside-detector force-admitted the first foreign tree for apps
  carrying no qualified resource-ids (pure Compose); the foreground check now
  runs first and catches it.
- Map edges were clipped at the world origin: the force layout produces
  negative coordinates but the edge SVG viewBox was "0 0 w h". Span the full
  [minX..maxX]x[minY..maxY] extent so arcs into negative-coord nodes draw;
  node cards keep their exact positions (the box stays anchored at 0,0).
- --deep-link had no client-side count cap, so >20 links surfaced as a raw
  ZodError blob; mirror the server's max(20) like the numeric flags.
2026-07-21 02:34:53 +02:00