mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
feat/argent-map
637 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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". |
||
|
|
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. |
||
|
|
1b12f83697 | Merge remote-tracking branch 'origin/main' into feat/argent-map | ||
|
|
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.
|
||
|
|
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"} ``` |
||
|
|
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>
|
||
|
|
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).
|
||
|
|
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) |
||
|
|
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)
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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). |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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: {}` |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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)
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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). |
||
|
|
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. |
||
|
|
80b9b63330 | chore(map): remove a stray scratch test accidentally committed | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |