Commit Graph

641 Commits

Author SHA1 Message Date
filip131311 a6422a3f5f chore: align argent-private submodule to its main (#612)
Bumps the `packages/argent-private` submodule pointer from `2d6c292` to
`c3fa4fc`, the current tip of its `main`.

Newly included commits:
- `c3fa4fc` Merge pull request #28 —
@Filip131311/self-hosted-macos-builds
- `cc189e6` ci: build and sign native binaries on the self-hosted Mac

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-31 16:37:10 +02:00
filip131311 23b9ae5c87 chore: bump to 0.18.1 (#603)
Patch bump. First release whose npm tarball carries `mcpName` (added in
#598), which unblocks the MCP registry publish (`mcp-publisher publish`
rejects `0.18.0` because its manifest predates the field).

`server.json` is bumped alongside `packages/*` for the first time —
`scripts/check-workspace-versions.mjs` now enforces it (`All workspace
packages and server.json are at 0.18.1` locally).

| Commit | Change |
| --- | --- |
| `eea6c27c` | `style`: fix prettier formatting on main (#604) |
| `7e4ece69` | `chore`: server.json for the official MCP registry (#598)
|
| `fc09612f` | `feat(config)`: `recordings.directory` — configurable
save location for screen recordings (#601) |
| `cccda92d` | `feat(config)`: `ios.additionalDeviceSets` — additive
array of extra CoreSimulator device sets (#542) |

Version-only diff, rebased on main as of #604.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-31 15:49:05 +02:00
filip131311 11f384d37f fix(chromium): make Electron apps testable without window focus (#605)
## Problem

Field report: *"electron needs focus to be tested."* Analysis confirmed
three focus/visibility dependencies in the Chromium/Electron surface:

1. **Hidden window → throttled renderer.** A minimized/occluded window's
compositor throttles: mouse-input acks stall **~5 s per event** (they
wait on hit-testing), wheel scrolls hang (`gesture-scroll` already
refused), `visibilityState` flips to `hidden`.
2. **Unfocused window → focus-gated app logic breaks.**
`document.hasFocus()` is `false` before any input arrives;
focus/blur-dependent UI misbehaves.
3. Focus-gated web APIs (clipboard already worked around it locally with
`Page.bringToFront`).

## Fixes

**1. CDP focus emulation on every page session** (`primePageSession`, né
`enableCoreDomains`): `Emulation.setFocusEmulationEnabled(true)`,
best-effort, re-applied automatically after tab switches (Playwright
precedent). Live A/B on the Electron testbed: identical
boot→unfocus→probe sequence gives `hasFocus:false` on main, `true` on
this branch. Bonus verified by A/B (kill server → same minimized window
flips to `hidden`): in current Chromium/Electron, emulation also pins
reported visibility to `visible` and keeps input unthrottled while
genuinely minimized — minimized tap latency **~5 s → ~65 ms**, wheel
scroll works.
*Trade-off (same as Playwright ships):* while attached, the app can
observe neither real blur nor real hidden, so pause-on-hidden behavior
is unobservable during a test.

**2. Anti-throttling switches for every argent-booted Electron app**
(`--disable-background-timer-throttling
--disable-backgrounding-occluded-windows
--disable-renderer-backgrounding`). Sessions are created lazily and die
with the idle-exiting tool-server while the app outlives it — the flags
cover that detached lifecycle plus emulation-less runtimes. Also covers
flow-booted apps via the shared `bootElectronApp`.

**3. Hidden-window fail-fast extended from gesture-scroll to gesture-tap
+ gesture-drag** via shared `assertChromiumWindowVisible`, under a new
dedicated `CHROMIUM_WINDOW_HIDDEN` failure code (scroll migrated;
`failure_stage` strings unchanged so telemetry stays joinable —
analytics keyed on `CHROMIUM_INPUT_INVALID` +
`chromium_scroll_window_hidden` should switch to the new code). With
emulation active the guard is a backstop that fires only where emulation
couldn't be applied — exactly where the stall is real. Keyboard/button
stay unguarded on purpose: measured live, key events skip hit-testing
and stay fast on hidden windows.

## Verification

- Unit: 3075 tool-server + 87 registry tests green, `typecheck:tests`
green, new tests for emulation priming, boot args, and the guard (all
three tools).
- Live matrix on the Electron testbed (dev tool-server): focused
baseline; unfocused (`hasFocus` true pre-input, typing lands); minimized
booted app (tap 69 ms, scroll works, typing + slider drag land,
screenshot fine); minimized **external** un-flagged app on :9222 (tap 63
ms, scroll 460 ms — emulation alone suffices while attached).
- Plan + implementation went through three adversarial review rounds
(scope, CDP semantics, failure-code taxonomy/telemetry compat, doc
accuracy).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-31 15:36:36 +02:00
filip131311 05f53502ab feat(tool-server): drive iOS simulators from additional CoreSimulator device sets (#600)
> Recreates #543 — GitHub auto-closed it when the base branch of the
stacked PR (#542) was deleted on merge, and a closed PR cannot be
reopened after its head is force-pushed. Same commit, rebased onto main.

## Summary

**Was stacked on #542** (now merged). Consumes the
`ios.additionalDeviceSets` configuration across the iOS tool surface, so
simulators living in extra CoreSimulator device sets — e.g. **Radon
IDE's** `~/Library/Caches/com.swmansion.radon-ide/Devices/iOS` — are
discovered by `list-devices` and drivable by every tool, by UDID,
exactly like default-set devices.

### The core piece: a UDID → device-set map (`utils/ios-device-sets.ts`)

simctl scopes every operation to one device set, so each per-device call
must know the target's owning set. The new module owns that mapping and
the single argv choke point:

- **Learned from discovery**: `list-devices` and the simulator watcher
enumerate the default set + every configured set and `rememberDeviceSet`
each sighting — any tool used after a list hits a warm cache.
- **Lazy fallback**: an unknown UDID triggers one `simctl list` probe
per configured set, cached on hit. An unknown-everywhere UDID resolves
to the default set **without** caching (a device that appears later is
found on the next call) — preserving pre-feature behavior and error
messages byte-for-byte.
- **`simctlArgsForUdid(udid, args)`** builds the full argv (`--set`
injected only for additional-set devices); `simctlPrefix` covers
multi-call sites that resolve once; sync variants
(`simctlArgsForUdidSync`, `cachedDeviceSetForUdid`) cover the
`ChildProcess`-returning and `execFileSync` call sites, each of which
runs after an async step that warmed the cache.
- Config is **read off disk per call** (the #534 design): editing
`ios.additionalDeviceSets` applies without a tool-server restart.
- **Guard**: a configured set whose directory doesn't exist is never
queried — simctl would materialize the dir, turning a config typo into a
stray directory.

### Wired consumers

- **`list-devices`** — enumerates every set, tags additional-set devices
with `deviceSet`, documents the field.
- **`simulator-watcher`** — polls every set (a set failing to list
throws → the tick is skipped, so a transient error still can't
mass-dispose tracked services); warms the map for the injection path.
- **simulator-server blueprint** — passes `--device-set` (the same flag
Radon IDE passes to this shared binary); default-set spawns unchanged.
- **`ax-prefs`** — the pre-boot AX plist now lands under the owning
set's device dir (previously hardcoded to the default
`CoreSimulator/Devices` root, so the write would land nowhere for these
devices and describe would degrade).
- **`boot-device`** — set-aware shutdown/boot/bootstatus; skips the
Simulator.app attach + `CurrentDeviceUDID` write for additional-set
devices (one Simulator.app instance displays one device set — these run
headless and are streamed, exactly how Radon IDE presents them).
- **Every remaining per-device simctl call site**: launch-app /
restart-app / reinstall-app (both the direct impls and the
`SimctlBackend` shared handlers), open-url, settings-permissions
(privacy + install probe), the tvOS screenshot fallback, status-bar
pin/restore, device-shutdown, the ios-host `simctl spawn` family behind
describe/native-devtools, tv-control's AX daemon, and the native
profiler's sync helpers (cache warmed at `startNativeProfilerIos`).

**Out of scope**: `sim-remote` — the remote orchestrator host has its
own device-set story.

## Testing

- **Unit**: new `ios-device-sets.test.ts` (mapping, lazy probe + caching
semantics, no-probe fast path with no sets configured, discovery
enumeration/tagging/dedup, missing-dir guard); blueprint test asserts
the `--device-set` argv; the profiler shell-injection mock extended for
the new import. Full tool-server suite green: **276 files / 2752
tests**; `tsc --build`, eslint, prettier clean.
- **Live, end-to-end on a real Radon IDE device** (iPhone Air, iOS 26.4,
in `com.swmansion.radon-ide/Devices/iOS`), driving this branch's
tool-server over HTTP with the config set at project scope:
- `list-devices` → the Radon device appears, tagged with its `deviceSet`
(invisible to argent 0.16.1 on the same machine);
  - `boot-device` → boots headless in its set, no Simulator.app window;
- `screenshot` → real home-screen PNG via simulator-server spawned with
`--device-set`;
  - `launch-app` (Settings) → launched via `simctl --set launch`;
- `describe` → full accessibility tree from the Settings screen, i.e.
the AX bootstrap (set-aware prefs + in-sim ax-service daemon via `simctl
--set spawn`) works;
- clean shutdown; default-set devices meanwhile listed and behaved
exactly as before.
2026-07-31 15:14:03 +02:00
filip131311 eea6c27cf0 style: fix prettier formatting on main (#604)
## Summary

`npx prettier --check .` fails on current `main` with one violation:
`packages/argent-tools-client/test/artifacts.test.ts` (a single `expect`
line wrapped where it now fits the 100-char print width). This PR is the
`prettier --write` output for that file — whitespace-only, no behavior
change.

## Test plan

- [x] `npx prettier --check .` passes on the branch (was failing on
`main`)
- [x] Diff is whitespace-only (1 file, 1 reflowed statement)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-31 13:13:12 +02:00
Ignacy Łątka 7e4ece697d chore: add server.json for the official MCP registry (#598)
Adds the two files the official MCP registry
(`registry.modelcontextprotocol.io`) needs in order to list argent.
Argent is not in that registry today
(`?search=io.github.software-mansion` returns 0 servers).

The registry is metadata-only — it points at the npm package rather than
hosting anything.

### `server.json`

Describes the server and how a client starts it: `npx @swmansion/argent
mcp`. The `mcp` positional is the dispatcher case in
`packages/argent/src/cli.ts:137`, and it is the same subcommand `argent
init` writes into every client config —
`packages/argent-installer/src/mcp-configs.ts:130-141` spells it four
ways (`node <path> mcp`, `yarn argent mcp`, `npx --no-install argent
mcp`, and the global `{ command: MCP_BINARY_NAME, args: ["mcp"] }`,
where `MCP_BINARY_NAME` is `"argent"`). None of them is literally the
npx-by-package-name form in `server.json`, which is the form the
registry expects a client to construct from `identifier` + `runtimeHint`
— so it is verified below by running it, not by matching a string.

`repository.id` is the GitHub repo id (`1164885639`); the schema
recommends it so a registry can detect a delete-and-recreate of the
repo.

### `mcpName` in `packages/argent/package.json`

The ownership proof. The registry fetches the npm package and requires
`mcpName` to equal the server name in `server.json` before it will
accept a publish under `io.github.software-mansion`. It is the field the
registry's own quickstart names for npm packages. Nothing in the publish
path drops it: `scripts/pack-mcp.cjs` shells out to `npm pack` without
rewriting the manifest, and the canary path's
`scripts/next-canary-version.mjs --write` round-trips the JSON and only
sets `version`.

### Namespace

`io.github.software-mansion/argent` — GitHub OAuth auth. The registry
grants an org namespace only to org **Owners**; plain membership is no
longer enough. If no Owner is available to run the publish, the
alternative is DNS auth via an apex TXT record on `swmansion.com`, which
changes the name to `com.swmansion/argent` (and the `mcpName` value with
it).

### Version sync

`server.json` sits at the repo root, outside `packages/*`, so a release
bump would have left its two `0.18.0`s untouched — and the registry
rejects a publish whose `packages[].version` names a version npm has
never seen. `scripts/check-workspace-versions.mjs` (already run by the
Static checks job) also holds `server.json` to the version in
`packages/argent/package.json` — the package it names — and pins the two
cross-file links a publish depends on: `name` == `mcpName`, `identifier`
== the published package name. Both halves are reported in one run, so a
half-finished bump doesn't cost a CI round-trip per file.

### Not in this PR

- **Publishing.** Nothing has been published; this is just the manifest.
Publishing is `mcp-publisher login github && mcp-publisher publish`, and
it can only succeed **after** a release whose npm tarball carries
`mcpName` — i.e. not against `0.18.0`, which is `latest` (published
2026-07-30) and predates `mcpName` — `curl
registry.npmjs.org/@swmansion%2Fargent/0.18.0 | jq .mcpName` → `null`.
The first publishable release is the next bump.
- **`github.com/mcp`.** A separate, manual step. Onboarding a new server
there is hand-curated: per
[github/github-mcp-server#1257](https://github.com/github/github-mcp-server/discussions/1257)
(trent-j, a collaborator on that repo, 19 May 2026) "onboarding a new
server is still a manual curation process today. Once a server has been
onboarded, newly published versions from the OSS registry should sync
from there." The nomination channel is `partnerships@github.com`, from
GitHub's [registry how-to
post](https://github.blog/ai-and-ml/generative-ai/how-to-find-install-and-manage-mcp-servers-with-the-github-mcp-registry/)
(24 Oct 2025) — that address does not appear anywhere in the discussion
thread.

### Verification

- `mcp-publisher validate` (v1.8.0, which posts the file to the live
registry) → `server.json is valid`. Mutation-checked what that actually
covers: removing `transport` fails it and removing `$schema` is a 422,
but a foreign namespace (`io.github.anthropics/argent`), a nonexistent
npm identifier and a `99.0.0` package version all still pass — so it
checks shape only, and the ownership/existence checks happen at publish
time. Also validated offline against the pinned `2025-12-11` schema.
- Ran the entrypoint the file declares. `npx -y @swmansion/argent@latest
mcp` fed an `initialize` over stdio answered
`{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"argent","version":"0.18.0"},"instructions":"…"}`,
with stderr empty.
- Mutation-checked the new version guard: drifting `server.json`
`version`, drifting `packages[0].version`, deleting `mcpName`, and
typo-ing `identifier` each fail `check-workspace-versions.mjs` with the
specific line; the pre-existing packages/* drift check still fires on
its own.
- Asserted across the two files: `mcpName` == `server.json` `name`,
`identifier` == package `name`, all three versions equal, description 89
chars (registry limit is 100).
- `prettier --check` and `eslint` clean on both changed files.
- Mutation-checked the guard as a whole — 22 mutants, 1 survivor.
Killed: reverting the self-invocation guard to a raw `argv[1]` compare
and making it never match; `exit(1)` → `exit(0)` on the failure path;
dropping either `failed = true`; exiting early on `packages/*` drift
instead of collecting; `packages.slice(0, 1)`; hardcoding `packages[0]`
in either message; dropping the no-packages-array guard, the
non-object-entry guard, the non-object-JSON guard, and the presence
checks for `version`, `name` and `mcpName`; `continue` → `break` in the
`packages/*` scan (both arms) and in the entry loop; dropping the
on-message read, the `git checkout` hint, and the per-version name
accumulator. The survivor is the `packages/*` readdir sort: it fixes the
scan order rather than any output, so no single run can observe it — it
is there so a scan that stops early drops the same packages on every
machine instead of a filesystem-dependent set.
- Reproduced the symlink no-op on the real repo: with
`packages/registry` at `9.9.9` and `server.json` at `0.17.0`, `node
/tmp/argent-alias/scripts/check-workspace-versions.mjs` now exits 1 and
prints both problems, where before it exited 0 with no output at all.
- Closed the vacuous-pass class in `serverJsonMismatches`: all four
comparisons it makes were bare `!==`, so a field absent from *both*
sides compared equal and passed. A `packages/argent/package.json` with
no `version` printed `All workspace packages and server.json are at
undefined.` and exited 0; with no `name`, a `server.json` naming nothing
installable was reported in sync. `version` and `name` now require
presence, as `mcpName` already did.
- `readTrackedJson` guarantees an object, not just parseable bytes — a
`server.json` holding `null` used to reach the field reads and throw,
and `"0.18.0"` or `[]` produced mismatch lines that never said the file
was the wrong shape.
- Full CI parity locally: `test:scripts` 85/85, `tsc -p
tsconfig.scripts.json`, `eslint --max-warnings 0`, `prettier --check`.
2026-07-31 12:36:44 +02:00
filip131311 fc09612f30 feat(config): recordings.directory — configurable save location for screen recordings (#601)
## Summary

Adds a `recordings.directory` configuration entry (built on the #534
config system) that lets users choose where finished screen recordings
(mp4) are saved, instead of the fixed `<project>/.argent/recordings/`.

- **Schema entry** (`configuration-core`): scopes `project` + `global`,
`prioritize-local` merge (project wins when both are set). Picked up
automatically by `argent config set/get/unset/list`.
- **Value semantics**: absolute path, `~`-prefixed (expands to home), or
relative — relative anchors at the project root (home dir when not in a
project).
- **Client-side wiring** (`argent-tools-client` `durableSaveTarget`):
the wire `saveDir` hint stays `.argent/recordings` (wire contract
unchanged → old/new client-server interop). Past the existing client
allowlist, the recordings hint is remapped to the configured directory.

## Security model unchanged

The allowlist exists because `saveDir` arrives from a possibly-hostile
remote tool-server. That gate still runs first and is unchanged: a
server can only *select* the sanctioned recordings category, never name
a destination. The configured directory comes from the client's own
trusted config files. Exclusive never-overwrite writes (`name (2).mp4`),
the durable download size caps, and the symlink real-path guard all
remain; for a configured dir the realpath check degenerates to "the dir
resolves to itself" (a user-chosen symlink is legitimate, matching how
the default base may be symlinked). A test asserts hostile wire values
(`.git`, `..`, absolute) stay rejected even with config set.

## `argent link` interaction

Materialization always runs on the **client** (co-located → copy from
`hostPath`; remote → download over `/artifacts/:id`), and the config is
read there at materialize time — so with a remote tool-server the mp4
lands on the client host per the *client's* config, on both paths.

## Testing

- New unit tests: 10 in `argent-tools-client` (absolute/`~`/relative
resolution, local-vs-global precedence, both materialization paths,
hostile wire values with config set) + 4 in `configuration-core` (scope
writes, trim, precedence, validation).
- Suites green: tools-client 188, configuration-core 87, tool-server
screen-recording 62, CLI config 16; full workspace vitest 4727 pass (3
pre-existing `scripts/*.test.mjs` node:test files don't parse under root
vitest — unrelated).
- **Live E2E** on an iPhone 15 Pro simulator via the branch tool-server:
real recording captured, then materialized through the branch client
with configs written by the real `argent config` CLI in a sandboxed
HOME/project. 7 scenarios green: default location, global-only (`~`
path), project-only (relative), both-set → project wins (co-located
**and** remote download with Bearer auth), unset fallbacks, `argent
config list` rendering. Remote-downloaded mp4 ffprobe-verified
(byte-exact size, 4.17 s duration); name-collision suffixing observed
live.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-31 12:36:31 +02:00
filip131311 cccda92d15 feat(config): ios.additionalDeviceSets — additive array of extra CoreSimulator device sets (#542)
## Summary

**Stacked on #534.** Adds the first *additive* configuration entry on
the new schema — `ios.additionalDeviceSets`, a list of extra
CoreSimulator device-set directories — delivering the config side of the
#401 discussion on the #534 machinery.

### The schema entry

One registry entry in `config-schema.ts`:

- key `ios.additionalDeviceSets`, scopes `project` + `global`, `parse:
asStringArray`, **`merge: "union"`**

Unlike `lens.agent` (project *shadows* global), this is additive: the
effective value is the **union of both scopes** — the global baseline
first, the project's extras appended, deduplicated. A repo can commit
the device sets its tooling needs to `.argent/config.json` and they
*extend* (never replace) the user's global list. `argent config
list/get/set/unset` handle the array key with zero command changes —
arrays already round-trip through the JSON value coercion.

### The runtime reader

`getAdditionalIosDeviceSets(options): string[]` in `config-access.ts` —
the surface iOS tooling should call. It reads each scope separately so
paths normalize against the base the author meant:

- `~` / `~/…` expands to the home directory (honoring the sandbox
`homeDir` override)
- relative **project** entries resolve against the **project root**,
relative **global** entries against **home**
- output order matches the `union` preset (global first), with
**post-normalization** duplicates dropped — `~/DeviceSets/ci` in global
and its absolute spelling in the project file collapse to one entry
- empty array when unset

Support refactor: `resolveHomeDir()` extracted in `paths.ts` (the
HOME/USERPROFILE + override logic previously inlined in
`argentHomeDir`/`configDir`).

### Out of scope (follow-up)

Actually pointing simctl at the extra sets. There is no central simctl
wrapper today (~25 call sites build `xcrun simctl …` argv inline, and
`ax-prefs.ts` hardcodes the default set's `CoreSimulator/Devices` path),
so consuming this reader in `list-devices`/boot/etc. is its own change —
the runtime-read-off-disk design here means that consumer needs no
CLI/env threading, matching how #534's description framed the split.

## Testing

- `configuration-core`: 5 new tests (82 total, green) — additive union
across scopes + per-scope file isolation, non-array rejection + entry
normalization, reader `~`/relative resolution per scope,
post-normalization dedup, empty-when-unset
- `argent-cli`: new real-filesystem CLI round-trip (274 total, green) —
`config set` an array at each scope, effective `get` shows the union
while `get --scope` shows only that scope's entries
- `tsc` build + `typecheck:tests` + eslint clean on touched packages
2026-07-31 10:27:25 +02:00
Ignacy Łątka 646f726bc4 chore: bump to 0.18.0 (#596)
bump

debated myself whether this should be a QoL patch or a minor release and
i think the latter makes more sense

| Commit | Change |
| --- | --- |
| `7189333a` | `feat(tool-server)`: human-readable log message (#582) |
| `5ac69349` | `feat(flow)`: CSS combinator scopes for flow selectors
(#569) |
| `e23f0e28` | `feat(flow)`: snapshot `cropOn` for single-element visual
diffs (#516) |
| `6a53e66a` | `feat(flow)`: rotate directive (#546) |
| `68ceb314` | `ci`: cut over npm publishing to the unified
trusted-publishing workflow (#571) |
| `2546788a` | `ci`: unified publish-npm workflow (#565) |
v0.18.0
2026-07-30 23:47:28 +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 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
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
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 eb16d1c193 fix(tool-server): hard-kill (SIGKILL) timed-out iOS simctl calls so describe can't hang (#525)
## What happened

An iOS `describe` call hung for **~24 minutes** on the benchmark host,
logging the incoming request and then going completely silent until the
tool-server was killed.

The iOS `describe` path shells out to `xcrun simctl` for nearly every
step — `isTvOsSimulator` (`simctl list`), the ax-service bootstrap
(`simctl spawn defaults …`), and the native-devtools env setup (`simctl
spawn launchctl …`). Every one is guarded by `execFileAsync(…, {
timeout: 10_000 })`, so on paper each is bounded to 10s.

The problem is in Node itself: **`execFile`'s `timeout` sends its
`killSignal` (default `SIGTERM`) exactly once and never escalates to
`SIGKILL`.** When CoreSimulatorService wedges (which rapid
clone/boot/delete churn induces), the `simctl` child ignores SIGTERM,
its stdio never closes, and the promise never settles — so the
"10-second timeout" never actually fires. There is no outer deadline on
the `describe` tool to catch it, so the call hangs until the process is
killed.

## Why this is the root cause (not a guess)

- **Static elimination:** every JS-level guard on the describe path
(ax-service's 10s connect/query timers, native-devtools' 5s RPC timers)
*would* have fired and let `describe` return — and it never returned.
That leaves only an `execFileAsync` whose OS-level kill failed to settle
the promise.
- **Server logs:** the hung process logged the POST, then nothing for 24
min until the kill — the exact signature of a stuck subprocess await
with no error thrown.
- **Existing precedent:** `adb.ts` already fixes this identical class of
bug for Android via `ADB_KILL_SIGNAL = "SIGKILL"` (see its comment:
"Node's execFile default kill signal is SIGTERM, which an `adb` process
blocked on a hung daemon can ignore — leaving the parent waiting past
the deadline"). The same fix was simply never applied to the `simctl`
path.
- **Deterministic repro:** `execFileAsync('sh', ['-c', 'trap "" TERM;
…'], { timeout: 2000 })` never settles; adding `killSignal: "SIGKILL"`
makes it reject at ~2006ms. Reproduces both the hang and the fix.

## The fix

- Add `SIMCTL_KILL_SIGNAL = "SIGKILL"` to `simctl-config.ts` and pass it
alongside the timeout on every describe-path `simctl` invocation
(`ax-prefs.ts`, `ios-devices.ts`, `ios-host.ts`), mirroring
`ADB_KILL_SIGNAL`.
- Add the missing `timeout` + kill signal to
`listRunningUIKitApplicationBundleIds`, which had **no timeout at all**
(a latent forever-hang on the `native-devtools-status` path).
- Add `test/ios-simctl-hardening.test.ts` pinning every describe-path
`xcrun simctl` call to `killSignal: SIGKILL` + a finite timeout.

## Verification

- `tsc --noEmit` clean (src + tests).
- Full tool-server suite: **2670/2670 pass**.
- Confirmed the new test is a real guard: stripping the flag makes it
fail, restoring makes it pass.

## Scope note

This fixes the **proven** describe / native-devtools path. The same
latent hang exists in other `simctl` call sites (`boot-device`,
`launch-app`, `screenshot`, etc.) that use `execFileAsync` with the
default signal — extending SIGKILL to the whole `simctl` surface is a
reasonable follow-up but was kept out of this PR to stay scoped to the
reported hang.

One caveat: if `simctl` is ever stuck in true uninterruptible kernel
state, even SIGKILL can't reap it until the syscall returns; the fully
robust version would detach the await from the child via an independent
deadline. SIGKILL matches the `adb` precedent and handles the realistic
CoreSimulator-wedge case.

---------

Co-authored-by: Ignacy Łątka <claude-hubert.gancarczyk@swmansion.com>
2026-07-20 19:26:33 +02:00
Piotr Krawiec 49e020bafd fix(tool-server): resolve ps by absolute path so VVD dedup survives a sanitized PATH (#508)
## What

A running Vega VVD registers a second adb transport (`emulator-<port>`),
so it can surface in `list-devices` **twice** — once as
`platform:"vega"` and once as `platform:"android"`. There's dedup logic
(`resolveVvdShadowAdbSerials` + `filterVvdShadowsFromAndroid`) that
drops the adb "shadow" row by matching the running VVD's console port —
read from the process table via `ps` — against the adb serial's port.

That dedup was spawning a **bare `"ps"`**, which relies on `PATH`.

## Why it broke

An MCP server is frequently launched from a GUI / launchd context that
inherits a **sanitized `PATH`** omitting `/bin`. There, `execFile("ps",
…)` fails with `ENOENT`; the surrounding `catch` returns an empty port
set, and the dedup silently no-ops — so the VVD shows up as two devices.
It works fine from an interactive shell (where `/bin` is on `PATH`),
which is why it doesn't reproduce in a terminal or in the existing
tests.

Observed: `list-devices` returned both a phantom `platform:"android"`
`emulator-5554` and the real `platform:"vega"` device for a single
running VVD.

## Fix

Pin `ps` to its canonical absolute location, matching how the codebase
already hardcodes `/bin/sh` (`android-binary.ts`, `vega-cli.ts`,
`check-deps.ts`):

```ts
export const PS_BIN = ["/bin/ps", "/usr/bin/ps"].find((p) => existsSync(p)) ?? "ps";
```

Applied to all four `ps` spawn sites:
- `vega-process.ts` — `listRunningVvdConsolePorts`, `listRunningVvdPids`
- `vega-cli.ts` — `collectDescendantPids`, `pgidMembers` (same latent
PATH fragility in the process-reaping paths)

The bare-`"ps"` fallback is retained only if neither canonical path
exists, preserving `PATH` resolution for an atypical layout.

## Tests

- New regression test in `vega-process.test.ts` that strips `/bin` and
`/usr/bin` from `PATH` and asserts the probe still spawns `ps` (a bare
`"ps"` ENOENTs there — verified the test fails against the pre-fix
code).
- Updated the `list-devices.test.ts` mock to match `ps` by basename,
since production now passes an absolute path.
- `npm run build`, typecheck, lint (`--max-warnings 0`), and the full
vega/list-devices suite (47 tests) all pass.

## Verification

End-to-end `list-devices.execute()` under a launchd-style minimal `PATH`
now returns exactly **one** device (the Vega), with no android shadow.
`PS_BIN` resolves to `/bin/ps` and reads the VVD's console port even
with `/bin` removed from `PATH`.

---------

Co-authored-by: Piotr Krawiec <krawipio@amazon.pl>
2026-07-20 17:05:16 +02:00
Ignacy Łątka cd4190b80e Full tgz-only E2E harness (scripts/e2e-full) (#495)
Now verified end-to-end on the real Mac:
install/introspection/validation, the android tier (against a booted
emulator), and the chromium tier all pass from a locally packed tgz; the
rn tier auto-skips without `~/dev/bluesky` as designed.

## What this is

A release-gating end-to-end harness under `scripts/e2e-full/` that
starts from **only a `swmansion-argent-*.tgz` bundle** and exercises the
whole product: the install flow, every CLI command, all 70 tools'
argument validation, and a happy-path run of every tool against real
devices. Intended to be run on the real Linux box and the real Mac
before a release.

Everything runs under a throwaway `HOME` + npm prefix, so it never
touches the machine's real `~/.argent`, editor MCP configs, or global
packages (safe on a shared box). It emits a JSONL log + a markdown
coverage report and exits non-zero if any hard assertion fails.

## Phases

| phase | needs | covers |
|---|---|---|
| `install` | npm + network | `npm i -g <tgz>`, bundled binaries, `init`
(global + `--local`), `update`, `uninstall`, telemetry, MCP-config
generation |
| `introspection` | — | `--version/--help`, `tools describe` for **all
70** tools, feature flags, `server start/status/logs/stop`,
`link/unlink` |
| `validation` | — | per tool: missing-required / bad-enum / bad-type
rejection (schema-driven, no hardware) |
| `android` | Android emulator | happy-path of every
touch/gesture/screenshot/app-lifecycle tool |
| `chromium` | Electron (bundled optional dep) + a display | boots a
generated Electron app; drives CDP tools
(scroll/drag/tabs/cookies/storage) |
| `rn` | `~/dev/bluesky` + Android device | debugger + react/native
profiler + network chain against the real Bluesky app |

Tiers auto-skip with a recorded reason when prerequisites are missing.
iOS / tvOS / Vega are intentionally out of scope for now.

## What I actually verified (real runs, Linux)

- **install** 18✓/0✗, **introspection** 85✓/0✗ (all 70 `tools
describe`), **validation** 81✓/0✗, **chromium** 24✓/0✗ (real Electron
app + CDP).
- Holistic run: **190✓ / 0✗ / 34∼, exit 0.** Failure-gate confirmed
(non-zero on fail), `bash -n` clean on all files, all 72 android/rn JSON
arg literals linted.

## What I did NOT verify live (please scrutinize)

- **`android` and `rn` tiers were never run against a live device.** The
Android emulator would not stay up on my box (memory-constrained shared
machine; QEMU threads hung seconds after boot across ~7 attempts). The
code is complete and uses the same machinery the `chromium` tier
exercised live, but it has **not** been proven end-to-end. This is the
main reason for the "not certain how well it works" caveat.
- The RN tier's exact debugger/profiler arg shapes are derived from
`tools describe`, not confirmed against a running Metro.

## Run it

```bash
bash scripts/e2e-full/run-e2e.sh                                   # everything applicable to this OS
bash scripts/e2e-full/run-e2e.sh --skip-install --phase introspection,validation   # fast offline core
bash scripts/e2e-full/run-e2e.sh --phase android --android-serial emulator-5554     # inject a device
```
2026-07-20 16:43:34 +02:00
filip131311 66e051fe8b fix(debugger): collapse a device's two ids onto one debugger instance (#528)
## The problem

A device is reached through **two id namespaces** that name the same
thing but are different strings:

- the **stable id** the caller connects with — an iOS UDID / Android
serial / Vega serial from `list-devices`;
- the **`logicalDeviceId`** Metro's inspector-proxy echoes back for that
connection (an opaque per-connection handle).

`logicalDeviceId` is not derived by Argent — it is echoed verbatim from
Metro (`js-runtime-debugger.ts`), and Metro never sees the UDID, so
there is **nothing to join on synchronously**.

The debugger service is cached by its URN, which embeds `device_id`
verbatim:

```
JsRuntimeDebugger:<port>:<device_id>
```

`debugger-connect` took the UDID, returned the logicalDeviceId, and its
description told callers to *"forward it as device_id to all subsequent
debugger-\* calls."* Following that instruction produced a **different
URN** → a **second `JsRuntimeDebugger` instance** → a second
`CDPClient`, a second console-log server, and a split log file — **all
for one device**. The second attach also kicks the first off Metro's
single-debugger-per-target proxy. Nothing deduplicated them.

It only *looked* fine because with a single device on a Metro port,
`selectTarget`'s single-device fallback quietly connects anyway; the
split surfaces as "the debugger randomly drops" and logs landing in the
wrong bucket.

Secondary damage the same split caused:
- `classifyDevice()` is shape-based and was fed the logicalDeviceId → an
iOS device's forwarded id classified as **Android** in the capability
gate + telemetry.
- The docs asserted *"iOS simulator UDID, Android logicalDeviceId"* as
if a UDID were the iOS logicalDeviceId — the repo's own test says the
opposite.
- `debugger-component-tree` printed the logicalDeviceId under a `udid:`
label — teaching callers the wrong name.

## The fix — synchronous, within the existing `services()` design

- **Learn an alias** `logicalDeviceId → connect-id` as a side effect of
a successful connect (the one place both ids are known at once; see
`utils/debugger/device-alias.ts`), and **canonicalize `device_id`
through it at every URN-building site**. A forwarded logicalDeviceId now
resolves to the already-open instance; unknown ids (the connect id
itself, Chromium ids) pass through unchanged, so it is safe to call
everywhere. **No Metro round-trip on the hot path — `services()` stays
synchronous.** The alias is 1:1 (a logicalDeviceId is unique per
connection) and is cleared on dispose.
- **Rewrite the `device_id` docs** across `debugger-*`, `network-*`, and
`react-profiler-*`: pass the **same list-devices id you connected
with**; the returned logicalDeviceId is informational (forwarding it
still resolves, via the alias). Removes the instruction that caused the
double instance and the false "UDID = logicalDeviceId" wording.
- **Fix the `debugger-component-tree` header** (`udid:` →
`logicalDeviceId:`).

Covered call sites (all that embed `device_id` into a Metro URN):
`debuggerServiceRef` (connect/evaluate/status/log-registry),
`debugger-reload-metro`, `debugger-component-tree`,
`debugger-inspect-element`, `react-profiler-start` (+ its session URN),
`react-profiler-stop`, `network-logs`, `network-request`.

## Verification

**Live**, against a real iOS sim + Metro (my-app, RN 0.81.5), driving
the branch tool-server directly:

| step | result | URNs instantiated |
|---|---|---|
| `debugger-connect { device_id: <UDID> }` | returns `logicalDeviceId:
d52ad36b…` | `JsRuntimeDebugger:8081:<UDID>` |
| `debugger-evaluate { device_id: d52ad36b… }` (forward the
logicalDeviceId) | `42` ✅ | **no new URN — reused** |
| counterfactual: `debugger-evaluate { device_id: some-unaliased-id }` |
`42` ✅ | **`JsRuntimeDebugger:8081:some-unaliased-id` — a distinct 2nd
instance** (the pre-fix behavior, reproduced) |
| `debugger-component-tree { device_id: <UDID> }` | header reads
`logicalDeviceId: d52ad36b…` ✅ | reused |

**Unit** — new `test/metro/device-alias.test.ts` covers the alias (learn
/ canonicalize / self-alias no-op / missing-id / forget) and that
`debuggerServiceRef` yields the same URN whether called with the UDID or
the forwarded logicalDeviceId. Full tool-server suite green (2726
tests).

## Not in scope

Renaming the `device_id` param to `udid` (or vice-versa) — that is a
breaking MCP wire change. This PR is additive: a forwarded
logicalDeviceId keeps working, it just no longer opens a second
connection.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:21:56 +02:00
Ignacy Łątka 78d3184ba8 fix(scripts): stop extract-tools dropping tools with long descriptions (#436)
## Defect

`scripts/extract-tools.mjs` extracts MCP tool definitions by regex to
feed the downstream `spidershield scan --tools-json` security scan. For
each `id:` match it scanned only the next 2000 chars (`const descWindow
= afterId.slice(0, 2000)`) for the `description:` value, and the
description regexes require the string's closing delimiter to fall
inside that window.

Any tool whose description's closing delimiter sits past ~2000 chars is
therefore matched as `null` and **silently dropped** - never emitted,
never security-scanned. Two shipping tools hit this:

- `run-sequence`
(`packages/tool-server/src/tools/run-sequence/index.ts`) - a ~4285-char
multi-line template-literal description whose closing backtick sits
~4324 chars past its `id:`.
- `describe` (`packages/tool-server/src/tools/describe/index.ts`) - a
~2195-char description whose closing backtick sits ~2252 chars past its
`id:`.

**Security impact:** `describe` is the accessibility / DOM tree tool,
exactly the kind of tool a `spidershield` scan should cover, and it was
being silently excluded alongside `run-sequence`. A dropped tool is not
a scan finding, so the gap was invisible.

### Repro (before)

```
$ node scripts/extract-tools.mjs | grep -c '"run-sequence"'
0
$ node scripts/extract-tools.mjs | grep -c '"describe"'
0
$ node scripts/extract-tools.mjs | grep -c '"gesture-tap"'
1
# total tools emitted: 70  (run-sequence AND describe both missing)
```

### After

```
$ node scripts/extract-tools.mjs | grep -c '"run-sequence"'
1
$ node scripts/extract-tools.mjs | grep -c '"describe"'
1
# total tools emitted: 72  (both captured, run-sequence's full 4285-char description intact)
```

## Fix

- Drop the fixed 2000-char lookahead. Each tool's `description:` is
resolved at the **same object-literal brace level** as its `id:` by a
forward scan that tracks brace depth over real code only: comments,
string literals, template literals (with `${...}` interpolations skipped
opaquely, recursively), and regex literals (prev-token heuristic incl.
regex-permitting keywords like `return`) are skipped whole, so nothing
inside them can open a fake string, shift the depth, or match as a
`description:`/`id:` token. The value is then parsed to its actual
closing delimiter, so a description of any length is captured in full.
- Because matching is by brace scope rather than raw position, a stray
`id:`/`description:` token can't corrupt extraction: a description-less
tool can't borrow the next tool's description, an `id:` appearing inside
description text or a comment can't truncate or overwrite anything, and
an `id:` key nested inside a value object (e.g. `defaultPayload: { id:
"example" }`) is neither emitted as a spurious tool nor allowed to drop
the real one.
- Captured text follows full JS cooked-string semantics: named escapes,
`\xNN`, `\uNNNN`, `\u{...}`, identity escapes, line continuations, and
template `\r\n`/`\r` -> `\n` normalization - the emitted (and
security-scanned) text is the string the runtime renders.
- A description value that is not a single plain literal - a
concatenation, a `${...}` interpolation, a method/operator suffix
(`"...".slice(0, 3)`), `as const` - is never emitted as a
truncated/wrong literal: it warn-skips on **stderr** (stdout stays valid
JSON for the scanner). Non-interpolated template-literal ids (`` id: `x`
``) are extracted; interpolated ids stay out of scope like
const-reference ids.
- Duplicate ids keep the first occurrence and warn (exported as a pure,
unit-tested function).
- The extractor stays dependency-free (node: builtins only): the
tool-description-quality workflow runs it on a bare checkout without
`npm ci`.
- Output JSON shape is unchanged.

## CI guard

Adds `scripts/extract-tools.test.mjs` (node's built-in test runner,
wired into `npm run test:scripts`; 36 tests). Ground truth: the
real-tree tests parse every tool source file with the actual
**TypeScript parser** (a root devDependency, test-only) and require the
extractor's output to equal the AST **exactly - names and descriptions
byte-for-byte**. Any drop, corruption, or fabrication on a shape that
enters the tree fails CI loudly, with no reliance on stderr warning
formats and no false red from `id:`-like text in comments or strings.

Two policy gates keep un-extractable shapes out of the tree instead of
letting them silently leave the scan: a `description` that cannot be
read statically (non-literal value, shorthand/getter/method/computed
member, or a spread sibling that may carry/override it) and a
plain-literal description written **before** its `id:` (the scan is
forward-only) each fail with an actionable, file-naming message.

Fixture tests pin the parsing rules independent of any real tool's
wording - long descriptions (>4000-char value, >2000 chars of siblings
between `id:` and `description:`), embedded `id:`/`description:` tokens,
comments, keyword-position and sibling regex literals, division, nested
templates and interpolation content, template/`as const` ids, the full
escape suite, CRLF cooking, single-quoted forms, trim, warn-skip
classes, and the duplicate-id warning. Each new fixture was verified to
fail under a targeted mutation of the extractor.

Note: tools registered with a const-reference / dynamic id (e.g. `id:
AWAIT_UI_ELEMENT_TOOL_ID`) rather than a static literal stay out of
scope for this extractor - a separate, pre-existing limitation unrelated
to the description window, and one this static/lexical extractor cannot
close soundly (it has no tool-scope knowledge, so a blanket warning on
every `id: <identifier>` would also fire on legitimate nested non-tool
keys like `{ id: someVar }` in a payload/example object). The full set
currently taken out of the scan this way is `app-map-crawl`,
`app-map-export`, `app-map-navigate`, `app-map-query`,
`await-screen-idle`, and `await-ui-element`. A proper fix is a follow-up
covering const-ref id resolution plus a real-tree emit-count and
empty-stderr assertion so this silent-omission class trips CI if it ever
regresses.

## Verification

- `node scripts/extract-tools.mjs` -> 72 tools, `run-sequence` and
`describe` both present (total 70 -> 72), byte-identical to the
TypeScript-AST parse of the tree, no stderr warnings
- `npm run test:scripts` -> all pass (`extract-tools.test.mjs`: 36
tests)
- mutation checks: window-bounding, depth-anchor removal, trim removal,
dedup neutralization, single-quote-branch neutralization each fail the
suite
- `npm run typecheck:scripts` -> clean
- `prettier` + `eslint` on changed files -> 0 problems
- lockfile: `npm install --package-lock-only --ignore-scripts` -> no
diff (idempotent)
2026-07-20 12:56:44 +02:00
Jakub Piasecki 3f602a7a9b feat(flow): conditional when blocks (#498)
## What

A `when:` block runs a step list only when one condition holds — a UI
condition (`exists`/`visible`/`hidden`/`text`, the await/assert shapes)
or a static `platform` test. **No else**, by design: a block exists to
restore determinism (dismiss an interstitial, reconverge), never to
test two paths. Tap-if-present is a one-step block:
`when: { visible: "Got it" }` + `steps: [tap: "Got it"]`.

No per-step `optional:` — `when:` covers every directive once. The key
is rejected at parse with a pointer to `when:`, not silently dropped (a
Maestro habit that would leave a "can't fail" step hard-stopping the
flow).

## How

- The guard reuses the assert engine at the 1s grace (`timeout` is a
parse error), so a skipped block barely costs a clean run.
- Verdicts are evidence-based: no trustworthy read in the window →
indeterminate → the step **errors** ("could not evaluate" is not
"condition false"); a transient read failure after trusted reads showed
the condition false stays a clean skip.
- `platform` folds `ios-remote` into `ios` — the parser rejects that
spelling, so the fold is the only way a guard matches a remote sim.
- A skipped block reports one line per authored step, same shape for
unmet guard, hard stop, or cancellation; everything goes through
`pushReport`, so when lines stream live. Failures inside an entered
block hard-stop as usual.
- Results carry an explicit `aborted` flag; `ok` keys off
fail/error/cancel, not skip lines.
- Nesting is capped at 20, so a cyclic YAML alias is a structured parse
error, not a stack overflow.

## Testing

`vitest` 2546 pass (17 when-specific); `tsc --noEmit` (tool-server,
argent-mcp, argent-cli), eslint, prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:26:34 +02:00
Jakub Piasecki 8d633700d7 feat(flow): coordinate targets for tap and long-press (#504)
## Summary

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

Supported forms include:

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

## What changed

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

## Behavior notes

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

## Validation

- Full tool-server suite: 2,618 tests passed across 266 files.
- Added parser, serialization, recording, touch execution, Chromium
execution, reporting, and malformed-input coverage.
- Tool-server build, test typecheck, ESLint, Prettier, and skill
validation passed.
2026-07-17 14:48:29 +02:00
Jakub Piasecki 1a4f89cd5f feat(flow): allow to compose all flows via run: (#507)
## Summary

`run:` accepted **fragments only** — an e2e flow (one starting with
`launch:`)
was rejected as a `run:` target. This lifts that: an e2e flow can now be
nested
via `run:`, its `launch` running inline (a real terminate + relaunch on
native).
Composition becomes uniform — any flow can be a reusable building block.

## What changed

- **`flow-run.ts`**: dropped the `isE2eFlow` rejection in `execRunStep`;
cycle /
  `MAX_RUN_DEPTH` guards still bound recursion.
- **`flow-add-step.ts`**: the recorder now captures a recorded
`flow-execute` of
an e2e sibling as a portable `run: <name>` directive (was: raw `tool:`
step).
- **`flow-utils.ts`, `argent-create-flow/SKILL.md`**: updated docs/skill
that
  said e2e flows can't be `run:` targets.

## Known limitation — Chromium

Chromium `launch` boots an Electron app from a *path*, wired to the
top-level
flow only — so a nested **chromium** e2e flow doesn't boot its own app.
Native
nesting is correct; chromium nested e2e is unsupported.

## Testing

Rewrote the two affected tests (composition + recorder). Full flows
suite green
(349 tests); `tsc --noEmit` clean.
2026-07-17 14:18:32 +02:00
Jakub Piasecki 49991e9fe1 feat(screenshot-diff): support full-image comparisons (#515)
## Summary

- Add an explicit top-mask policy to the shared PNG differ.
- Preserve status-bar masking by default while allowing isolated image
regions to compare every row.
- Add real-PNG regressions for top-band changes and one-row images.

This prepares the differ for cropped comparisons without changing
existing callers.

## Testing

- Screenshot-diff tests
- Tool-server build
2026-07-16 17:21:59 +02:00
filip131311 b50d51a2de chore: bump workspace packages to 0.16.0 (#513)
## What

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

## Why

The `packages/argent-private` submodule is already pinned at
**v0.16.0**, while the workspace packages sat at 0.15.0. This aligns the
whole repo on the single 0.16.0 version.

## Verification

- `node scripts/check-workspace-versions.mjs` → `All workspace packages
are at 0.16.0.`
- `git submodule status` → clean (no `+`), pinned at `v0.16.0`.
- Lockfile diff is exactly 17 version-field swaps (no unrelated churn —
a full `npm install --package-lock-only` regeneration was rejected
because it introduced unrelated `"peer": true` churn from a newer npm).
v0.16.0
2026-07-15 20:53:36 +02:00
filip131311 92b8c039c3 fix(flow): refuse invisible-only text selectors — record coordinates instead (#511)
## The bug (found in the 0.15.0 full-surface QA, on both the Android
emulator and a physical S24)

Recording a `gesture-tap` on an icon-font tab-bar item wrote a flow step
that displays as an **empty selector**, with the coordinates gone:

```yaml
steps:
  - tap:
      text:      # ← actually U+E163, an icon-font Private Use Area glyph
```

and `flow-execute` then **silently passed** against target `""`, tapping
an element no reader of the flow can predict. Root cause (hexdump of the
QA-recorded file: `ee 85 a3` = U+E163): icon fonts expose their glyph as
the element's accessibility label; `deriveSelector` accepted any label
that survived `trim()`, so a PUA code point counted as "stable text".
This violated the rewrite's own documented contract — *"otherwise
coordinates are kept with a warning"* — whose fallback path existed and
worked, but never ran for this case.

## The fix — one predicate, three layers

`hasVisibleText()` (strip Unicode `Cf` format / `Co` private-use / `Cc`
control, then trim; require something left):

1. **`deriveSelector`** — invisible-only label/value is not stable text;
fall through to role, then to the recorder's existing kept-coordinates
path.
2. **`selectorToYaml`** — the empty-text guard previously covered only
the bare-string spelling; it now also rejects empty/invisible text on
the strict `{text: …}` map form, so a hand-built selector fails loudly
instead of writing an unreadable flow file.
3. **`selectorSchema`** — parse-side refinement, so flow files already
recorded broken on 0.15.0 fail with an actionable message ("text must
contain at least one visible character … select by identifier or role
instead") instead of false-passing at replay. This also hardens the
`await-ui-element` tool surface, which shares the schema.

Text that merely *contains* an icon glyph alongside real text ("⟨U+E163⟩
Explore") stays valid throughout.

## Verification

- Unit: `deriveSelector` PUA/ZWSP/mixed cases, serialize + parse
rejection round-trips (explicit `\uXXXX` escapes, no invisible bytes in
the test source).
- Full tool-server suite: **2667 passed**.
- **Live on the Pixel_9 emulator against the original QA repro** (my-app
tab bar, same coordinates): recording now produces `tap: {x: 0.75, y:
0.947}` with the documented *"kept coordinates (brittle)"* warning — the
role fallback was correctly caught by the existing
resolves-to-a-different-element guard — replay passes 2/2 on the
coordinate targets, and executing the old broken YAML now fails parse
with the visible-character error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-15 20:31:23 +02:00
filip131311 07427917e2 fix(launcher,lens): drop the npm postinstall — its jobs move to runtime (#510)
## Why

Shipping *any* `postinstall` makes pnpm 10+ wall the install behind its
build gate: `ERR_PNPM_IGNORED_BUILDS`, `pnpm install` exits 1, and `pnpm
exec argent` is unusable until the user runs `pnpm approve-builds` —
their first experience of argent is a bricked CLI. The same script is
also silently skipped by `--ignore-scripts`, Yarn PnP, and locked-down
CI, so anything correctness-relevant in it was already unreliable (the
codebase says as much in several places, e.g. `runTrustingDisk`).

Live-verified during the 0.15.0 QA: with **all builds declined** in pnpm
11, argent installs, inits, and serves MCP fine — the postinstall was
never load-bearing for install correctness. Removing it means pnpm users
never see the approve-builds wall for argent itself.

## What the postinstall did, and where it went

| Job | New home |
|---|---|
| SIGTERM a tool-server stranded by an upgrade that replaced its bundle
dir (pnpm/yarn version-pinned layouts) | `sweepDeadStateFiles` in the
launcher's slow path now retires **live** servers whose recorded bundle
is gone from disk, with the same pid-identity guard as
`killToolServerForInstallDir` (never signal a recycled pid; keep
unidentifiable live records reachable for `server stop`). Same-bundle
in-place bumps were already self-healed by the disk-authoritative
version gate. |
| `chmod +x` node-pty's macOS `spawn-helper` prebuilds (shipped without
the exec bit; first `pty.spawn` otherwise fails `posix_spawnp`) |
`ensureSpawnHelperExecutable()` in `lens-pty.ts`, run when the module
loads, best-effort with the AppleScript fallback intact. |
| Print the `argent init` hint | Dropped — init flows drive the install,
and the hint never printed on pnpm/PnP anyway. |

`ARGENT_SKIP_POSTINSTALL` in `update.ts` is kept deliberately:
downgrades and pinned installs fetch older versions whose postinstall
still exists and honors it.

## Testing

- New `launcher-sweep.test.ts` (6 tests) exercises the sweep against
**real processes**: live-server-with-deleted-bundle is terminated and
its record unlinked; live server with intact bundle untouched;
recycled-pid record kept, not signalled; dead-pid record unlinked;
legacy slot untouched; a SIGTERM-trapping orphan doesn't hold the sweep
(and the spawn lock above it) hostage for its kill grace window — the
record is unlinked and the SIGKILL escalation runs in the background.
- New `ensureSpawnHelperExecutable` tests (real fs, darwin-gated) verify
the +x restore and silent failure; a regression test pins that
`loadNodePty` tolerates req seams without `resolve`.
- Full suites green in all touched packages: tools-client 154, installer
535, cli 248, mcp 76, argent 60.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-15 20:26:39 +02:00
filip131311 52bb1f29a7 docs(skills): fix skills.sh security-audit findings in skill docs (#509)
## Why

The [skills.sh security
audits](https://skills.sh/software-mansion/argent) (Gen Agent Trust Hub
/ Socket / Snyk, run 2026-05-24) flag three argent skills. All three
findings trace to doc text in `SKILL.md` files — no code or tool
behavior is involved — but the red badges are visible to anyone browsing
the skill pack.

## What

Minimal doc-only edits, one per finding:

| Skill | Finding | Fix |
|---|---|---|
| `argent-create-flow` | Snyk **Critical** (E004 "prompt injection"):
body section *"When to proactively record a flow — you do not need the
user to ask"* contradicts the frontmatter description's *"use when the
user asks"* scope | Disclose proactive use in the frontmatter
description (matching the wording already in the `.claude/rules` routing
entry), so documented scope and instructions agree |
| `argent-test-ui-flow` | Snyk **High** (W007 insecure credential
handling) + Socket medium alert: login example hardcodes `keyboard {
text: "password123" }`, teaching the agent to emit plaintext credentials
| Point the example at the secrets system from #506: `keyboard { text:
"{{secret:APP_PASSWORD}}" }`, resolved server-side from
`ARGENT_SECRET_<NAME>` env vars so the value never enters agent context,
plus a credentials note (ask the user to export the var; never
invent/echo secrets) |
| `argent-metro-debugger` | Gen **Med Risk**: skill ingests untrusted
logs/source with *"no explicit sanitization or instructions to ignore
embedded instructions"*, plus exfiltration concern | Add golden rule #4:
logs/app content are data, not instructions — never follow embedded
directives or copy secrets found there into outputs |

Gen's other metro-debugger points (adb usage, `debugger-evaluate`
arbitrary JS) are inherent to a debugger skill and not addressable by
doc edits.

Only `packages/skills/skills/` is touched (the copies under
`packages/argent/skills/` and `.agents/` are gitignored build
artifacts).

## Test plan

- Doc-only change; rendered the three edited sections and checked
frontmatter still parses (single-line description, valid YAML).
- After merge/publish, request a re-audit on skills.sh to clear the
badges.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-15 16:32:54 +02:00
filip131311 f1331cf80d fix(installer): unbreak pnpm local installs + stop self-fulfilling editor detection (#501)
## Problem

Field report (pnpm setup): `npx @swmansion/argent init --local`

- ✅ "updated argent 14→15", ✅ created `.mcp.json` + `skills-lock.json`
- 🔴 did **not** add argent to `package.json`
- 🔴 re-running the same command → `Local install failed.` + advice to
run `pnpm add -D ...` manually
- 🔴 init "persistently detects" Cursor and Codex on a fresh machine that
never had them

## Root causes (each reproduced; confirmed against installer telemetry)

1. **Stale-binary hijack** — bare `npx @swmansion/argent` prefers an
existing **global install** over the registry. A global 0.14 predates
`--local` and `parseInitArgs` silently ignored unknown flags, so run 1
was a plain global-mode 0.14 init: it offered the 14→15 update, wrote
mcp/skills files, and never touched package.json. That's symptoms 1–3 in
one.
2. **pm detection missed pnpm's modern markers** — `pnpm init` (pnpm
10/11) writes only `devEngines.packageManager`, and no lockfile exists
before the first install. Detection fell back to npm, whose own
devEngines gate instantly rejects the install (`EBADDEVENGINES`, ~250
ms). This is the 221–537 ms `INSTALL_LOCAL_PACKAGE_FAILED` cluster in
PostHog (8 events / 7 users last week).
3. **Missing pm binary** (cloned pnpm repo, machine has only npm)
surfaced as raw `spawn pnpm ENOENT` plus manual advice that fails
identically in the user's shell.
4. **Self-fulfilling detection** — Cursor/Codex `detect()` keyed on bare
`~/.cursor` / `~/.codex` dirs, which argent itself creates (an `init -y`
with nothing detected configures *all* editors; allowlist/skills writes
too).

Telemetry also shows a ~19 s failure shape where an identical manual
retry succeeds (transient registry / pnpm first-run state errors).

## Fixes

- **Init flag parsing stays lenient**: unknown flags remain silently
ignored — aborting on them could break existing scripts/setups that pass
flags an installed version doesn't know. The parser now also accepts the
`--from=path` spelling, ignores a dangling `--from`, and keeps
first-`--from`-wins semantics. The stale-binary hijack is addressed on
the docs side (`@latest` in the README) rather than by a hard abort.
- **`detectProjectPackageManager`** honors `devEngines.packageManager`
(object + array forms) and `pnpm-workspace.yaml` — the latter ranked
*below* real lockfiles so a stray file left by a pnpm→yarn migration
can't outrank `yarn.lock`.
- **Missing-binary failures** name the real problem ("This project uses
pnpm, but the pnpm command was not found on PATH" + corepack hint).
Windows detected via cmd.exe's "is not recognized" text, since `shell:
true` never ENOENTs there. Other pm failures **retry once**.
- **Editor detection requires real evidence**: a `.cursor`/`.codex` dir
counts only if it holds something beyond argent's own artifacts (argent
mcp.json entry, `permissions.json` allowlist, managed
`developer_instructions`, bundled rules/agents/skills). Corrupt,
unparseable, or empty content conservatively counts as user evidence.
The stale-config sweep now scans **all** adapters so dead entries in
no-longer-detected dirs still get pruned.
- **README**: recommend `npx @swmansion/argent@latest init` and a `pnpm
dlx` alternative (npm's devEngines gate blocks the *outer* npx in fresh
pnpm-init projects).

## Testing

- Live-tested a packed tgz under the exact repro conditions: fresh
pnpm-init project A/B (published 0.15.0 fails EBADDEVENGINES in ~250 ms;
this branch installs the devDependency correctly), missing-pnpm message,
flag-parsing edges (`--from=` spelling, dangling `--from`),
retry-on-transient-failure (flaky pm shim), detection flips (argent-only
dirs incl. permissions.json → not detected;
`argv.json`/`sessions/`/corrupt configs → detected), plus regressions:
npm project local init, global-mode `init -y`, recovery of the broken
post-0.14 state.
- High-effort multi-agent code review of the diff surfaced 10 confirmed
defects in the first iteration (permissions.json not whitelisted,
corrupt configs misclassified as argent-only, pnpm-workspace.yaml
outranking yarn.lock, `--from=` rejected, Windows ENOENT never firing,
stale-sweep interaction, …) — all fixed and re-verified.
- 493 argent-installer + 87 registry unit tests green (new suites for
flag parsing, devEngines/workspace detection, detection evidence).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-15 15:37:54 +02:00
Ignacy Łątka af8476c0a9 feat(vega): support the JS-runtime debugger and network inspector on Fire TV (#502)
Vega's React Native is a fork of **RN 0.72**, so it serves the *legacy*
Hermes inspector rather than the post-0.73 (Fusebox) debugger. The
`debugger-*` / `view-network-*` / `react-profiler-*` tools were never
gated for it, so every call against a VVD was rejected before any RN
code ran:

```
Tool 'view-network-logs' is not supported on vega vvd (no vega support declared).
```

That gate turned out to be the only thing in the way for most of them.

The concern that prompted this was that the network inspector needs the
new debugger. It does not: argent's network inspector never touches the
CDP `Network` domain, it monkey-patches `fetch` over `Runtime.evaluate`
and reads back a global array. `Runtime.evaluate` has worked in Hermes
since long before 0.73, so the network inspector is precisely the tool
that is *unaffected* by the missing new-debugger stack.

## What now works on Vega

Gated in via `DEBUGGER_TOOL_CAPABILITY` (these need only
`Runtime.evaluate`):

`debugger-connect` · `debugger-status` · `debugger-evaluate` ·
`debugger-log-registry` · `view-network-logs` ·
`view-network-request-details`

## What stays gated off, and why

`debugger-component-tree` · `debugger-inspect-element` ·
`debugger-reload-metro` · `react-profiler-*` · `profiler-*`

Two different reasons, and it is worth keeping them apart:

- **`debugger-component-tree` and `debugger-inspect-element` are
hard-blocked.** They are the only two callers of
`cdp.evaluateWithBinding`, i.e. they deliver their payload over
`Runtime.addBinding` / `Runtime.bindingCalled`. RN 0.72's Hermes **ACKs
`Runtime.addBinding` and never installs the binding** — confirmed on the
VVD: after a successful `debugger-connect`, `typeof __argent_callback`
is still `"undefined"`, and no `bindingCalled` ever fires. They would
hang until timeout.
- **`debugger-reload-metro` and the profiler tools are simply unverified
here.** They do *not* use the binding (`reload-metro` is `Page.reload`
plus an HTTP `/reload` fallback; the profilers ride `Runtime.evaluate`
and the CDP `Profiler` domain). They share `RN_ONLY_TOOL_CAPABILITY` and
stay out of scope rather than ship untested.

Either way the capability gate turns a hang (or an unknown) into an
immediate, explicit "not supported". `describe` remains the way to read
on-screen structure on Vega.

Worth noting for a follow-up: the component-tree data itself *is*
reachable on RN 0.72 — walking the DevTools hook via a plain
`Runtime.evaluate` returns the real fiber tree (640 fibers on the sample
app). Component-tree is blocked by argent's transport choice, not by a
missing runtime capability, so a non-binding fallback would unlock it.

## Two bugs this surfaced

**A Vega `device_id` could bind to another device's runtime.** Vega's
legacy inspector-proxy emits no `reactNative` block at all, so its
targets carry no `logicalDeviceId`. `selectTarget` both matched
`device_id` on `logicalDeviceId` *and* counted connected devices the
same way — so a Vega device was invisible to the multi-device guard from
#397: its id matched no target, the guard counted zero devices,
concluded there was nothing to disambiguate, and fell through to the
priority target. With a Fusebox iOS/Android app on the same Metro, that
target wins the priority list, so `debugger-evaluate` would have run JS
**in the wrong app** while `debugger-status` reported the other device's
name and `isNewDebugger: true`. Devices are now keyed by
`logicalDeviceId || deviceName` (the legacy proxy always reports a
name), so the guard sees them and refuses to guess. A lone VVD still
falls back to its own targets. This is not Vega-specific: any RN ≤ 0.72
device was invisible the same way.

**Metro's missing project-root header.** RN 0.72's Metro never sends
`X-React-Native-Project-Root`, and `discoverMetro` treated a missing
header as fatal — which took down evaluate, console logs and the network
inspector too. The header only feeds source-map / `file:line`
resolution, which degrades cleanly to no-location, so it is now
optional. Platform-agnostic, not a Vega special-case: a missing
source-map hint should never kill an otherwise working debugger session.

That made `projectRoot: ""` a reachable state, which two
source-resolution paths mishandled. `symbolicate` stripped the root with
`file.replace(projectRoot + "/", "")`, which on an empty root degrades
to `.replace("/", "")` and deletes the first slash *anywhere* in the
path (`src/screens/Home.tsx` → `srcscreens/Home.tsx`). And
`readSourceFragment` did fail closed, but only because
`fs/promises.realpath("")` happens to reject where the sync API returns
the tool-server's cwd — it now bails explicitly instead of leaning on
that quirk. Both are reachable from `debugger-inspect-element` on any
legacy Metro (not on Vega, where it is gated off).

## Verification

Driven end-to-end against a real VVD (`amazon-6b8a…`) running the
`react-native-multi-tv-app-sample` Vega app (Debug `.vpkg`, Metro
reachable via reverse port-forward), through a tool-server built from
this branch:

```
debugger-status   -> connected: true, deviceName: kepler-device, isNewDebugger: false, projectRoot: ""
debugger-evaluate -> "hermes=object fetch=function global=object"
debugger-log-registry -> captured console.log "PR502_E2E_MARKER"

=== NETWORK LOGS (page 1/1, 2 total) ===
{id: rn-net-1} "GET /todos/7" 200  Fetch 98 B  55 ms
{id: rn-net-2} "GET /users/3" 200  Fetch 520 B 17 ms

view-network-request-details -> full request/response headers, status, timing
```

And the gated tools fail fast (0.02s) rather than hanging:

```
debugger-component-tree  -> not supported on vega vvd
debugger-inspect-element -> not supported on vega vvd
debugger-reload-metro    -> not supported on vega vvd
react-profiler-start     -> not supported on vega vvd
```

Re-ran the whole sequence against the fixed target-selection to confirm
the single-VVD path still connects.

Also run locally: `tsc --build`, `tsc --noEmit -p tsconfig.test.json`,
`eslint . --max-warnings 0`, `prettier --check`, `grade-skills`, and the
tool-server unit tests (199 files / 1740 tests). Tests added:
`vega-target-routing` (the mis-route, using a target payload captured
verbatim from the live VVD), `source-resolver-empty-root` (both
empty-root paths), and `vega-debugger-capability` — which now drives the
real `ToolDefinition.capability` fields and `resolveDevice` instead of a
hand-written name↔constant pairing, pinning all 6 enabled and all 14
gated tools. The old version passed even with `debugger-status` wired to
the wrong capability constant.

Scope note: VVD only — no tool declares `vega: { device: true }`, so
physical Fire TV hardware is untouched.
2026-07-15 15:36:12 +02:00
filip131311 70660378bb feat(tool-server): type secrets via {{secret:NAME}} placeholders — plaintext never enters agent context (#506)
## Why

Feedback: unlike Stagehand's `act("type %pw% into password", {
variables: { pw } })`, argent had no way to type a credential without
the LLM seeing the plaintext — which disqualifies it as a CI agent. In
the MCP model the agent composes the `keyboard` call itself, so a
plaintext password lands in **six** places: the agent transcript,
`~/.argent/mcp-calls.log` (spyLog records full args + result), the
flag-gated tool-server event log, the `typed` result echo, recorded flow
YAMLs (committed to the repo), and the post-type auto-screenshot
(plaintext back into context as pixels for non-secure fields).

## What

`keyboard` and `paste` now accept **`{{secret:NAME}}`** placeholders in
`text`, resolved server-side from **`ARGENT_SECRET_NAME`** environment
variables:

```yaml
# GitHub Actions
env:
  ARGENT_SECRET_APP_PASSWORD: ${{ secrets.APP_PASSWORD }}
```

```json
{ "udid": "<UDID>", "text": "{{secret:APP_PASSWORD}}", "key": "enter" }
```

**Resolution happens inside the tool's `execute`** — after every
existing logging boundary — so spyLog, the event log, the transcript,
and recorded flows all see only the placeholder, with near-zero extra
redaction work:

- `run-sequence` keyboard steps and flow `type` steps go through
`invokeSubTool` into the same `execute` → covered for free. A recorded
login flow stores the placeholder verbatim, so the committed YAML is
secret-free — and agent-less `argent flow run` (#429) resolves it in CI
from the job's env, no LLM in the loop at all.
- The mandatory `ARGENT_SECRET_` prefix is an allowlist by construction:
a prompt-injected agent can never exfiltrate `AWS_SECRET_ACCESS_KEY` or
`GITHUB_TOKEN` through this mechanism — only vars the user deliberately
exposed under the prefix resolve.
- `typed` echoes the placeholder, never the value.
- Backend errors are scrubbed at the resolution site (e.g. a failed
Android `input text` echoes the device-side command line containing the
secret) — message and stack, in place, preserving the error class and
with it the HTTP-status/telemetry mapping.
- Unknown placeholder → 400 listing the **names** of available secrets,
never values (`SECRET_PLACEHOLDER_UNKNOWN`).
- **Auto-screenshot skip:** the MCP layer deep-scans args for
`{{secret:` and replaces the after-action screenshot with a text note —
otherwise a non-secure-entry field hands the plaintext straight back to
the model as an image. Covers nested `run-sequence` steps too.
- Remote-routed setups resolve on the machine that types (the
tool-server's env) — documented in the skill.
- Inert unless a placeholder is used; no feature flag.

## Docs

- `keyboard` / `paste` tool descriptions
- `argent-device-interact` skill: "Typing secrets" section (incl.
*don't* `describe`/`screenshot` the field you just filled — submit
first)
- `argent-create-flow` skill: never record a real credential;
placeholder flows stay secret-free in the repo

## Known residual risks (shared with Stagehand-style designs)

- A prompt-injected agent could direct `{{secret:X}}` into an
attacker-visible field and screenshot it — inherent to any "agent types
secrets" design; the prefix allowlist and no-read-back keep the blast
radius to secrets deliberately exposed.
- `describe` can read back a non-secure field's value after typing; iOS
secure fields briefly show the last typed character; Android typing puts
the resolved string on the device-side `input` command line. Acceptable
for emulator CI; noted in the skill.
- `keys` in the result still reflects the real keystroke count (secret
length) — kept for debuggability.

## Tests

- `keyboard-secrets.test.ts`: resolution (embedded/repeated
placeholders, malformed shapes ignored), unknown-name error lists names
but never values, redaction preserves error class, full `execute`
round-trips on the chromium branch asserting the plaintext appears
nowhere in results or thrown errors, paste via mocked `sendCommand`.
- `auto-screenshot.test.ts`: `containsSecretPlaceholder` on
flat/nested/unserializable args (fails safe).
- Full suites green: tool-server 2550, argent-mcp 76, registry 87;
builds + `typecheck:tests` + eslint + prettier clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-15 13:42:55 +02:00