The motion oracle borrowed screenshot-diff's DEFAULT_THRESHOLD, a value
sized for a baseline held across sessions and machines and so obliged to
absorb real drift. This comparison holds two captures one poll apart
from a single live session, where a static screen reads back
byte-identical: both capture paths are lossless PNG and the downscale is
deterministic. The borrowed value put the gate at ~25.5 levels/channel,
which mattered because spatially uniform change is all-or-nothing —
every pixel moves by the same amount, so it clears the gate on all of
them or on none, and MOTION_FRACTION is never the deciding term. A
cross-fade slower than ~1.4s therefore counted zero pixels and reported
settled mid-animation, over a screen still painted and still
hit-testing; and being settled rather than timed-out, degradedReason
stayed silent while updateBaselines could adopt a mid-fade frame as
golden.
0.03 (~7.6 levels/channel) keeps a margin over the +5/channel drift the
tests treat as the noise floor and moves that cliff out ~3.3x, to ~4.5s
at full contrast. It stays a rate floor rather than a duration floor, so
the comment now names the gate as the only term bounding what is left.
This supersedes the parity pin below it: coordinated drift is covered
instead by pinning the value and the direction, so restoring parity
trips a test rather than silently re-widening the blind spot. The
+25/channel case gives way to a two-sided bracket at +7/+8, and two
samples of a 2s indigo-over-white dismissal — a distance deliberately
between the new gate and the old — pin the regression itself.
The parity pin stopped the two constants drifting apart but not
together: the behavioral tests bracket the tolerance only loosely from
above, so raising both flow-pixels' PIXEL_THRESHOLD and screenshot-diff's
DEFAULT_THRESHOLD to 0.2 widened the per-pixel gate — the whole motion
oracle — with the suite still green. Assert the shared value alongside
the mirror.
Dropping `&& settled.converged` from either arm of the crop settle
mapper left the suite green: without it a crop snapshot whose settle
never converged reports clean on the skipped arm, and swaps the
timed-out note for an unavailable one on the pixel-dark arm — blaming
a missing backend for a screen that simply never stopped moving. Both
shapes are now driven through the real settle and frame resolution — a
churning hierarchy that never matches two fingerprints, and a dark
capture whose sticky verdict survives a restart — with the full reason
strings, read counts, and capture counts asserted, so each mutation
fails exactly its own test.
The settle-timeout catch's timed-out half — the only report that a
snapshot was captured while the hierarchy source never answered — was
pinned by nothing: collapsing the catch to settled left the suite
green while an update-baselines run adopted a screen never proven
still under a bare, healthy-looking write. The new case hangs every
hierarchy read, drives the real settle to its typed timeout at exactly
the action deadline, and pins the full degraded reason, the elapsed
budget, the untouched capture backend, and the written file — leaving
both halves of the ternary independently load-bearing.
The latch's timed-out override and its outage catch were pinned, but
the in-loop guard covering the remaining pixel-dark shortfalls —
unavailable, and skipped without convergence — was held by nothing:
widening it to every verdict left the suite green while a snapshot
whose stillness was already proven picked up a spurious unavailable
note. The new case drives the real machinery through a stale-but-
settled first round and four completed pixel-dark freshness retries,
asserting the exact undegraded write plus the 15-read/6-capture shape;
the mutant fails on the suffix and the counts alike.
The revalidation read is the combined settle's newest await point and
its abort exit had no direct coverage — every abort case landed on a
different read. Fabricating a converged-and-fresh result there turned a
cancelled run into a dispatched gesture recorded as a pass, with the
raw-coordinate shape uniquely exposed: it accepts settled pixels
without consulting freshness. The new case reaches a genuine settled
verdict on a capture-capable device, aborts exactly on read 3, and
pins the skip status, the run-aborted reason, no dispatched gesture,
and the capture and read counts.
The raw-coordinate path's abort guard is the only thing turning a
cancelled settle into the uniform aborted skip, and it had no abort
coverage: replacing it with a literal-point return dispatched the
gesture to the device after the client gave up, recorded as a pass —
runTap re-checks nothing before invokeOnDevice. The new case aborts
inside the settle's pair-completing read and pins the skip status, the
run-aborted reason, and that no gesture-tap reaches the device.
Deleting the visual === 'settled' disjunct left the suite green: the
tests named for it asserted only ok and coordinates, both satisfied by
the terminal best-effort dispatch at deadline exhaustion. The existing
pair now pins the dispatch instant (the first settled round at the 5s
combined phase window, not the 7.5s deadline), and a new pair covers
the transition the clause exists for — a source that stops hanging and
starts failing after pixels already settled taps the literal point it
never needed the tree for, pinned by ok, dispatch time, and a read
count proving the failing source is never consulted.
settleSnapshot keys its stale-settled override on the exact string the
pixel loop emits at budget exhaustion, but every snapshot test scripted
settleTree wholesale — relabelling the sleep-clamp arm to unavailable
left the suite green while a stale-settled update-baselines run wrote a
bare baseline with the degradation silently dropped. The new test
drives the real settleTree and real settleSnapshot end to end: a
stale-but-settled first round, a freshness retry whose animator burns
the pixel budget through the sleep-clamp arm, and the degraded write
asserted as the full reason string, pinned by read and capture counts.
Every comparison test fed the motion oracle grey — each channel term
could be deleted independently, and misindexing blue onto alpha (the
byte the docstring says is ignored) survived too, admitting motion
confined to one channel: a navy scrim fading in over black. Full-frame
red-only, green-only, and blue-only changes (plus the mid-amplitude
navy case) now register as motion, and an alpha-only change registers
as still — killing each channel-drop mutant and catching the alpha
misindex from both directions.
The authoring guidance partitioned steps by hierarchy dependency but
left pinch/rotate with an on: selector, when: UI guards, and
snapshot cropOn out of both buckets — exactly the steps an agent
repairing a flow against an unavailable hierarchy would wrongly keep.
The paragraph now lists them as hierarchy-dependent (with the guard's
error-and-hard-stop semantics), and states the complement precisely:
selector-less pinch/rotate dispatch without the flow settle, and a
full-screen snapshot degrades to a pixels-only settle on an outage
instead of erroring.
MOTION_FRACTION's comment claimed the fraction sits far below any
screen-filling transition — false for spatially uniform change: only
pixels individually clearing the per-pixel gate (~25 levels/channel
between captures) count, so a slow fade, dim, or tint shifts every
pixel by the same sub-gate amount and measures zero changed pixels by
construction, whatever it covers or how long it runs. The comment now
states the rate-floor contract and names the class the degradation
note can never fire for.
Two guards justified themselves with 'a zero-budget settle would
misreport a healthy tree source as an outage' — impossible: with no
budget no read ever launches, so settleTree can only throw
FlowTreeSettleTimeoutError, which both surrounding catches already
convert to the exact outcome the guards produce. The guards are real
micro fast-paths (skip one doomed settle round); the comments now say
so instead of stating an invariant the next person tuning these
deadlines would falsely reason from.
An already-spent caller deadline collapses into timed-out where the
flow-actions twin splits out not-attempted — deliberate here, but
undocumented and unpinned: relabelling the arm to aborted left the
suite green while turning an outage-fallback snapshot from a degraded
pass into a skip blaming a cancellation that never happened.
The collapse is now justified in prose (the outcome feeds only snapshot
degradation reporting, with no freshness contract for the twin's split
to protect, and the note text is honest for both expiry shapes) and
pinned twice: a zero-budget settlePixels call resolves timed-out with
no backend consulted, and the real fallback driven through runSnapshot
after a window-consuming outage settle degrades instead of skipping.
The simulator-server writes its temp PNG before replying, and the reply
is the only place the path is learned — so an abort landing mid-capture
severed the fetch, capturePixels soft-failed without ever learning the
path, and the file was orphaned, breaking the stated ownership contract
(the capture promise decodes and deletes its own temp file when it
eventually completes).
captureFile now passes no signal, matching the Chromium arm that never
took one: cancellation stays responsive because settleWithin abandons
the promise, while the capture self-completes on its own bounds, learns
the path, and cleans up in the finally on every path.
The pixel phase's not-attempted arm hardcoded treeFresh: true on the
grounds that zero time passed since the converged pair — false whenever
the awaited capability probe itself spent the budget: a cold iOS
runtime-kind lookup is a real simctl round trip, so the pair could be
~7s old while the settle reported it safe, and the verdict flipped from
honest to dishonest on whether that probe resolved just before or just
after the deadline.
The arm now compares the pair's convergence instant against the pixel
window: a window already spent at convergence means the probe answered
from cache and staleness is bounded by the final-read reserve, while a
window the probe consumed marks the tree unsafe — the same reasoning as
the probe-deadline sibling.
A tree-phase read outliving the source read budget returned the carried
treeFresh: after one successful read, a hung second read handed back a
tree up to the full 7.5s budget old while reporting it safe for selector
coordinates. scroll-to — the one deadline-less settleTree consumer — was
the visible casualty: its stale-round skip never fired for the case it
was written for, so one 8s hierarchy read dispatched a swipe off the
stale tree and misread the scroll as ended.
The hung-read exit now returns treeFresh: false. That is unconditional
because no path reaches it with a recent successful read in hand: every
phase window is capped by the read budget, so a non-first poll only
launches with budget open, and a first read starting past the budget
holds no successful read and throws instead. The treeFresh docstring
gains the fourth staleness source.
One combined settle on a screen with perpetual tree-invisible motion
polled captures to the caller's hard deadline minus the 250ms reserve,
consuming the entire action budget: waitForFrameResult got a single
resolution attempt instead of retrying across the window, a late retry
whose no-read window closed threw FlowTreeSettleTimeoutError out of the
step, and every directive on such a screen paid ~6s of added wall clock.
Polling for a pixel pair now stops at the ordinary phase window, the
same boundary tree polling already honors; the hard ceiling bounds only
work in flight when it closes — a first capture's first-frame-aware
budget plus the one warm capture completing its pair. After a
phase-bounded pixel timeout the mandatory revalidation read runs on the
ordinary source read budget instead of being cut to the reserve, and
waitForFrameResult treats a late retry's settle timeout as deadline
exhaustion (best-effort resolve from the last settled tree) rather than
a step error, matching the throw taxonomy.
Collapsing COMBINED_HARD_TIMEOUT_MS to the 3s tree window left the
suite green while cutting the slow-first-frame capture the constant's
pixel term exists for — scroll-to's round-0 settle, the one combined
settle with no caller deadline, silently degraded instead of proving
stillness. Drive a 6.25s first capture plus a matching warm capture
through the deadline-less settle and assert full convergence, and pin
the constant's composition directly.
The crop mapper's skipped arm was held by nothing: deleting it sent a
converged tree-only settle to the timed-out fallback, stamping every
Vega and Apple TV cropOn snapshot with a degradation note for a pixel
phase that is architecturally absent. Drive runSnapshot through the
mapper with the no-backend settle shape and assert the exact undegraded
baseline-write reason.
Collapsing the finalTreeDeadline ternary to the 250ms reserve left the
suite green while cutting every ordinary-latency post-pixel read short:
a 400ms uiautomator revalidation after a fast pixel phase came back
non-fresh, the verdict selector resolution refuses, wasting a round per
settle. Drive the everyday scenario and assert full convergence on
exactly three reads and two captures.
The two pixelsDiffer colour tests probed only the extremes, leaving the
band where a real transition lives — a dim, a fade, a crossfade —
unexercised: a 5x threshold mutant swallowed a full-frame 120-per-channel
change with the suite green. Bracket the tolerance from both sides
(grey120 registers, +25 stays still) and assert the documented mirror of
screenshot-diff's DEFAULT_THRESHOLD directly, exporting both constants.
Every existing settlePixels test fed a constant frame, hung the capture,
or aborted, so nothing past the first comparison was pinned — deleting
the predecessor advance left the suite green while a transition coming
to rest looking different from its first frame would burn the whole
window and report timed-out. Drive A/B/C/C through the real settle and
assert settled on exactly four captures.
The docstring predated the settle rework and claimed a throw means every
read in the window failed. Name the two typed shapes and their meanings
— a completed failure with zero successes is a proven outage, a window
with no completed read at all is a settle timeout that proves nothing —
and that any successful read in hand turns deadline exhaustion into a
best-effort result, the contract resolveTargetPoint and settleSnapshot
are written against.
SnapshotSettle.treeFresh was written at every construction site and read
nowhere — runSnapshot keys degradation on the outcome alone — and its
docstring claimed the cropOn path consumes it, which it never did. The
snapshot settlers now return the bare outcome; the settle-level
SettleResult.treeFresh consumers are untouched.
The throw on the preceding line already narrows lastTree to defined, so
the ternary's undefined arm could never be taken — and undefined is the
value settleTree reserves for aborts alone. The exit now matches its
sibling deadline exits.
scroll-to is the only settleTree caller that supplies no action
deadline, so its tree reads fell back to the 5s combined-phase cap —
below the budgets the sources themselves get (getHierarchy 15s,
uiautomator up to 20s) — and a healthy 5.2s hierarchy read failed only
scroll-to steps as a fabricated settle timeout. The unowned fallback now
matches DEFAULT_ACTION_TIMEOUT_MS, putting the timeout cliff at the same
7.5s every deadline-owning caller already has.
settleSnapshot's stale-settled latch treated every later reading as
unable to take back established stillness, so a retry that re-ran the
whole pixel phase without re-proving a matching pair was discarded and
the deadline exit wrote an undegraded baseline — one transient slow
post-pixel read made a mid-animation capture look clean under
--update-baselines. A completed retry ending timed-out now returns that
degradation over the latch (the capture happens after the settle, so the
retry describes the captured screen better than the stale claim); only
pixel-dark retries — outages and hard tree timeouts — still honor it.
When the tree pair converges inside the final-read reserve of the
caller's deadline, the first capture was never launched — no backend was
consulted and zero time passed — yet the settle reported visual
"timed-out", the verdict snapshots degrade on. Align the code with the
SettleResult contract: a phase that never ran returns the sticky visual
verdict (skipped by default) with the still-fresh converged tree, and no
revalidation read is spent. Budget exhaustion after a capture ran stays
an honest pixel timeout.
A combined settle whose healthy tree read consumed the caller's whole
budget threw an untyped Error from the capability-probe deadline check —
outside the settle error taxonomy, hard-stopping steps whose tree had
just converged. The budget check now distinguishes a probe that was
never consulted (no time passed — the converged tree is returned fresh)
from a probe that was awaited and outlived the wait (returned non-fresh),
so raw-coordinate gestures dispatch best-effort and snapshots degrade
honestly instead of erroring.
The existing rejection tests fail every read (an outage, thrown before
the post-pixel stage) and the restart tests move the tree via a
successful differing read, so a describe blip landing exactly on the
revalidation read exercised an untested arm: re-seed with the stable
fingerprint and restart. Two tests now pin it by read/capture counts —
the seeded restart converges on one extra read (5 total), the pixel
phase re-runs after the restart (4 captures), the blip surfaces nowhere,
and the settle returns fully converged and fresh.
The post-pixel tree revalidation gets as little as a 250ms reserve, which
one slow uiautomator/full-hierarchy read can exceed on a healthy source,
and review showed settle outcomes leaking into step failures. Settling
now stabilizes best-effort and never fails a step by itself; the only
settle-caused failure left is a genuine tree-source outage (every read
in the window failed):
- Snapshots retry a settled-but-stale settle for freshness on the shared
action deadline (the planned cropOn selector will consume the tree),
accept the stale-but-settled screen at exhaustion, and no longer
refuse --update-baselines on a timed-out settle — the write proceeds
with a degradation note instead of failing.
- Raw-coordinate tap/long-press accept a fresh tree OR settled pixels,
retry to the action deadline, then dispatch anyway: literal
coordinates depend on nothing the settle failed to prove.
- Selector resolution prefers fresh trees while budget remains and falls
back to the last valid settled tree at exhaustion, so a slow freshness
read cannot masquerade as "element not found".
- scroll-to skips a stale round instead of failing the step.
So callers can trust visual:"settled", settleTree downgrades it to
"skipped" on a tree-phase restart: the tree moving after the pixels
matched proves those pixels stale.
Fixes#644.
## Before
```
$ argent flow run echo-only # .argent/flows/echo-only.yaml: two `echo` steps
[Tool:flow-execute] No booted device found. Pass a device id or platform explicitly.
Available devices: 18BE573F-… (ios, Shutdown), <70 more>
```
and when a device *was* booted:
```
PASS on 18BE573F-… — 0 passed, 0 failed, 0 errored, 0 skipped
```
## After
```
$ argent flow run echo-only # nothing booted
Flow "echo-only"
› first step
› second step
PASS — 0 passed, 0 failed, 0 errored, 0 skipped (no test steps)
```
## Symptom 1 — the device
Whether a device is needed is decided from the flow's own steps, before
resolution.
Classification is **per step kind and defaults to needing one**, so a
kind added later inherits
today's behaviour instead of silently running against no device — and
the `never` binding makes
leaving a new kind unclassified a compile error, backed by a test keyed
on the step union so it
fails to build rather than to run.
Two classifications worth calling out:
- **`when` always needs a device**, whatever its body contains, because
the guard reads one itself
(its platform, or its view tree). A `when: {platform: ios}` block
containing only narration still
resolves.
- **`run` needs one without the fragment being read here.** The named
flow is resolved at run time;
resolving it a second time would duplicate that lookup and could
disagree with it if the file
changed in between. A deliberate over-approximation: composing a
narration-only fragment still
resolves a device. Stated in the code and in the tool description.
For `tool:` steps the tool's own schema decides, via a superset of the
keys the runner injects —
`device` is included because a nested `flow-execute` step drives a
device without being handed the
run's own. A tool that declares no input at all
(`stop-all-simulator-servers`) is device-free, and
reading its absent schema must not throw — it previously would have.
`ExecState.device` is nullable, which is the safety mechanism rather
than just typing: the compiler
enumerates every site that acts on a device, each of which now says so
via one `deviceEnv()`
narrowing. `flow-actions.ts`, `flow-visual.ts` and `ActionEnv` are
untouched. The executor also
re-checks each step against the **same predicate**, so if the scan and
the executor ever disagree
the step reports it instead of failing obscurely deeper in.
A run that resolved no device reports `device: ""`, and neither renderer
claims it ran somewhere.
## Symptom 2 — the counters
Reproducing this showed the issue's framing is slightly off: `--json`
doesn't only disagree with the
text, it disagrees with **itself** — counters all zero while its own
`steps[]` marks both steps
`"pass"`. So the CLI text was a faithful rendering of the server's
counters.
**The counters are right and stay as they are.** Narration is
deliberately excluded, and it should
be: after a hard stop every remaining step *including narration* is
reported skipped, so counting it
would inflate `skipped` on every real failing run — a worse and far more
common lie than the vacuous
one being reported here. It would also contradict the JUnit model in
#578, where echo is
`<system-out>` and not a `<testcase>`.
What was missing was saying *why* zero. Both the CLI and the MCP
renderer now append
`(no test steps)`, and only on a pass — a cancelled run can be a FAIL
with all four counters zero,
where "no test steps" would read as though the failure had no cause.
No wire format changes: `steps[].kind === "echo"` already distinguishes
counted from uncounted steps,
so the JSON is self-describing without widening the status enum.
## Conflicts, for whoever lands second
This is a hot area — seven open PRs touch `flow-run.ts`.
- **#578** rewrites the CLI summary line I also change (one hunk, one
7-line function, semantically
independent — resolution is keep-both), and its
`attachFailureDiagnostics` dereferences
`env.device.platform`. **It will need a null-device guard**, or every
failure in a device-less flow
silently degrades to `read-failed` diagnostics. Happy to rebase onto it
rather than the reverse.
- **#585** rewrites `resolveRunDevice` at the same seam and edits the
same description sentence.
- **#538** rewrites `flow-device.ts`. Worth noting it makes
`resolveFlowDevice` reject a physical
iPhone for flows — skipping resolution means a narration-only flow now
passes on a
physical-iPhone-only host, which I think is desirable but is a second
behaviour change on this seam.
All new tests are in new files, since every existing flow test file in
the blast radius is rewritten
by an open PR.
## Behaviour changes a reviewer should weigh
- A device-free flow no longer prints `PASS on <id>` **even when a
device is booted**. That is the
point: otherwise an identical flow reports differently in CI (no
simulator) than on a laptop with
one open. An explicit `--device` is still honoured and still named.
- `--platform` is ignored for a device-free flow, where it previously
errored with nothing booted.
- A 0.18.1 CLI against a newer server would print `Flow "x" on ` —
cosmetic version skew.
## Verification
Live against the built CLI: device-less flow with nothing booted (the
reported bug) and with a device
booted (identical output, no ambient attribution); `--json` showing
`device: ""`; explicit `--device`
still naming it; a mixed flow and a composed flow both still demanding a
device; a mixed flow with a
device booted still reporting `PASS on <id> — 1 passed`.
24 new tests. tool-server 3103 passed, CLI 284 passed, MCP 78 passed —
no existing test modified.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
> **#697 is stacked on this branch** — it conflicts with this one in
`flow-run.ts`'s import block, so it was rebased on top. Merge this
first.
Fixes#643.
## Before / after
```
$ argent config set ios.additionalDeviceSets "~/Library/Developer/Radon/Devices" --scope project
Error: Invalid value for config key "ios.additionalDeviceSets". # ← the whole message
```
```
$ argent config set ios.additionalDeviceSets "~/Library/Developer/Radon/Devices" --scope project
Error: Invalid value for config key "ios.additionalDeviceSets": expected an array of strings.
Did you mean: argent config set ios.additionalDeviceSets '["~/Library/Developer/Radon/Devices"]' --scope project
Run `argent config list` to see each key's expected value.
```
```
$ argent config unset ios.additionalDeviceSets --scope project
$ cat .argent/config.json
{ "ios": {} } → {}
```
## Symptom 1 — the message
The issue names three gaps: no expected type, no example, no pointer to
`config list`. All three are
closed, plus one the issue didn't ask for and I think is the actual fix.
**The suggestion carries the user's own value.** A generic `Example: …
'["~/DeviceSets/ci"]'` would
hand someone a path they have never heard of and expect them to spot the
difference. Where the
correction is mechanical — a single item offered to a list-valued key —
the error offers back what
they typed, wrapped, shell-quoted, and **on the scope they asked for**.
Pasting it works; verified
live that it lands in project scope, not global.
Nothing is coerced. The issue floated accepting a bare string and
storing it wrapped; I didn't,
because `config set` persists, gets committed, and is read by other
machines, so a value silently
stored in a shape the user didn't type is expensive to debug and
`coerceCliValue` already has one
silent fallback. Showing the corrected command costs the user one
keystroke and keeps the store
honest. If a reviewer prefers coercion, this is the smaller change to
build on.
**The expected shape comes from the validator, not a per-key string** —
`asStringArray` *is* "an
array of strings", so swapping a key's `parse` swaps its description
with it and the two cannot
drift. A bespoke validator can override via a new optional `expected`,
and degrades to today's exact
message if it says nothing.
**`config list` states it too.** The issue notes `list` doesn't close
the gap either — its
description describes the *elements* but never says the value is a list.
It now shows
`value: an array of strings, e.g. ["~/DeviceSets/ci"]`, and `list
--json` carries both fields.
`telemetry.enabled` deliberately gets no example: `setConfigValue`
refuses it before validating
(it's managed by `argent telemetry`), so a "here's how to set it" hint
would point at a command you
may not run. A schema guard test keeps that honest as keys are added —
and asserts every example
**actually round-trips through its own validator**, so an example can
never ship that reproduces the
error it exists to fix.
## Symptom 2 — the empty group
Deleting a key now removes any container the delete emptied, stopping at
the first ancestor that
still holds something.
**This is file hygiene, not a behaviour change.** I traced every raw
reader of the document —
`readConfigObject`, and telemetry's `consent.ts` and `notice.ts`, which
each do their own read — and
an empty parent is already indistinguishable from an absent one:
`getAtPath` returns `undefined`
either way, before `parse`, so the merge layer never sees a container at
all. The only observable
difference is the bytes on disk, which is exactly the complaint.
Guards worth noting:
- **The root always survives** — an emptied config is `{}`, never a
deleted file. `.argent/` is
itself a project-root marker, so removing the file wouldn't restore the
prior state anyway, and
`updateConfig` is a shared publish path with a lock/rename protocol that
has no defined meaning for
file removal.
- **A no-op unset still doesn't touch the file.** That fast path exists
so an unset that removes
nothing can't materialise a project config and dirty git status; pruning
lives inside the mutate
callback, which the fast path returns before reaching. So a group
someone deliberately left empty
by hand is never tidied away — pinned by a test asserting the file stays
byte-identical.
- Siblings at any level stop the unwind, including an empty array value,
which is preserved.
**Two existing tests asserted the old behaviour and now assert the new
one** —
`config-access.test.ts:47` and `config.test.ts:109` (the latter is
`clearRememberedAgent`'s
sibling-preservation test, so it doubles as proof siblings survive).
## Known same-class case, not fixed here
`resetFirstRunNotice` (`packages/telemetry/src/notice.ts`) does its own
inline delete and still
leaves `{"notices":{}}`. It doesn't go through `deleteAtPath`, so it
doesn't inherit this. It's a
one-line change to route it through the same function — happy to do it
here or as a follow-up, but
it pulls the telemetry package into a config PR so I left it out by
default.
## Verification
Live against the built CLI: the reported case; pasting the suggestion
back and confirming it lands in
**project** scope; unset leaving `{}`; a sibling key surviving; a
non-array key correctly getting the
generic example and *no* bogus "Did you mean"; `config list` output;
`telemetry.enabled` still
delegating unchanged.
configuration-core 103 passed, argent-cli 285 passed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fixes#586.
## The bug
`--includeChildren false` left the flag **true** and silently dropped
the word, so the tool ran with the opposite of what was asked. The
reporter spent a while chasing a filter bug that did not exist — the
output was simply the unfiltered kind.
Reproduced on 0.18.1, device-free, with a discriminator that shows the
flag's value directly:
```
$ argent run screenshot-diff --udid U --baselinePath A.png --currentPath B.png --captureCurrent=false
{ "summary": "Screenshot diff summary … pixel_mismatch: 1.79% …" <-- false, diff runs
$ argent run screenshot-diff --udid U --baselinePath A.png --currentPath B.png --captureCurrent false
[Tool:screenshot-diff] Provide either currentPath or captureCurrent, not both. <-- stayed TRUE
```
That message is the whole problem in one line: the user typed `false`,
and the tool refuses on the grounds that they asked to capture.
## Why the old behaviour existed, and why it does not need to
The parser deliberately declined to look ahead:
> `// Bare boolean flag: true. Don't consume next argv to avoid
surprising behavior when a positional follows.`
That protects a case which does not exist. `parseFlags` has exactly one
caller, and **`positional` is never read anywhere in the repo** — every
bare token is collected and dropped, for booleans and for every other
type. There is no positional to steal. The module header had documented
the space form as legal all along:
```
// --name value (string / number / integer / boolean-with-value)
```
This matters because several tool booleans **default to true**, so
`false` is the only way to say what you mean:
`screenshot.includeImageInContext`, `network-request.includeBody`,
`debugger-component-tree.onScreenOnly`,
`debugger-inspect-element.resolveSourceMaps`.
## What changed
A boolean flag consumes a following token when it is exactly the word
`true`/`false`, case-insensitively. `1`/`0` and everything else are left
alone — a bare number after a switch is more likely an unrelated
argument than a value, and guessing at an ambiguous token is what caused
this bug. `--flag -- false` still keeps the word positional.
Three consequences are handled here rather than left to bite later:
- **`argent run` now reports arguments it ignored.** This is what stops
`--flag 0` and `--flag yes` being the same silent inversion, in a syntax
users are now being taught works. stderr, so `--json` stays parseable.
- **`--no-flag true` / `--no-flag false` now name the positive form**
instead of quietly meaning false. One is a contradiction, the other a
double negative.
- **`--flag=True` used to throw** while `--flag True` would now work —
the inline coercion was case-sensitive and did not trim. Both paths now
share one helper, so a single word cannot get two answers one function
apart.
The **flow** and **lens** parsers take switches that accept no value at
all, and would have silently ignored the syntax their users just learned
elsewhere. They now refuse it — in the flow parser that also stops
`false` being swallowed as the flow name, since its first bare token is
the flow to run.
`--help` gains **one legend line** for tools with a boolean field,
rather than widening the flag column on every field of every tool:
```
Booleans: --flag or --flag true sets true; --flag false, --flag=false, or --no-flag sets false.
```
It deliberately does not begin with `--`.
`scripts/e2e-full/lib/discover-tools.sh` reads any such line in the
`Flags:` section as a flag row and takes the first `--token` as its
name, so a legend starting with a flag would inject a phantom flag into
every tool model it builds. **There is a test pinning that shape**,
verified live: running the harness's awk over the new help output yields
exactly the real flags and no legend row.
## Verified live
```
--captureCurrent false -> diff runs (was: "Provide either currentPath or captureCurrent, not both.")
--captureCurrent=false -> unchanged
--captureCurrent 0 -> "Note: ignoring unused argument(s): 0. Pass values as --flag <value> or --flag=<value>."
screenshot --help -> legend present; harness awk parses only the real flags
```
25 new tests; 8 confirmed to fail before and pass after. The rest pin
what deliberately did *not* change: bare flag still true, a following
flag or non-boolean word left alone, `1`/`0` still positional in the
space form, `--` escape, string and unknown fields untouched,
`--no-flag` semantics, boolean arrays, last-write-wins. The parser had
**zero** boolean coverage before — which is why this survived. Full CLI
suite 298 passing; lint, prettier and `typecheck:tests` clean.
## Scope and neighbours
Only `flag-parser.ts` (the schema-driven parser for `argent run`)
changes semantics. The other CLI parsers take fixed, default-off
switches where `false` has no coherent meaning; three of them already
reject a stray token outright, and the two that did not now do.
`flow.ts`'s deliberate rejection of `--flag=value` is untouched.
**#648** also edits `flag-parser.ts` (rewrites `renderFlagName`, inserts
helpers at the same anchor). Behaviourally compatible — it keeps boolean
→ bare `--name` — so this is a textual conflict only, and either order
works.
## Found while here, not fixed
`scripts/e2e-full/lib/discover-tools.sh:60-64` detects flag kinds with
`\bboolean\b` in awk, where `\b` is **backspace**, not a word boundary —
so it never matches and every non-enum flag is typed `unknown`. That
makes the `bad-type` validation case downstream unreachable.
Pre-existing and unrelated to this fix; filing separately.
---
> **Stacked on #648** (`filip/run-cli-validation-messages`). Both
rewrite the same region of `packages/argent-cli/src/flag-parser.ts` —
#648 factors out `flagNameFor`/`isJsonField` and rewrites
`renderFlagName`, this one adds `booleanWord` beside them and a boolean
legend to `formatSchemaUsage`. Merging them independently conflicts;
this branch is rebased on #648, so review only the top commit. GitHub
retargets it to `main` when #648 merges.
---------
Co-authored-by: Filip131311 <f.kaminski2000@gmail.com>
Fixes#638.
## The report understated it
The issue says `argent mcp --help` "does not hang and does not start the
server — it simply prints
nothing". Reproducing it showed the opposite:
```
$ argent mcp --help < /dev/null ; echo $?
0 # 0 bytes ← what the reporter saw
$ argent mcp --help # stdin open (a pipe, an editor, a CI step)
# HANGS — I hit a 300s timeout
```
`--help` **starts the MCP stdio server**, which blocks reading JSON-RPC
from stdin. The "prints
nothing, exit 0" is just the server getting an immediate EOF from a
closed stdin. So this is the same
"`--help` runs the real command" bug that was fixed for the installers —
`mcp` was missed.
`startMcpServer` takes no argv at all (`mcp-server.ts:96` — its only
parameter is `{ paths }`), so a
help flag was *structurally* unreachable; there was nowhere downstream
that could have handled it.
## Fix
`mcp` joins the existing guarded set rather than growing a second help
path. That set's defining
property is "the handler never inspects its argv", and `mcp` qualifies
more strongly than the
installers do — it isn't merely ignoring `rest`, it is never handed it.
**It cannot fire on a real launch.** The guard tests `rest`, which is
`[]` for every generated editor
config (all four shapes end at `mcp`, and `isArgentManagedEntry`
requires exactly that), and
`[].some(...)` evaluates no predicate. This is pinned in both
directions, because a guard that *did*
fire here would print usage onto the JSON-RPC channel and break every
editor at handshake — the
highest-cost regression in this file, and it was untested.
The top-level table's `mcp` row now reads the same metadata as its own
help, so the two cannot drift.
**No rename.** An earlier draft renamed the module and six identifiers
(`installer-help` →
`command-help`, etc.) on the grounds that its comments had become false.
They hadn't quite: fixing
`mcp` *and* `tools` makes "every other subcommand parses `--help`
itself" true again — I checked all
ten siblings. Since #530 and #568 both edit `cli.ts`, a rename would
have forced them through churn
for no behavioural gain. Prose-only.
## Second commit: `argent tools --help`
The issue mentions it in passing; it's a different mechanism and a
genuinely dead branch.
`argv.filter((a) => !a.startsWith("--"))` dropped `--help` before the
subcommand was read, so it fell
through to listing all 77 tools — **contacting the tool-server, starting
one if none was running**,
to do it. Only `-h` ever reached the existing help branch.
Scoped to the subcommand position on purpose: a global check would have
broken
`argent tools describe <name> --help`, which today prints *that tool's*
flags. It still does — there
is a test.
Separate commit so it can be reverted alone.
## Verification
Live, against the built CLI:
| | before | after |
|---|---|---|
| `mcp --help`, stdin open | hangs indefinitely | usage, returns
immediately |
| `mcp --help`, stdin closed | 0 bytes, exit 0 | 849 bytes of help, exit
0 |
| `mcp` (real launch) | works | works — verified with an actual
`initialize` handshake, server replies with its capabilities |
| `mcp --metro-port 8082` | starts server | starts server (guard keys on
help, not on "any argv") |
| `tools --help` | 77-line catalogue | usage |
| `tools describe gesture-tap --help` | tool's flags | tool's flags |
| `argent --help` `mcp` row | — | byte-identical |
argent 80 passed, argent-cli 284 passed.
## Note on the help text
Every claim in it is sourced (stdio/JSON-RPC transport, stdout
ownership, the `~/.argent/mcp-calls.log`
default and `ARGENT_MCP_LOG` override, auto-spawn vs
`ARGENT_TOOLS_URL`/`argent link`). It deliberately
does **not** suggest running `argent mcp` by hand to check an install —
that is the reported hang — and
instead says it will look like it has hung and why.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fixes#645.
## Before
```
$ argent run gesture-tap
[
{ "expected": "string", "code": "invalid_type", "path": ["udid"],
"message": "Invalid input: expected string, received undefined" },
... two more
]
```
## After
```
$ argent run gesture-tap
Error: missing required flags --udid, --x, --y
argent run gesture-tap [flags]
...full per-flag help...
```
## Scope
The issue names the missing-flag case. Reproducing it showed the same
raw dump has a second
trigger: any constraint the tool itself rejects, which the CLI cannot
know in advance.
| invocation | before | after |
|---|---|---|
| `gesture-tap` (no flags) | raw issue array | `missing required flags
--udid, --x, --y` |
| `screenshot --scale 99` | raw issue array | `--scale Too big: expected
number to be <=1` |
| `gesture-scroll --udid X --x .5 --y .5` (zero deltas) | raw issue
array | `Pass a non-zero deltaX and/or deltaY …` |
| `run-sequence --udid X` | raw issue array | `missing required flag
--steps-json` |
| `gesture-tap --x abc` | already good | unchanged |
| runtime failure | plain message, exit 1 | unchanged |
Fixing only the first row would have left the CLI's error voice less
predictable than before, so
both go through the same renderer.
## How it works
Two sources, one output. Missing flags are found locally from the tool's
published schema — a
**presence** check only, never a type or range judgement, which stays
exclusively the server's.
That is sound because the schema generator excludes defaulted fields
from `required`
(`debugger-connect` requires `device_id`, not `port`), so it cannot
reject a call the server would
have accepted. Everything else is read off the rejection the tool-server
already returns.
Both produce one report rendered by one function and reported through
one call site, so the
wording, the output channel and the exit code cannot drift between them.
Recognition of a rejection is **structural** — the shape of the issue
list plus the tool's own
schema — never the wording of a message, which is not ours to depend on
and changes between Zod
releases. Four gates must all hold, and any failure leaves the existing
handling untouched.
Details worth a reviewer's attention:
- **A rule spanning the whole payload reports no field.**
`gesture-scroll` and `gesture-rotate`
validate at the object level, and `screenshot-diff` is `.strict()`.
Treating those as
field-scoped would print `Error: --undefined …`; they are stated on
their own instead.
- **A rule firing on an *optional* field is not called a missing
required flag.**
`await-ui-element` refines on `expectedText`, which it does not require
— reporting that as
missing would contradict the help block printed directly underneath,
where the field carries no
`(required)` marker.
- **The check runs after `--args` is merged**, so `argent run
gesture-tap --args '{...}'` is not
falsely reported as missing everything. Verified live: it reaches the
device and taps.
- Presence uses own-key semantics, so a required field named e.g.
`constructor` is not treated as
supplied by the prototype chain.
## Behaviour changes a reviewer should weigh
- **A tool-rejected value now exits 2 instead of 1.** `run.ts` already
uses 2 for bad input and 1
for a failed run; a Zod 400 means the tool never ran. No in-repo
consumer tests for a specific
code — `scripts/e2e-full` only checks zero/non-zero. Note this does
**not** make 2 universal:
an unknown tool name still exits 1, which is untouched here.
- **`--json` gains a defined failure shape** (it had none — the blob
printed regardless). One
object on stderr, nothing on stdout, so `--json | jq` on a failed run
reads an empty stream and
a non-zero status:
```json
{ "error": "missing required flags --udid, --x, --y",
"missing": ["--udid", "--x", "--y"],
"issues": [] }
```
`issues` carries the tool's list verbatim, which is the raw data the
issue asked to keep.
- **One adjacent fix**, called out because it is not the reported bug:
the global-option parse ran
*outside* the error handler, so `argent run screenshot --out` printed a
raw stack trace. Same
bad-input-to-ugly-output defect, one line above the fix.
## Not done
- The unknown-flag hole (`argent run gesture-tap --bogusflag 1` is
silently accepted and the tool
runs). The pass-through is deliberate — see the comment in
`flag-parser.ts` — and belongs with
#586, the same CLI-argument family.
- `argent flow` builds its own payload and has its own required check;
it cannot reach this path.
- The server still returns the issue list verbatim, so MCP and other
clients are unaffected. All
prettifying is CLI-side.
## Verification
Live against the built CLI for every row of the table above, plus:
`debugger-connect` asks only
for `--device_id` and never `--port`; `--args '{...}'` reaches the
device and taps; a thrown
runtime error still prints its bare message with no `Error:` prefix, no
help block, exit 1.
43 new tests. `run-telemetry.test.ts` passes **unedited** — it already
asserted the non-Zod
passthrough, which makes it the regression guard for the untouched path.
Full CLI suite 320
passed, registry 87 passed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
> **#699 is stacked on this branch** — it conflicts with this one in
`flag-parser.ts`, so it was rebased on top rather than left to collide
at merge time. Merge this first.
Fixes#617.
## Reproduced three ways
**The reported false green**, using an image whose 2× downscale is
lossless so no resampling noise can mask it:
```
baseline 800x1600 current 400x800 (exact half)
→ status: unchanged pixel_mismatch: 0% (size difference never mentioned)
```
**Worse on real screenshots** — a 1152×648 capture vs its
aspect-preserving half:
```
→ status: changed pixel_mismatch: 0.35% changed_areas: 8
Text changes: Disappeared "General" / Appeared "Remotes and Devices"
```
The content is identical; every region and both OCR "text changes" are
resampling artifacts. So the tool doesn't merely stay silent about the
size difference — it manufactures a plausible-looking regression report
out of noise.
**The control shows the asymmetry is the bug** — aspect-*breaking*
correctly reports `dimension_mismatch: expected=1,152x648
actual=1,152x400`. Sizes are disclosed exactly when the tool refuses to
compare, and never when it silently rescales.
## The fix
`PngDiffResult` gains `sizeNormalization` (both original sizes plus the
size compared at), populated only when a rescale actually happened — so
its presence *is* the signal that the comparison carries resampling
uncertainty. The summary prints it directly under the status:
```
- status: resized_no_change
- size_normalized: baseline=800x1,600 current=400x800 compared_at=400x800
- the inputs share an aspect ratio but not a resolution, so the larger was downscaled before
comparing; any pixel or text differences below may include resampling artifacts, and the diff
images are at compared_at rather than full size
- re-capture the baseline at the same scale as the current image (screenshot with scale: 1.0)
to compare without resampling
```
**`resized_no_change` is checked last**, after the difference checks, so
a rescaled comparison that *did* find something is still a plain
`changed` — a caveat status must never soften a real regression. There's
a test pinning that ordering specifically.
**The name matters.** `unchanged_after_resize` contains `"unchanged"`,
so the one naive gate this protects — `summary.includes("status:
unchanged")` — would still read it green. `resized_no_change` contains
neither `"unchanged"` nor `"changed"`.
## What I deliberately did not do
**Report `dimension_mismatch` whenever sizes differ** — the issue's
primary suggestion. The normalization is deliberate:
`screenshot-diff/index.ts:292-294` states that same-aspect normalization
*"keeps a scaled capture diff-compatible with a baseline saved at any
scale"*. The `screenshot` tool defaults to **0.3** for iOS/Android while
the diff's live capture is full-res, and on Android the live side falls
back to the default scale when full-res capture fails. Hard-failing
would break the case this code exists for.
**Expose the internal `normalizeSizes` flag.**
`screenshot-diff-tool.test.ts:10-23` is titled *"rejects public tuning
options so defaults stay internal"* and asserts exactly this kind of
param is refused. And the better remedy is the one the summary now gives
— re-capture the baseline at the same scale, which the skill already
instructs and which removes the resampling rather than reporting it.
Worth reconsidering separately if a user actually wants "green means
byte-identical".
*(An earlier draft proposed exposing it via `z.coerce.boolean()`. Worth
recording why that was dropped beyond the posture conflict: in zod,
`z.coerce.boolean("false")` is **`true`** — so a user asking for strict
comparison would have silently got normalization anyway, re-creating
this exact bug through the fix's own escape hatch.)*
**Suppress the OCR noise.** Text analysis runs on each *original file at
its own resolution* and only rescales the resulting bounds, so a
diff-time filter cannot separate "OCR read a 576px image badly" from a
real text change. Suppressing it whenever normalization occurred would
silence text analysis on every scaled comparison — a new, worse false
green of exactly the class this issue is about. Disclosure is the honest
treatment; running OCR on the normalized pair is a separate change to
`text-diff`'s contract.
## Tests
Three new cases plus three strengthened ones: the false green now
discloses and no longer reads `unchanged`; a normalized comparison with
a real change still reports `changed` *and* still discloses; equal sizes
say nothing; and the retina case still compares rather than
hard-failing.
Mutation-verified both halves — removing the status fails the #617
regression test; moving its check before the difference checks fails the
ordering test.
Full tool-server suite green — 3089 passed. No existing assertion needed
changing, which is also why this survived: nothing pinned it.
## Notes for the reviewer
- The flow snapshot path is untouched and **cannot** reach
normalization: full-screen baselines key on the capture's own pixel
dimensions (`flow-visual.ts:212`), so a differently-sized capture misses
the baseline entirely and fails; `cropOn` diffs already pass
`normalizeSizes: false`.
- `SummaryStatus` is unexported and nothing switches on it — the status
reaches the outside only as text inside `summary`, which the MCP layer
passes through verbatim. No skill doc, flow assertion or e2e script
matches on a status string.
- The diff artifacts were already produced at the normalized size; that
was simply never stated. The caveat now says so.
Fixes#634.
```
before: [Tool:chromium-tabs] Not supported
after: [Tool:chromium-tabs] Could not open a tab: Not supported. An Electron app has no
browser-level target creation, so a tab cannot be made from outside it — have the app open
one (e.g. `window.open()` through `debugger-evaluate`) and it will show up in
`chromium-tabs list`. The list, select and close actions are unaffected.
```
The old text is Electron's own CDP answer to `Target.createTarget`,
passed through untouched. The
limitation is real, but the message names neither the cause nor a way
forward, and reads as though the
tool were unavailable — when only this one action is.
## Two things I checked before writing the message
- **The workaround actually works.** On the Electron testbed I ran
`window.open('about:blank')` via
`debugger-evaluate` and the new window appeared in `chromium-tabs list`
as `t2`. I didn't want to
ship advice I hadn't run.
- **The rest of the tool really is fine** — `list` returns correctly
both before and after.
## Why the cause is kept rather than matched on
`Target.createTarget` can fail for reasons other than Electron's missing
browser shell — a dropped
connection, or a real Chrome refusing the url. The CDP client keeps only
the error's `message` and
discards its numeric code, so branching would mean matching on the
string `"Not supported"`, which
breaks the moment Electron rephrases it.
Instead any failure is wrapped, with the original message embedded. The
explanation is worded as a
property of Electron rather than a claim about *this* failure, so it
stays accurate when the cause is
something else — and the underlying error is still right there to read.
## Verification
Live against the Electron testbed (Electron 42 / Chrome 148): the
message above is the real output,
and `list` still works alongside it. tool-server 3087 passed, plus a new
test asserting the message
keeps the original cause *and* carries the reason, the workaround, and
the note that the other actions
are unaffected.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fixes#640.
```json
"engines": { "node": ">=20.12.0" }
```
## Why 20.12.0 and not 20
My first answer was `>=20` — CI's lowest tested line, satisfied by every
runtime dependency, and the
version the issue reporter (and I) installed successfully. **It is
wrong**, and shipping it would
have told npm it is safe to install onto runtimes where the CLI cannot
execute a single line.
The mistake was looking at first-party `src/`. The published artifacts
are four esbuild bundles with
their dependencies **inlined**, and that is where the real floor lives.
Confirmed in
`packages/argent/dist/`:
| blocker | added in | consequence below it |
|---|---|---|
| `import { styleText } from "node:util"` (via inlined `@clack/*`, in
`installer.mjs` and `cli-cmds.mjs`) | **20.12.0** | a **link-time**
`SyntaxError` — `argent init` and every CLI subcommand fail to load |
| `import.meta.dirname` at module scope (`bundled-paths.js`, `cli.js`) |
20.11.0 | `TypeError` during module init |
So 20.12.0 is the lowest version on which the package actually loads. It
is also the lowest line CI
exercises (7 workflows pin Node 20), and it satisfies every runtime
dependency's own constraint
(`@fails-components/webtransport` `>=20`, `@modelcontextprotocol/sdk`
`>=18`).
`electron`'s `>=22.12.0` does **not** apply — it is an
`optionalDependency`, and npm does not
engine-check those. Verified: installing `0.18.0` on Node 20.19.5
produces no `EBADENGINE` at all and
electron is never materialized.
I did not go to `>=22` on EOL grounds: it would refuse a runtime seven
CI workflows still test and
that users demonstrably run.
## Docs corrected alongside
- `README.md` said **20.11** — one minor below where the package can
load.
- `CONTRIBUTING.md` said **Node.js 18+** — predates what the repo's own
lint toolchain needs
(`eslint@10` requires `^20.19.0 || ^22.13.0 || >=24`).
`packages/argent/README.md` is generated by `scripts/sync-readme.cjs` on
`prepack`, so it follows
automatically and is deliberately untouched.
## What a reviewer should know
**Enforcement is package-manager-dependent**, which matters because
`argent init` installs with
whichever PM the project uses:
| | behaviour on too-old Node |
|---|---|
| npm (default) | `npm warn EBADENGINE` — installs anyway |
| npm + `engine-strict=true` | hard error, install aborts |
| **yarn 1.x** | **hard error by default** |
| **pnpm** | **silently ignores it** |
So this achieves the issue's "warn" but only conditionally its "refuse".
There is no `.npmrc` in the
repo, so contributors are unaffected either way.
**Nothing in the repo validates `engines`** — `repo-hygiene`,
`check-workspace-versions.mjs` and
`pack-mcp.cjs` all ignore unknown manifest keys, and
`next-canary-version.mjs` round-trips the
manifest through `JSON.parse`/`stringify`, which preserves the new field
and stays prettier-clean.
**One inconsistency I did not resolve:**
`packages/argent/scripts/bundle-tools.cjs:383` builds with
`target: "node22"`, which now sits above the declared floor. In practice
the node20→node22 syntax gap
is negligible (tsc targets ES2022 and I found no post-20 *syntax*), so I
left the shipped artifact
alone rather than change build output in a manifest-only PR — but it
should either be lowered to
match or consciously kept. Your call.
## Separate bug found while verifying this
`ios-remote` is broken on the **entire Node 20 line**, independent of
this change: the inlined
`@moq/*` client uses `Promise.withResolvers` (Node 22+) ungated — 6
occurrences in
`dist/tool-server.cjs`, reached from `utils/moq-client.ts` via the
simulator-server blueprint.
Reproduced on Node 20.19.5: `TypeError: Promise.withResolvers is not a
function`.
I deliberately did **not** raise the floor to `>=22` to paper over it —
that would refuse Node 20 for
every user to hide one broken path. `moq-client.ts` already has an
`ensurePolyfill()` that polyfills
`WebSocket`/`WebTransport`; this belongs there. Filed separately.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Companion to software-mansion/argent-private#29. Together they fix#639.
## Split of work
The binaries users install are **not** built by this script — they come
from
`build-native-binaries.yml` in `argent-private`, which duplicates the
compile commands inline. So the
load-bearing fix is
[argent-private#29](https://github.com/software-mansion/argent-private/pull/29);
this PR keeps `build.sh` (the `pack:mcp:local` path) in step, which that
workflow's own comment
promises it mirrors. Letting them drift is how the gap appeared.
## The change
The three daemon compiles built arm64 only while the dylibs five lines
away already cross-compile
both slices, so a locally packed tarball had a universal
`simulator-server` next to an arm64-only
`ax-service`. `-target` is kept and `-arch arm64 -arch x86_64` added —
identical output, but the
platform stays pinned by something that fails loudly rather than by a
flag whose loss would silently
yield a macOS binary that packs fine and only dies at `simctl spawn`.
`tvos-hid-daemon` also gets `-mmacosx-version-min=14.0`. It was the only
compile with no deployment
target, so its floor was whatever Xcode built it — on a current machine
that is macOS 26, which would
exclude nearly every Intel Mac the x86_64 slice exists for. 14.0 is what
the shipped binary already
reports, so this is a no-op against the current artifact.
## Verification
Ran this script end to end against the real private sources:
```
ax-service x86_64 arm64 IOSSIMULATOR 17.0 (both slices)
tvos-ax-service x86_64 arm64 TVOSSIMULATOR 17.0 (both slices)
tvos-hid-daemon x86_64 arm64 MACOS 14.0 (both slices, was 26.0 locally)
libNativeDevtoolsIos.dylib x86_64 arm64 (unchanged)
```
**Not verified:** that the x86_64 slices *run* — no Intel Mac and no
x86_64 simulator runtime here.
This restores a build pattern the repo already uses everywhere else; it
is not a tested claim of Intel
support, and no doc is changed to say otherwise.
## Note for whoever merges
Merging both PRs is still not enough for the fix to reach an npm
release: `publish-npm.yml` pins a
frozen `argent-<ref>` binaries tag built by a manual dispatch of the
private workflow. After
argent-private#29 lands, that workflow needs a dispatch against the tag
the next release will use.