Commit Graph

340 Commits

Author SHA1 Message Date
Jakub Piasecki ec8321206c Sdk 2026-06-12 10:47:12 +02:00
Alexander Brokking 232762ebd0 fix(tool-server): make debugger-component-tree work on New Arch / multi-renderer apps (#316)
## Summary

`debugger-component-tree` returns **"No visible components found on
screen"** on React Native **New Architecture (Fabric / bridgeless)**
apps that also use a secondary React reconciler such as
`react-native-skia` — even when the app is fully rendered and CDP is
connected. This PR fixes the two root causes so the tool returns the
real component tree with names, testIDs, and accurate tap coordinates.

## Root cause

The injected fiber-walk script
(`packages/tool-server/src/utils/debugger/scripts/component-tree.ts`)
made two assumptions that don't hold on modern RN:

1. **Hardcoded renderer id.** `hook.getFiberRoots(1)` assumes the React
Native renderer is DevTools renderer id `1`. Libraries that ship their
own React reconciler (`react-native-skia`, `react-native-svg`,
`react-three-fiber`, …) register a renderer too and frequently take id
`1`. On such apps `getFiberRoots(1)` returns only that library's roots
(e.g. Skia shapes), so the walk never reaches the app UI → `No visible
components`.

2. **`nativeFabricUIManager.measure` under bridgeless.** Layout was read
via `nativeFabricUIManager.measure(stateNode.node, …)`. Under
bridgeless, `globalThis.nativeFabricUIManager` is an **empty object**
(no `.measure`), so every measure threw and all rects became `null` —
components were then filtered out (and the few kept by testID reported
no usable rect).

## Fix

- **Renderer-agnostic root selection:** enumerate `hook.renderers`,
collect roots from every renderer, and walk the mounted root with the
largest fiber subtree (the real app UI) instead of hardcoding id 1.
- **Fabric layout via the public host instance:** measure through
`stateNode.canonical.publicInstance.measureInWindow()` (a
`ReactNativeElement`) — the **same approach this repo's
`inspect-at-point.ts` already uses** — falling back to
`nativeFabricUIManager.measure(shadowNode, …)` when that global is
functional (non-bridgeless).
- **Hang guard:** a per-measure 1200 ms timeout so `Promise.all` can't
stall on a measure callback that never fires (detached / off-screen
nodes).

No change to the output schema or the Paper (old-architecture) path.

## How to reproduce

On a Fabric + bridgeless RN app that also renders `react-native-skia`:

```
argent run debugger-connect       --device_id <udid> --port <metro>
argent run debugger-component-tree --device_id <udid> --port <metro>
# before: "No visible components found on screen."
```

The DevTools hook reports multiple renderers — e.g. `getFiberRoots(1)`
is the Skia reconciler (only canvas nodes), while the real app UI lives
in a different react-native renderer that the hardcoded id never
reaches.

## Testing

- Full `npm run build` (`tsc --build`, project references) passes; the
modified file typechecks under `strict`.
- Verified live against a **React 19.2.0 / Fabric (bridgeless)** app
whose DevTools hook exposes 3 renderers (id 1 = `react-native-skia`
`0.0.1`; id 3 = `react-native` `19.2.0` holding the real ~2,900-fiber UI
tree).

Before (unpatched): `No visible components found on screen.` — even with
`--includeSkipped` and after forcing a fresh commit.

After (this change) — names + testIDs + accurate coords (example
genericized):

```
Screen: 440x956
OnboardingQuestion "<question text>"            (tap: 0.50,0.17)
  SelectableCard [testID=onboarding.option.yes]  (tap: 0.50,0.84)
    Text "Yes"                                    (tap: 0.50,0.84)
  SelectableCard [testID=onboarding.option.no]    (tap: 0.50,0.93)
Pressable [testID=screen.back-button]             (tap: 0.09,0.09)   ← top-left ✓
TouchableOpacity [testID=dev-settings.trigger]    (tap: 0.06,0.97)   ← bottom-left ✓
```

Coordinates were spot-checked against the visible layout (back button
top-left, an off-screen/hidden trigger bottom-left).

## Notes

`inspect-at-point.ts` has the same hardcoded `getFiberRoots(1)` /
`renderers[0]` renderer assumption (its Fabric layout path already uses
`publicInstance`, which is what motivated this fix). I scoped this PR to
`component-tree`; happy to follow up on the inspect path separately.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Filip131311 <contact@filipkaminski.com>
Co-authored-by: filip131311 <159789821+filip131311@users.noreply.github.com>
2026-06-10 12:34:47 +02:00
Filip131311 9267940391 style: prettier formatting in inspect-at-point-script test 2026-06-10 12:32:36 +02:00
Alexander Brokking d2071fafc5 fix(tool-server): select the correct renderer in debugger-inspect-element (#317)
## Summary

Companion to #316. `debugger-inspect-element` has the same
hardcoded-renderer assumption as the component-tree script, so it
inspects the wrong React renderer on multi-renderer apps.

## Root cause

`makeInspectScript`
(`packages/tool-server/src/utils/debugger/scripts/inspect-at-point.ts`)
picks:

```js
var renderer = Array.from(hook.renderers.values())[0];
var roots = hook.getFiberRoots(1);
var root = Array.from(roots)[0];
```

On React Native **New Architecture** apps that also use a secondary
React reconciler (`react-native-skia`, `react-native-svg`,
`react-three-fiber`, …), that library registers its own renderer and
frequently takes id `1`. `renderer` then points at the Skia/SVG
reconciler and `getFiberRoots(1)` returns only its roots, so
`renderer.rendererConfig.getInspectorDataForViewAtPoint(...)` runs
against the wrong tree and returns no app component at the touch point.

## Fix

Enumerate `hook.renderers`, walk each renderer's roots, and select the
**(renderer, root)** pair with the largest fiber subtree (the real app
UI) before inspecting — then call `getInspectorDataForViewAtPoint` on
that renderer. Falls back to the previous first-renderer behavior if
enumeration yields nothing.

Unlike the component-tree script, the Fabric layout path here was
already correct (it uses
`hostFiber.stateNode.canonical.publicInstance`), so no measurement
change is needed — this PR is purely the renderer/root selection.

## Testing

- Full `npm run build` (`tsc --build`) passes; the modified file
typechecks under `strict`.
- Same environment as #316: a React 19.2 / Fabric (bridgeless) app whose
DevTools hook exposes a `react-native-skia` renderer at id 1 alongside
the real `react-native` UI renderer. With the fix, inspection resolves
against the app renderer instead of the Skia one.

No change to the output schema or the Paper path.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:27:52 +02:00
filip131311 20cb452fd4 feat(remote): artifact transport for files produced by a remote tool-server (#278)
## Why

Files produced on the **tool-server host** (screenshots, profiler
exports) were surfaced to the agent as host paths / `127.0.0.1` media
URLs, which the MCP client (`argent-mcp`) then fetched a *second* time
to get the bytes. When the tool-server runs **remotely**, that localhost
URL resolves to the client's own machine — nothing there — so
screenshots fail with `(Screenshot unavailable: no valid PNG …)`.
Profiler exports have the same shape: paths that only exist on the
remote host.

## What

A reusable **artifact boundary layer** so any file-producing tool works
regardless of where the tool-server runs:

**Foundation**
- **tool-server** (`src/artifacts.ts`, `http.ts`): in-process artifact
registry + `GET /artifacts/:id` streaming route.
`registerArtifact(hostPath)` returns a wire handle (`{ __argentArtifact,
id, filename, mimeType, size }`) instead of a raw path.
- **argent-mcp** (`src/artifacts.ts`, `content.ts`, `mcp-server.ts`): a
client materializer deep-walks tool results for handles, downloads each
over the remote-aware `TOOLS_URL`, writes it into a **structured
cache**, and rewrites the result to the **local** path (+ inline image
rendering).

**Cache layout** (override root with `ARGENT_ARTIFACTS_DIR`):
```
~/.argent/artifacts/<project>/<session>/<device>/<filename>
```
- `project` = `basename(cwd)` + short hash of the full path (readable +
collision-safe)
- `session` = minted once per argent-mcp process (groups a run, easy GC)
- `device` = udid/serial when device-scoped, omitted otherwise

**Consumers wired**
- **screenshot** → returns an image artifact; rendered inline from
downloaded bytes with **no second `127.0.0.1` fetch**. Fixes
`auto-screenshot` and `flow-execute` screenshots via the same path.
- **native-profiler-stop** → `exportedFiles` (XML) become downloadable
artifacts materialized client-side.

## Deferred (follow-ups)
- **`.trace` bundle** — it's an Instruments bundle (a directory);
streaming needs archiving (no archiver dep yet). `native-profiler-stop`
now returns a `traceFileNote` explaining it stays on the profiling host.
- **Project source on remote** — `react-profiler-component-source` /
`react-profiler-analyze` read the user's source tree on the tool-server
host. The profiler analysis chains already work remotely
(server-internal); tracked separately.

## Compatibility
Legacy `{ url, path }` screenshot rendering is retained as a fallback
when no artifact context is present (older tool-servers / unit tests).

## Tests
- tool-server: artifact registry (infer/override), `GET /artifacts/:id`
(200/404/410), updated `stop-recovery` assertions.
- argent-mcp: materializer
(download/rewrite/nested/failure/device-scoping), artifact-aware
`toMcpContent`.
- argent-mcp typecheck + full suite green; tool-server artifact/profiler
tests green. (Note: a full local tool-server typecheck trips a
pre-existing zod-v4-vs-v3 mismatch in `run-sequence` that's unrelated to
this change and resolves on this branch's own lockfile.)

## Notes
Routes around the `argent-private` native simulator-server binary (which
emits the `127.0.0.1` media URL) rather than changing it — no submodule
change.

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

---------

Co-authored-by: pFornagiel <pawel.fornagiel2@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Paweł Fornagiel <85541429+pFornagiel@users.noreply.github.com>
2026-06-10 12:04:30 +02:00
Ignacy Łątka 869f341735 fix(tool-server): reject platform-absent hardware buttons in the button tool (#283)
## Problem

The `button` tool's description states that buttons not present on the
target platform "are rejected with a clear error", but the
implementation accepts any button in the enum on any device. On iOS,
`button: "back"` (there is no hardware back key) returns `{ pressed:
"back" }` — a **silent no-op reported as success**, so an automated
caller believes a back-navigation happened when nothing did. Reproduced
live on an iOS 18.5 simulator.

## Root cause

`execute` (`tools/button/index.ts`) calls `sendCommand` and returns `{
pressed }` unconditionally, with no per-platform validation. The
"rejected … from the backend" promise is also architecturally
impossible: `sendCommand` (`utils/simulator-client.ts`) is
fire-and-forget — it pushes a WebSocket frame and never reads a reply,
so a backend rejection cannot surface to the caller.

## Fix

Validate the requested button against a per-platform set in the tool
layer (the platform is known synchronously from the UDID via
`resolveDevice`), throwing the existing `UnsupportedOperationError` (→
HTTP 400) for buttons that don't exist on the device:

- **iOS:** `home`, `power`, `volumeUp`, `volumeDown`, `appSwitch`,
`actionButton` (no `back`)
- **Android:** `home`, `back`, `power`, `volumeUp`, `volumeDown`,
`appSwitch` (no `actionButton`)

Cross-platform correctness is preserved — `back` still works on Android.
The zod enum stays the union of both platforms' buttons (a flat enum
can't express the dependency); the per-platform `Set` enforces the
refinement. The description is reworded to match (validation lives in
the tool, not "the backend").

## Testing

Adds `test/button.test.ts` — the tool previously had no test.

```
vitest run test/button.test.ts  →  4 passed
```

Verified fail-before/pass-after: with `src/tools/button/index.ts`
reverted to `main`, the two rejection tests fail (old code resolves `{
pressed: "back" }` / `{ pressed: "actionButton" }`); with the fix, all
four pass.

---------

Co-authored-by: Filip131311 <contact@filipkaminski.com>
2026-06-09 17:25:01 +02:00
Paweł Fornagiel 3ccd89f333 feat: add native android profiling (#275)
# Android native profiling via in-process Perfetto WASM

Connected with:
https://github.com/software-mansion/argent-private/pull/16

## TL;DR

Adds **Android** to the native profiler (previously iOS-only) and does
it with a
brand-new engine: instead of shipping Perfetto's `trace_processor_shell`
native
binary and forking it per query, we now run **Perfetto's trace-processor
compiled
to WebAssembly, in-process** inside the tool-server.

- **One ~13 MB `trace_processor.wasm`** runs on every OS/arch — no
per-platform
  binary, no subprocess, fully offline.
- The native profiler is refactored from an iOS-only implementation into
a
**platform-dispatch facade** (`platforms/ios.ts` +
`platforms/android.ts`) over
a new **`profiler-shared/`** layer, so iOS and Android share the
aggregator,
  types, lifecycle, and time-alignment code.
- New **release/pack pipeline** fetches + sha256-verifies the WASM
bundle from
`argent-private-releases`, and bundles it (plus SQL queries and the
Perfetto
  TraceConfig) into the published `@swmansion/argent` package.

End-to-end on a 76 MB trace with 1013 jank rows: **6.3 s** total analyze
(the older per-hang-subprocess approach was projected at ~47 minutes and
never
finished within the tool-call deadline).

---

## TESTING MANUALLY

To test manually run the following packing command checked-out on this
branch:
```
npm run pack:mcp -- argent-@pFornagiel-native-android-devtools-perfetto
```

This will bundle the package with the new binaries. After this PR is
fixed and the binaries are resolved from the main release, the
`argent-@pFornagiel-native-android-devtools-perfetto` release can be
deleted.

## Why

Android needed native CPU/hang/memory profiling to reach parity with
iOS. The
natural engine is Perfetto's trace-processor, but the obvious ways to
ship it are
both bad:

- **`trace_processor_shell` binary** — per-platform (a mac-arm64 pack
shipped a
mac-arm64-only tarball), pinned an OS/arch matrix, and cost a fresh ~1.3
s
subprocess fork **per query**. With one subprocess forked per detected
hang, a
  large trace projected to ~47 minutes.
- **`get.perfetto.dev/trace_processor` Python launcher** — adds a
runtime Python 3
dependency, resolves "latest" at first run (defeats version pinning),
and makes
  the first analyze slow via a network fetch.

The WASM engine is one cross-platform artifact, in-process and
warm-cached: the
trace is parsed **once** and reused across the whole analyze run plus
every
drill-down query.

---

## The three big pieces

### 1. In-process Perfetto WASM engine

New package surface in `@argent/native-devtools-android`:

| File | Role |
| --- | --- |
| `src/wasm-trace-processor.ts` (+476) | The whole engine: boot, glue
patching, warm-cache, fault containment, chunked parse, cell decoding |
| `src/perfetto-engine.d.ts` (+49) | Hand-written types for the
version-agnostic RPC decoder (imported by path, so tsc can't see its own
`.d.ts`) |
| `src/errors.ts` (+68) | `TraceProcessorUnavailableError`
(`wasm_load_failed` / `wasm_path_invalid`) |
| `src/bundled-meta.ts` (+12) | `PERFETTO_VERSION` stamp, regenerated at
pack time |
| `src/index.ts` | Re-exports + package-root accessors
(`traceProcessorQueriesDir`, `traceConfigPath`) |

Public surface: `resolveTraceProcessorAssets()`,
`queryWarm<Row>(tracePath, sql)`,
`ensureTraceProcessorReady(tracePath)`, `disposeWarmEngine(tracePath)`.

Highlights of how it works:

- **Boots Google's prebuilt web/worker wasm under Node.** Installs a
minimal
worker scope (`self`, `WorkerGlobalScope`, `location`), reads the wasm
bytes,
patches the Emscripten glue (`useMemory64=false`, hand the bytes in
directly,
expose the bridge), and drives it over a `MessageChannel` + `EngineBase`
subclass. Glue patches are applied **by unique string anchors that throw
if they
  don't match** — the canary for glue-format drift on a Perfetto bump.
- **Warm-engine cache, one per trace path.** LRU with `MAX_WARM_ENGINES
= 3`
(bounds resident memory at ~26–76 MB trace + wasm heap each), 5-min idle
dispose
  (timer `unref()`'d), no failed-promise caching.
- **Fault containment (`fatal` promise).** `EngineBase.fail()` throws
synchronously
inside the `MessagePort.onmessage` handler (e.g. an RPC framing error);
left
unhandled it becomes a Node `uncaughtException` and would **kill the
entire
tool-server**, wiping every device + profiler session. Each engine
exposes a
`fatal: Promise<never>`; the handler try/catches, rejects `fatal`,
evicts the
engine, and every load/query is `Promise.race`'d against it — so a
contained
  fault is a clean rejection and the server stays up.
- **Chunked trace parse.** Perfetto's RPC ring buffer rejects any frame
> 64 MiB,
  so the trace is fed in 32 MiB chunks before `notifyEof()`.

### 2. Android native profiler + the shared/dispatch refactor

**Capture** (`utils/android-profiler/capture.ts`, `detect-app.ts`):
spawns
`adb shell perfetto --txt -c - --background-wait -o
/data/.../argent-<ts>.pftrace`,
piping the TraceConfig on **stdin** (SELinux denies `shell:s0` writes
under
`/data/misc/perfetto-traces/`, so the config can't be staged as a file),
waits for
the PID on stdout, then on stop sends `kill -TERM`, polls `/proc/<pid>`,
and
`adb pull`s the trace.

**Pipeline** (`utils/android-profiler/pipeline/`): PerfettoSQL does the
parsing and
aggregation, so the Android pipeline is intentionally **two files, not
iOS's four**.
The per-hang annotation pass is **batched** — `runBatchedHangFolds`
inlines all
hang windows as a `VALUES` table into one SQL script (two derived views
unioned in
a single terminal SELECT, because the engine returns only the final
statement's
rows) and runs it once against the warm engine.

**The platform-dispatch refactor** —
`native-profiler-start/stop/analyze` shrank
from monolithic iOS files (~160–330 lines each) into **thin routers**
(~60–110
lines) that branch on `api.platform`:

```
native-profiler-start ─┬─ ios?    → platforms/ios.ts      (xctrace)
                        └─ android → platforms/android.ts  (adb perfetto)
```

A new **`utils/profiler-shared/`** layer holds everything both backends
use:

| File | What was hoisted |
| --- | --- |
| `types.ts` | Unified `Bottleneck = CpuHotspot \| UiHang \| MemoryLeak
\| MemoryRssGrowth`, each now carrying a `platform` field; shared
`NativeProfilerAnalyzeResult`; `RECORDING_CAP_MS` |
| `aggregate.ts` | Generic `aggregateCpuHotspots(AggregatorInputRow[])`
— burst windowing (`BURST_GAP_MS=500`), severity banding (RED >15%,
YELLOW 3–15%, <3% dropped). iOS feeds it via a pre-pass; Android feeds
it precomputed SQL rows |
| `lifecycle.ts` | `shutdownChild` SIGINT→SIGTERM→SIGKILL ladder +
`waitForChildExit` (no PID-reuse hazard) |
| `thread.ts` | `normalizeThreadName` for both xctrace and Perfetto
thread names |
| `time-align.ts` | New `buildPerfettoAnchor()` + `windowsOverlap()`
alongside the existing iOS/React anchors |
| `format.ts` | `formatBytes` (moved out of the combined report) |

iOS was refactored to fit this shape: `ios-profiler/lifecycle.ts` and
`types.ts`
become thin re-exports; `pipeline/02-aggregate.ts` shrank from ~200 to
~60 lines
(keeps its iOS-only dominant-function pre-pass, delegates
grouping/bursting/
severity to the shared aggregator). **No iOS behavior change** — same
outputs,
shared internals.

**Session blueprint** (`native-profiler-session.ts`): generic
`capturePid` /
`captureProcess` (was `xctracePid` / `xctraceProcess`), new `platform`
and
`androidOnDeviceTracePath` fields, and **platform-branched dispose**
(iOS SIGKILLs
xctrace; Android `kill -KILL`s perfetto, `rm`s the device trace, and
disposes the
warm WASM engine).

**Drill-down tools**: `profiler-load.ts` learns to restore Android
`.pftrace`
sessions (with an optional metadata sidecar / `app_process` override);
`profiler-stack-query.ts` splits into iOS and Android branches. Unlike
iOS (which
caches `parsedData` in memory), Android **re-queries the `.pftrace`**
per
drill-down — the warm engine is the cache, so each query costs only SQL
execution
time.

### 3. Packaging & release pipeline

The WASM bundle's three third-party artifacts (`trace_processor.wasm`,
`engine_bundle.node.js`, `engine.mjs`) + `LICENSE` are **never
committed**
(`.gitignore` lists the generated files but not the dir). They're built
and
sha256-checksummed in `argent-private` CI, published to
`argent-private-releases`, and on our side:

- **`scripts/download-trace-processor.sh`** (+88, new) — `gh release
download` →
verify tarball sha256 (**FATAL on mismatch** — the blobs are unsigned) →
extract
  → re-verify via `SHA256SUMS` → assert all four present.
- **`scripts/pack-mcp.cjs`** (+60, new) — single pack entry point:
downloads
simulator-server, native binaries, **and** the trace-processor bundle,
then
builds and `npm pack`s `@swmansion/argent`. `package.json`'s `pack` /
`pack:mcp`
  now call this script.
- **`packages/argent/scripts/bundle-tools.cjs`** (heavily reworked,
+574/-...) —
copies `assets/trace-processor/`, `assets/queries/`, and
`argent.tracecfg.pbtxt`
into `packages/argent/assets/`, and regenerates `bundled-meta.ts` to
stamp the
  Perfetto version.
- **`publish.yml` / `publish-next.yml`** — run the download step and
then **verify**:
  all four WASM files present, the wasm magic-byte check
(`head -c4 … | grep $'\x00asm'`), plus the queries dir, TraceConfig,
manifest,
  and Android helper APK.

Asset reorg: `native-devtools-android` now ships `dist/`, `bin/`, and
`assets/`
(SQL queries, TraceConfig, manifest moved under `assets/`); `.gitignore`
swaps the
old per-file `packages/argent/manifest.json` entry for the whole
`packages/argent/assets/` dir.

---

## Testing

15 new test files under `packages/tool-server/test/android-perfetto/`
cover the
engine and pipeline, including:

- `dispatch.test.ts` — start/stop/analyze route to the right platform
backend.
- `aggregate-shared.test.ts` — the shared aggregator on both iOS and
Android-shaped input.
- `hang-folds-batched.test.ts` / `hang-fold.test.ts` — the batched
per-hang SQL fold.
- `hang-severity.test.ts` — jank-reason → RED/YELLOW classification.
- `dispose.test.ts` / `dispose-warm-engine.test.ts` — session dispose
kills the daemon, rms the device trace, and tears down the warm engine.
- `stop-recovery.test.ts` — `profilingActive` is forced false even if
`adb pull` fails (so a retry-start doesn't wedge).
- `run-tp.test.ts` — `renderSqlTemplate` token rendering + the "unused
substitution" guard.
- `manifest-hint.test.ts` — zero-callstack detection emits the manifest
hint.
- `profiler-load.test.ts` — `.pftrace` session restore with/without the
metadata sidecar.

Plus `native-profiler-analyze-failure.test.ts` for the
engine-unavailable banner,
and updated iOS tests for the shared-lifecycle refactor.
`@argent/native-devtools-android` gains its own vitest setup
(`vitest.config.ts`, `tsconfig.test.json`, `test/errors.test.ts`).

---

## Notes & follow-ups

- **Manifest requirement.** Perfetto's `linux.perf` data source needs
`/proc/<pid>/mem` access, granted only to a **debuggable** or
`<profileable
android:shell="true"/>` app. A release build without either silently
produces
zero callstacks; the pipeline detects this and emits a **manifest hint**
instead
  of an "All clear" report.
- **Version pinning lives in the `argent-private` submodule**
(`PERFETTO_VERSION`
/ `PERFETTO_WASM_TAG` / `LYNX_TRACE_PROCESSOR_VERSION`). Bumping is
documented in
`argent-private/docs/ANDROID_PERFETTO.md`. JS↔engine version skew is
structurally
  impossible (version-agnostic decoder + vendored wasm).
- **`disposeWarmEngine` is exported but not wired into session-stop** —
warm
engines are reclaimed only by the idle timer + LRU cap. Acceptable today
(bounded
memory); wire it into session dispose if explicit teardown is ever
needed.
- **Submodule bump.** This PR moves `packages/argent-private` to
`22e55f90` — see the companion `argent-private` PR description,
including its
merge-hygiene note (that branch should pick up latest `argent-private`
main
  before the pointer is finalized).

### Companion docs added in this branch
- `utils/android-profiler/ANDROID_PROFILER_REFERENCE.md` — stack,
capture, queries, manifest requirement.
- `utils/android-profiler/PIPELINE_DESIGN.md` — the "why" (two-file
pipeline, shared-aggregator hoist, re-query-don't-cache, batched fold).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:06:24 +02:00
Ignacy Łątka 8c080c9189 fix(screenshot-diff): make live capture work on Android + surface real capture errors (#307)
## Problem

`screenshot-diff` with `baselinePath` + `captureCurrent: true` — the
tool's own
documented *"common visual-regression flow"* — fails reproducibly on
Android with:

> Screenshot failed: server response missing url or path. The
simulator-server may be misconfigured. Try restarting it.

That message is misleading (the server is healthy), and the flow is 100%
broken on Android.

## Root cause (verified against a live emulator)

`captureLiveInput` requests a **full-resolution** capture (`scale:
1.0`). The Android
simulator-server can't stream some framebuffers at full res and returns
**HTTP 200**
with an `error` field. Confirmed directly against the running
simulator-server:

| request | response |
|---|---|
| `{"scale":0.5}` | `{"path":…,"url":…}` ✅ |
| `{}` (full res) | `{"error":"wrong data size, expected 7853760 got
17627328"}` ❌ |
| `{"scale":1.0}` | `{"error":"wrong data size, expected 7853760 got
17627328"}` ❌ |

(`7853760 = 1080×2424×3`; the emulator returns ~2.24× that.)

Two compounding bugs:
1. **`httpScreenshot`** only reads `error` on a non-2xx status. On the
`200 + {error}` path it falls through to the generic *"missing url or
path … restart it"*, hiding the real cause.
2. **`captureLiveInput`** hard-codes `scale: 1.0` with no fallback, so
the whole diff fails on Android.

## Fix (TS-only)

- **`httpScreenshot`**: when `url`/`path` are missing, surface
`resBody.error` if present (so *"wrong data size …"* reaches the caller)
before the generic hint.
- **`captureLiveInput`**: attempt full-res, and on any failure **retry
at the server's default scale**, which captures reliably. #285's
same-aspect normalization already makes a scaled capture diff-compatible
with a baseline saved at any scale, and full-res is preserved wherever
it works (iOS). If the fallback also fails, its error propagates.

## Tests

`screenshot-diff-tool.test.ts` — full-res→default-scale fallback
(asserts the `1.0` call then a default-scale retry), and error
propagation when both fail.
`simulator-client.test.ts` (new) — `httpScreenshot` surfaces the server
`error` on a `200+{error}` response, still uses the generic hint when
there's genuinely no info, and returns `url`/`path` on success.

All 21 screenshot-diff/simulator-client tests pass; `tsc` clean on the
changed files.

## Scope note

The underlying inability to capture a full-resolution Android frame is a
**simulator-server (native)** issue — the *"wrong data size"*
framebuffer mismatch. This PR makes the tool **degrade gracefully**
(works at the default scale) and **diagnose clearly** instead of failing
outright; a proper native fix for full-res Android capture is a separate
follow-up.
2026-06-09 10:55:21 +02:00
Ignacy Łątka 25975637dd fix(boot-device): gate -crash-report-mode on emulator -help support (#306)
## Problem

The Android emulator's `-crash-report-mode` flag is only present in
newer emulator builds (~36.x and late 35.x) and is **undocumented** in
the release notes, so support can't be inferred from a version string.
Passing it to an emulator that doesn't recognize it makes the binary
**abort before boot**.

## Change

- New `emulatorSupportsFlag(flag)` helper in `utils/adb.ts`: resolves
the emulator binary, runs `emulator -help`, and reports whether the flag
appears in the listing. Memoized per `binary|flag`; best-effort (returns
`false` and never throws if the binary can't be resolved or `-help`
fails — including non-zero-exit builds, whose output is still read from
the error).
- `boot-device.ts` now gates `-crash-report-mode never` on that check
and spreads it into **both** the hot- and cold-boot arg lists. The probe
is computed after the already-running reuse fast-path returns, so it's
skipped when no emulator is spawned.

## Verification

- `tsc --build` clean.
- Functionally checked against a real Emulator **36.5.11**:
`-crash-report-mode` → detected (flag added); an unknown flag → not
detected (flag omitted).
- `boot-device` tests 7/7; full tool-server suite 690 passed. The single
failing `android-binary` test is pre-existing and environmental (host
has a real `/usr/bin/adb` that defeats the test's PATH stripping); this
diff doesn't touch `android-binary.ts`.
2026-06-03 23:43:18 +02:00
filip131311 18974cab93 fix(release): ship ax-service in bin/darwin/ so describe works in published packages (#303)
## Summary

`describe` is broken in every published package since **0.9.0**: on a
normally-booted simulator it returns only a bare `ROOT AXGroup` (empty
tree) or silently falls back to native-devtools, always accompanied by
the misleading hint *"this simulator was not booted through argent …
call boot-device with force=true"* — even right after a forced argent
boot, so there is no recovery.

**Root cause:** the published npm package is **missing the `ax-service`
binary** entirely.

## Bisect

| Release | `ax-service` in tarball | `describe` |
|---|---|---|
| 0.7.1 | `bin/ax-service` (flat) ✅ | works |
| 0.8.1 | `bin/ax-service` (flat) ✅ | works |
| **0.9.0** | **absent** ❌ | **broken** |
| 0.10.0 | **absent** ❌ | **broken** |

- **Last good release:** 0.8.1
- **First broken release:** 0.9.0
- **Breaking commit:** `80661fad` — *feat: Linux support (#249)*

## Why it broke

#249 migrated native binaries to a per-platform `bin/<platform>/` layout
and updated **two of the three** places that touch the ax-service path:

- `packages/native-devtools-ios/src/index.ts` — `axServiceBinaryPath()`
now resolves `bin/darwin/ax-service` (was flat `bin/ax-service`).
- `packages/argent/scripts/bundle-tools.cjs` — copies the published
binary **from** `bin/darwin/ax-service`.

…but it **did not** update `scripts/download-native-binaries.sh` — the
script the standard `pack` / CI release path runs — which kept
downloading ax-service to the **flat `bin/` root**.

Producer (download script) and consumer (bundler/resolver) disagreed.
During `pack`, the bundler looked under `bin/darwin/`, found nothing,
**warned and skipped the copy**, and the tarball shipped with no
ax-service. At runtime the ax-service factory throws *(binary not
found)* → `describe` catches it → empty tree + degraded hint →
native-devtools fallback (which itself fails on plain screens like
SpringBoard).

`pack:mcp:local` (which runs `build:ios-binaries` → `build.sh`, already
writing to `bin/darwin/`) was unaffected — only released/downloaded
builds were broken, which is why it wasn't caught locally.

## Fix

`download-native-binaries.sh` now downloads ax-service into
`bin/darwin/`, matching the resolver and the bundler. Plus a regression
test (`package-manifest.test.ts`) that pins the producer→consumer path
agreement so the two scripts can't silently drift again (verified it
fails on the pre-fix path and passes after).

## Verification

Rebuilt via **`npm run pack:mcp`** → tarball now contains
`package/bin/darwin/ax-service` (byte-identical, signed Mach-O). Drove
the **packaged** binary's `describe` RPC directly and via the full MCP
`describe` tool:

- iPhone 17 Pro — iOS 26.4 → `source: ax-service`, full tree (32
elements), no degraded hint ✅
- iPhone Air — iOS 26.4 → `source: ax-service`, full tree ✅
- iPhone 16 Pro — iOS 18.5 → `source: ax-service`, full tree (33
elements) ✅

Before the fix all three returned a bare `ROOT AXGroup` (or
native-devtools fallback) + the degraded hint.

Tests: `packages/tool-server` ax-service/describe suites (40) green;
`packages/argent` suite (12, incl. new guard) green.

## Notes
- Script-only change to release tooling + one test; no runtime TS
changes. The bundled `bin/` artifacts are gitignored (produced by
`pack`), so the diff is just the script and the test.
- Independent of `fix/boot-device-refresh-stale-services` (stale-service
eviction after reboot) — that fix can't help while the binary is absent
from the package; both are needed for fully healthy `describe`.
2026-06-03 22:28:22 +02:00
Ignacy Łątka 01eca45eb4 fix(tool-server): compare same-aspect screenshots in screenshot-diff instead of failing on a resolution mismatch (#285)
## Problem

`screenshot-diff` rejects the workflow its own docs call "the common
visual-regression flow" — `baselinePath` + `captureCurrent: true`.

The `screenshot` tool saves PNGs at a default scale of `0.3` (e.g.
`396x860`), while `screenshot-diff`'s live capture is hardcoded to full
resolution `1.0` (e.g. `1320x2868`). Feeding a saved `0.3x` baseline
together with a `1.0x` live capture returns `status: dimension_mismatch`
and refuses to compare — even though both images are uniform scalings of
the same framebuffer and share an identical aspect ratio. The advertised
default workflow is therefore broken out of the box.

## Root cause

`diffPngFiles` (in
`packages/tool-server/src/tools/screenshot-diff/screenshot-diff.ts`)
treated **any** difference in pixel dimensions as a hard failure:

```ts
if (baseline.width !== current.width || baseline.height !== current.height) {
  return summarizeResult(buildDimensionMismatchResult({ baseline, current }));
}
```

There was no allowance for two images that depict the same content at
different scales.

## Fix

Before the hard dimension-equality check, normalize the two decoded
images to a common size when their aspect ratios match (within a 1%
tolerance, to absorb integer-rounding of pixel dimensions). The
**larger** image is downscaled to the **smaller** one's exact dimensions
using the existing Lanczos3 resampler — we never upscale, so we never
invent detail. When the aspect ratios genuinely differ, the existing
`dimension_mismatch` hard-fail is preserved unchanged.

- New pngjs-free module
`packages/tool-server/src/tools/screenshot-diff/resize.ts` exports
`normalizeToCommonSize(baseline, current)` (returns same-sized images,
or `null` when aspects differ) and `resizeDecodedPng(src, width,
height)`. The pure Lanczos3 resampling math (which has no `pngjs`
dependency) was moved here; `screenshot-diff.ts` now imports it, and its
`downscalePng` delegates to `resizeDecodedPng`, so there is a single
resampler implementation.
- The OCR / font-geometry pass (`analyzeScreenshotTextChangesSafely` →
`detectFontGeometryChange`) re-reads the original files from disk and
samples in-memory pixels using OCR-derived bounds in those files'
coordinate space. It therefore keeps receiving the **originally
decoded** images (not the normalized copies), and its top cutoff is
derived from the original baseline height. Only the pixel-diff path
operates on the normalized, same-sized images. This keeps the text pass
byte-for-byte consistent with prior behavior.

## Testing

- **`packages/tool-server/test/screenshot-diff-resize.test.ts`** (new,
pngjs-free, runs locally): unit tests for the new pure helpers —
same-aspect different-size pairs normalize to the smaller common size;
uniform color survives the resample; transposed and out-of-tolerance
aspect ratios return `null`; an in-tolerance (~0.1%) aspect difference
still normalizes. **11/11 passing locally.**
- **`packages/tool-server/test/screenshot-diff.test.ts`** (existing):
added an integration case asserting that a same-aspect,
different-resolution baseline/current pair is **not** reported as
`dimension_mismatch` and produces a diff at the smaller common size.
This is the true before/after for the bug.

### CI caveat

`screenshot-diff.ts` imports `pngjs` at module load, so
`screenshot-diff.test.ts` (and the integration case above) can only run
in an environment where `pngjs` is installed — i.e. **CI**. The new
`resize.ts` and `screenshot-diff-resize.test.ts` are intentionally
`pngjs`-free so the core normalization logic is verifiable without that
dependency. Typecheck (`tsc --noEmit -p tsconfig.test.json`) is clean
for the changed files.
2026-06-03 22:28:20 +02:00
Ignacy Łątka 1706fb5c2a fix(boot-device): don't tear down a healthy-but-slow emulator on the final boot attempt (#305)
## Problem

On Linux (headless, software-GPU) I hit `boot-device` repeatedly failing
with
`PackageManager did not respond ... Emulator has been terminated`, even
though
the emulator had booted fine. Root cause: **Stage 5** of `attemptBoot`
ran a
single `pm path android` probe with a 10 s timeout and, on failure,
killed the
emulator and threw.

On a loaded host (or a freshly `-wipe-data`'d image still finishing its
first-boot package scan), `pm` can take well over 10 s to answer while
the guest
is perfectly healthy — it already reached `sys.boot_completed` and
registered
with adb. On the **final cold attempt** there is no fallback, so a
momentarily
slow PackageManager turned a usable emulator into a hard boot failure
(and
destroyed it, so the next try started from a cold boot again).

## Fix

- Stage 5 now **retries** `pm path android` within a budget instead of a
single
  10 s window.
- Threads a `tearDownIfUnready` flag through `attemptBoot`:
- **hot-boot attempt** — tight ~10 s budget, tear down on failure so we
still
    fall through to the cold boot.
- **final cold attempt** — retry up to 45 s and, if PM is still slow,
**return
the emulator as booted rather than destroying it**. gRPC
screenshots/gestures
work without PM, and killing it just guarantees failure with nothing to
fall
back to. A confirmed QEMU crash (mid-probe or via the early-exit racer)
still
    tears down and rethrows the real cause.

Also adds a note to the `argent-android-emulator-setup` skill:
hard-killing qemu
(`pkill -9`) leaves the userdata image dirty — the usual cause of a cold
boot
that hangs for minutes with runaway writes — and `-wipe-data` resets it.

## Tests

`packages/tool-server/test/boot-device-hotboot.test.ts` — added a
regression
test asserting the final cold attempt returns the emulator as `booted`
(and does
**not** issue `adb emu kill`) when PM stays slow. All 28 boot-device
tests pass.

## Out of scope / follow-ups (found during the same Linux session)

- `simulator-server` (Rust) **panics** (`android_emulator.rs:104 "should
be
running"`) when asked to attach to a pre-existing emulator launched with
`-read-only` that never registered gRPC discovery. Better to return a
clear
"can't attach, reboot via Argent" error. Separate (cross-language)
change.
- `screenshot-diff` with `captureCurrent: true` fails reproducibly with
  `"server response missing url or path"` while plain `screenshot` and
  file-vs-file diff both work.
- `react-profiler-component-source` returns `found: false` for valid
top-level
  `export function` components.
2026-06-03 22:23:29 +02:00
filip131311 d87fbcc973 chore: bump version to 0.10.0 (#301)
Bumps the version from `0.9.0` to `0.10.0` across all versioned packages
in the workspace.

## Packages bumped (0.9.0 → 0.10.0)
- `@swmansion/argent`
- `@argent/cli`
- `@argent/installer`
- `@argent/mcp`
- `@argent/tools-client`
- `@argent/native-devtools-android`
- `@argent/native-devtools-ios`
- `@argent/registry`
- `@argent/skills`
- `@argent/tool-server`
- `@argent/update-core`

`package-lock.json` updated to match.

## Left unchanged
- `@argent/ui` — on a separate version track (`0.0.1`)
- `packages/argent-private` — has no `version` field

Internal dependencies use `file:` references, so no dependency
specifiers required updating.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-03 17:48:42 +02:00
filip131311 8efd7c6346 feat: add argent server start for long-lived tool-server (#182)
## Human summary: 

This adds `argent server start` command to enable us to spawn tools
server in arbitrary location. we want this to enable using remote
tools-server via our mcp not only local ones.

## Summary

- Adds `argent server start` to spawn a tool-server that does **not**
auto-shutdown on idle, with configurable bind port and host — the
missing piece for running tool-server on a remote/dedicated host.
- Foreground by default so process supervisors (systemd, Docker,
supervisord) own the lifecycle; `--detach` keeps the existing
daemon-spawn behavior. Refuses to clobber a healthy running server
unless `--force`.
- Phase 2 (consumer-side `--tools-url` flag pointing the MCP / CLI at a
remote tool-server) is now a one-flag change away — `tools-client.ts`
and `mcp-server.ts` already honor `ARGENT_TOOLS_URL`. Out of scope for
this PR.

## What changed

- **`tool-server`** — read `HOST` env var (default `127.0.0.1` so the
existing auto-spawn path is unaffected), bind to it, attach an `error`
listener on `app.listen` so `EADDRINUSE` / `EACCES` exit cleanly instead
of routing through `uncaughtException`.
- **`argent-tools-client`** — broaden the readiness regex to match any
host; extend `buildToolsServerEnv` with optional `{ host,
idleTimeoutMinutes }`; record `host` in `~/.argent/tool-server.json`
(backward-compat: missing field defaults to `127.0.0.1`); export
`spawnToolsServer`, `findFreePort`, `isToolsServerHealthy`,
`isToolsServerProcessAlive`, the state read/write/clear helpers, and
`formatToolsServerUrl` so the CLI can reuse them.
- **`argent-cli`** — new `server start [flags]`: `--port`/`-p` (default
3001, `0` = pick free), `--host` (default `127.0.0.1`), `--idle-timeout`
(default `0` = never), `--detach`/`-d`, `--force`, `--help`. Warns when
host is non-loopback. Foreground mode forwards `SIGINT`/`SIGTERM` to the
child and writes the state file so local `argent run` / `argent mcp` can
attach.
- **`argent` dispatcher** — pass `BUNDLED_RUNTIME_PATHS` to `server()`
so the new subcommand can find the bundled tool-server.

## Test plan

- [x] `argent server --help` lists the new `start` subcommand
- [x] `argent server start --help` shows full flag reference
- [x] `argent server start --detach --port 0` → status alive/healthy,
`/tools` reachable, state file populated with `host` field
- [x] Second `argent server start --detach` refuses (`exit 1`) with
helpful message
- [x] `argent server start --detach --force` kills the old PID and
starts a new one
- [x] Foreground `argent server start --port 0` runs with inherited
stdio; child PID is the actual tool-server (not the wrapper); `argent
server stop` from a sibling shell tears down both
- [x] Existing auto-spawn path (`argent tools`) still works — verifies
the regex broadening + default-host change is backward-compatible
- [x] `tool-server` (444 tests) + `argent-tools-client` (1) + `argent`
dispatcher (1) suites pass
- [ ] Manual smoke on `--host 0.0.0.0` — could not exercise locally
(sandbox declined the public-interface bind); the warning + bind path is
identical to loopback besides the listen address

---------

Co-authored-by: pFornagiel <pawel.fornagiel2@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Paweł Fornagiel <85541429+pFornagiel@users.noreply.github.com>
2026-06-03 17:44:15 +02:00
Ignacy Łątka 2be58a5cf1 fix(boot-device): five correctness fixes from post-merge Linux review (#296)
## Summary

Five issues found in the post-merge code review of #249 (Linux support).
All confirmed present on main; none fixed by #194.

### H1 — `awaitFirstRealFrame` timeout leaves emulator running

`awaitFirstRealFrame` is called after the cold-boot `try/catch` closes.
On a SurfaceFlinger timeout the emulator process stayed alive:
`inFlightBoots.finally` cleared the map entry, so the next `boot-device`
call found the stale serial via the `alreadyRunning` fast path, had to
run `assertScreencapAlive`, killed it there, and then fell through to
another full cold boot — doubling latency and surfacing a confusing
"wedged framebuffer" error instead of the original timeout.

**Fix:** wrap the `awaitFirstRealFrame` call in a try/catch that calls
`killEmulatorQuietly` before re-throwing.

### H2 — `awaitFirstRealFrame` budget not clamped against
`overallDeadline`

Every `attemptBoot` stage clamps via `Math.min(params.attemptDeadline,
…)`. `awaitFirstRealFrame` received the fixed
`STAGE_BUDGET.firstRealFrame` (90 s) with no knowledge of
`overallDeadline`, allowing total wall time to silently exceed
`bootTimeoutMs` by up to 90 s — violating the documented contract and
the Zod schema's `max(900_000)`.

**Fix:** pass `Math.min(STAGE_BUDGET.firstRealFrame, Math.max(0,
overallDeadline - Date.now()))`.

### H3 — `ARGENT_SIMULATOR_SERVER_DIR` semantic changed without
migration hint

The env var previously pointed directly at the directory containing
`simulator-server`; it now points at the root of the per-platform tree.
A user with the old flat layout gets a bare "binary not found" with no
explanation of what changed.

**Fix:** detect the old flat layout and include a migration hint in the
error message; update the env var's doc comment.

### M4 — `ARGENT_EMULATOR_NO_WINDOW=false` silently enables headless
mode

`selectExtraEmulatorArgs` used a deny-list (`!== "0"`) so `"false"` and
`"no"` — natural shell idioms for "disabled" — both mapped to
`-no-window`. Test suite covered `""`, `" "`, `"0"` but not `"false"`.

**Fix:** switch to an allowlist `["1", "true", "yes"]`; add `"false"`
and `"no"` to the parameterized off-test.

### M5 — `bootstrapDylibPathTcp` / `nativeDevtoolsDylibPathTcp` missing
`requireDarwin`

All six non-TCP dylib/binary exports gained explicit `requireDarwin()`
guards in #249. The two TCP dylib exports did not. A Linux caller gets
"dylib not found" instead of "requires macOS host".

**Fix:** add `requireDarwin()` as the first statement in both TCP dylib
functions; add a test.

### S7 (bonus) — `selectGpuMode` validation fires mid-function after
slow I/O

`selectGpuMode()` and `selectExtraEmulatorArgs()` were called inside
deep nested code after several seconds of I/O. A typo in
`ARGENT_EMULATOR_GPU_MODE` surfaced late with a spurious "emulator has
been terminated" suffix appended.

**Fix:** call both once at the top of `bootAndroidImpl`, just after
`ensureDep`, and reuse the captured values — eliminates the late error
and the redundant double-call.

## Test plan

- `npm run test -w @argent/tool-server` — **847 passed** (was 845; +2
for `false`/`no` NO_WINDOW cases)
- `npm run test -w @argent/native-devtools-ios` — **10 passed** (was 8;
+2 for TCP dylib darwin gate and migration hint)
2026-06-03 17:32:28 +02:00
Ignacy Łątka 372f7c123f feat(boot-device): Linux boot diagnostics for KVM access and AVD sizing (#294)
## Summary

- Adds `linux-preflight.ts` with two non-blocking diagnostics run at
boot time on Linux hosts:
- `/dev/kvm` readability check — warns when KVM is missing or
inaccessible, which causes 10–50× TCG software-emulation slowdown
- AVD `hw.ramSize` / `vm.heapSize` floor check — warns when values are
below the threshold where Android's Watchdog kills `system_server` under
Hermes+Metro+swiftshader load
- Both warnings name the exact fix command; neither throws nor blocks
- Wired into `boot-device.ts` via `linuxBootDiagnostics(avdName)`
immediately after `ensureDep` succeeds

## Stacking

Stacked on `feat/linux-host-support`. Base will be retargeted to `main`
after that PR merges.

## Test plan

- [ ] `linux-preflight.test.ts` covers the pure `diagnoseAvdSizing`
helper for all four sizing combinations
- [ ] `npm run test -w packages/tool-server` passes
2026-06-03 17:16:24 +02:00
Ignacy Łątka f2b0c0be1a feat(android-binary): probe OS-default SDK install locations when env vars are absent (#293)
## Summary

- Extends `androidRoots()` to fall back to OS-default Android SDK
install paths after `ANDROID_HOME` / `ANDROID_SDK_ROOT` come up empty
- Covers Android Studio defaults (`~/Library/Android/sdk` on macOS,
`~/Android/Sdk` on Linux) and common system-wide paths
(`/opt/android-sdk`, `/usr/lib/android-sdk`, etc.)
- Fixes the case where an MCP server spawned by a GUI process (Claude
Code desktop, VS Code) doesn't inherit the shell-exported
`ANDROID_HOME`, causing argent to silently fail to find `adb`/`emulator`
even on a correctly configured machine

## Stacking

Stacked on `feat/linux-host-support`. Base will be retargeted to `main`
after that PR merges.

## Test plan

- [ ] New tests in `android-binary.test.ts` cover the Studio-default
paths and the `ANDROID_HOME` priority invariant
- [ ] `npm run test -w packages/tool-server` passes
2026-06-03 17:14:35 +02:00
Ignacy Łątka 74c2065ca0 fix: multiple minor bugs (#194)
## Human Summary

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

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

## AI Summary

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

### Fixes

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

### Individual branches

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

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

## Test plan

Unit tests (already in CI):

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

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

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

## Notes for reviewers

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

</details>
2026-06-03 15:57:42 +02:00
Ignacy Łątka 80661fad8f feat: Linux support (#249)
This PR makes Linux work.

# AI Summary 

<details>
Brings Argent up on Linux as a host: per-platform npm bundle, renderer
selection that actually works on hybrid-GPU / Wayland / containerized
Linux, hot-boot guarding that survives the SwiftShader composite gap, a
real Linux preflight, and a Wayland-headless E2E job in CI. macOS stays
on
the path it was on before (no `-gpu` flag was passed; AVD config
decided);
nothing about the iOS Simulator behavior changes.

The diff is broader than "ship the Linux artifact" — calling it out
below
because several pieces affect macOS too and reviewers should be primed
for
that rather than skim past expecting "just packaging."

### What's actually in here

**npm packaging (the original framing — still load-bearing)**

| File | Change |
|---|---|
| `packages/argent/scripts/argent-simulator-server.cjs` (new) | Node
dispatcher exposed as the `argent-simulator-server` bin entry. Joins
`process.platform` into the bin path and execs. Forwards
SIGTERM/SIGINT/SIGHUP parent→child so non-TTY supervisors can't orphan
the binary. |
| `packages/argent/scripts/bundle-tools.cjs` | Copies per-platform
binaries from `packages/native-devtools-ios/bin/<platform>/` into
`packages/argent/bin/<platform>/`. darwin required, linux best-effort. |
| `packages/argent/package.json` | `bin.argent-simulator-server` points
at the dispatcher. |
| `packages/native-devtools-ios/src/index.ts` | Resolver joins
`process.platform` into the simulator-server bin path. iOS-only dylibs
and `ax-service` throw `"requires a macOS host"` on Linux callers so
they get the root cause, not "file not found". |
| `scripts/download-simulator-server.sh` | Downloads
`simulator-server-argent-{macos,linux}` into platform-keyed subdirs.
**Purges each platform dir before its download attempt** so a failed
download can't leave stale binaries in place from a prior run. |
| `scripts/dev.cjs` | Local dev copies the current host's binary into
`bin/<process.platform>/` to match the resolver layout. |
| `.github/workflows/publish{,-next}.yml` | Replaces single `test -x
darwin/...` with a per-platform inventory loop. darwin always required;
Linux gated by `ARGENT_REQUIRE_LINUX_BINARY=1` (flip once radon#118
lands). |

**Linux runtime — emulator boot path**

| File | Change |
|---|---|
| `packages/tool-server/src/tools/devices/boot-device.ts` |
`selectGpuMode()`: Linux defaults to `-gpu swiftshader` (`auto` lands on
lavapipe with ~10× cold-boot slowdown; `-gpu host` produces silent
black-window failures on Optimus / NVIDIA+libglvnd / Wayland-on-hybrid /
containers). macOS keeps `auto` (ANGLE→Metal).
`ARGENT_EMULATOR_GPU_MODE` overrides, **validated against `emulator
-help-gpu`'s whitelist** so a typoed value fails fast instead of burning
the whole boot budget. `ARGENT_EMULATOR_NO_WINDOW=1` appends
`-no-window` for headless contexts (CI / containers / Wayland sessions
with no Qt wayland plugin). |
| `packages/tool-server/src/tools/devices/boot-device.ts` (cont.) |
**Cold-boot:** `awaitFirstRealFrame` polls SwiftShader's lockscreen
composite (PNG byte-count via `screencap -p`); blocks `booted:true`
until a real frame shows up. Without this, callers chaining `boot-device
→ screenshot` get an all-black PNG because `sys.boot_completed=1` fires
5–60 s before the first composite. |
| `packages/tool-server/src/tools/devices/boot-device.ts` (cont.) |
**Hot-boot:** `assertScreencapAlive` polls within an 8 s budget instead
of single-probing. The earlier single-probe caught the sticky-blank
state but also killed the emulator on every transient snapshot-restore
blank, forcing "every boot is cold" cycles. Sticky vs transient is
discriminated by whether the budget exhausts. |
| `packages/tool-server/src/utils/linux-preflight.ts` (new) |
`linuxBootDiagnostics()` checks `/dev/kvm` access. `diagnoseAvdSizing()`
warns when a target AVD's `hw.ramSize`/`vm.heapSize` is below the floor
an aggressive RN/Expo app needs (2G/228M avdmanager defaults wedge under
load). |
| `packages/tool-server/src/utils/android-binary.ts` |
`defaultAndroidRoots()` probes typical SDK install locations when
`ANDROID_HOME`/`ANDROID_SDK_ROOT` are unset — **cross-platform**,
benefits macOS launches from GUI-launched MCP hosts (Claude Code Desktop
etc.) where shell rc env doesn't propagate. |

**Cross-platform fixes that landed alongside**

| File | Change |
|---|---|
| `packages/argent-installer/src/utils.ts` | `getLatestVersion()` now
redirects `npm view`'s stderr to `ignore` so `EBADDEVENGINES` warnings
from a consumer project's `package.json` (e.g. Bluesky's pnpm pin) don't
leak to the user's terminal. **Cross-platform fix**, not Linux-specific.
|

**CI**

| File | Change |
|---|---|
| `.github/workflows/wayland-e2e.yml` (new) | Boots an AVD through the
tool-server under headless Weston; asserts `hw.gpu.mode = swiftshader`
and that screenshots + gesture-tap work without a display. Currently
runs on `pull_request` for the affected paths + `workflow_dispatch`.
Pinned to `radon-daily` for the simulator-server until radon#118 ships a
Linux asset to `radon-main`. |

### Tests added in this PR

- `packages/argent/test/dispatcher.test.ts` — e2e against tmpdir-staged
dispatcher + fake `<platform>/simulator-server`: missing-binary error
path, exit-code passthrough, argv passthrough, SIGTERM forwarding.
- `packages/tool-server/test/native-devtools-ios-resolver.test.ts` —
`requireDarwin` gates for dylibs and `ax-service` on Linux, per-platform
`simulator-server` path resolution, missing-binary error,
`ARGENT_NATIVE_DEVTOOLS_DIR` override.
- `packages/tool-server/test/linux-preflight.test.ts` — KVM diagnostic
shape + `diagnoseAvdSizing` (ram low, heap low, both, neither, missing
keys).
- `packages/tool-server/test/boot-device-hotboot.test.ts` — extended
with `it.each([linux→swiftshader, darwin→auto])` so a single CI runner
exercises both GPU branches (was runner-platform-dependent, regressions
could pass on both runners individually). New cases for the wedged-frame
fallback (under fake timers because the polling budget is short but
real-time enough to exceed vitest's 5 s default), the GPU-mode whitelist
rejection, and the cold-boot composite-wait timeout.
- `packages/tool-server/test/android-binary.test.ts` — extended for
`defaultAndroidRoots` probe ordering and HOME pinning.

### Verification

- `tsc --build`: clean apart from pre-existing
`@argent/native-devtools-android` import errors unrelated to this
branch.
- `vitest run` in `packages/tool-server` and `packages/argent`: 689 + 11
= 700 tests passing; the 4 pre-existing test-file load failures
(native-devtools-android missing) reproduce on `main` and are out of
scope.
- `npm pack -w @swmansion/argent`: produces `swmansion-argent-0.8.0.tgz`
with the per-platform layout.
- Wayland-e2e: triggered on this branch — link will be in a PR comment
once the run completes.
- Manually verified end-to-end on a Wayland-on-hybrid-GPU developer
laptop: AVD boots through the tool-server with `-gpu swiftshader`,
screencap returns real frames, gesture tools work.

### Dependencies / what still gates this

- **radon#118** must land before the Linux artifact ships through the
default channel. The download script tolerates the asset being absent
today (best-effort), so this PR is technically safe to merge before —
Linux installs just won't get a working `simulator-server` until the
upstream artifact is published. Once it lands:
  - Drop the `radon-daily` arg in `.github/workflows/wayland-e2e.yml`.
  - Flip `ARGENT_REQUIRE_LINUX_BINARY` to `1` in both publish workflows.
- **wayland-e2e is not a required status check** today (no branch
protection on `main` requires it). Worth turning on before merging
anything else that touches the Linux boot path.

### Related

- radon#118 — produces the `simulator-server-argent-linux` artifact this
PR consumes.
- argent-private#11 — cross-builds the iOS Simulator dylibs on Linux.
Strictly orthogonal: those dylibs are macOS-runtime-only regardless of
where they're built, and this PR keeps them under `dylibs/` with a clean
error path on Linux callers.

</details>

Fixes #246
2026-06-03 15:55:39 +02:00
filip131311 aed3978c10 fix(update): only refresh editors that have argent configured (#241)
## Summary

- `argent update` was probing the filesystem for editor dirs (`.gemini`,
`.cursor`, ...) and writing MCP entries, allowlists, rules, and agents
into every detected editor — including ones the user explicitly
unchecked during `init`.
- Switch update to read the actual config file for each adapter and only
refresh scopes where an argent MCP entry already exists. Editors without
one are skipped end-to-end: no MCP write, no allowlist, no rules/agents
copy.
- Adds `hasArgentEntry(configPath)` to every adapter and a
`findConfiguredAdapterScopes()` helper that drives the new branch.

Closes #195

## User experience & product implications

**Before:** running `npx @swmansion/argent update` could resurrect
editors the user had explicitly unchecked during `init`. For example, a
Claude Code-only user with `~/.gemini` on disk would find
`.gemini/settings.json`, `~/.gemini/rules/`, and `~/.gemini/agents/`
populated after every update.

**After:** update only touches editors where argent's MCP entry is
already present in that scope's config. The `init` choice is now
authoritative across runs.

**Alternatives considered:**
- *Persist the init selection in a side file.* Heavier — adds a new
state file, migration story for existing users, and a recovery story
when the file is missing. The MCP entry itself is already a perfect
record of "the user opted into this editor in this scope," so we use
that.
- *Keep `detectAdapters()` but invert the gemini/etc detection rule.*
Same root cause (treating editor-dir presence as consent); just shifts
the bug to other adapters.

**Backwards compatibility / schema changes:**
- No tool input/output schema changes. The interface change is internal
to the installer package.
- New `hasArgentEntry` method on `McpConfigAdapter` is required (not
optional). External consumers of `McpConfigAdapter` would need to
implement it, but the interface isn't part of the public package API.
- For an existing user who runs `update` *and has never run `init`*,
nothing happens to their editor configs (correct: nothing to refresh
until they opt in). They'll still get the global package version bump
and the skills lockfile refresh.

**Follow-ups this unlocks:**
- Same predicate could power a future `argent status` command that
summarizes which editors are configured.
- Opens the door to a `--add-editor <name>` flag on `update` for users
who want to opt new editors in without re-running `init`.

## Test plan

- [x] `npm test -w @argent/installer` — 257 tests pass, including 21 new
ones covering `hasArgentEntry` per adapter and the
`findConfiguredAdapterScopes` semantics.
- [x] `npm run build` — clean tsc build for the whole workspace.
- [x] `npx prettier --check` on touched files.
- [ ] Manual: in a workspace where `init` was run with gemini unchecked
but `~/.gemini` exists, run `argent update` and confirm
`~/.gemini/settings.json` is not modified and no `~/.gemini/rules/` or
`~/.gemini/agents/` directories are created.
- [ ] Manual: in a workspace where `init` configured Cursor + Claude
Code, run `argent update` and confirm both still get refreshed (MCP
entry, allowlist, rules, agents).
- [ ] Manual: run `argent update` in a workspace with no editor ever
configured — confirm it still performs the package upgrade and skills
refresh, just skips MCP/rules/agents.
2026-06-03 14:41:05 +02:00
Juliusz Wajgelt 800b781faf feat: make ax-service a socket client, add optional TCP connection to native tools (#270)
- flips the server-client relationship between `tool-server` and
`ax-service` launched on iOS simulators: now, `ax-service` connects to a
listener in `tool-server`
- adds an option to connect to `ax-service` / native devtools dylib via
TCP instead of UNIX sockets

Corresponds to [argent-private PR
#12](https://github.com/software-mansion/argent-private/pull/12)
2026-06-02 17:11:18 +02:00
Ignacy Łątka 4d8e3e45ed chore(bundle): purge stale Android assets and alias the Android source (#268)
## Summary

Two small follow-ups to #237's Android helper bundling.

1. **Stale-artifact purge.** `bundle-tools.cjs` purges `bin/`,
`dylibs/`, `skills/`, `rules/`, `agents/` at the top of every run, but
the two Android assets live elsewhere — `manifest.json` at the package
root and the APK inside the shared `dist/` folder — so they fall through
the cracks. A rebuild where the source APK is missing leaves the
previous manifest behind, and at runtime `helperManifest()` happily
reads it while `bundledHelperApkPath()` points at an APK that no longer
exists. Adds an explicit purge of both at startup so a clean tarball
either ships both files or neither.

2. **Android source alias.** Adds `@argent/native-devtools-android` to
the esbuild alias map so the tool-server bundle resolves from
`src/index.ts` directly, mirroring the iOS sibling. Removes the implicit
dependency on `tsc --build` having produced `dist/index.js` before
`bundle-tools.cjs` runs.

## Context

<details>

This came out of an E2E review of #237. Of the six findings flagged
there:

- H1 (missing keystore in the argent-private submodule) — **resolved on
main** by `software-mansion/argent-private@9770740` ("chore: update
.gitignore to remove Android signing keys and add debug.keystore").
- H2 (no APK in `argent-private-releases`) — **resolved on main**:
`argent-android-devtools.apk` is now present in `argent-main` and
`argent-v0.8.1`. `bash scripts/download-native-binaries.sh` succeeds
end-to-end.
- M1 (silent stale-manifest survival across builds) — **fixed by this
PR**.
- L3 (Android alias missing from esbuild map) — **fixed by this PR**.
- L2 (only `console.debug` surfaces the legacy-uiautomator fallback) —
the original author intentionally chose debug-level "without leaking
into the per-call result"; leaving as-is.
- L1 (no Java-side tests for `SnapshotInstrumentation`) — needs a test
harness in `argent-private`; out of scope here.

## Test plan
- [x] `npx prettier --check packages/argent/scripts/bundle-tools.cjs` —
clean
- [x] `npm run build` from the workspace root — `tsc --build` succeeds
- [x] `npx vitest run` in `packages/tool-server` — 718/718 pass
- [x] Repro before/after: built once with APK present, deleted source
APK, re-ran `bundle-tools.cjs` standalone — confirmed
`packages/argent/manifest.json` (210 B) and stale
`packages/argent/dist/argent-android-devtools-0.1.0.apk` are both
purged; resulting `npm pack` ships 44 files with no Android stragglers
(vs 46 with both present).
- [x] Alias check: deleted
`packages/native-devtools-android/dist/index.js` and re-ran
`bundle-tools.cjs` — bundling still succeeded (esbuild resolved from
`src/index.ts` via the new alias). With the alias removed, the same
delete causes `Failed to resolve entry for package
"@argent/native-devtools-android"` at bundle time.

</details>
2026-06-02 12:52:46 +02:00
filip131311 57dce88fbf chore: bump argent-private (Set-Cookie shared-storage fix) (#289)
Bumps the `packages/argent-private` submodule `6fe3607` → `cc22310` to
pull in
[software-mansion/argent-private#13](https://github.com/software-mansion/argent-private/pull/13).

That fix makes `NativeDevtoolsURLProtocol` copy `Set-Cookie` response
headers from the native network inspector's internal ephemeral
`NSURLSession` into `NSHTTPCookieStorage.sharedHTTPCookieStorage`, so
cookie-based sessions keep working while native network inspection is
active (mirrors radon-ide
[#122](https://github.com/software-mansion/radon/pull/122)).

The fix was validated on-device (before/after) against a real Expo/RN
app: with the unfixed dylib a freshly-issued `Set-Cookie` was dropped
and the next request sent a stale cookie; with the fix the new cookie
was propagated and echoed back on the subsequent request.
2026-06-02 11:37:38 +02:00
Ignacy Łątka c5c3f7af11 feat: feature flags (#260)
## Summary

- New top-level CLI commands `argent enable <flag>` / `argent disable
<flag>` / `argent flags`.
- Storage is a small JSON file at `~/.argent/flags.json` by default;
`--scope project` writes to `<project-root>/.argent/flags.json` instead.
- `enable` writes `true`. `disable` removes the entry at the chosen
scope (and deletes the file + empty `.argent/` when it becomes the last
flag).
- `isFlagEnabled` walks project → global, so a project entry shadows the
same key in the global file. To opt a single project out of a
globally-enabled flag, hand-edit `<project>/.argent/flags.json` to
`{"flags":{"name":false}}` — there is no CLI for an explicit override.
- The dispatcher routes the new commands to `@argent/cli` exports;
bundling needs no config changes (only `node:fs|path|os`).

## Behaviour

- `argent enable foo` → writes `{ "flags": { "foo": true } }` to global
file.
- `argent disable foo` → removes `foo` from the global file (clean round
trip with `enable`).
- `argent enable foo --scope project` → writes to
`<project-root>/.argent/flags.json`.
- `argent flags` → lists effective state with the winning scope;
`--json` for scripting.
- Project root is the nearest ancestor with `.argent`, `.git`, or
`package.json` — falls back to cwd when none found.
- POSIX `--` separator: `argent enable -- weird-name` is supported.
- Help (`--help` / `-h`), invalid `--scope`, invalid flag names, missing
arg, unknown flags all produce a clear stderr error and `exit 2`.
- Flag-name validation: `/^[a-zA-Z][a-zA-Z0-9._-]*$/`.
- Writes are atomic (tmp file + rename) so concurrent invocations cannot
tear the JSON.

## Reader API

`isFlagEnabled(name)` is exported from `@argent/cli` for future
consumers; it walks project → global and returns `false` when unset.

## Files

- `packages/argent-cli/src/flags.ts` (new) — storage + CLI handlers
- `packages/argent-cli/src/index.ts` — re-exports `enable` / `disable` /
`flags` / `isFlagEnabled` / `FlagScope`
- `packages/argent-cli/test/flags.test.ts` (new) — 41 tests
- `packages/argent-cli/vitest.config.ts` (new)
- `packages/argent-cli/package.json` — description bump
- `packages/argent/src/cli.ts` — dispatcher routes + help text
- `README.md`, `CONTRIBUTING.md` — command tables

## Test plan

- [x] `npm test --workspaces --if-present` → 1085 tests pass (41 in
`@argent/cli`).
- [x] `npm run typecheck:tests --workspaces --if-present` → clean.
- [x] `npm run build` (root `tsc --build`) → clean.
- [x] `npm run build -w @swmansion/argent` (dispatcher + esbuild
bundles) → clean.
- [x] `npx prettier --check .` → clean.
- [x] End-to-end against the built dispatcher in a tmpdir:
- `argent enable my-feature-flag` (global) writes to
`~/.argent/flags.json`.
- `argent enable proj-only --scope project` writes to
`<proj>/.argent/flags.json`.
- `argent disable my-feature-flag` removes the entry; round trip leaves
a clean tree (file + empty `.argent/` removed when no other state lives
there).
  - `argent disable foo` preserves sibling flags in the same file.
  - `argent flags` lists each flag with the winning scope.
- `argent enable --` errors cleanly; `argent enable -- name-after`
accepts the literal.
- `argent enable foo --scope project` walks up to `.git` /
`package.json` / `.argent`; stray editor configs (`.claude`, `.cursor`,
…) do NOT count as project roots.
- Error paths (`--scope bogus`, no name, `1bad-name`) exit `2` with a
clear message.
2026-06-01 23:22:10 +02:00
filip131311 dee49473c8 refactor(tool-server): remove ARGENT_DISABLE_UPDATE_NOTIFICATIONS opt-out (#282)
## What

Removes the `ARGENT_DISABLE_UPDATE_NOTIFICATIONS` environment variable
and everything tied to it: the `DISABLE_ENV` constant, the exported
`areUpdateNotificationsDisabled()` predicate, the early-return gate in
`startUpdateChecker()`, the reminder-note bullet that advertised it, and
the corresponding test.

## Why

The env var made `startUpdateChecker()` a no-op, which left
`getUpdateState()` stuck at its defaults (`updateAvailable: false`,
`installableVersion: null`). Because the `update-argent` tool reads that
shared state, **silencing the periodic reminder also blinded the
explicit, user-consented update path** — `update-argent` would report
*"Argent is already up to date"* even when a newer version existed on
npm. A user who'd quietened the nags but then explicitly asked the agent
to update was wrongly told there was nothing to do (only the CLI `npx
@swmansion/argent update` still worked).

Postponing/suppressing reminders is already handled by the time-based
`dismiss-update` tool, whose suppression persists in
`~/.argent/update-suppression.json` and does **not** disable the
underlying check — so the update state stays accurate. That makes the
env var both redundant and a footgun, so it's removed rather than worked
around.

## Behavior after this change

- The 24h update checker always runs; `getUpdateState()` reflects real
registry state.
- Reminders are still gated by `dismiss-update` (and the 30-minute
auto-suppress after each surfaced note).
- `update-argent` now sees accurate state regardless of reminder
suppression.

## Tests

- Removed the now-obsolete "does not check or notify when notifications
are disabled" test.
- `vitest run` for `update-checker`, `http-update-note`, and
`update-argent-tool` passes (42 tests); `tsc --noEmit` clean for
`tool-server`.
2026-06-01 18:29:16 +02:00
filip131311 d670751db3 chore: bump all package versions to 0.9.0 (#281)
## Summary
- Bump all 11 workspace packages from `0.8.1` → `0.9.0` (`package.json`
+ `package-lock.json`)
- Includes `@argent/update-core`, which was added since the last bump
(#267)
- `@argent/ui` left at `0.0.1` (independent versioning)

## Notes
- Ran `npm install` to update `package-lock.json` — resolved cleanly
- Ran `npm run format` (prettier) — no files needed reformatting
- No `lint` script / eslint config exists in this repo, so there was
nothing to lint

## Test plan
- [ ] Verify `npm install` resolves cleanly
- [ ] Verify `argent --version` reports `0.9.0`
2026-06-01 17:23:13 +02:00
Ignacy Łątka cb8db6d4ac feat(boot-device): pass canonical launch-hardening flags (#269)
## Summary

argent's boot-device currently spawns `emulator -avd <name> -gpu auto`
plus the snapshot flags — nothing else. Five flags every MCP-driven boot
benefits from were missing; add them as `LAUNCH_HARDENING_ARGS` and
spread into the snapshot probe, hot boot, and cold boot.

### Performance (3 flags)

- `-noaudio` — guest skips pulseaudio init; saves one thread and ~50 MB
RSS.
- `-no-boot-anim` — skips the Pixel boot animation, a major CPU spike on
software-rendered GPU modes for ~30 s.
- `-netfast` — disables qemu network shaping; pure overhead for MCP use.

Measured on a 4-core i5-6300U with a 4096 MB / 228 MB-heap AVD,
warm-cache cold boot, no other workload, `-gpu swiftshader`:

| | Boot time | Δ |
|---|---|---|
| Baseline (no extras) | 66 s | — |
| + 3 perf flags | 49 s | −26% |
| + all 5 (incl. dialog suppression) | 47 s | −29% |

qemu RSS at +20 s post-boot drops ~190 MB (5%) with the perf flags.
`android-emulator-runner` (the canonical CI launcher) defaults to the
same three.

### Dialog suppression (2 flags)

- `-crash-report-mode never` — keeps emulator crashes from popping a Qt
consent dialog that blocks the next boot until a human dismisses it.
Crash dumps still land in `/tmp/android-unknown/emu-crash-*.db`; only
the modal popup is suppressed.
- `-no-metrics` — same for the metrics-collection consent dialog.
Unrelated to any argent profiler tool: those run guest-side via
Perfetto/simpleperf or via Metro CDP, none of which depend on the
emulator's host-side Google telemetry.

### Snapshot parity

`-noaudio` and `-netfast` change qemu device topology. The snapshot's
saved state has to match the runtime config the next boot uses or the
snapshot validator rejects perfectly loadable snapshots — the same class
of bug as the prior `-gpu auto` parity work that introduced
`RENDERER_ARGS`. Plumb the hardening args into the same three call sites
(probe `extraArgs`, hot spawn, cold spawn) so the probe agrees with both
boot paths.

## Test plan

- [x] Unit tests: extended `boot-device-hotboot.test.ts` with two new
cases asserting the flags reach the hot-boot and cold-boot spawn argv;
tightened the "same renderer args" parity test to also require
`-noaudio`/`-netfast` on the probe. **712 tests pass.**
- [x] Live-emulator verification on Linux: actual emulator boot with all
5 flags completes in 47 s, no flag-rejection warnings in the emulator
log.
- [ ] Confirm no regression on a macOS test host (please run locally
before merging — I can only verify Linux here).
- [ ] Confirm CI green.

Pre-existing test failure in `android-binary.test.ts` on `origin/main`
reproduces without this change — host-side `$PATH` leakage in the test
setup, not in scope for this PR.
2026-06-01 17:15:47 +02:00
filip131311 ee97c443b7 feat(tool-server): make the update reminder configurable and release-age aware (#276)
## Why

The tools-server shows an update reminder (a `note` appended to every
tool response) once a newer `@swmansion/argent` lands on npm. Two pain
points:

1. **No way to turn it off.** Suppression is in-memory only
(`dismiss-update`, 30-min auto), so it comes back on every server
restart.
2. **It nags about versions you can't install yet.** Registries with a
**minimum-release-age** (package-cooldown) security policy refuse to
install a version until it has been public for N days. Until then the
reminder repeats every day for the whole cooldown window with nothing
the user can do about it — exactly the "reminded for 5+ days" situation.

## What's in this PR

**1. A config option to disable the reminder**
- New env var **`ARGENT_DISABLE_UPDATE_NOTIFICATIONS=1`** (`"1"` or
`"true"`).
- When set, `startUpdateChecker()` is a no-op: **no registry request**
is made and **no note** is ever attached.
- The opt-out is also surfaced inside the reminder text itself, so the
agent can tell the user how to silence it permanently.

**2. Minimum-release-age awareness** (answers "detect the policy and
check the update is actually installable first")
- The checker now fetches the **npm packument**
(`registry.npmjs.org/@swmansion/argent`) instead of `/latest`, because
only the packument carries the `time` map of version → publish
timestamp.
- It detects the machine's policy and only reminds once the latest
version has **aged past** it. Notification is gated on a new
**`updateInstallable`** flag (newer **and** old enough) rather than
`updateAvailable`.
- `updateAvailable` keeps its pure version-comparison meaning; with no
policy, `updateInstallable === updateAvailable` (no behavior change for
users without a policy).

### Is detecting the user's min-age policy possible? Yes — partially.

Each package manager stores it differently, and the tools-server has no
reliable signal for *which* PM installed argent, so we probe every PM
present and take the **most restrictive** (largest) value. Erring toward
over-waiting is intentional: a slightly late reminder beats nagging
about a forbidden version.

| PM | Config key | Unit | Detect via | Min PM version |
|----|-----------|------|-----------|----------------|
| npm | `min-release-age` | days | `npm config get min-release-age` |
11.10.0 |
| pnpm | `minimumReleaseAge` | minutes | `pnpm config get
minimumReleaseAge` | 10.16 |
| yarn (berry) | `npmMinimalAgeGate` | minutes | `yarn config get
npmMinimalAgeGate` | 4.10 |
| bun | `minimumReleaseAge` (bunfig.toml `[install]`) | seconds | no
`config get` — not auto-probed | 1.3 |
| yarn classic | — (unsupported) | — | — | — |

Because bun has no `config get` for this and auto-detection is
best-effort, there's also an explicit override:

- **`ARGENT_MIN_RELEASE_AGE_DAYS`** — positive number of days; wins
outright and skips probing (useful for bun users, locked-down CI, or
pinning the value).

When a policy holds an update back, a single stderr line explains the
silence; no reminder reaches the agent.

## Conservative edge handling
- Network/registry failure → state unchanged (never crashes the server).
- Policy in effect but publish time missing/unparseable → treated as
**not** installable (stay silent rather than nag).
- PM probe errors / PM absent → treated as no policy (0).
- Probes shell out via PATH (`exec`, 3s timeout, constant commands only
— no caller input) so Windows `.cmd`/`.ps1` shims resolve.

## Tests
- `test/min-release-age.test.ts` (new): unit-level parsing, override
precedence, unit normalization, most-restrictive-wins, PM-absent.
- `test/update-checker.test.ts`: rewritten for the packument shape; new
cases for held-by-policy, aged-past-policy, unknown publish time, and
the disable flag (probe mocked so tests never spawn a real PM).
- `test/http-update-note.test.ts`: gate now keys off
`updateInstallable`; added a "available but held by policy → no note"
case.
- Full tool-server suite green (**732 tests**), `tsc --noEmit` and
`typecheck:tests` clean.

## Follow-up (not in this PR)
The env-var reference lives in the `argent-private` submodule
(`docs/environment-variables.md`), which is a separate repo — needs a
companion PR. Suggested rows for the tools-server table:

```
| `ARGENT_DISABLE_UPDATE_NOTIFICATIONS` | `"1"`/`"true"` or unset | disabled | When truthy, disables the update reminder entirely (no registry check, no note). |
| `ARGENT_MIN_RELEASE_AGE_DAYS`         | number                  | _(auto-detect)_ | Minimum age (days) a newer version must reach before it is advertised. Overrides auto-detection of the npm/pnpm/yarn minimum-release-age policy. |
```

## Open questions for review
- **Most-restrictive-across-PMs** vs. detecting a single PM — is "max
wins" the behavior we want, or should it follow `npm_config_user_agent`?
- Should an update held by policy stay fully silent (current), or emit a
softened "available, installable in ~N days" note?

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 17:15:12 +02:00
Ignacy Łątka f34ce22b9a fix(tool-server): persist update-note suppression across restarts (#273)
## Summary

`dismiss-update` (and the auto-suppression after delivering an update
note) only stored `suppressUntil` in a module-scoped variable, so the
dismissal evaporated the moment the tool-server process exited — which
the launcher does after 30 min of idleness, on machine reboot, and on
`npx @swmansion/argent update`. Users asking to silence the note for 24
h would see it reappear well within the window, contradicting the tool's
own description.

This PR persists `suppressUntil` to `~/.argent/update-suppression.json`
(alongside the existing `tool-server.json` / `tool-server.log`) and
reads it back on module init, so the dismissal survives restarts.

- `suppressUpdateNote` now writes through to disk and throws on write
failure (matches `dismiss-update`'s documented "Fails if … the
suppression state cannot be persisted").
- The HTTP auto-suppress call (after delivering a note alongside an
unrelated tool result) is wrapped in try/catch — a disk-write failure
there must not 500 the user's tool call. Worst case: the note repeats
next request.
- Missing/malformed/wrong-typed file all degrade to "no suppression" —
never crashes the server.

## Test plan

- [x] `npx vitest run test/update-checker.test.ts` — 19/19 pass (10
original + 9 new persistence tests).
- [x] `test/http-update-note.test.ts` + `test/http-tools-meta.test.ts` —
14/14 pass (mock update-checker; persistence is transparent).
- [x] Full `@argent/tool-server` suite — 726/727 pass. The single
failure (`test/android-binary.test.ts:73`, `/usr/bin/adb` resolution) is
pre-existing on `main` and unrelated.
- [x] `npm run typecheck:tests` clean, `npm run build` clean, `prettier
--check` clean.
- [x] Restart simulated via `vi.resetModules()` + re-import →
suppression loaded back from disk.
2026-06-01 15:18:12 +02:00
Hubert Gancarczyk de0dbbda66 feat(tools): implement screenshot-diff tool (#229)
## Summary

Adds a new `screenshot-diff` tool for comparing mobile screenshots
during UI QA and visual regression checks. The tool can compare two
saved PNGs, or compare one saved PNG against one live full-resolution
device capture, then returns a compact text summary and generated diff
artifacts.

## Context

Mobile UI validation often needs more than a single screenshot: small
layout, color, typography,
copy, clipping, and regression changes are easy to miss manually. This
feature gives agents a
deterministic before/after comparison path while keeping
`screenshot-diff` as supporting evidence.

## What Changed

- Registered a new `screenshot-diff` tool in the tool server.
- Supports saved PNG comparisons and one-sided live capture via
`captureBaseline` or
`captureCurrent`.
- Adds PNG diffing with fixed internal defaults, changed-region
detection, status-bar-band
ignoring, full-size diff output, and downscaled context diff output.
- Adds optional OCR/text analysis for moved, appeared, disappeared,
changed, and restyled text.
- Updates MCP content rendering to attach the downscaled context diff
image plus the text summary.

1. Capture a stable full-resolution baseline.
2. Perform an interaction or apply a UI change.
3. Compare the current screen against the baseline.
4. Review a summarized diff plus generated artifacts.
2026-06-01 15:04:25 +02:00
filip131311 05fb58552d chore: bump all package versions to 0.8.1 (#267)
## Summary
- Bump all 10 workspace packages to version `0.8.1` (`package.json` +
`package-lock.json`)
- Includes `native-devtools-android` (`0.7.1` → `0.8.1`), all others
from `0.8.0` → `0.8.1`
- `ui` package left at `0.0.1` (independent versioning)

## Test plan
- [ ] Verify `npm install` resolves cleanly
- [ ] Verify `argent --version` reports `0.8.1`
2026-05-27 16:37:17 +02:00
Filip131311 c6f4995cfe chore: bump argent-private submodule to latest main 2026-05-27 16:30:52 +02:00
Ignacy Łątka 90c6081204 fix: prevent returning empty screenshots (#261)
## What broke
`toMcpContent` base64-encoded whatever bytes the simulator-server URL
fetch returned and labelled them `image/png` with no checks. A 404 on
the `/media/...` endpoint (file missing) returns an empty body — that
became `data: ""` shipped as a PNG, which the model API rejects with
"Image could not be processed". Claude Code then loops on the error and
burns the session limit (cf. anthropics/claude-code#60334). Reproduces
directly:

```
$ curl -sI http://127.0.0.1:<port>/media/missing.png
HTTP/1.1 404 Not Found
content-length: 0
```

## What changed
One helper added: `fetchPngBytes(url)` returns the bytes only if:
- `fetch` resolves (catches ECONNREFUSED / DNS / abort)
- `res.ok` is true
- the buffer starts with the 8-byte PNG signature

If any check fails, `toMcpContent` emits a single text placeholder
instead of an image block. Diff: **+103 / −27** across `content.ts` +
tests.

## E2E verification
Tested against the real MCP stdio JSON-RPC interface (not just unit
tests):

**Happy path** — real Android emulator, real tool-server:
```
[1] initialize: argent v0.5.3
[3a] screenshot returned 2 block(s)
     [0] image: mime=image/png dataLen=66968 validPngSig=true
     [1] text: Saved: /var/folders/.../media/430131000-...png
HAPPY PATH overall: PASS
```

**Failure path** — mock tool-server returning a URL pointing at a closed
port:
```
mock on :56529
returned 1 block(s)
  [0] text: (Screenshot unavailable: no valid PNG at http://127.0.0.1:1/stale.png. Take a new screenshot.)
hasImage=false, hasPlaceholder=true
FAILURE PATH: PASS
```

## Test plan
- [x] `npx vitest run` — 1048/1048 pass (5 new regression tests in
`content.test.ts` cover 404, non-PNG body, fetch-throw, and the no-url
fallback)
- [x] `npm run build` — tsc clean
- [x] `npx prettier --check` — clean
- [x] Bundled MCP server rebuilt and driven via real JSON-RPC stdio
against (a) a real Android screenshot and (b) a mock tool-server
reproducing the bug

---------

Co-authored-by: Filip131311 <contact@filipkaminski.com>
2026-05-27 16:25:08 +02:00
Ignacy Łątka 324d8c6049 fix(android): stop forcing cold boot on every emulator launch (#251)
## Summary

`hasDefaultBootSnapshot` enforced a 60s mtime-skew guard between
`snapshot.pb` and `ram.bin`. In practice the emulator updates
`snapshot.pb` metadata on every load (load count, last-loaded timestamp)
even with `-no-snapshot-save`, so the two mtimes drift apart by hours or
days after a few hot-boot sessions and the guard rejects every valid
snapshot — forcing a cold boot every time.

The bug hits macOS too; Linux just makes it painful because cold boot is
slow there. Confirmed against my own `Pixel_6_API_34`, whose
`snapshot.pb` is 4 days newer than `ram.bin` while the snapshot itself
is fully usable.

## What changed

- **Drop the mtime-skew guard.** `-check-snapshot-loadable` + the
hot-boot spawn's `-force-snapshot-load` already make truncated / corrupt
`ram.bin` fail loudly via `attemptBoot`'s `earlyExitError` race. The
on-disk pre-check only needs to confirm both files exist and `ram.bin`
is non-empty.
- **Resolve the `.avd` folder via the AVD's `.ini` `path=` line**,
matching what the emulator binary itself does
(`external/qemu/android/android-emu/android/avd/util.cpp`).
Snap-installed Studio on Linux and users who relocate AVDs to faster
disks both put `.avd` outside the convention root, where the old
`<root>/<name>.avd` assumption silently failed.
- **Honor `$ANDROID_USER_HOME`, `$XDG_CONFIG_HOME`,
`$ANDROID_SDK_HOME`** in priority order alongside `$ANDROID_AVD_HOME`
and `$HOME/.android/avd`, mirroring the emulator binary's own lookup
precedence.
- **Reject whitespace-only and relative `path=` values.** The regex's
non-greedy `(.+?)` still captures a single space for `path= `, which
would have leaked past `if (!avdPath)` as truthy; a relative `path=`
would have resolved against `process.cwd()` and silently mis-located
snapshots.

## Test plan

- [x] 18 new unit tests in `avd-snapshot.test.ts` covering: priority
order across all five env-var candidates, relocated AVDs, drifted mtimes
(4-day skew explicitly regression-tested), zero-length / missing /
directory-shaped files, whitespace-only paths, relative paths,
bad-then-good fallthrough.
- [x] Full `npm run test -w @argent/tool-server` — 703 passing.
- [x] `npm run typecheck:tests -w @argent/tool-server` — clean.
- [x] `npm run build` from workspace root — clean.
- [x] `npx prettier --check` on changed files — clean.
- [x] End-to-end repro against a real local AVD: drifted
`Pixel_3a_API_34`'s `snapshot.pb` mtime to 5 days newer than `ram.bin`
(the exact pathology in the wild), drove `boot-device` through this
branch's built code, observed `qemu-system-aarch64 ...
-force-snapshot-load -no-snapshot-save` in the spawned process — i.e.
the hot-boot path was taken with a snapshot the old check would have
rejected.

## Risk

Pre-check is now more lenient on disk shape. If `ram.bin` is non-empty
but truncated/corrupt, the hot-boot spawn still catches it via
`-force-snapshot-load` (loud early child-exit) and `attemptBoot` falls
back to cold boot. So the worst case has not regressed; it just surfaces
at spawn-time instead of pre-spawn.
2026-05-27 16:24:31 +02:00
Ignacy Łątka 11df817616 test: drop real-time timeouts from the slowest unit tests (#236)
The slowest unit tests were waiting on real wall-clock timers, not doing
work. This makes them deterministic and fast with **no production
behavior change and no production test seams**.

## Speedup

**Suite-level**

| Scope | Before | After | Faster |
|---|---|---|---|
| tool-server — wall clock | 2.72s | ~1.3s | ~2× |
| tool-server — aggregate test time | 9.70s | 4.03s | 2.4× |
| argent-mcp — aggregate test time | 0.90s | 0.28s | 3.2× |

**Per file**

| File | Before | After | Faster |
|---|---|---|---|
| simulator-server-blueprint.test.ts | 2.55s | 0.05s | ~50× |
| boot-device-hardening.test.ts | 2.17s | 0.18s | 12× |
| boot-device-spawn-error.test.ts | 1.11s | 0.005s | ~200× |
| boot-device-hotboot.test.ts | 1.01s | 0.06s | 16× |
| fetch-timeout.test.ts | 0.81s | 0.18s | 4.5× |
| flow-tools.test.ts (delayMs test) | 0.31s | 0.04s | ~7× |

**Worst individual tests, before → after**

| Test | Before | After |
|---|---|---|
| rejects the boot promise…when spawn emits error | 1.06s | ~1ms |
| reports the emulator crash error… | 1.00s | ~2ms |
| falls back to cold boot when hot-boot child exits early | 1.00s | ~1ms
|
| reports a signal-terminated emulator (SIGSEGV)… | 1.00s | ~1ms |
| 5× simulator-server-blueprint tests | ~0.50s ea | ~0.001–0.02s ea |
| throws after retries when POST never responds | 0.60s | 0.14s |
| sleeps the step's delayMs | 0.31s | 0.04s |

No test exceeds ~0.7s anymore (the slowest,
`workspace-reader-integration`, is genuine subprocess I/O — `node
--version` etc. — left as-is on purpose; it is not a timeout).

## What changed

- **`boot-device.ts` (only production change):** the three hardcoded
poll cadences are extracted into a single immutable
`BOOT_POLL_INTERVALS_MS` constant (values unchanged: 1500/1000/500ms)
and referenced at the four `setTimeout` sites. Pure readability
extraction — **no mutable state, no exported test hooks, no runtime
behavior change.**
- **Boot-device tests:** the timing-sensitive tests now use vitest fake
timers (`vi.useFakeTimers()` + `vi.advanceTimersByTimeAsync`).
`boot-device-hardening` / `-spawn-error` add the same partial
`vi.mock("../src/utils/adb")` already used by
`boot-device-hotboot.test.ts`, so pre-flight does no real filesystem I/O
and the timers drive deterministically.
- **`simulator-server-blueprint.test.ts`:** `signalReady` now emits
`stream_ready` then `api_ready` (matching the real streaming server's
ordering) so the blueprint resolves immediately instead of waiting out
the 500ms `STREAM_GRACE_MS` fallback. Adds a dedicated fake-timer test
that explicitly covers the non-streaming grace path (coverage gained,
not lost).
- **`fetch-timeout.test.ts` / `flow-tools.test.ts`:** smaller timing
knobs; same behaviors asserted.

## No regressions

- 1011/1011 tests pass (tool-server 656, argent-mcp 53, argent 6,
registry 64, tools-client 1, installer 231); `tsc --build` and Prettier
clean. The converted boot-device, simulator-server-blueprint, and
flow-tools tests are deterministic under fake timers (verified across
stress runs).
- No production behavior change; `simulator-server.ts` is byte-identical
to `main`; net +1 test.
- Heads-up: `argent-mcp/test/fetch-timeout.test.ts` has a
**pre-existing** macOS flake (~10–20% rate; `connect EADDRNOTAVAIL
127.0.0.1:<port> - Local (0.0.0.0:0)` — kernel source-port exhaustion
under rapid-fire loopback fetches). Stress-testing showed it on `main`'s
pristine file too (2/10 failures vs 1/10 on this branch), so this PR
does not introduce or worsen it. Fixing it (e.g., EADDRNOTAVAIL retry,
or moving the test off real loopback fetch) is worth a follow-up.
2026-05-27 16:24:07 +02:00
Juliusz Wajgelt 18cf0f12cd chore(deps): bump zod to v4 (#265) 2026-05-27 13:47:26 +02:00
Ignacy Łątka 8d17dc9b4b fix(init): stop cramming homedir into mcp config (#257)
`argent init` was writing `/Users/<username>/.argent/mcp-calls.log` into
every generated MCP config (Cursor, Claude Code, VS Code, Windsurf, Zed,
Gemini, Codex, Hermes, opencode). Those files get committed and shared,
so collaborators ended up with the original author's username pinned
into their environment.

Drop the env var from `buildMcpEntry` and have each adapter omit the
`env`/`environment` field when the entry carries no env vars. The MCP
server already falls back to `${homedir()}/.argent/mcp-calls.log` at
runtime, so logging keeps working for every user on their own machine.

Fixes #238
2026-05-27 12:27:36 +02:00
Paweł Fornagiel a96f1f1578 feat: refactor android describe to not rely on uiautomator (#237)
## Summary

- `formatDescribeTree` was branching on `source === "uiautomator"` to
pick nested rendering; the newer `android-devtools` source fell through
to flat mode and lost every node below depth 1. Extends the predicate to
cover both Android sources so the helper-service path renders with the
same hierarchy as the legacy `uiautomator dump` path.
- Bumps `packages/argent-private` to pick up the persistent Android
describe service that emits `source: "android-devtools"`, plus its
build-and-sign CI workflow.
- Wires the Android helper APK + `manifest.json` into the published
`@swmansion/argent` package via `bundle-tools.cjs`, and adds
`pack:mcp:local` / `build:android-binaries` so a local tarball can be
produced without going through `download:native-binaries`.
- Minor: tightens a couple of comments in `android-helper-install.ts`
and `format-tree.ts`.

This is connected to argent-private PR:
https://github.com/software-mansion/argent-private/pull/8
2026-05-27 11:46:12 +02:00
Paweł Fornagiel 5b147e9c25 fix: profiler attribution on older React DevTools backends (#262)
## Summary
- Resolve profiler display-name lookup across both
`getDisplayNameForElementID` and legacy `getDisplayNameForFiberID`
backends.
- Add a fallback diagnostic (`displayNameApiAvailable` /
`unattributed_reason`) so fully-unattributed captures are reported as an
API-surface mismatch instead of a transient lifecycle race.
- Extend multi-renderer script coverage for modern, legacy, mixed, and
missing display-name accessor scenarios.
2026-05-27 10:57:45 +02:00
filip131311 d091421e3a fix(describe): unlock iOS 26.5 SpringBoard-hosted dialogs via IgnoreAXServerEntitlements (#244)
## Summary

iOS 26.5 tightened cross-process AX access: SpringBoard's AX server
rejects MIG queries from non-UIApplication clients with kAXError
\`-25215\`. Our \`ax-service\` daemon is a \`simctl spawn\`-launched CLI
binary, so it fails the new check, and \`describe\` returns an empty
ROOT for any SpringBoard-hosted dialog — most visibly the TCC permission
prompts (\`Allow X to use your location?\`, camera, contacts, photos,
microphone, notifications). On iOS 26.4 the same daemon code works fine.

Apple ships a debug switch in \`AccessibilityUtilities\` for exactly
this case: the \`com.apple.Accessibility/IgnoreAXServerEntitlements\`
preference. When set, the server skips the entitlement check, and our
existing \`-[AXElement explorerElements]\` walk returns the dialog tree
as expected.

This PR wires the pref next to the existing \`AutomationEnabled\` write
in \`ensureAutomationEnabled\`, plus a one-time SpringBoard kickstart so
SB re-reads its AX-server config. The pref persists on the sim's disk,
so subsequent ax-service spawns detect it's already set and skip the
kickstart — avoiding the disruptive app-icon reflow on every argent run.

## Before / after on iOS 26.5

**Before** (raw socket dump for the Maps location prompt):
\`\`\`
{ \"alertVisible\": true, \"elements\": [], \"systemElements\": [], ...
}
\`\`\`
→ describe returns \`source: ax-service ROOT AXGroup\` with no children.

**After**:
\`\`\`
Source: ax-service
ROOT  AXGroup (0.000, 0.000, 1.000, 1.000)
  AXStaticText \"Allow 'Maps' to use your location?\"
AXStaticText \"Your location is used to show your position on the map,
...\"
  AXButton \"Allow Once\"
  AXButton \"Allow While Using App\"
  AXButton \"Don't Allow\"
  ...
\`\`\`

Matches iOS 26.4 behaviour. Same daemon binary, no entitlement claims,
no resigning.

## What I changed

Single file: \`packages/tool-server/src/blueprints/ax-service.ts\` —
added \~50 lines to \`ensureAutomationEnabled\` that:

1. Read the current value of \`IgnoreAXServerEntitlements\` from
\`com.apple.Accessibility\`.
2. If it isn't \`1\` already: \`defaults write\` it true, then
\`launchctl kickstart -k system/com.apple.SpringBoard\` so SB re-reads.
3. \`kickstart\` emits a warning about user/foreground service
identifier on iOS 26 — tolerated with \`.catch(() => undefined)\`; the
restart still happens.

## Cost characterisation

| Scenario | Cost |
|---|---|
| Fresh sim never touched by argent | One SpringBoard restart (~3-5s,
apps reflow, status bar blinks) |
| Sim with the pref already on disk (post-first-run, post-reboot, etc.)
| Zero — just one \`defaults read\` |
| Sim shut down when argent first targets it | Pref is written, SB picks
it up on next boot — no restart needed |
| iOS < 26.5 sim | Pref-write is harmless;
\`_shouldValidateEntitlements\` doesn't read it on older versions |

The kickstart fires at most once per sim, ever. The pref is per-sim in
CoreSimulator data; \`simctl erase\` wipes it (next run sets it again).

## Background — why this is the right layer

Briefly investigated:

- **Daemon code-path tweaks** (enumerating
\`currentApplicationsIgnoringSiri\`, fresh
\`_AXUIElementCreateAppElementWithPid\`, etc.) — all hit the same
server-side gate and return \`-25215\`. Cf. closed
software-mansion/argent-private#10 and software-mansion/argent#243.
- **Sign daemon with private AX entitlements** — sim AMFI rejects
(\`Security policy issue\` 163) for anything stricter than what we
already have.
- **Re-host daemon as UIApplication** — would pass the new check but is
a substantial refactor (Info.plist, scene config, background-task
management for the socket server, install/launch instead of spawn). Not
worth it when a one-line debug-pref unlocks the same outcome.

The IgnoreAXServerEntitlements path is the only one that's small,
contained, and reversible.

## Test plan

- [x] \`vitest run\` in tool-server passes (175/175)
- [x] \`tsc --noEmit\` clean on tool-server
- [x] Manual verify on iPhone Air iOS 26.5: Maps location prompt now
returns 5 AX elements from \`describe\` with \`source: ax-service\`
- [x] Manual verify on iPhone 17 Pro iOS 26.4: still works (pref-write
is a no-op for the gating logic on that version)
- [ ] CI / unit coverage for the new conditional kickstart path

## Files

- \`packages/tool-server/src/blueprints/ax-service.ts\` — extended
\`ensureAutomationEnabled\`
2026-05-26 17:00:13 +02:00
filip131311 69538e74a3 fix(ax-service): remove unnecessary AutomationEnabled pref write (#256)
## Summary
- Removes the `ensureAutomationEnabled` function and all its call sites
- The ax-service binary uses `AXRuntime`/`AXSpringBoardServer` private
frameworks directly and does not need the `AutomationEnabled` preference
on iOS 26.x simulators
- Writing it pre-boot into the Accessibility plist actively breaks the
AX subsystem by interfering with `cfprefsd` domain initialization,
causing `describe` queries to hang

## Investigation

Tested on erased sims across five device types (iPhone 17e, iPhone 17,
iPhone Air, iPad mini, iPad Pro 11-inch) on iOS 26.5 (runtime `23F77`):

1. **No plist at all** → `describe` returns full hierarchy including
SpringBoard-hosted TCC dialogs ✅
2. **Empty plist** → works ✅
3. **Plist with `AutomationEnabled=true`** → `describe` hangs
indefinitely ❌

The pref was originally added in #98 (April 2026) likely cargo-culted
from older iOS automation patterns. On iOS 26.x the
`AXSpringBoardServer` MIG interface accepts connections from the
ax-service binary without it.

## Test plan
- [x] TypeScript clean
- [x] All affected tests pass (simulator-server-blueprint,
describe-missing-xcrun-424)
- [x] E2E: erased sim → boot → launch Maps → `describe` returns TCC
dialog with all buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-26 10:07:04 +02:00
filip131311 09a18a65ef bump to v0.8.0 (#245) 2026-05-21 17:46:28 +02:00
Ignacy Łątka 2180dd8e6a ci: typecheck test files in CI (#202)
We've had lots of type errors in our tests, scripts and other non-core
code despite all CI being green. This PR fixes that.

This PR also removes some redundant features like `@ts-check` inline
directives which have been replaced by proper ts config files.

<details>

## AI Summary

Audit of every CI workflow against every package's `package.json` and
`tsconfig` surfaced several gaps where CI did partial coverage. This PR
closes them and fixes every pre-existing type error the new gates
surfaced.

## Gaps closed

| # | Gap | Fix |
|---|---|---|
| 1 | `tsc --build` only covers `src/**`; test files (`test/**`,
`tests/**`) never typecheck. Vitest transforms tests with esbuild —
strips types instead of checking them. | New `tsconfig.test.json` per
test-bearing package (`composite: false`, `noEmit`, `rootDir: "."`,
includes `src/**` + `test/**` (or `tests/**`) + `vitest.config.ts`). New
`typecheck:tests` npm script per package. New CI step `npm run
typecheck:tests --workspaces --if-present`. |
| 2 | Build/publish scripts (`scripts/*.{cjs,mjs}`,
`packages/argent/scripts/*.cjs`, `packages/skills/scripts/install.js`) —
JS, never typechecked. `sync-readme.cjs` runs in `prepack`;
`postinstall.cjs` runs on every install; `bundle-tools.cjs` is
build-critical. A typo only surfaces at runtime. | Root
`tsconfig.scripts.json` with `allowJs` + `checkJs` + `noImplicitReturns`
covers all script paths. Root `typecheck:scripts` npm script. New CI
step. Per-file `// @ts-check` comments removed — tsconfig drives
inclusion. |
| 3 | `argent-cli` had no `typecheck:tests` script. When tests get added
there they'd silently bypass the gate. | Added `tsconfig.test.json` +
`typecheck:tests` script. `--if-present` would have skipped it anyway,
so this is future-proofing. |
| 4 | Local `prettier --check .` walked into the `argent-private`
submodule and reported violations there. CI didn't see this because
`actions/checkout@v4` doesn't init submodules — so local `prettier
--check` did not match CI behavior. | New `.prettierignore` excludes the
submodule plus `dist/`, `node_modules/`, lock files, `tsbuildinfo`.
No-op in CI; aligns local with remote. |

## Test type errors fixed

These were already present. The `npm test` passed because esbuild
stripped the offending types:

- **argent-installer**: widen `scope` literal type for
dead-code-elimination guard; non-null `addAllowlist!`/`removeAllowlist!`
(optional methods on adapter).
- **argent-mcp**: add `.js` extensions for NodeNext module resolution.
- **registry**: broaden `StaticBlueprintResult` blueprint api from `{
id; deps? }` to `Record<string, unknown>` so existing tests can pass
arbitrary api shapes.
- **tool-server**:
- non-null `zodSchema!` on tool defs that always carry one
(`launchAppTool`, `restartAppTool`, etc.)
- cast sentinel `"ignored"` strings to `DeviceInfo` in factory-rejection
tests
- match the 4-arg `dispatchByPlatform<IosServices, AndroidServices,
Params, Result>` signature (tests still passed the old 3-arg shape)
- `assertFlowRunResult` type guard for the `FlowRunResult |
FlowPrerequisiteNotice` union before `.steps` access
- swap broken `typeof import("supertest").default` for static `import
supertest` (supertest is `export = supertest`)
- fill in the 4 fields missing from a `NativeProfilerSessionApi` mock
(`xctraceProcess`, `recordingTimedOut`, `recordingExitedUnexpectedly`,
`lastExitInfo`)
- cast a vitest `Mock` to a plain function at one direct call site where
the `Procedure | Constructable` union loses callability
- fix `update-checker.test.ts` reading `../../package.json` (off-by-one
— went past tool-server; was relying on a Vite resolver quirk)
- replace one cross-package `../../../registry/src/index` import with
`@argent/registry` so registry's private `services` field isn't compared
between src and dist declarations

All fixes are type-level. Full `npm test --workspaces --if-present`
still reports **991 passed across 87 test files**.

</details>
2026-05-20 17:03:34 +02:00
filip131311 7f1c78104b fix(describe): surface dialogs hosted by remote-view services (#240)
## Summary

Bumps the \`argent-private\` submodule to pull in the ax-service daemon
fix in
[software-mansion/argent-private#9](https://github.com/software-mansion/argent-private/pull/9).
The radon-lite side of this PR is just the submodule pointer — the
actual source change is one if-statement in \`ax_service.m\`.

\`describe\` returns nothing when a \`_UIRemoteViewController\`-hosted
overlay is on top of the foreground app — most visibly, the
\`StoreKitUISceneService\` Apple ID auth sheet from the reporter's case.
Root cause: \`+[AXElement primaryApp]\` returns nil while the
remote-view service is frontmost, so the daemon never reaches
\`collectExplorerElements\`. The submodule PR adds a single fallback:
when \`primaryApp\` is nil, look up the AXElement via \`+[AXElement
applicationAtCoordinate:<screen-centre>]\` instead, which returns the
*hosting* app's AXElement regardless of which process owns the frontmost
view. AX bridges the XPC boundary, so the hosting app's
\`explorerElements\` walk surfaces the remote-view's content in the same
flat-leaf shape the daemon already emits.

## Why this PR vs. #234

I closed [#234](https://github.com/software-mansion/argent/pull/234)
after empirical reproduction showed the TS-side \`systemElements\`
fallback there had no effect on the reporter's case — the daemon's
\`isSystemAppShowingAnAlert\` gate doesn't fire for XPC-hosted sheets
either, so \`systemElements\` was empty too. The reachability gap was at
the daemon layer (\`primaryApp\` selector returning nil), not the TS
adapter. This PR addresses that root cause.

## Empirical verification

Built the patched daemon + ran against the reporter's exact trigger on
an erased iOS 26.5 sim (News → "Get News+" → "Get Started" → "Apple
Account Sign in Requested" sheet):

- **Old daemon** (md5 \`d5240e8…\`, currently shipped): raw describe
socket returned \`{ "alertVisible": false, "screenFrame": {…} }\` — no
\`elements\` field, agent has no labels.
- **Patched daemon** (md5 \`75fdffcc…\`, dev build): 42 elements, all
dialog content labelled: heading "Apple Account Sign in Requested",
email field, "Forgot password?", **"Sign In" at normalized (0.500,
0.414)**, **"Cancel" at (0.500, 0.482)**, plus 37 keyboard keys.

The agent can now tap by label.

## Risk

- No wire-format change, no TS/adapter change. \`@argent/tool-server\`
and the describe handler consume the same shape they did before.
- Fallback gates on \`primaryApp == nil\`, so every working case stays
on its existing path.
- If \`applicationAtCoordinate:\` also returns nil at the centre, the
daemon emits no \`elements\` — same as current behaviour. The existing
native-devtools fallback in \`describeIos\` still kicks in afterwards.

## Open items before un-drafting

- [ ] Merge https://github.com/software-mansion/argent-private/pull/9
(the source change lives there)
- [ ] Publish a new \`argent-main\` build to
\`software-mansion-labs/argent-private-releases\` so \`npm run
download:native-binaries\` picks up the patched \`bin/ax-service\`
- [ ] Re-verify \`argent run describe\` end-to-end against the same
dialog with the released binary
- [ ] One regression-style smoke test: an in-process dialog (e.g. Wallet
location prompt) to confirm the existing \`primaryApp\` path still fires
for the unchanged cases

## Notes

- Daemon binary lives in \`packages/native-devtools-ios/bin/ax-service\`
but is \`.gitignore\`d; the release pipeline supplies it. This PR cannot
ship the new binary in-repo for the same reason.
- I empirically introspected the AXRuntime surface inside the simulator
before committing to this approach — \`+systemWideElement\` and
\`+systemEventDispatchElement\` segfault from a non-UI process, so
coordinate lookup is the practical option that doesn't require a
daemon-process redesign.


Fixes #207
2026-05-20 15:46:24 +02:00
Ignacy Łątka 2977d2ca77 fix(describe): bump argent-private with WKWebView describe fix (#157)
The native ax-service now recurses into `primaryApp.children` beyond the
flat `explorerElements` pass, so WKWebView web content (labels from the
WebContent process) is surfaced alongside the host app's VoiceOver
targets.

Details: https://github.com/software-mansion/argent-private/pull/7
2026-05-20 13:19:24 +02:00
Paweł Fornagiel 4255b0bd66 fix: stop simctl spawn loop on iOS init failure (#233)
Fixes #210 — `argent mcp` pinning CPU to 100% and spawning hundreds of
`simctl` processes that survive `kill -9`.

## The problems

1. **No retry cap.** The simulator watcher polls every 10s; on any
failure it forgets the UDID and re-classifies it as "new" on the next
tick. Each retry runs 4–5 `xcrun simctl spawn` calls in series. A
persistently failing simulator produces an unbounded loop — hundreds of
`entered ERROR state` log lines and a steady process pile-up.

2. **No timeouts on `simctl spawn`.** A degraded `CoreSimulatorService`
makes simctl block forever. The factory `await`s the hung call, never
rejects, never retries, but the stuck child process stays alive.

## How this fixes it

- **Bounded retries with terminal "given up" state.** Each
`NativeDevtools` service instance now tracks `{attempts, lastError,
givenUp}`. After 3 consecutive init failures the service stops trying
and reports `init_failed` to any caller. The watcher keeps polling but
skips UDIDs in the given-up state.

- **10s timeout on every `simctl spawn` call** in `ensureEnv`,
`ensureAccessibilityEnabled`, and `ensureAutomationEnabled`. Healthy CSS
responds in ~300ms; 10s is well above any plausible legit latency and
well below "hung forever." A hung simctl now rejects cleanly, increments
the attempt counter, and feeds the bounded retry instead of accumulating
children.

- **Tools surface `init_failed` as a typed result** instead of throwing.
`launch-app`, `boot-device`, `native-devtools-status`, and the rest of
the native tools now return a structured error with the failure reason
and a hint to restart the simulator, instead of throwing an opaque
exception.

- **Factory no longer throws on first env-init failure.** Previously a
single failed `simctl` call during factory creation leaked the socket
server. The factory now tolerates init failure; the watcher's bounded
retry handles the recovery.

Verified on live simulator and against test simulating `simctl`
timeouts. Happy path works as before, `restart-needed` is still
signalled and the new failure path works as well.
2026-05-20 08:56:33 +02:00
Ignacy Łątka fbe7b087e7 chore(deps): batch-apply Dependabot updates (#214–#227) (#228)
This PR consolidates every open Dependabot PR that requires **no
source-code changes** into a single branch with one regenerated,
coherent lockfile.

| PR(s) | Update | Status |
|---|---|---|
| #214 | GitHub Actions group — checkout@v6, setup-node@v6,
setup-python@v6, github-script@v9, upload-artifact@v7 | ✅ Applied |
| #215 | prettier 3.8.3, @clack/prompts 1.4.0, semver 7.8.0, yaml 2.9.0,
@modelcontextprotocol/sdk 1.29.0, esbuild 0.28.0 | ✅ Applied |
| #216 / #221 / #224 / #227 | typescript 6.0.3 | ✅ Applied |
| #217 / #222 / #223 | @types/node 25.9.0 | ✅ Applied |
| #220 / #226 | vitest 4.1.6 | ✅ Applied |
| #218 / #225 | zod 4 | ⏸️ Excluded — requires code migration |
| #219 | express 5 | ⏸️ Excluded — requires code migration |
| #215 (partial) | tree-sitter 0.25.0 | ⏸️ Excluded — see below |

**tree-sitter 0.25.0** ships no prebuilt binaries and its `binding.gyp`
hard-codes `-std=c++17`, which fails to compile against Node 20+/24
C++20 `cppgc` headers — a hard `npm install` blocker (would also break
CI). Kept at `^0.21.1`; all other #215 updates applied.

zod 4 and express 5 are excluded here because they require real source
changes; tracked separately.

Verified: `tsc --build` clean, esbuild bundles, **1010 unit tests
pass**, **zero source files modified**.
2026-05-19 12:57:07 +02:00
Paweł Fornagiel 896e775e01 Fetch argent skills from GitHub for portable lockfiles (#230)
### Description

Install and sync argent skills from a GitHub-pinned source
(`software-mansion/argent/packages/skills/skills#v<version>`) instead of
the local bundle when possible, so `skills-lock.json` stays portable
across machines. Falls back to the bundled skills directory when offline
or the version is unknown.

### Fixes #208
2026-05-19 12:31:40 +02:00
Ignacy Łątka 95d2ffa0e4 fix(skills): remove iOS bias from skill/tool docs (#212)
We've had multiple instances of iOS-biased references in our skills.
This PR makes these references platform-agnostic.
 
<details>

## Summary

After Android emulator support landed (#148), several skill docs and
tool descriptions still treated iOS as the only/default platform. A QA
reported that asking the agent to "test … using argent" makes it spin up
an iOS simulator even when an Android emulator is already running.

These are **minimal de-biasing edits** — mostly one-word `simulator` →
`device` swaps. Android is **not** promoted above iOS; the incidental
iOS bias is just removed where it was unnecessary. Genuinely iOS-only
tools (`paste`, `native-*`, `native-profiler-*`) are left untouched
since the capability gate already rejects them on Android.

## Changes (14 files, +43/-29)

- **`argent.md` rule**: one-line `PLATFORM DETECTION` note (call
`list-devices` first, don't default to iOS); `simulator actions` →
`device actions`; mention `adb` next to `xcrun` in the escape-hatch
bullet. Routing order unchanged (iOS still listed first).
- **Skill docs**: `native iOS app navigation` → `app navigation`;
`simulator` → `device` where the action is platform-neutral; added the
missing `argent-android-emulator-setup` row to the RN-workflow Related
Skills table; `argent-ios-simulator-setup` description scoped to iOS.
- **`flow-run` / `flow-start-recording`**: `simulator interactions` →
`device interactions`.
- **`network-logs` / `network-request`**: `device_id` description `iOS
Simulator UDID` → `Device UDID`.
- **`list-devices`**: merged result now sorts by readiness so a booted
Android emulator is not buried beneath idle iOS simulators (the only
logic change).

## Test plan

- [x] `npx tsc --build` clean
- [x] `@argent/tool-server` 478 tests pass; `@argent/mcp` 51;
`@argent/installer` 231; `@argent/registry` 57
- [x] `prettier --check` clean
- [ ] Sanity-check that an agent with only an Android emulator booted no
longer reaches for an iOS simulator

</details>
2026-05-19 11:57:42 +02:00
filip131311 94fa867818 feat(describe): render tree as text to cut token cost (#197)
## Summary
- Move the JSON tree out of the describe tool's response and add an
in-between layer (`format-tree.ts`) that converts it to a sectioned text
rendering. On a typical iOS screen the new `description` field is ~6×
cheaper on the wire than the previous nested tree once braces, keys, and
indentation are counted.
- Public response shape becomes `{ description, source, should_restart?
}`. All existing pruning (Android v2 trimmer in `uiautomator-parser.ts`,
iOS frame clamp in `ios-ax-adapter.ts`) is untouched — the new layer
only re-presents what the platform adapters already produced.
- Header now spells out the coordinate space and the tap-point formula
so an agent can act on a frame without further docs.

## How it renders
Two modes picked automatically from the tree shape:
- **flat** (iOS ax-service): bucket children by `frame.y` into Top bar /
Body / Composer / Bottom; split the bottom zone into keyboard rows by
rounded y and classify each row (letters / shift / return /
globe-dictate).
- **nested** (Android uiautomator, iOS native-devtools fallback):
iterative DFS with depth indentation, preserving `clickable` /
`scrollable` / `scrollHidden` / `disabled` / `password` flags inline.

Example (Messages with keyboard up):
```
Source: ax-service
Mode: flat
Coordinates are normalized [0,1] fractions of the screen (x, y, width, height), not pixels — pass them straight to gesture-tap / gesture-swipe / gesture-pinch, which expect this same space. To tap an element, use its centre: tap_x = frame.x + frame.width / 2, tap_y = frame.y + frame.height / 2.

ROOT  AXGroup (0.000, 0.000, 1.000, 1.000)

— Top bar —
  AXButton     "Messages"  (0.040, 0.071, 0.109, 0.050)
  AXButton     "+1 (888) 555-1212"  (0.267, 0.134, 0.466, 0.037)

— Body / content —
  AXStaticText "iMessage  Encrypted"  (0.040, 0.156, 0.920, 0.030)
  ...

— Bottom row 1: letters —
  AXImage      "Q"  (0.012, 0.675, 0.098, 0.062)
  ...
```

## Files
- `packages/tool-server/src/tools/describe/contract.ts` — split into
`DescribeTreeData` (internal) and `DescribeResult` (public)
- `packages/tool-server/src/tools/describe/index.ts` —
`withDescription(data)` wrapper applied to both platform handlers
- `packages/tool-server/src/tools/describe/format-tree.ts` — the
renderer (new)
-
`packages/tool-server/src/tools/describe/platforms/{ios,android}/index.ts`
— return `DescribeTreeData`
- `packages/tool-server/test/describe-tool.test.ts` — assertions
migrated to `result.description` / `result.source` /
`result.should_restart`
- `packages/tool-server/test/describe-format-tree.test.ts` — renderer
unit tests (new): flat zoning, nested indentation + flags,
embedded-newline escaping, empty tree

## Test plan
- [x] `npm test` in `packages/tool-server` — 49 files / 482 tests
passing locally
- [x] `tsc --build` at workspace root — clean
- [x] Live MCP sanity check: `argent run describe --udid <booted>
--json` returns the new shape; tested against Messages (full keyboard
surface), Safari (chrome-only, expected), and a minimal in-process
WKWebView host. Existing pruning behaviour unchanged in every case.
2026-05-19 11:54:13 +02:00