`attemptBoot` only listened on `child.on('exit', ...)`, so a `spawn` failure
(ENOENT — emulator binary missing/EACCES, transient FS hiccup) emitted an
`error` event with no listener and Node crashed the tool-server with an
uncaught exception while leaking the in-flight Map entry. Funnel the error
into the same `earlyExitError` race so the boot promise rejects cleanly and
`bootAndroid`'s `finally` clears the map.
CI Linux runners don't have `adb` on PATH, so the Android paths in
`simulatorServerBlueprint.factory` hit the real `ensureDep('adb')` probe
and threw `DependencyMissingError`. Locally the tests passed because
`adb` was on PATH. Prime the dep cache in `beforeEach` (matching the
existing pattern in boot-device.test.ts and describe-tool.test.ts) so
the dep check is a no-op and the test stays focused on dispatch
behaviour. Lazy-imported via dynamic `await import(...)` so check-deps.ts
does not load before the hoisted `vi.mock("node:child_process")` factory
has its `spawnMock` initialised.
- argent-react-native-app-workflow Quick Reference still labelled
stop-simulator-server / stop-all-simulator-servers as 'iOS only'
(superseded by the cross-platform commits) and only listed the
iOS run / log commands. Add Android counterparts.
- rules/argent.md session-end hint claimed stop-all-simulator-servers
is 'for iOS targets'; the tool now tears down Android servers too.
- argent-device-interact described 'describe' as 'current simulator
screen' — Android uses uiautomator; reword to be device-neutral and
name both backends.
Modern AGP templates default to *.gradle.kts (Kotlin DSL); the inspector
agent's classification rule and quality-control reference only listed
the Groovy variant. Update both to call out Kotlin DSL alongside the
Groovy form so a Kotlin-only project still classifies as native Android.
Test file covers nativeDevtoolsBlueprint and nativeProfilerSessionBlueprint
gates but axServiceBlueprint is the third iOS-only blueprint with the
same shape and was untested. Add the Android-rejection case and the
missing-options.device case for symmetry.
Android branches throw 'am start failed: …', 'adb install failed: …',
'open-url failed: …' — a uniform 'verb failed: detail' shape MCP
clients can pattern-match on. iOS branches let the raw 'Command failed:
xcrun simctl …' bubble out of execFile so the same client check works
on Android but not iOS. Add a small wrapXcrunError helper in
utils/format-error.ts and apply it to launch-app, restart-app,
reinstall-app, and open-url iOS handlers.
Both are session-start essentials in the new Android workflow yet were
missing the discoverability flags peers have (screenshot, gesture-tap,
launch-app already set both). Add alwaysLoad and a searchHint covering
ios/android, simulator/emulator, avd, serial, udid keywords so
LLM-driven harnesses surface them when the user is choosing a target.
A pre-PR snapshot's schema rejected steps: [] up-front; somewhere in
this PR's restructuring the .min(1) was dropped, so an empty array now
silently no-ops with { completed: 0, total: 0, steps: [] } instead of
returning a clear schema error to the caller.
LaunchAppServices and RestartAppServices declared { nativeDevtools }
as required even though Android's services() returns {} — a documented
lie that would type-check 'services.nativeDevtools.foo()' inside the
Android handler and crash at runtime. Extend dispatchByPlatform to
accept IosServices and AndroidServices as separate generics so each
branch types against its real shape. Reinstall-app/open-url/describe
keep the same shape on both sides; launch-app/restart-app split into
*IosServices (with nativeDevtools) and *AndroidServices (Record<string,
never>).
If the user launched a non-launcher activity (e.g. .SettingsActivity)
and then called restart-app, the Android branch silently bounced them
back to the package's default LAUNCHER activity because there was no
activity param at all — the resolver always picked the launcher. Add
the optional activity field to the schema/Params (mirroring launch-app's
shape and regex), and forward it through the same component-string
construction launch-app uses. Ignored on iOS.
iOS reinstall-app does 'simctl uninstall' then 'simctl install' so
app data and runtime permissions are wiped. Android did only
'adb install -r -d -g' which preserves data and previously-granted
permissions. Same tool name, divergent post-state. Run 'adb uninstall
${bundleId}' first (swallowing the not-installed case the way iOS
does), then the install. Update the description to match the new
unified semantics.
The simulator-server binary shells out to adb internally for Android
targets. Without this preflight, a host without android-platform-tools
surfaces the failure as 'Timed out waiting for simulator-server to
become ready' (READY_TIMEOUT_MS = 30s) instead of the structured 424
DependencyMissingError other Android tools emit. The simulator-server-
backed tools (gesture-*, button, keyboard, screenshot, rotate, run-
sequence) share one handler so don't go through dispatchByPlatform's
branch-aware preflight; gating once at the blueprint factory covers
the whole family in a single place.
The route was filtering /preview/simulators down to platform === 'ios'
so booted Android emulators were invisible to the preview UI even
though list-devices already classifies them and the downstream
/simulator-server/:udid route accepts the serial via resolveDevice.
Map Android entries to the same { udid, name, state, runtime }
shape the iOS branch emits (state 'device' becomes 'Booted', avdName
becomes the display name, sdkLevel formats the runtime label) so the
existing UI dropdown picks them up without UI changes.
Two simultaneous bootAndroid calls for the same AVD both passed the
'already running' fast path (no emulator yet) and both spawned QEMU.
The second collided on the AVD's exclusive on-disk lock and bailed
after the boot deadline with a misleading 'Running multiple emulators'
error. Coalesce per-AVD so a duplicate caller reuses the in-flight
result (or shares its rejection).
Tests get a __resetInFlightBootsForTesting hook because some
intentionally abandon a half-started boot to assert orphan-cleanup —
without the reset that leaked promise contaminates the next test that
boots the same AVD.
Enrichment getprops used the runAdb default of 30s, so a single device
stuck mid-daemon-attach could stall every `adb devices`-driven loop
(boot-device's adb-register stage, list-devices) for 30s × 3 = up to
90s per call. boot-device's adb-register budget is 60s — so a single
hung device exhausts the whole stage.
Cap each enrichment getprop at 5s. Plenty for a healthy device; not
enough to mask the underlying issue.
The retry loop swallowed every getprop failure, so a device that came
up 'unauthorized' / 'offline' / 'not found' burned the full 2-minute
boot budget before producing a generic timeout message. The user
needs to authorise the prompt, reconnect the device, or pick a
different target — telling them only after 2 minutes is unhelpful.
Classify the rewrapped adb error and abort early on those states.
Every other react-profiler-* tool (start/stop/analyze/renders/fiber-tree/
cpu-summary) describes device_id as 'iOS simulator UDID or Android
logicalDeviceId'; only react-profiler-status said iOS-only. Align the
wording so agents don't conclude the status check is iOS-only.
screenshot, gesture-{tap,swipe,pinch,rotate}, button, keyboard, and
run-sequence all advertise an Android capability but their descriptions
and searchHints still said 'simulator screen' / 'simulator server is
not running' — implying iOS only. The simulator-server binary now
dispatches both iOS and Android internally, so the tools really are
cross-platform; the docs just hadn't caught up.
`list-simulators` was deleted and replaced by `list-devices` (which
lists both iOS simulators and Android emulators/AVDs). The CLI help
text still pointed at the old name, so anyone copy-pasting the example
got 'Tool not found.'
am start can fail without an 'Error:' prefix — a deep link to a
permission-protected intent emits 'Permission Denial' /
'SecurityException' / 'requires permission' / 'denied'. Without these
in the matcher the tool returned { opened: true } while nothing
happened. Widen the regex.
The gate keyed only on parsedData.udid, so debugger/profiler/network
tools (which spell the parameter `device_id`) silently bypassed
capability checks. An Android serial reaching, say, native-profiler-start
fell through to the deeper blueprint and surfaced as HTTP 500 instead
of the clean 400 the gate would have produced. Honour both names.
A bare 'MainActivity' (no leading dot, no '/') was emitted as
${bundleId}/MainActivity, which Android's `am start -n` parses as
package=${bundleId} class='MainActivity' in the default package — and
rejects with 'Activity not found'. The user's obvious intent is the
class within their package, so resolve it to ${bundleId}/.MainActivity.
'pkg/X' and FQCN inputs continue to work unchanged.
The Android handler was returning source: 'native-devtools' even though
its tree comes from `uiautomator dump`, not the React Native devtools
injection layer. Agents that branch on `source` (to decide whether to
also call `native-find-views` for a richer tree, or to log which
provider answered) couldn't distinguish an iOS native-devtools fallback
from a normal Android describe.
Add 'uiautomator' to the DescribeResult.source union and emit it from
the Android branch.
5s cache TTL outlived a rotation by orders of magnitude (rotation
completes in <500ms), so describes within the window divided
landscape-X by portrait-W and produced normalized frames with x>1 /
width>1 — every element collapsed onto the right edge. Drop the cache
entirely; one extra `adb shell wm size` per describe is cheap next to
the uiautomator-dump exec-out it sits beside.
Also reject non-positive dimensions explicitly so a malformed `wm size`
output fails clean instead of producing NaN frames downstream.
Follow-up to 8885fe2 — the new attribute charclass [^"'<>] also matched
the '/' that precedes self-close, swallowing it into the attr block and
treating <node ... /> as an opener. Exclude '/' from the unquoted class
and require the trailing \s*\/?> to recognise self-close.
Also: instead of dropping orphaned top-level elements when a stray
closing tag empties the stack, attach them to the existing root so
sibling content stays reachable in the dump tree.
The /No activities found|Error:/ scrape on monkey output is the same
false-positive trap launch-app moved away from — class names like
com.example.ErrorReportingActivity in the Activity: line tripped it,
and monkey returns as soon as the intent is injected so describe/tap
race a still-forking process. Resolve the LAUNCHER activity (or use
the user-supplied one), then run am start -W -n <component> and
positive-match Status: ok, sharing the helpers exported from
launch-app/platforms/android.ts.
A bounds-less node with 2+ children (a routine Compose pattern: an
unmeasured group around real components) used to be replaced with
`null`, dropping the entire subtree. The collapse rule only handled
the 0-child and 1-child cases — anything else lost its children
silently from the describe tree, so the agent never saw them.
Synthesise a frame that unions the children's frames so the wrapper
becomes a real node and every child remains reachable.
Independent clamping of x and width to [0,1] let the sum exceed 1 for
partially-off-screen views — e.g. a right-edge badge at bounds=[1090,0]
[1280,200] on a 1080-wide screen produced x=1, width=0.176, putting the
computed tap centre at 1.088 (off-screen). Same on the left for negative
origins from drawer/overlay states.
Add clipBoundsToScreen() and use the visible portion before normalising.
The describe-frame contract (x+w in [0,1]) now holds even for views the
device reports past the screen edge.
Two robustness issues in parseUiAutomatorXml:
1. Tag regex used [^<>]*? for the attribute block, rejecting any tag
whose attribute value contained a literal '>'. XML §2.4 only requires
'<' and '&' to be escaped, and real Android dumps emit text='A > B',
text='5 > 0', etc. unescaped. Switch to a charclass that consumes
complete quoted strings or any non-quote non-'>' character — the '>'
inside quotes is no longer a tag terminator.
2. A stray '</node>' with no matching opener called stack.pop() blindly,
then the next opening tag became a second 'root' (overwriting the
first) — silently dropping all earlier siblings. Guard the pop and
make root assignment idempotent so malformed dumps degrade
gracefully instead of losing entire subtrees.
The simulator-server blueprint takes a 'platform' arg and disposes
purely via proc.kill(), so the xcrun preflight was a stale iOS-era
gate that would 424 a host that only used Android. Drop 'requires'
and reword the descriptions to mention both iOS UDIDs and Android
serials.
These 10 tools have iOS-only blueprints under the hood, but their
zod schemas / ToolDefinitions never declared a capability matrix.
With nothing to gate against, an Android serial slipped past the HTTP
capability check and surfaced as a deep xctrace / dylib-injection
failure. Declaring 'apple: { simulator, device }' makes the gate
respond with 'Tool X is not supported on android emulator', matching
the contract paste / launch-app already use.
When the adb background server isn't running yet, `adb devices`
prefixes its output with banner lines like:
* daemon not running; starting now at tcp:5037 *
* daemon started successfully *
The previous `^(\S+)\s+(\S+)` regex parsed these as a phantom device
{ serial: '*', state: 'daemon' }, which the boot loop then adopted as
'a new emulator I just booted.' Skip lines starting with '*' and reject
any state outside the canonical adb state set.
Two related issues in runAdb / runAdbBinary:
1. execFile rejection drops the underlying `adb` stderr. Callers saw
a generic 'Command failed: adb …' instead of the real diagnostic
('device offline', 'more than one device', 'no devices/emulators
found'), so a hung daemon or a serial typo looked identical to a
transient failure. Catch + rethrow with stderr (or stdout, or the
raw message) interpolated into the error text.
2. execFile's default killSignal on timeout is SIGTERM, which an adb
client blocked on a wedged daemon socket can ignore — leaving the
parent waiting past the deadline and breaking the per-stage budget
that boot-device relies on. Switch to SIGKILL on both paths.
assertScreencapAlive checked `out.trim() === "0"`, so an empty
output (screencap missing, pipe broken, exec-out drained nothing)
trimmed to "" which !== "0" and was silently treated as healthy —
the exact opposite of intent. Switch to positive match on "1" so
any unexpected reading triggers the cold-boot fallback.
The recursive setTimeout chain in waitForEarlyExit kept firing forever
after Promise.race resolved with the adb winner, leaking a 500ms timer
across the entire process lifetime — real handle leak across many
boot/restart cycles. Switch to createEarlyExitRacer { promise, cancel }
and clear in a finally on every call site.
Same racer is now wired into stage 5 (pm path android), so a QEMU crash
between sys.boot_completed=1 and PackageManager replying surfaces as
the actual exit/signal error instead of the misleading 'PackageManager
did not respond' timeout.
The paste tool's capability matrix is { apple: { simulator, device } }
(no android entry), so calls with an Android serial fail at the HTTP
capability gate. Skills told users to 'try paste first, fall back to
keyboard' uniformly; tighten the guidance to use keyboard directly on
Android.
boot-device probes default_boot via -check-snapshot-loadable, hot-boots
with -force-snapshot-load and falls back to cold on failure (commit
4c48223). The skill still told users 'cold boot by default'; reword
to match the implementation and document the coldBoot escape hatch.
reinstall-app's zod schema only accepts { udid, bundleId, appPath };
the Android impl unconditionally passes -g, so 'grantPermissions: true'
was both unsupported by the schema and unnecessary in practice. Update
the four skills/rules that documented it.
Bare names like 'react-native-profiler' / 'create-flow' / 'device-interact'
won't resolve via the registered slug; switch to the canonical
'argent-<short>' form.
The registered slug for every shipped skill is 'argent-<short>'; bare
references like 'device-interact' or 'react-native-app-workflow' don't
resolve in MCP/Claude harnesses that look up by the canonical id.
The old /^[A-Za-z0-9._-]+$/ accepted '--user' / '-X', which would
let an attacker masquerade as a flag inside on-device 'am start -n …',
'am force-stop …', or 'cmd package resolve-activity …'. Tighten the
head to letters or underscore (and dot for activity, so '.MainActivity'
still works). Add positive injection-hardening cases for '--user',
'-X', '-foo', and a leading-dash activity.