Commit Graph

42 Commits

Author SHA1 Message Date
Ignacy Łątka 43d5b9d464 fix(native-devtools): let only a verdict that was reached be repeated
`indeterminate` turned terminal on `wasAdvisedToRelaunch` alone, which is a pid
change since the hand-out and nothing more — already true one `connecting`
reading after the relaunch. One unreadable `ps` in that window answered
`injection_failed` for an app two seconds into a healthy cold start, asserting an
earlier reading that measured a fresh process which never connected when the
earlier reading had said "wait a few seconds".

The verdict is now recorded when it is handed out, and `indeterminate` repeats
that record rather than inferring one. Both terminal readings share the endpoint
withdrawal too: losing the socket makes the premise a statement about the socket
for the unreadable reading exactly as for the measured one.

describe stops pairing `should_restart: true` — rendered to an agent as "call
restart-app and retry" — with a message that says no restart on either side
changes anything, and the six feature tools' `service_stale` clause gains the
caveat its `native-devtools-status` twin already carries, now that the status can
arrive with the endpoint-lost message.
2026-09-08 11:52:07 +02:00
Ignacy Łątka 673aae8dc6 Merge origin/main into fix/native-devtools-dyld-skip-terminal
Attach mode (provider_attached) short-circuits the shared precheck before the
injection advice: a provider-owned app has no relaunch of ours to prescribe.
native-network-logs keeps main's not-connected sentence alongside this branch's
injection_failed clause.
2026-09-08 10:59:13 +02:00
Mateusz Aliyev 6e9e45c9f0 feat: attach to devices offered by external providers (#735)
This pull request lets Argent drive a simulator or emulator another
process is already running, attaching to its simulator-server rather
than spawning a second one.

A provider writes a JSON descriptor to `~/.argent/providers/` listing
the devices it offers and the mechanisms Argent may use on each. They
appear in `list-devices` with an `ext:` id and work with the existing
tools. The file is re-read on every call, so a withdrawal or a narrowed
grant applies immediately.

`ios.additionalDeviceSets` (#600) already makes such a simulator
reachable by UDID. What changes is ownership. `boot-device` and
`stop-simulator-server` refuse these devices, anything the provider did
not grant is refused with a message naming it and Argent uses only the
endpoints its own simulator-server build serves. A grant binds to the
device rather than to one of its names, so the real udid or serial is
gated exactly like the `ext:` id. Android emulators are covered too and
a device visible to both a provider and `adb`/`simctl` is listed once.

The contract ships as `schemas/device-provider-v1.json`, validated by
`argent providers check`, and is documented in
`docs/reference/device-providers.mdx`. Includes unit tests and an e2e
phase where the harness acts as its own provider.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for discovering and connecting to externally managed
devices.
* Added `argent providers` commands for listing, validating, publishing,
withdrawing, and pruning providers.
* Added provider-aware simulator, debugger, profiler, native tools, and
device listings.
* Added capability controls, endpoint validation, revocation handling,
and provider-specific diagnostics.
  * Added automatic CLI discovery for provider integrations.
* **Bug Fixes**
  * Improved service recovery and paused-runtime error reporting.
  * Improved simulator keyboard and paste command reliability.
* **Documentation**
* Documented provider descriptors, capabilities, lifecycle rules, and
CLI usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-04 22:02:57 +02:00
Ignacy Łątka e7f1000e1e fix(native-devtools): keep the terminal verdict standing when a read cannot inspect the process
An `indeterminate` reading measured nothing, but its own remedy prescribes a
relaunch and then a tool-server restart - and that restart clears the record the
terminal verdict rests on. So one unreadable `ps` handed an agent both steps the
verdict had just forbidden, and two reopened the cycle. It is now terminal while
a verdict is standing, saying the read measured nothing rather than re-arguing
it. ios-remote cannot reach it: it never inspects a process, so it never records
the relaunch this consults.

Also from review:

- A ViewInspector reply whose `error` is not `{message}` rejected the RPC with
  an empty string; rendered as the sibling ax-service reader renders it.
- The create-flow injection_failed line said coordinates were not authorised,
  which contradicts the coordinate remedy the messages themselves prescribe; it
  now names what that yields - a fragment, not a QA flow.
- `service_stale` on the six feature tools promised the message names a terminal
  fallback, which the endpoint-lost message does not; `unregistered` on
  native-devtools-status forbade a restart that same message prescribes.
- Narrowed the ndjson-socket and hintReachesAgent docs to what the code does:
  empty frames are skipped without an onDropped report, and a describe read
  re-rendered by MCP auto-describe carries no hint at all.

Pins for five behaviours nothing caught: the flow tree's terminal short-circuit,
the handshake pattern's leading digit, its 256-character cap, that
`indeterminate` never records a relaunch, and the RPC error rendering.
2026-09-02 21:36:59 +02:00
Ignacy Łątka dd7170da05 fix(native-devtools): refuse non-record frames in the shared reader, and correct the prose the terminal verdict falsified
The null-frame guard moves from native-devtools' handler into
attachNdjsonReader, where it covers the ax-service and Android-helper
consumers too and narrows onMessage to `object` so no future consumer can
skip it. A frame that parses but is not a record now reports through
onDropped like any other unusable frame.

Also: bound the refused-handshake log line on every branch (only the string
one was capped), stop the endpoint-lost message asserting a rival that may
no longer be on the path, say "carries argent's dylib but never dials" on
the two surfaces that still claimed dyld loaded it, correct the terminal
diagnosis's enumeration of re-probe outcomes and its 2x-budget gloss,
narrow holdsEndpoint's doc to the one reading it gates, un-orphan
bindNativeDevtoolsUnixSocket's JSDoc, and drop the create-flow parenthetical
that authorised coordinates the gate it cited forbids.
2026-09-02 21:01:23 +02:00
Ignacy Łątka 4adf96e137 fix(native-devtools): let a status probe report the terminal state without failing its flow step
`isNativeDevtoolsBlockResult` matched a flat status set against a flat tool set,
so `injection_failed` off `native-devtools-status` failed a flow step with
"native-devtools-status did not run" - about a call that ran, and whose whole
work is reporting that state. A recorded status probe flipped from pass to fail
the day its app broke. The set is now per tool: the six feature tools block on
all five, and the three that run the 2-arg precheck block on `init_failed`,
which is the only one it can produce.

The seven tool descriptions no longer frame the message as diagnostic-only.
They said the state is TERMINAL and to read the message "for the likely cause",
while that message prescribes a wait and one passive re-probe - the
disambiguator for the cold start this verdict cannot tell apart, and so the
step an agent skips first. The prohibitions on both restarts stay; the
finality claim ahead of the message goes.

`noteRelaunchAdvice` keeps the first hand-out instead of re-stamping it. A
process replaced inside the age slop reads `stale_process` on some polls, so a
polling reader - the flow tree reads several times a second - kept moving the
anchor onto the replacement and withheld the verdict indefinitely.
2026-09-02 18:19:13 +02:00
Ignacy Łątka 9ad53b7217 fix(native-devtools): stop the terminal verdict speaking for a socket this service lost
The per-UDID socket path carries no server identity and the last binder owns
it, so a second argent tool-server on one simulator takes every future dial.
The first then reads a healthy, connected app as `unregistered`, turns that
terminal, and closes with "a tool-server restart does not help here either" -
while restarting it and calling restart-app once is what recovers. The service
now compares the path's inode against the one it bound: when it no longer owns
the endpoint the reading is about the socket, so the verdict is withheld and
the message names the rival binder and that recovery.

The re-probe the diagnosis prescribes is also re-anchored to an outcome the
surfaces emit. Once terminal they return `{status, message}` with no `state`,
so "if the re-probe still reads unregistered" gated the boot-device rung on
something no tool can report; a repeat of the diagnosis is what an agent
actually sees, and doubles as the second-landing test the message lacked. The
wait is derived from the connect budget instead of spelled out beside it.
2026-09-02 18:15:23 +02:00
Ignacy Łątka 6d8b006696 fix(native-devtools): pin the terminal contract, and correct three claims it falsified
The review round found the injection_failed work resting on prose and tests
that do not hold it.

`relaunchAdvised.delete` on the handshake was unpinned: the test named for it
never took a reading, so `lastSeenPid` had no entry, `noteRelaunchAdvice`
recorded `null`, and the verdict compared one absent pid with another. It now
drives the case the code's own comment describes — an app that answers the
relaunch, registers, then drops its socket — and fails when the delete goes.

`native-devtools-status` is the surface the flow-recovery ladder routes to, and
neither its terminal return nor its description arm was held by anything; the
precheck's dead-end warning was one identifier away from the non-injectable
text, which is false for this state; and `describeIos` recorded the relaunch
hand-out from await-ui-element's and await-screen-idle's per-poll reads, whose
hints are discarded, letting any later process replacement pass as a relaunch
nobody was asked to perform. Each is now pinned, and describe records only
where its hint is rendered.

Three claims this work falsified are corrected: the launch gate's exclusion
rationale still credited the stale/unregistered flip with proving the relaunch,
which the pid keying replaced; the precheck-tools JSDoc said the non-feature
tools can only answer `init_failed`; and the no-peer localisation read the live
connection map while asserting history. The description arms also stated the
dyld cause as settled where the message they point at declines to, and
`native-devtools-status` alone left the tool-server restart un-forbidden four
lines under a rule prescribing it.
2026-08-18 20:29:07 +02:00
Ignacy Łątka 0d05e02a84 Merge remote-tracking branch 'origin/main' into fix/native-devtools-dyld-skip-terminal
#560 landed on main as a squash, so every file it touched conflicted between
main's copy and this branch's merged-in copy of the same work. Resolved against
#560's pre-squash tip as the base, which reduces those to the places main
genuinely moved past it.

Two real conflicts:

- `NATIVE_DEVTOOLS_BLOCK_STATUSES`, which main added to make a blocked
  precheck fail its flow step instead of passing it, enumerated the four
  statuses that existed when it was written. This branch adds a fifth,
  `injection_failed`. A `Set<NativeDevtoolsPrecheckBlock["status"]>` accepts a
  subset, so tsc stays green while a `tool: native-describe-screen` step blocked
  as `injection_failed` reports `pass` for a read that never ran. Added the
  status and a row to the table-driven gate test, which fails without it.

- `reliability-and-recovery.md`, where both sides rewrote the same section after
  the launch gate stopped failing system-app launches. Kept main's prose, which
  states the mechanism and the cost, and carried over this branch's
  `injection_failed` step and its coordinate-tap bullet.

Verified: eslint 0, prettier 0, tsc --build 0, tsc -p tsconfig.test.json 0,
extract-tools 46/46, tool-server suite 4293 passed / 1 skipped.
2026-08-18 13:29:24 +02:00
Ignacy Łątka 5ac6805040 fix(native-devtools): derive restart_required from the running process instead of asserting it (#560)
this is urgent 

---

## Problem

Every `native-*` tool can get stuck telling the agent to do something
that will never work.

I hit this during an end-to-end pass over 0.17.0. After a mid-session
simulator reboot, `native-devtools-status` reported:

```json
{ "envSetup": true, "appRunning": true, "connected": false,
  "requiresRestart": true, "nextLaunchWillBeInjected": true, "injectable": true }
```

and every `native-*` tool returned `restart_required` with "Call
restart-app then retry." Following that advice never helped, and the
message offered nothing else. `flow-execute` was blocked too, since its
`launch` step needs the connection, and flow recording silently degraded
a portable `tap: { selector }` to raw coordinates when it couldn't reach
native devtools.

The app was injected the whole time. All three dylibs were mapped into
the process and the simulator log showed a successful dial on every
launch, while the tool-server held the listening end of that exact
socket and still reported `connected: false`. `argent server stop &&
argent server start` cleared it instantly.

## The real defect is the state, not the wording

`requiresAppRestart` returned `true` for every app without a live
connection:

```ts
async requiresAppRestart(bundleId) {
  if (connections.has(bundleId)) return false;
  await reverifyEnv();
  return true;   // <- asserted, never measured
}
```

That is not a measurement. `restart_required` was a constant wearing a
state's clothes, and the advice built on it stays literally true
forever, so an agent obeying it restarts the app indefinitely.

Softening the message would have left that intact. This PR measures the
state instead.

## Fix — derive it from the running process

`restart-app` relaunches into the simulator's **current** launchd
environment, so it can only ever help a process launched under different
terms than a fresh launch would get. That is answerable: read the
process. `launchctl list` already gives its pid; `ps eww -p <pid> -o
etime=,command=` gives the environment it was exec'd with and its age.
Simulator apps are ordinary host processes owned by the same user, so
both are readable.

`appConnectionState` (replacing `requiresAppRestart`) returns:

| state | meaning | remedy |
| --- | --- | --- |
| `connected` | devtools are live | — |
| `not_running` | no process to read | `launch-app` |
| `stale_process` | no bootstrap dylib, a previous run's endpoint, or
older than this service's listener | `restart-app` |
| `unregistered` | injected against **this** endpoint, started after the
listener came up, still silent | restart the **tool-server** |
| `indeterminate` | process could not be read (ios-remote, unreadable
`ps`) | `restart-app`, with the loop warning |

Only `not_running` / `stale_process` / `indeterminate` report
`restart_required`. `unregistered` gets a new `service_stale` precheck
status that names the tool-server restart and explicitly says not to
relaunch the app — the launch a restart would perform has demonstrably
already happened.

The listener-age comparison is load-bearing: after a tool-server restart
the unix path is identical (it is derived from the udid) but the inode
is new, so an injected process older than the listener still needs
relaunching. Without that check this would send people off to restart a
tool-server they had just restarted — a new loop in place of the old
one. Both grace comparisons lean away from `unregistered` - the first
towards `stale_process`, the second towards `indeterminate`, and both of
those ask for a relaunch - so an uncertain read costs at most one wasted
`restart-app`.

Call sites follow the same rule rather than each inventing guidance:

- `describe` withholds `should_restart` for `unregistered` (setting it
would rebuild the restart-app → describe loop through a live service
instead of a system app) and carries the diagnosis as a `hint`, which
still marks the empty read untrustworthy for `await-ui-element`.
- `flow-run` raises the measured diagnosis instead of a blanket
"relaunch it" — both when a flow
resolves a selector against the full hierarchy, and at the `launch`
step's readiness gate, which
previously polled for the connection and then guessed at "a stale or
duplicate argent server".
- `native-devtools-status` reports `state`, and `requiresRestart` is now
false for `unregistered`.
- The six `native-*` tools type their block as the shared
`NativeDevtoolsPrecheckBlock` instead of restating it, so a new precheck
outcome cannot reach an agent through a tool that never mentions it.

## Scope

Why the service can stop registering a connection it is listening for is
still #561 and is not fixed here. What changes is that the state is now
measured, so that defect reports itself accurately instead of
masquerading as an app that needs restarting.

## Verification

**E2E, real iOS 18.6 simulator, Bluesky (RN 0.81.5).** Two tool-servers
were raced onto the same per-udid socket — the #561 shape, where the app
is genuinely injected and connected, just to the newer instance. Both
were then queried on the same app at the same instant:

| | `connected` | `requiresRestart` | `state` | `native-describe-screen`
|
| --- | --- | --- | --- | --- |
| main, stale instance | false | **true** | — | `restart_required` —
"Call restart-app then retry." |
| this branch, stale instance | false | **false** | `unregistered` |
`service_stale` — "Restarting the app cannot change that … restart the
tool-server" |
| healthy instance | true | false | `connected` | `ok`, 44 elements |

Restarting the app in main's row lands on the newer instance again,
leaving the queried one stale forever. That is the loop, reproduced on
demand.

The other states were walked on the same simulator: a pre-existing
process against a fresh listener → `stale_process` (`requiresRestart:
true`); `restart-app` → `connected`; terminate → `not_running` ("Call
launch-app (or restart-app)").

**Unit.** `native-devtools-app-state.test.ts` drives the real blueprint
factory with a stubbed process table and covers all five states plus the
`etime` and env-token parsers. Mutation-checked — dropping the
listener-age comparison, the endpoint match, or the connect grace each
fails exactly one test and nothing else.

**Suite.** `vitest run` in `packages/tool-server`: 296 files, 3113
passed, 1 skipped. `tsc --build` across the workspace, `tsc --noEmit -p
tsconfig.test.json`, `eslint` and `prettier --check` on every changed
file: clean. `origin/main` merged in first.



## Review round

A review pass over the derivation itself, with each finding reproduced
before it was acted on.

**`describe` computed the diagnosis and threw it away.**
`buildAppStateMessage` ran for every
unconnected state, but only `unregistered` carried it out; the rest
returned a bare
`should_restart: true`. That boolean reaches an agent as an undocumented
JSON field — `describe`'s
own description never mentions it — and the single place it is rendered
as prose is
`await-ui-element`'s timeout note, which spells it *"call restart-app
and retry"*. So the two states
whose message carries the escape hatch lost it exactly where it was
needed. `indeterminate` is the
sharp end: on `ios-remote`, whose app processes are out of reach of the
local process table, it is
the **only** reachable unconnected state, so that path had no exit at
all. The diagnosis now rides
out as a `hint` for every state; `should_restart` still marks only the
states a relaunch fixes.

**`native-devtools-status` measured the same device twice.**
`isAppRunning` ran its own
`launchctl list`, and `appConnectionState` ran another one after
re-verifying the launchd env — so
the two snapshots sat seconds apart and could contradict each other
(`appRunning: true` beside
`state: "not_running"`), with `requiresRestart` gated on the staler of
the pair. Both are derived
from one measurement now; only `indeterminate`, which genuinely leaves
running-ness unanswered,
pays for a second probe.

**Coverage.** Mutation testing found the 3 s grace term entirely
unpinned — the fixtures sat 200x
away from the boundary on either side, so both dropping the term and
weakening `>=` to `>` kept the
suite green while flipping a relaunchable process to `unregistered`, the
one verdict the file exists
to withhold. Also unpinned: the `reverifyEnv` call (swapping it for the
latching `ensureEnvReady`
the comment explicitly forbids passed the whole suite) and an
unparseable `ps` age staying "no
evidence" rather than becoming a measurement. All three are now covered,
and each new test was
confirmed to fail on the un-fixed code.

**Verified against the real thing.** The `launchctl` and `ps` contracts
were re-checked on an
iOS 18.6 simulator rather than reasoned about: row layout and pid
column, one-line `ps eww` output
with the argent tokens present in a live app's environment, `etime`
matched against `lstart` to the
second, and the four `etime` format strings read out of the `ps` binary.
Two corrections came out of
it — the environment can reach `kern.argmax`, which is exactly Node's
default `maxBuffer`, so the
probe now sets the same 16 MiB its siblings use; and `ps` suppresses the
environment of a
SIP-protected platform binary, which the docstring's "never as 'not
injected'" promise did not
account for (unreachable here, since `com.apple.*` is rejected as
non-injectable upstream, but the
promise now says so).

**Also corrected:** the `argent-device-interact` skill offered
`restart-app` as the only remedy for
a missing injection; the dylib-platform check in
`download-native-binaries.sh` and a
`requiresAppRestart` reference in `boot-device.ts` described states that
no longer exist; and three
tool descriptions still said *"Fails if native devtools are not
connected"* directly beside the new
sentence explaining that this case returns a status rather than failing.

**Known and deliberately left** (both pre-existing, neither introduced
here):
`describe` with no `bundleId` auto-targets from the connected list, so
an unregistered app throws
`NATIVE_TARGET_NO_CONNECTED_APPS` before any state is measured and the
message is swallowed by the
fallback's catch — surfacing it would newly mark those empty reads blind
for `await-ui-element`'s
`hidden` condition, which wants its own verification pass. And a dylib
that is inserted but silently
skipped by dyld (a platform mismatch) reads as injected-but-silent, so
`service_stale` → tool-server
restart → `stale_process` → `restart-app` is a two-step cycle;
distinguishing it needs a load
confirmation the process table cannot give.



## Review round 2

A second pass over the derivation, run as four independent sweeps
(claims-vs-code, nearest-twin
and reachability, non-happy paths and inputs, and absence). Three of the
four independently landed
on the same defect.

**The flow launch gate was the one caller that never asked whether the
app could be injected at
all.** Every other consumer of `appConnectionState` gates `com.apple.*`
first - the precheck
throws, `describe` returns its terminal hint, `native-devtools-status`
short-circuits,
`flow-ios-tree` only ever auto-resolves a connected app.
`waitForNativeDevtools` measured the raw
launch id. That matters because the launchd environment carrying the
bootstrap dylib is
simulator-wide, so a system app's process inherits the very tokens the
measurement reads.
Confirmed on an iOS 18.6 simulator: `com.apple.Preferences`, launched
under argent, reports

```
71940	0	UIKitApplication:com.apple.Preferences[9a63][rb-legacy]
etime=00:03
DYLD_INSERT_LIBRARIES=.../libArgentInjectionBootstrap.dylib
NATIVE_DEVTOOLS_IOS_CDP_SOCKET=/tmp/argent-nd-39B9C547.sock
```

Run through the shipped algorithm that is `processCarriesInjection:
true` against an older
listener, i.e. **`unregistered`** - so a flow with an `ios:` launch
entry of `com.apple.Preferences`
failed its launch step with *"restart the tool-server"*, for an app no
tool-server restart can ever
help. Library validation means it can never connect, so the gate now
says that terminally.

**`describe` dropped the entire diagnosis when the probe rejected.**
`appConnectionState` re-applies
the launchd env before it can answer anything, so it throws outright on
a sim that goes away
mid-call; that landed in the native fallback's outer `catch` and
returned an empty tree with no
hint. An agent saw "nothing on screen" rather than "could not be read",
and `await-ui-element` had
nothing to append to its timeout note. The two other consumers of the
same call already degraded to
`indeterminate` instead - this one now matches them.

**`native-devtools-status` re-ran the probe that had just failed.** One
route into `indeterminate`
is `inspectRunningApp` rejecting, i.e. `simctl spawn <udid> launchctl
list` failing. The
running-ness fallback then issues that same command unguarded, so a
shut-down simulator surfaced a
raw subprocess error rather than the structured `init_failed` guidance -
which the branch 40 lines
above it handles correctly, for exactly this reason.

**`native-devtools-status` was the only surface withholding the loop
escape.** Every other consumer
carries `buildAppStateMessage`, whose `indeterminate` text ends *"do not
keep restarting the app;
restart the tool-server"*. This tool returned booleans only, and
`requiresRestart: true` is the
whole of what an `indeterminate` app tells you. On ios-remote, where no
app process can ever be
inspected, that is the only state a running app reaches - so the tool
whose stated job is verifying
readiness answered with the restart loop and nothing else. It now
returns `message`.

**Also fixed:** both probes log their own failure (a broken probe
degrades every app to
`indeterminate`, indistinguishable at the tool surface from a genuinely
uninspectable one - the
sibling `ps` probe in `vega-process.ts` logs for the same reason); the
gate skips its
several-second measurement once the run is cancelled; and the gate no
longer repeats the bundle id
its caller's prefix already names.

**Prose that had drifted from the code:** `not_running` is reachable on
ios-remote too, so
`indeterminate` is only the sole unconnected state a *running* app
reaches there; the second grace
comparison leans to `indeterminate`, not `stale_process`; four of the
five states settle
running-ness, not four besides `not_running`; a null pid means a row
that did not parse, not a job
launchd reported as having no process (measured: a `UIKitApplication`
row is removed outright when
the app exits, and `-` appears only on daemon labels);
`waitForNativeDevtools`'s documented return
contract described the branch the change had rewritten; and a rationale
comment was attached to the
test above the one it described.

**Coverage.** The gate's measured reason had none at all - replacing the
whole diagnosis with a
literal left all 542 flow tests green. Eleven mutations were run against
the fixes and every one is
now killed: the injectability guard, the measured reason, the doubled
bundle id, `describe`'s reject
guard, `describe`'s `unregistered` hint merge, the `message` field, the
precheck fallback, the
`appRunning` guard on `requiresRestart`, a state renamed in the
description only, the connect grace
widened from above (the old fixtures all sat 10x clear of it, so it
could have grown to 25 s
unnoticed), and the `indeterminate` guidance line.


---

## Review round 3 — external review findings

Six findings from @hubgan's E2E review, each reproduced before it was
acted on. Fixed in b9d17437
and 3eb59ef7. **This round supersedes several claims made above — they
are listed at the end.**

**The launch gate refused every `launch:` of an Apple system app.** A
coordinate-only flow gave
`["launch:error","tool:skip"]` with `resolveService` called 0 times —
the guard short-circuited
before any measurement — and the reason it emitted said *"Drive it by
coordinate instead"*, from
the gate that had just killed a purely coordinate-driven flow.
`argent-create-flow`'s worked
example (SKILL.md:171, :199-209) is exactly that shape.

The premise underneath it did not survive checking. #453, which
motivated `isInjectableBundleId`,
recorded `connected: false` for `com.apple.Preferences` on an iOS 26.5
sim; the review recorded
`connected: true`, with both dylibs mapped, on 18.5. So injection into a
simulator system app
appears to be runtime-dependent rather than categorically impossible,
and neither reading is
checkable from CI. The gate therefore no longer decides: it polls
exactly as before — where the app
does connect, nothing changes — and only on timeout does a
non-injectable id return "no failure"
instead of a reason. The launch step's job is to start the app; it
succeeded. **Whether
`isInjectableBundleId`'s blanket prefix rule is correct at all is now an
open question, and a
separate one from this PR.**

**The flow tree source could not report it either, because its
measurement was unreachable.**
`resolveNativeTargetApp(api, undefined)` draws candidates from
`listConnectedBundleIds()` — the same
map `appConnectionState` reads to answer `connected` — so a
non-connected app threw the stock
auto-target error first, whose "Launch or restart the app first" is the
loop this PR exists to
break. Driving the real factory: `appConnectionState === "unregistered"`
and
`listConnectedBundleIds() === []` at the same instant. The race between
the two is closed, not
narrow: no macrotask boundary separates them, while the socket `close`
handler is one. The branch's
own test passed only because its stub let the two accessors disagree.
The flow now threads the id
its `launch` step named — which does not come from that map — through
`ActionEnv` → `fetchFlowTree`
→ `queryFullHierarchyTree`; the unreachable branch is gone.

**The gate emitted `buildAppStateMessage` verbatim for every state but
`unregistered`.** Those are
written for the native-* tool surface, whose reader has not launched
anything, so a crash-on-launch
app was answered with "Call launch-app (or restart-app) then retry" —
the action the step had taken
9.5 s earlier. `flowLaunchGateReason` now rewrites each state for a
caller that has just launched,
exhaustively, so a state added later cannot inherit the tool-surface
remedy.

**`describe` without a `bundleId` — the default form — lost the whole
diagnosis.** Auto-targeting
throws before anything is measured and the outer catch returned the tree
bare, so
`await-ui-element`'s blind-read guard (which keys off exactly
`hint`/`should_restart`) let a
`hidden` wait release in 0 ms against an element still on screen, where
the same call *with* a
`bundleId` correctly refused. An empty read the hierarchy could not
corroborate now says so. A
device with **no** native-devtools service at all is deliberately left
unqualified — that is an
absence of a second opinion, not a failed attempt at one, and marking it
blind broke a real
`await-ui-element` case.

**Three of the six `native-*` descriptions named the terminal system-app
rejection and three did
not**, though all six throw the identical error (verified: one
byte-identical message across all
six). The clause is inlined verbatim in all six —
`scripts/extract-tools.mjs` accepts only a single
plain literal and silently drops anything else from the SpiderShield
scan.

**`not_running` cannot tell "stopped" from "not installed"** — both
simply lack a
`UIKitApplication:<id>` row — and for the second reading its only remedy
is refused outright by
`simctl launch`. It now names both readings instead of asserting the one
it cannot see. A real
probe was considered and not taken: `get_app_container` needs a booted
sim and ios-remote has no
such verb, so it would answer `undefined` on exactly the host where
`indeterminate` is already the
only reachable state.

### Claims above that this round supersedes

- *"Library validation means it can never connect, so the gate now says
that terminally."* — the
gate now lets system-app launches through; the premise is contested (see
above).
- *"The flow launch gate was the one caller that never asked whether the
app could be injected at
all"* and *"`flow-ios-tree` only ever auto-resolves a connected app"* —
both inverted here.
- *"Known and deliberately left"* — the `describe` no-`bundleId` path
was closed, with tests, so
  that paragraph now over-counts by one.
- The suite figures in **Verification** and **Review round 2** are
pre-round-3 counts.


---

## Review round 4 — reviewing round 3

Three lenses over 856efd8e's two parent commits (claims-vs-code,
non-happy
paths + reachability, absence + mutation). Round 3 did not survive
intact, and
what follows is what was wrong with it.

**Three statements were false in prose an agent acts on.** `connecting`
requires
a process inside the 3 s connect grace, while this gate takes its
verdict 9.5 s
after launching the app — so its remedy claimed the step had launched
the very
process it was measuring, next to a base message reading "it launched
moments
ago". The figure that arm and `not_running` quoted was the connect wait
alone,
omitting the 1.5 s post-launch settle that precedes it
(`LAUNCH_TO_VERDICT_MS`
now names the whole spend). And `stale_process` blamed the launchd
environment,
which is false for the producer reached only when
`processCarriesInjection` is
**true** — that process carries this endpoint and is merely older than
the
listener. The measured text it wraps names both producers, so the two
contradicted each other in one message.

**A `hidden` wait still released in 1 ms.** Round 3 carved out a failed
native-devtools resolution as "no service on this device at all". The
blueprint
is registered unconditionally, so on iOS that rejection only ever means
the
service failed to come up — a socket bind losing to a concurrent
same-udid
server, which this repo documents as the dominant crash source in 0.16.0
telemetry. Reproduced: `describe` → no hint, then `await-ui-element
hidden` →
`{"success":true,"elapsed":1}`. It is now as unexplained as its
siblings.

**The failure-code discrimination was defeatable.** The flow tree source
classified auto-targeting's throw by reading a code off a module-local
`Symbol`,
so a duplicate `@argent/registry` instance would read it as absent and
fall back
to the exact message the change replaces. It tests the empty connected
list
directly now — the same condition, structurally, with no symbol
involved. The
`connected` arm added in round 3 was unreachable for the same reason the
branch
it replaced was, and is gone.

**Coverage the mutation lens found missing.** Three of the four sites
threading
the launched bundle id were unpinned — including the `settleTree` read
every
`tap`/`type`/`scroll-to` uses, so an action step could have silently
lost the
terminal reason with the suite green. Also unpinned: `unexplainedHint`
dropping
a pre-existing degraded hint (where its exact twin fifty lines up **is**
pinned), the tree source's "Flows resolve selectors" half, and both of
the
gate's withholdings.

**Test cost.** The four per-state gate tests drove the whole runner in
pumped
fake time, ~9 s each, which is what pushed this file past vitest's
default under
parallel load. The mapping is a pure function and is tested as one; one
end-to-end test still proves the gate reaches it. 46 s → 16 s, with more
tests
than before. Widening the fake-timer step was tried and is worse (52 s)
— the
pumps are what let the run's disk I/O proceed.

**Also corrected:** comments the round-3 commits had falsified in the
very files
they edited; the `ActionEnv` claim that a fragment never carries a
launch step
(a flow is classified by its FIRST non-echo step, so a later launch is
legal and
does thread its id); `isBlindRead`'s enumeration of what sets its flags;
and
change narration that had leaked into permanent prose.
`argent-create-flow` now
states what a `com.apple.*` target costs a flow — its worked example is
selector-driven, and round 3's justification comment claimed the
opposite.

**Verification.** 17 mutations across both rounds, every one killed by a
specifically-named test. Two consecutive full runs of
`packages/tool-server`:
3181 passed, 1 skipped, and 6 failures in `boot-device-hotboot.test.ts`
that are
pre-existing on this host (it asserts `-gpu darwin`/`swiftshader` where
a Linux
box resolves `-gpu host`; the file is untouched by this PR). `tsc
--build`,
`tsc --noEmit -p tsconfig.test.json`, eslint and prettier clean on every
changed
file; `scripts/extract-tools.mjs` emits all 75 tools with zero warnings.


---

## Review round 5 — reviewing round 4

Round 4 left two of the defects it set out to fix and introduced one. It
also
broke CI.

**ESLint failed on the push.** Two `` \` `` escapes inside a
double-quoted string
(`no-useless-escape`). The pre-push check missed it because it was piped
through
`tail`, so the shell read the pipe's exit status rather than eslint's
and
reported success over a failure. Gates are run unpiped now, and
repo-wide —
`eslint .` is what CI runs, and a changed-files invocation covers a
subset of it.

**`unregistered` still quoted the connect wait alone** — the exact
understatement `LAUNCH_TO_VERDICT_MS` was introduced for, applied in
round 4 to
`not_running` only. And the end-to-end test asserted `8000 ms` while its
sibling
fifty lines down asserted the whole spend, so the file enforced two
contradictory rules and the *correct* figure would have failed CI. The
post-launch settle counts: the connection is read off the live map and
the poll
checks it once before its first sleep, so a dial landing inside those
1.5 s is
caught. All three arms that quote a figure now quote the same one,
pinned
together.

**`stale_process` named the launchd environment as fact** four lines
below a new
comment saying that is false for one of the state's two producers. It is
in fact
sound — but only where the message already scopes it, to a *second*
landing,
whose relaunch makes the process younger than any established listener
and so
rules that producer out (it needs `processAge + grace >= listenerAge`).
The
comment now says which producer and why. The test named
"does not blame the launchd environment" asserted only the phrase it
kept, so it
was green with the blame present; it now pins the ordering it is named
for.

**The `connected` arm is reachable — round 4's "unreachable" claim was
wrong.**
`appConnectionState` re-reads the live connections map *after* its env
re-apply
and process probe (`native-devtools.ts`), several simctl round-trips
past the
empty list that routed the call there, precisely so a dial landing in
that window
is not reported as unregistered. Folding the arm away left that case
throwing a
bare "Flows resolve selectors…" with no app named and no remedy. It is a
branch
again, and says the connection arrived mid-read.

**`unexplainedHint` dropped `screenshot` along with the `bundleId`
suggestion**,
so a caller that passed a bundle id received a hint with no next action
at all —
while `argent-device-interact` tells the agent to follow that hint.
`screenshot`
is unconditional now. Its JSDoc also claimed two of three call sites are
reached
only after an explicit id resolved and measured connected; all three are
reachable with an explicit id, as the file's own comment twenty lines
above says.

**Also:** `argent-create-flow` recommended `tool: gesture-tap` where the
same
file documents the first-class, equally tree-free `tap: { x, y }`; and
change
narration removed from four more comments.

### Standing caveats

- **The `com.apple.*` premise is still unsettled and is not settled by
this PR.**
  #453 recorded `connected: false` on iOS 26.5; the review recorded
`connected: true` with both dylibs mapped on 18.5. Every change here is
correct
under either reading, but `isInjectableBundleId` remains a blanket
prefix
  predicate and may be wrong on some runtimes. Worth its own issue.
- `boot-electron-spawn-error.test.ts` and `boot-device-hotboot.test.ts`
fail on
the Linux host used for this work; both were confirmed failing at the PR
base
  `b2e5c54d` and neither file is touched here.


---

## Review round 6 — reviewing round 5

Round 5's own headline fix was both broken and unpinned.

**The hint stopped parsing.** Moving `screenshot` out of the conditional
clause
without rejoining the sentence produced, on the default no-`bundleId`
form:

> … not evidence that nothing is on screen. Pass `bundleId` to have the
connection state measured, or Take a `screenshot` to see what is there.

That is the exact string `argent-device-interact` instructs an agent to
act on.
Both forms are one sentence again, and both are now pinned by a test
anchored to
the end of the string rather than matching a substring.

**And deleting the action entirely left the suite green.** The defect
round 5
describes — "a caller that passed a bundle id got a hint with no next
action at
all" — could be reintroduced with 635 tests passing. Nothing asserted
the hint
named a next step. It does now.

**Two claims round 5 corrected in the source survived verbatim in
tests.** The
"two of the three call sites are reached only after an explicit id
resolved and
measured connected" partition (all three are reachable with an explicit
id), and
"waited only NATIVE_READY_TIMEOUT_MS" — sitting ten lines above the
assertion
that now forbids that figure.

**A guard test did not test its own name.** `does not blame the launchd
environment for a stale process` pinned only *where* the blame sits.
Restating
it as fact on a first landing — `It lands here twice, so the …` —
passed. It
asserts the conditional now, which is the whole distinction: only a
repeat rules
out the producer that carries this endpoint.

**The `connected` branch still had no coverage** — which is how round 4
came to
delete it as dead in the first place. It is exercised now, including
that its
message still names the app and an action.

**Also:** the terminal system-app reason recommended `tool: gesture-tap`
where
the same round-5 fix in `argent-create-flow` had moved to the
first-class,
equally tree-free `tap: { x, y }`. The twin was missed.

**Verification.** Four mutations against these fixes, all killed:
dropping the
`screenshot` action; reintroducing the broken join; stating the launchd
blame as
fact; folding the `connected` branch away. Gates run unpiped and read by
exit
code — `eslint .` 0, `tsc --build` 0, `tsc --noEmit -p
tsconfig.test.json` 0,
`prettier --check .` 0.


---

## Review round 7 — reviewing round 6

**The gate asserted the contested fact as settled.** Round 3's
justification for
keeping the 8 s wait read "a system app's process inherits the injection
tokens
**and does connect on some runtimes**". Eight other places in this repo
say the
opposite — `blueprints/native-devtools.ts` ("our dylib can never load",
and the
agent-facing precheck throw), `native-devtools-status`, `describe`'s iOS
hint,
and `test/describe-tool.test.ts` ("a com.apple.* app never connects"), a
file
these commits edit. `flow-ios-tree.ts` contradicted *itself*, its
docblock
saying "can never load" above a message saying "cannot be relied on to
inject".

The fact is not settleable here, so nothing asserts it now. The comment
cites
both observations — #453's `connected: false` on iOS 26.5, the review's
`connected: true` with both dylibs mapped on 18.5 — and justifies the
wait
without picking: polling costs this step its timeout under the
pessimistic
reading and is the only thing that makes selectors work under the
optimistic
one, so waiting spends time on an open question where skipping would
bake an
answer into behaviour. The tree source's docblock is hedged to match the
message
it already had.

**This PR does not resolve that inconsistency**, and
`isInjectableBundleId`
remains a blanket `com.apple.` prefix predicate. It deserves its own
issue.

**A guard let the thing it guards be deleted.** Round 6 made the
repeat-landing
assertion conditional (`if (blame !== -1)`), so removing the whole
clause —
leaving a reader who lands there twice with "re-run the flow" and no
escape —
passed 36/36. Unconditional again, wording pinned.

**A rationale that was false in both directions.** "Keeps the
`not.toContain`
guards non-degenerate: they would pass vacuously if the constants were
made
equal" — equal constants make them *fail*, and 500 vs 1500 makes them
fail for
the wrong reason (`"1500 ms".includes("500 ms")`). It pins the substring
property those guards actually need.

**Two more presence-only guards.** "keeps the ax-degraded boot guidance
**ahead
of** the unreadable-hierarchy note" asserted only that both strings
appear, and
passed with them swapped. And round 6's own `tap: { x, y }` twin fix had
no test
at all — the same absence that got the `connected` branch deleted as
dead two
rounds earlier. Both pinned, and the `tap: { x, y }` claim was verified
end-to-end: that step form parses, resolves through `resolveTargetPoint`
with no
tree read, and passes in a flow whose selector step fails with this very
message.

**Two test names promised more than they checked** — a remedy "that is
not the
action the step just took", asserted for states whose remedy is another
relaunch; and a crash-on-launch guard forbidding only the literal `Call
launch-app` while the arm legitimately suggests launch-app for watching
the exit.

**Verification.** Three mutations, all killed: deleting the
repeat-landing
clause, reverting the coordinate form to `tool: gesture-tap`, and
putting the
read-note ahead of the boot guidance. Gates read by exit code, unpiped:
`eslint .` 0, `tsc --build` 0, `tsc --noEmit -p tsconfig.test.json` 0,
`prettier --check .` 0. Suite: 3187 passed, 1 skipped, and the 6
pre-existing
`boot-device-hotboot` failures on this Linux host.


---

## Review round 8 — reviewing round 7

**The gate's own justification was false, and the disproof was already
in this
PR's review.** Round 7's comment claimed polling "is the only thing that
makes
selectors work at all" under the optimistic reading. It is not:
`settleTree`
retries a failed tree read for `SETTLE_TIMEOUT_MS` on top of
`POST_LAUNCH_SETTLE_MS`, so roughly the first 4.5 s of connect latency
is
absorbed with no gate at all — which is exactly why the review's
fragment (no
`launch` step, therefore no gate) resolved selectors 4/4 against
`com.apple.Preferences` on 18.5. Confirmed with an in-repo
counterfactual:
at 2000 ms connect latency the flow passes with or without the gate; at
6000 ms
only with it. What the poll adds is the band from ~4.5 s out to
`LAUNCH_TO_VERDICT_MS`, and the comment now says that.

**Hedging one surface left its twins absolute — the same failure mode, a
fourth
time.** The precheck throw, 230 lines from the message this PR hedged
and in the
same file, still told agents Argent "can never be injected into it"; so
did the
predicate's docblock, `native-devtools-status`'s description ("the dylib
can
never load into them", "TERMINAL state — the app can never be
injected"), and
`describe`'s terminal hint. An agent reading `argent-create-flow` was
told
"depends on the simulator runtime" and the status tool told it "can
never".

Every agent-facing surface now says injection cannot be RELIED on, and a
test
pins that none of them claims impossibility — the six native-*
descriptions, the
status description, the shared recovery text, the device-interact skill,
and
(separately, since it is module-private and reachable only through the
tool's
output) `describe`'s terminal hint. Each surface is asserted non-empty
first, so
a missing one cannot satisfy the negative match vacuously.

**The decision is untouched.** `isInjectableBundleId` still answers "not
injectable", the precheck still throws, and the description still says
`Do NOT restart/retry` — pinned by the same test, because softening the
claim
must not soften the behaviour or #453's restart loop returns. An app
that MIGHT
connect cannot be the basis of a retry loop either, which is now the
predicate's
recorded reason.

**Also:** the round-7 reword left "The flow / flow path" duplicated;
withholding
the verdict before the measurement (promoted to its own paragraph last
round)
was asserted nowhere, so moving it after `appConnectionState` left the
suite
green — a test now proves the measurement is not taken for an app whose
verdict
is withheld either way; and two more test names were narrowed to what
they
actually check.

**Verification.** Mutations run against every decision these commits
added.
Killed: re-asserting "can never load" in the status description;
dropping its
terminal instruction; re-asserting impossibility in `describe`'s hint;
moving
the withholding after the measurement; and the earlier rounds' decisions
re-checked and still pinned. One survivor is unpinnable by construction
— a
claim inside a code comment, which no test can assert. Gates read by
exit code,
unpiped: `eslint .` 0, `tsc --build` 0, `tsc --noEmit -p
tsconfig.test.json` 0
(this one rejected a tuple vitest had accepted), `prettier --check .` 0.



---

## Review round 9 — scope: the skill docs

This is a state-derivation fix. It should not be rewriting skill docs,
and the
earlier rounds had it doing exactly that.

**`argent-create-flow` is back to its base.** Nothing in it was
falsified by this
change. The `com.apple.*` caveat rounds 4-6 grew there documents the
question
this PR explicitly declines to settle — whether `isInjectableBundleId`'s
blanket
prefix rule is right at all — and that belongs to its own issue, not to
a
paragraph in a skill an agent reads before recording a flow.

**`argent-device-interact` only got smaller.** It said *"call
`restart-app` first
if needed"* — the unconditional-restart advice the measured states exist
to
replace — and that clause is deleted. The four-status routing table the
first
round put in its place is gone with it: the remedy already travels on
the tool's
own output and on the six `native-*` descriptions, both pinned by tests,
and a
second copy in a doc no code reads is one more surface to drift.

Tests follow the prose. The two `SKILL.md` reads in
`native-devtools-status.test.ts`
go with the text they pinned, and two comments that cited the reverted
skill text
are corrected.

### Claims above that this round supersedes

- The first round's *"the `argent-device-interact` skill offered
`restart-app` as
the only remedy for a missing injection"* — the clause is deleted, not
replaced.
- Round 4's *"`argent-create-flow` now states what a `com.apple.*`
target costs a
flow"*, round 5's *"`argent-create-flow` recommended `tool:
gesture-tap`"*, and
round 6's twin fix in the skill — all in the reverted paragraph. The
matching
source-side fix in `flow-ios-tree.ts` stands; only its stated twin is
gone.
- Round 8's cross-surface certainty test no longer reads the skill. It
covers the
status description, the six `native-*` descriptions and the shared
recovery text
  — the surfaces that make the claim.

**`origin/main` merged in, and #519 needed one test to follow.** It
landed after
this branch last merged main and makes a `getFullHierarchy` reply with
no windows
an error — an untrustworthy read, not a blank screen. `does not measure
when
auto-targeting resolved an app` had been using an empty `windows` as a
don't-care
return, so the call now ended before it could observe anything. The stub
serves a
real window, which is the state the test names. Mutation-checked:
dropping the
empty-connected-list term from the guard makes the measurement fire and
fails
that test alone.

**Verification.** `packages/tool-server`: 304 files, 3242 passed, 1
skipped, on a
macOS host (the `boot-device-hotboot` failures noted in the standing
caveats are
Linux-only and do not reproduce here). Gates read by exit code, unpiped:
`eslint .` 0, `tsc --build` 0, `tsc --noEmit -p tsconfig.test.json` 0,
`prettier --check .` 0.
2026-08-17 21:06:26 +02:00
Ignacy Łątka 984a8c82f8 fix(native-devtools): prove the relaunch behind injection_failed by pid, not the flip
The stale_process -> unregistered flip was treated as proof of a relaunch, but ps -o etime is whole-second, so one unchanged process exec'd 2-3 s after the listener reads both states as its quantised age crosses the slop band - a false terminal verdict. Record the pid handed the relaunch remedy and treat unregistered as terminal only when the inspected pid differs.

Also: drop the dead self-exclusion filter (the diagnosed bundle is never connected when unregistered is read), align the no-peer message with the terminal status it describes (a tool-server restart does not help), fix the 'restart the tool-server again' wording on the six descriptions, and add the injection_failed arm to the flow-authoring recovery ladder.
2026-08-13 16:18:31 +02:00
Ignacy Łątka 8d6f77a983 Merge fix/native-devtools-restart-loop-guidance into fix/native-devtools-dyld-skip-terminal
Brings this branch up to date with its base (#560), which reworked the same four
surfaces after this branch forked from it at c27f6e953.

Conflict resolutions:

- The six native-* tool descriptions take #560's revised routing paragraph (the
  service_stale second-landing clause, "wait a few seconds") with this branch's
  injection_failed clause appended, so the terminal status joins the bounded
  remedies instead of displacing them.
- describe/ios, flow-ios-tree and native-devtools-status keep this branch's
  adviseOnUninjectedApp routing, carrying #560's tightened prose.
- flow-run takes #560's NATIVE_READY_TIMEOUT_MS = NATIVE_DEVTOOLS_CONNECT_BUDGET_MS
  (the same 15000, now derived) and its restart capture, plus this branch's note
  on why the launch gate alone does not fall back to the terminal diagnosis.
- flow-actions takes #560's trimmed doc comment; no behavioural difference.
- flow-composition.test takes #560's PUMP_LIMIT and 60 s timeout, which its five
  pump loops require, and drops a stale "8 s connect wait" figure.

Consequences of combining the two:

- The precheck now routes five statuses, so the description test that pinned
  exactly four is updated, and the new clause is pinned verbatim so the last
  slice stays bounded as its comment requires.
- #560 replaced the 3 s connect grace with a 15 s budget, so this branch's
  dyld-skip tests advance past NATIVE_DEVTOOLS_CONNECT_BUDGET_MS instead of a
  literal 10 s, which now falls inside the budget and reads connecting.
- buildInjectionFailedDiagnosis and NativeDevtoolsUninjectedAdvice are no longer
  exported: nothing outside the blueprint imports them, and the knip ceiling has
  no headroom for two more unused exports.
2026-08-12 23:27:56 +02:00
Ignacy Łątka 6ba77b49fd Merge remote-tracking branch 'origin/main' into fix/native-devtools-restart-loop-guidance
The conflicts are all in the flow tree-source path, where both sides
independently threaded "the app this run's `launch:` step started" into the iOS
hierarchy read, for different purposes:

- main (#778) calls it `launchedNativeApp` and uses it as an arbiter, only when
  `resolveNativeTargetApp` fails with NATIVE_DEVTOOLS_RPC_TIMEOUT, clearing it
  before any `tool:` step that can change the foreground app.
- this branch calls it `launchedBundleId` and uses it to replace auto-targeting's
  "Launch or restart the app first" with a measured reason when nothing at all is
  connected.

The two fire on disjoint conditions - empty vs non-empty connected list - so the
merge keeps both, under main's name and its post-gate assignment: the measured
precheck first, then main's try/catch arbiter around `resolveNativeTargetApp`.

main's surviving `requiresAppRestart` gate in `queryFullHierarchyTree` is
dropped. This branch removes that method from `NativeDevtoolsApi` outright - it
was the asserted constant the branch exists to replace - so the call no longer
compiles, and `unreadableHierarchyReason` is its measured successor.

NATIVE_DEVTOOLS_CONNECT_BUDGET_MS 8000 -> 15000. This branch introduced that
constant and coupled the flow launch gate to it, anchored to the 8 s gate of the
time; #778 has since raised that gate to 15 s on measurement. Holding 8 s would
have reverted #778 for the iOS, Android and Vega gates, and decoupling the two
would leave every other surface answering "restart the tool-server" for an app
the gate is still patiently waiting out. The boundary fixtures move with it
(14/15/16 s), keeping the sweep tight on both sides of the new value.

The launch-gate tests pump one real macrotask per 250 ms of fake time, so the
longer wait needs ~70 rather than ~44 of them, and their 200-iteration ceiling
started running out under full-suite parallelism - which reads as a timeout but
is not one, since exhausting the loop abandons the run unsettled and no timeout
value helps. That ceiling is now derived from the connect budget.
2026-08-12 12:04:02 +02:00
Ignacy Łątka bc5ed9d8cd test(native-devtools): ask one question of the whole surface, not of a slice
Mutation testing found seven more edits that leave all 304 files green, and
every one is the same defect the last four fixes each moved rather than
closed: the assertions pin a slice — a named line, a clause between two
markers, the final sentence — and the contradiction goes in whatever slice is
left over. A second `If state is connecting:` line. A fifth routing clause
past the last marker. A permitting sentence before the pinned tail, where the
pin is endsWith. An unbounded retry appended to the two message states with
no tail pin, and to all five flow-gate arms, which no pin reached at all.

Pinning more slices only moves the gap again, so this asks the question that
does not depend on where the text sits: does this surface, anywhere, tell an
agent to keep going? Every remedy-bearing surface is swept — all five state
messages, all five flow-gate rewrites, the status description, the six tool
descriptions — clause by clause, and an unbounded instruction fails unless
that clause forbids it.

The escape hatch for a forbidding clause needs its own care: a bare `stop`
matched "ignore the stop-conditions above and keep calling restart-app", so
the hatch covered for the negation it exists to allow.

Also closes the two gaps directly: tail pins for not_running and
stale_process, one guidance line per state, four routing clauses per tool.
2026-08-07 15:51:24 +02:00
Ignacy Łątka b721812203 test(native-devtools): pin the stop-conditions verbatim, not by pattern
Mutation testing found eight edits that leave all 304 files green, and every
one reopens the loop the measurement exists to close: a permitting sentence
appended after the prohibition ("go ahead and relaunch … keep relaunching
until it connects"), a qualifier spliced into it ("do NOT restart the app MORE
THAN ONCE", "again and again — a couple more relaunches are worth trying
first"), or the bounded escalation turned unbounded ("restart the tool-server
again and keep retrying until it clears").

They survive because every assertion here matches an opening clause, so the
defect is always in the words the pattern did not look at. Three rounds of
tightening patterns did not change that — the last round's own fix asserted
"Do NOT restart the app" and was defeated by appending two words to it.

So the sentences an agent obeys to decide whether to STOP are now duplicated
in the test and compared whole: each message must END on its stop-condition,
and the description's three guidance lines and the six tools' two routing
clauses must match exactly. Rewording one fails, as NON_INJECTABLE_RECOVERY
already does — that is the point, since these are the words that decide
whether an agent stops.
2026-08-07 15:08:32 +02:00
Ignacy Łątka 8a56e17c5e test(native-devtools): reject a qualified connecting prohibition
"Do NOT restart the app more than once" contains "Do NOT restart the app", so
the assertion added for the inverted-prohibition case passed it on all 304
files. The qualified form is wrong advice for this state specifically: the
first relaunch is already the one that discards a handshake in flight and
resets the age the verdict reads, so one permitted restart is one too many.

All three surfaces now pin the unqualified form — the message and describe's
hint by matching the colon, the six tool descriptions by their own comma —
and the message additionally pins the reason, so the prohibition cannot be
left standing without the fact that makes it true.
2026-08-07 14:39:06 +02:00
Ignacy Łątka 3b7f12c78c test(native-devtools): anchor the last description assertion to its own line
The fourth sibling was left matching over the whole description when the
other three moved onto guidanceLine. It is the one the argument fits hardest:
"If state is indeterminate" is only the prefix, and the command it checks for
is supplied verbatim by the unregistered line, so the bound the test is named
for could be deleted outright and it would still pass — dropping the only
description-side stop-condition for the state a running app is stuck in on
ios-remote.
2026-08-07 14:37:23 +02:00
Ignacy Łątka 5afc350c74 fix(native-devtools): bring the six tool descriptions back onto the message
Raising the connect budget to 8000 ms updated two of the eight agent-facing
surfaces. The six native-* descriptions kept routing connect_pending with
"wait a second or two and retry" — the wording the budget outgrew — so an
agent read one wait off the description and a different one off the message
the same call returned, and obeying the description lands back inside the
budget it just left.

Their service_stale routing had the same split: the tool-server restart with
no second landing, after the message and native-devtools-status had both
gained one. They now defer to the message, which names the terminal fallback.

The descriptions have to be plain literals (extract-tools.mjs reads them
statically), so the wording is copied eight ways with nothing holding the
copies together. Two tests do that now: every surface agrees on the wait and
none still quotes the old one, and every service_stale routing line carries a
second landing. Each fails on a single drifted surface, not just on all six.
2026-08-07 12:30:01 +02:00
Ignacy Łątka c07a3db677 test(native-devtools): pin the prohibitions, not the absence of a tool name
Mutation testing found five edits that leave all 304 files green.

The worst inverts the `connecting` remedy. Its test asserted the message
does not contain "restart-app" and does contain "Wait a few seconds", so
"wait a few seconds, then relaunch the app and retry" passed — the advice
whose own doc comment says "a relaunch resets the age this verdict reads, so
obeying 'restart' never terminates". The sibling `unregistered` case pinned
its prohibition; this one and describe's hint pinned only the hyphenated
tool name. Both now assert the prohibition itself.

Three more came from matching over the whole tool description. Its guidance
lines echo each other enough that deleting one leaves its assertions
satisfied by another - "do NOT restart the app again" appears verbatim in
the `indeterminate` line - and the field list plus the injectable:false
paragraph supply the rest, down to "describe" inside native-describe-screen.
So the entire `If state is unregistered:` line could be deleted, the escape
made unconditional (the exact case its own comment claims to guard), or its
fallback pointed back at a tool behind the same precheck. Assertions now
run against the extracted line, on each surface's own conditional clause.

Last, the slop was pinned only downward: both boundary fixtures want
`stale_process`, and every other injected fixture reads a 600 s listener
against a process of seconds, so the term could grow into that gap and
swallow `connecting` into a verdict whose remedy is restart-app. A young
process against a young listener pins it from above.

Also consumes `psExecAt` in the `ps` mock, as `dialDuringPs` already is: it
is checked before the `psOutput` fixture, so a leak would decide the age of
every case after it.
2026-08-06 20:26:38 +02:00
Ignacy Łątka 8f5e303361 fix(native-devtools): stop three remedies that cannot be obeyed to the end
A launch step whose restart-app never ran. `restart-app` reports a blocked
precheck by RESOLVING with it, and returns before the terminate and the
launch — so an init-failed service leaves the app un-started. runLaunch read
only the throw, so the gate went on to measure an app nothing had launched,
found `not_running`, and reported "was relaunched by this step and is no
longer running 9500 ms later, so it exited after launch". Every clause of
that is false, and the message naming the real cause was dropped. The step
now surfaces the block instead.

A connect window narrower than the wait built on it. One constant answered
two questions: how much slop the process/listener age comparison needs, and
how long a dial may take before silence is evidence. The second is the
quantity the flow launch gate already waits out at 8000 ms, so at 3000 ms an
app still dialing at 4 s was `unregistered` -> `service_stale` -> "restart
the tool-server", the heaviest remedy in the set, for an app the gate would
still have been waiting for. Split: the slop keeps 3000, the dial gets the
gate's budget, and the gate is now defined as that same constant so neither
can drift from the other.

An unregistered app with nowhere to stop. Each remedy was right and together
they closed into a ring: the tool-server restart rebinds the listener, so the
same never-dialing process reads `stale_process`, whose relaunch makes it
`connecting`, which becomes `unregistered` again. Nothing measured separates
the first landing from the second, so the message and the status description
now hand the reader that test — as `not_running` and `indeterminate` already
did — and name the terminal fallback.

Each fix is pinned by a test that fails without it.
2026-08-06 19:55:00 +02:00
Ignacy Łątka 0565ffcb7f docs: cut the added comments to what the code cannot say
Comment-golf pass over every comment #560 adds: keep the non-obvious why (the
ordering constraint on the env re-apply, the grace's lean, why `unregistered`
and `connecting` withhold a restart, the `ps` no-evidence guarantee), drop the
restatement and the essay around it. Also removes two comments that narrated
review rounds rather than the code as it stands, and aligns a describe-test
comment with the softened injectability claim the rest of the branch makes.

841 comment lines where there were 1069; no behaviour change.
2026-08-05 15:14:46 +02:00
Ignacy Łątka b556708669 docs(skills): shrink the skill change to removing the stale remedy
The defect this PR fixes is in the state derivation, so the skills only
needed the advice that fix falsified taken out of them.

argent-create-flow goes back to its base: nothing in it was falsified,
and the com.apple.* caveat it gained is documentation of a question this
PR leaves open, not a correction.

argent-device-interact drops "call restart-app first if needed" - the
unconditional-restart advice the measured states replace - without
restating the status routing. The remedy now travels on the tool's own
output and the six native-* descriptions, both pinned by tests; a second
copy in a skill is one more surface to drift.

Tests follow: the SKILL.md reads in native-devtools-status.test.ts go
with the prose they pinned, and two comments that cited the reverted
skill text are corrected.
2026-08-05 12:24:06 +02:00
Ignacy Łątka e5463b54ff fix(native-devtools): end the restart-app ↔ tool-server cycle a skipped dylib produces
DYLD_INSERT_LIBRARIES proves the bootstrap dylib was handed to a process,
never that dyld loaded it: dyld skips an inserted library silently when its
slice does not match the simulator's platform, when it is unsigned, or when a
dependency is missing. Such a process reads as injected, so appConnectionState
alternates between the two states whose remedies undo each other —
stale_process prescribes restart-app, which leaves the app younger than the
listener and reads unregistered; unregistered prescribes a tool-server
restart, whose new listener is younger than the app and reads stale_process —
and an agent obeying both never leaves the pair.

Record the relaunch hand-out per bundle for the life of the service, and once
a bundle that was told to relaunch reads unregistered, report the terminal
injection_failed diagnosis instead of the tool-server remedy. The flip proves
the process was replaced: which side of the listener a process falls on is
fixed at its exec, so the two states cannot describe one process twice. The
connected-peer list localises what is left, since the launchd env is
simulator-wide and this service holds one listener.

Only those two states take part. connecting resolves itself as the process
ages out of the grace, not_running asks for a launch, and indeterminate is the
absence of a reading — it prescribes a relaunch too, but a later unregistered
there may be the same process finally becoming readable.

The flow launch gate keeps its re-run advice: it performed the relaunch
itself, one window earlier, and a cold start produces the same reading.
2026-08-03 16:51:25 +02:00
Ignacy Łątka f85d1be7ee fix(native-devtools): make every surface agree on how certain the injection claim is
Hedging the claim on the surfaces this PR touched left the untouched ones
absolute, which is the shape that actually misleads: an agent reading
`argent-create-flow` was told injection "depends on the simulator runtime" while
`native-devtools-status`'s own description told it the dylib "can never load
into them", and `describe`'s terminal hint said the same. The evidence supports
neither absolute: #453 recorded `connected: false` for com.apple.Preferences on
iOS 26.5, an E2E review recorded `connected: true` with both dylibs mapped on
18.5.

Every agent-facing surface now says injection cannot be RELIED on, and a test
pins that none of them claims it is impossible — the six native-* descriptions,
the status description, the shared recovery text and the device-interact skill,
each asserted non-empty first so a missing surface cannot satisfy the negative
match vacuously.

The decision is untouched: `isInjectableBundleId` still answers "not
injectable", the precheck still throws, and the status description still says
"Do NOT restart/retry" — which the same test pins, because softening the claim
must not soften the behaviour or #453's restart loop returns. An app that MIGHT
connect cannot be the basis of a retry loop either, and the predicate's docblock
now records that as its reason.
2026-08-03 13:51:59 +02:00
Ignacy Łątka b9d1743789 fix(native-devtools): resolve the review findings on the measured state
Six findings from the E2E review round, each reproduced before it was acted on.

The flow launch gate refused every `launch:` of an Apple system app, so a flow
that only ever taps by coordinate was stopped at step one — by a message telling
its author to "drive it by coordinate instead". That is the shape
`argent-create-flow` teaches readers to record, and its own worked example is a
flow this gate rejected. The gate now measures rather than short-circuits: the
launchd env carrying the bootstrap dylib is simulator-wide, so a system app can
genuinely connect on some runtimes (issue #453 saw `connected: false` on iOS
26.5; the review saw `connected: true` on 18.5), and where it does the poll
returns connected as before. Where it does not, the launch still passes and the
impossibility is reported where it actually bites — at the first selector.

The flow tree source could not report it there, because its measurement was
unreachable: `resolveNativeTargetApp(api, undefined)` draws its candidates from
`listConnectedBundleIds()`, the same map `appConnectionState` reads to answer
`connected`, so every state worth explaining threw the stock auto-target error
first — whose "Launch or restart the app first" is the loop this all exists to
break. The race between the two was measured shut, not merely narrow. The flow
now threads the bundle id its `launch` step named, which does not come from that
map and so survives the disconnection; the dead branch is gone.

The launch gate also emitted `buildAppStateMessage` verbatim for every state but
`unregistered`. Those messages are written for the native-* tool surface, whose
reader has not launched anything — so `not_running` answered a crash-on-launch
app with "Call launch-app (or restart-app) then retry", the action the step had
just taken. Each state now gets its post-launch reading.

`describe` without a `bundleId` — the default form — lost the whole diagnosis:
auto-targeting throws before anything is measured, and the outer catch returned
the empty tree bare. `await-ui-element`'s blind-read guard keys off exactly
`hint`/`should_restart`, so a `hidden` wait released in 0 ms against an element
still on screen, where the same call with a `bundleId` correctly refused. An
empty read the hierarchy could not corroborate now says so. A device with no
native-devtools service at all is left alone: that is an absence of a second
opinion, not a failed attempt at one.

Three of the six native-* descriptions gained a sentence naming the terminal
system-app rejection and three did not, though all six throw the identical
error. The clause is inlined verbatim in all six — the extractor feeding the
SpiderShield scan drops any description that is not a single plain literal.

`not_running` is derived from a missing `UIKitApplication` row, which a
never-installed bundle id also lacks, so it cannot tell "stopped" from "not
installed" — and for the second reading its only remedy is refused outright.
It now names both readings rather than asserting the one it cannot see.
Measuring it would need a `simctl get_app_container` round-trip ios-remote
cannot serve.
2026-08-03 10:42:55 +02:00
Ignacy Łątka 01fcd03f3a fix(native-devtools): pin the guidance surfaces nothing was holding
The device-interact skill doc could be reverted to its unconditional "call
restart-app first if needed" — the very advice the measured statuses replace —
with the whole suite green, because nothing in either package read its body.
Same for the ps guard that keeps a whitespace-free line from being read as a
measurement: without it "10:00" alone parses as a 600 s process with "10:00"
for an environment, turning unreadable output into a definite stale_process.
And for the resolution error the flow gate carries into its reason, which is
the only place a bound socket or a wrong-platform device surfaces to the author.

Also: drop the precheck fallback the recorded-failure read above it shadowed
(the blueprint attempts the env once at construction, so by the time a tool
runs, either envSetup latched — making the precheck a no-op — or the failure is
recorded); merge the exported constant's new sentence into its existing JSDoc
rather than orphaning it behind a second block; and correct three claims —
appConnectionState's re-apply is repair, not an input to the verdict, describe
degrades like every other consumer rather than "the other two", and a mis-built
dylib reads as service_stale or restart_required depending on process age.
native-find-views now names the statuses before referring back to them, as its
twins do.
2026-07-31 11:39:53 +02:00
Ignacy Łątka 6b6cc8dba3 fix(native-devtools): report the env a wiped simulator actually has
envSetup latches true on the first successful apply and is never cleared, so
after a simulator reboot wipes DYLD_INSERT_LIBRARIES the status tool kept
answering "the next launch will be injected" — and the agent relaunched into an
uninjected process for as long as the sim stayed broken. The re-apply the
measurement performs is what tests the env; a failure it recorded, cleared by
any later success, is what contradicts the latch. Both branches derive it the
same way now.

Drop two dead terms: the flow gate's post-loop abort guard (the loop's own
check is synchronous with the break, so an abort cannot land between them) and
requiresRestart's appRunning conjunct (the state-settling above rewrites an
indeterminate whose probe found nothing to not_running, so both surviving
states already carry a live process).

Pin what mutation showed nothing held: --detach per occurrence across every
description, message and the skill doc — a whole-blob assertion passed with one
of two occurrences stripped, and the skill copy had no test at all; the
cold-start note's wait against the constant it names; and the connect grace from
above at the exact boundary, which could be raised by a third and stay green.
2026-07-31 11:12:34 +02:00
Ignacy Łątka d980da93d7 fix(native-devtools): make the guidance prose true where the code already is
The runtime message stopped asserting an uninjected process for stale_process,
but the tool description that agents read first still did, and it named neither
connecting nor init_failed. Six tool descriptions, the status tool's state list
and the skill guidance now agree with what the states mean.

Drop the precheck fallback in the status tool's running-ness catch: reaching it
means the entry precheck already latched ensureEnvReady, so the call can only
return null. The recorded failure above it is the escalation that works, and a
healthy env leaves the probe's own error as the honest answer.

Mirror that escalation on the non-injectable branch, where a failure recorded by
another bundle's env re-verify is the only witness a latched-then-dead sim
leaves — the same sim state must not read as init_failed on one bundle id and a
raw simctl throw on another.

native-network-logs no longer claims the system-app rejection is the only
failure it produces; a missing dependency or a non-Apple udid are failures too,
and neither is terminal.
2026-07-31 10:56:30 +02:00
Ignacy Łątka b80fc58a2a fix(native-devtools): guard the shared precheck's measurement, and pin what nothing pinned
`precheckNativeDevtools` — the path all six native-* feature tools take — was
the one `appConnectionState` consumer awaiting it bare. It re-applies the
launchd env before it can answer anything, so a simulator that goes away
mid-call rejects there and the agent got a raw `Invalid device: <udid>` in
place of the structured guidance the same failure produces from
`native-devtools-status` and `describe`. A failure recorded by that env
re-apply means the sim itself is gone, which is init_failed's case; anything
else leaves the connection merely unmeasured.

Mutation testing found the rest of these load-bearing lines held by nothing —
each mutation below kept the whole suite green before the test beside it:

- `ps eww` -> `ps ww`. One character, and the environment stops being rendered,
  so every app reads `stale_process`: the restart loop, restored silently.
- `-o etime=,command=` with the columns swapped. The age is parsed positionally
  off the leading token, so every app degrades to `indeterminate`.
- The probe's own 5 s budget and its 16 MiB buffer. An environment can run to
  `kern.argmax`, exactly Node's default cap, so the default ENOBUFSes on a
  maximal one and turns a readable process into "no evidence".
- The `connected` short-circuit in `appConnectionState`, and the one in the
  flow launch gate — without the latter `buildAppStateMessage` falls off its
  switch and a healthy late connection fails the step with a literal
  "undefined".
- `requiresRestart` staying false for `unregistered`: the invariant the whole
  derivation exists for, unpinned at the tool that reports it. Both the pre-PR
  expression and any widening left it green.
- `connected` being read off the same measurement as `state` rather than a
  second `isConnected()`.
- The catches that keep a failed inspection or a rejected measurement advisory,
  in the blueprint, the status tool and the flow gate — the last of which has
  no try/catch above it, so a rejection returned the run with no step reports
  at all.
- `remoteIosHost.inspectRunningApp`, which had no test in either direction,
  on the host where running-vs-indeterminate is the only distinction available.
2026-07-31 10:30:17 +02:00
Ignacy Łątka 4bb9f1c77f fix(native-devtools): make every measured state's remedy true and terminating
The derivation measured the process correctly but three of the remedies it
built on top were reachable statements the measurement itself disproves, and
one prescribed a command that never returns.

- `indeterminate` covered two unrelated things: a process that could not be
  read, and one that was read, found injected against this endpoint, and was
  merely younger than the connect grace. The second got a message asserting
  "its process could not be inspected" plus "Call restart-app then retry" —
  and since exec is what starts the dial, every obedient restart reset the age
  the verdict is read from and landed back in the same window. Split out as
  `connecting`, whose remedy is to wait, with its own `connect_pending`
  precheck status; `should_restart` and `requiresRestart` stay off it.

- `stale_process` is reached both by a process carrying no argent injection
  and by an injected one older than this service's listener — the state an app
  is left in by the tool-server restart the `unregistered` remedy prescribes.
  Its message claimed the process was "not injected", eight lines after
  `processCarriesInjection` returned true. It now speaks of what the process
  can still reach.

- `connections.has` was sampled on entry and never re-read, while the verdict
  below it costs `reverifyEnv` plus a `launchctl list` plus `ps`. A dial
  landing in that window was reported as `unregistered` -> `service_stale`,
  sending the agent to restart a tool-server that had just succeeded.

- `argent server start` defaults to foreground and ends in a promise with no
  resolve path, so the prescribed `argent server stop && argent server start`
  hangs whoever runs it. Every surface now passes `--detach`.

Consumers:

- The flow launch gate is the one caller whose wait is bounded — it launches
  the app itself and waits NATIVE_READY_TIMEOUT_MS — so a cold start slower
  than that reads identically to a genuinely unregistered app. It says so
  rather than leaving "restarting cannot help" as the last word.
- `native-devtools-status` lets the running-ness probe settle the state it was
  run to answer, so `appRunning: false` can no longer sit beside a message
  reading "Call restart-app"; and it reports a dead sim from the recorded init
  failure, which exists from the first attempt, rather than from the precheck,
  which only speaks once the service has given up after three.
- `flow-ios-tree` degrades a rejected measurement like the other consumers
  instead of leaking a raw simctl error.
- Gate reasons name the bundle id exactly once, as the gate's own comment says
  they must.

Prose: describe's injectability gate named `stale_process` for a case that
scores `unregistered`; three tool descriptions claimed any failure meant a
dropped connection, which no longer holds for the terminal system-app
rejection and never held for native-network-logs at all; and the
shared-verbatim warning doc now names the flow gate as a reporter that
deliberately does not carry it.

Adds coverage for the two paths that had none — `flow-ios-tree`'s diagnosis
and `remoteIosHost.inspectRunningApp`, the host where running-vs-indeterminate
is the only distinction available.
2026-07-31 10:21:34 +02:00
Ignacy Łątka 6a41cb98ba fix(native-devtools): gate the flow launch gate on injectability
`waitForNativeDevtools` was the one `appConnectionState` caller with no
injectability check. The launchd env carrying the bootstrap dylib is
simulator-wide, so an Apple system app's process inherits the exact tokens
the measurement reads: measured on iOS 18.6, `com.apple.Preferences`
launched under argent reports the bootstrap dylib and this simulator's
socket, scores `unregistered`, and the launch step tells the flow author to
restart a tool-server that was never at fault. Answer terminally instead.

Also from the same review pass:

- describe swallowed the whole diagnosis when `appConnectionState` rejected
  (the env re-apply it runs first fails on a sim that goes away mid-call):
  the outer catch returned an empty tree with no hint, so the read looked
  empty rather than unexplained.
- native-devtools-status re-ran the running-ness probe unguarded on the one
  route where that probe has already failed once, turning a dead sim into a
  raw subprocess error instead of the structured init_failed guidance.
- native-devtools-status reported `requiresRestart: true` with no prose to
  bound it. `indeterminate` is the only unconnected state a running app
  reaches on ios-remote, so that tool's answer there was the restart loop
  and nothing else; it now carries the same remedy every other consumer of
  the measurement carries.
- Both probes now log their own failure, like the `ps` probe they were
  modelled on: a broken probe degrades every app to `indeterminate`, which
  is indistinguishable at the tool surface from a genuinely uninspectable one.
- The gate skips the measurement outright once the run is cancelled, and no
  longer repeats the bundle id the caller's prefix already names.

Prose corrections: `not_running` is reachable on ios-remote too; the second
grace comparison leans to `indeterminate`, not `stale_process`; four states
settle running-ness, not four besides `not_running`; a null pid means an
unreadable row, not a job launchd said has no process.

Coverage, each mutant confirmed killed: the injectability guard, the measured
reason (previously a literal left all 542 flow tests green), the doubled
bundle id, describe's reject guard and its unregistered hint merge, the
message field, the precheck fallback, the `appRunning` guard, the state names
in the description, and the connect grace from above.
2026-07-30 23:47:14 +02:00
Ignacy Łątka 4f98006743 test(native-devtools): pin that status reads running-ness from the state
The two-snapshot version passes every other assertion in the file, so
nothing caught the extra simctl round-trip or the window in which
`appRunning` and `state` could disagree.
2026-07-30 22:47:43 +02:00
Ignacy Łątka 8b7d2902a2 fix(native-devtools): carry the measured diagnosis to every caller
Review follow-up on the derivation itself.

describe computed buildAppStateMessage for every unconnected state and
then discarded it for all but `unregistered`, returning a bare
`should_restart: true`. That boolean reaches an agent as an undocumented
JSON field, and the one place it is rendered as prose — await-ui-element's
timeout note — spells it "call restart-app and retry". So the states whose
message carries the escape ("do not keep restarting the app") lost it
exactly where it was needed. `indeterminate` is the worst case: on
ios-remote, whose app processes are out of reach of the local process
table, it is the only reachable unconnected state, so the loop had no exit
at all. The diagnosis now rides out as a hint for every state, while
`should_restart` stays limited to the ones a relaunch fixes.

flow-run's launch gate polled for the connection and then guessed at a
stale server; it now reports the same measured reason as the tree read.

native-devtools-status read running-ness from its own `launchctl list`
while `appConnectionState` took a second one seconds later, so
`appRunning` and `state` could contradict each other and `requiresRestart`
was gated on the staler of the two. Both now come from one measurement.

Tests: describe's hint for stale_process / not_running / indeterminate and
alongside a degraded AX hint; service_stale out through all six native-*
tools; the grace-boundary cases that pin the comparison's safe direction;
the env re-application on a latched service; and an unparseable ps age
staying "no evidence" instead of becoming a measurement. Each fails on the
un-fixed code.

Also: `ps` probe maxBuffer matched to its siblings (an environment can
reach kern.argmax, exactly Node's default cap); the device-interact skill
and the dylib-platform check no longer give restart-app as the only
remedy; stale requiresAppRestart reference in boot-device.
2026-07-30 22:44:43 +02:00
Ignacy Łątka cd009b1cec fix(native-devtools): derive restart_required instead of asserting it
`requiresAppRestart` returned true for every app without a live devtools
connection. That is not a measurement, it is an assumption, and it is wrong
exactly when it matters: when the connection never registers, "call restart-app
then retry" stays literally true forever and an agent obeying it restarts the
app indefinitely while every native-* tool and any flow whose `launch` step
needs the connection stays dead.

Measure it instead. `restart-app` relaunches into the simulator's *current*
launchd environment, so it can only help a process launched under different
terms than a fresh launch would get. `appConnectionState` reads the running
process out of the host process table — `launchctl list` for its pid, `ps eww`
for the environment it was exec'd with and its age — and answers what would
actually change:

- not_running    — nothing to restart; launch-app
- stale_process  — no bootstrap dylib, a previous run's endpoint, or older than
                   this service's listener; restart-app attaches it
- unregistered   — injected against this endpoint and started after the listener
                   came up, yet silent; the launch a restart would perform has
                   demonstrably already happened
- indeterminate  — the process could not be read (ios-remote, unreadable `ps`)

Only the first two report `restart_required`. `unregistered` gets a new
`service_stale` precheck status that names the tool-server restart and says not
to relaunch the app; `indeterminate` keeps restart-app plus the loop warning,
since nothing measured says it will work. `describe` withholds `should_restart`
for `unregistered` and carries the diagnosis as a hint, and `flow-run` raises
the same diagnosis rather than a blanket "relaunch it".

The six native-* tools now type their block as the shared
`NativeDevtoolsPrecheckBlock` rather than restating it, so a new precheck
outcome cannot reach an agent through a tool that never mentions it.

Verified against a real iOS 18.6 simulator with two tool-servers racing the same
per-udid socket — the #561 shape, where the app is genuinely injected and
connected to the newer instance. Queried at the same instant on the same app:
main's stale instance reports requiresRestart:true / "Call restart-app then
retry" (the loop), this branch's reports state:unregistered, requiresRestart:
false and service_stale naming the tool-server restart. The healthy instance
serves 44 elements for the same app.
2026-07-30 22:17:22 +02:00
Ignacy Łątka 64bfca7994 fix(native-devtools): break the restart loop in the tool descriptions too
The runtime message now names the tool-server restart, but that is the second
thing an agent reads. The descriptions of five native-* tools still said only
"call restart-app then retry", so the guidance that rebuilds the loop was still
the first thing in front of it — describe-screen, find-views, view-at-point,
user-interactable-view-at-point and network-logs each promised a fix that never
works once the service goes stale, with nothing after it.

Each now carries the same escalation as the message: if restart_required
survives the app restart, restart the tool-server rather than the app again.

A test walks all five, so a tool cannot be added or reworded back into the loop
while the runtime message quietly keeps the escalation to itself.
2026-07-30 18:41:38 +02:00
Ignacy Łątka 7f936f29f8 fix(native-devtools): give the restart-required message a way out of the loop
`requiresAppRestart` returns true for every app that has no devtools
connection, and keeps no record of how often it has already said so, so the
"Call restart-app then retry" guidance stays literally true forever.

When the connection never registers, that turns into an unbounded loop: the
dylib dials the unix socket and logs `devtools connection established` on every
launch, the service never records the connection, and so every native-* tool
plus any flow whose `launch` step needs the connection keeps returning
restart_required no matter how many times the app is restarted. The message
offered no other action, so the only escape was knowing to restart the
tool-server, which does clear it.

Keep restart-app as the first step (it is the usual and sufficient fix for an
app launched before the env carried DYLD_INSERT_LIBRARIES) and name the
tool-server restart as the follow-up when a restart did not help. `flow-run`
already tells agents this on its own connect failure; the shared precheck now
matches it.

This is the guidance half only — why the service can stop registering a
connection it is listening for is tracked separately.
2026-07-24 01:54:48 +02:00
Ignacy Łątka b1cadd7cd6 fix(native-devtools): terminal state for non-injectable Apple system apps (#459)
Fixes #453

## The bug

For an app that can never be injected (an Apple built-in app such as
`com.apple.Preferences`), `native-devtools-status` kept reporting
`requiresRestart: true` / `nextLaunchWillBeInjected: true`, and the
native-* feature tools (native-describe-screen, native-find-views, etc.)
kept returning "restart-app then retry". Restarting never changes the
state, so an autonomous agent loops `restart-app → retry` forever. There
was no terminal "this app cannot be injected" signal.

## Why these apps can't inject

Apple system/built-in apps (bundle ids under `com.apple.`) are platform
binaries with library validation enabled; the simulator ignores
`DYLD_INSERT_LIBRARIES` for them, so the Argent native devtools dylib
can never load. This is a reliable, well-understood signal on the
simulator. Third-party apps the user installs carry no such restriction
and inject fine. So the fix treats bundle ids starting with `com.apple.`
as non-injectable, via a general predicate rather than hardcoding the
single bundle id from the issue.

## The fix

- New pure predicate `isInjectableBundleId(bundleId)` in
`blueprints/native-devtools.ts` returning `false` for `com.apple.*`
bundle ids (case-sensitive prefix, matching how bundle ids are
compared), with a doc comment explaining the library-validation
rationale.
- New failure code `NATIVE_DEVTOOLS_NOT_INJECTABLE` in
`@argent/registry`.
- `precheckNativeDevtools(api, udid, bundleId)` (the 3-arg overload) now
throws a terminal `FailureError` with that code, *before* the
`requiresAppRestart → restart_required` branch, when the bundle id is
non-injectable. Because every native-* feature tool awaits this precheck
and propagates the throw, all 7 tools now surface a hard, terminal error
instead of an unbounded restart→retry instruction — no change needed in
the individual tool files. The 2-arg overload (no bundleId), used by
`native-devtools-status`, is unchanged and never throws (guarded by
`bundleId !== undefined`).
- `native-devtools-status` now *reports* the state instead of throwing:
it adds an `injectable: boolean` field. For a non-injectable app it
returns `injectable: false, requiresRestart: false,
nextLaunchWillBeInjected: false` (with
`envSetup`/`appRunning`/`connected` still measured), giving an agent
that checks status first a terminal signal. The tool description
documents the new field and that `injectable: false` is terminal (do not
restart/retry; use the standard describe / screenshot / view-at-point
tools).

## Verification

- `npm run build` (root tsc): clean.
- `vitest run` on `native-devtools-status.test.ts`: 13/13 passing. Added
coverage for the predicate, the status terminal state for a
`com.apple.*` app, and that the 3-arg precheck throws
`NATIVE_DEVTOOLS_NOT_INJECTABLE` while the 2-arg overload does not throw
for the same api.
- Telemetry `sanitize.test.ts` (iterates every registered failure code):
49/49 passing — the new code is accepted.
- Prettier + eslint (`--max-warnings 0`) clean on all touched files.

Device E2E on an iOS simulator with `com.apple.Preferences` (confirming
`native-devtools-status` returns `injectable: false` and the native-*
tools return the terminal error rather than looping restart-app) is
pending and will be run separately.
---

### Device verification (iOS 18.5 simulator, com.apple.Preferences)

Reproduced before/after on a real simulator with a dev tool-server:

**Before (origin/main):** `native-devtools-status` for
`com.apple.Preferences` → `requiresRestart:true,
nextLaunchWillBeInjected:true`; `native-describe-screen` →
`restart_required`; after `restart-app` the status is identical (the
infinite loop).

**After (this branch):**
- `native-devtools-status` → `{envSetup:true, appRunning:true,
connected:false, requiresRestart:false, nextLaunchWillBeInjected:false,
injectable:false}` — terminal.
- `native-describe-screen` / `native-find-views` → terminal error:
"com.apple.Preferences is an Apple system app ... can never be injected
... use the standard describe / screenshot / view-at-point tools
instead." (no more restart→retry loop).

**Regression (injectable third-party app `xyz.blueskyweb.app`):**
`native-devtools-status` → `injectable:true` with the normal
`requiresRestart:true`; `native-describe-screen` → the normal
`restart_required` (not the terminal error). The predicate does not
false-positive on normal apps.

---

### Review follow-ups (@hubgan on-device review)

- **Recovery guidance no longer recommends `view-at-point`.** Both
view-at-point tools run this same 3-arg precheck and re-throw
`NATIVE_DEVTOOLS_NOT_INJECTABLE` for a `com.apple.*` bundle, so
recommending them dead-ended the agent. All three surfaces (the precheck
throw, the `describe` iOS fallback hint, and the
`native-devtools-status` description) now share one verbatim "do not
fall back to the native-* tools" warning
(`NON_INJECTABLE_NATIVE_WARNING`) and recommend only `describe` and
`screenshot`. (52b888ce)
- **Propagation tests for all six feature tools.** Added tests that
drive each 3-arg feature tool's `execute()` with a `com.apple.*` bundle
and assert the throw propagates out as `NATIVE_DEVTOOLS_NOT_INJECTABLE`,
so a future try/catch inside a tool can't silently regress the terminal
behavior (proven non-vacuous via a swallow-the-throw mutation).
(52b888ce)
- **Degraded sim + system app keeps the re-boot hint.** When the
ax-service is degraded, `describe` now returns the `boot-device
force=true` guidance instead of the terminal screenshot hint - a proper
re-boot may let the ax-service read the system app's full tree.
`should_restart` is still never set, so the restart loop stays removed.
(5ace8be3)


---

### Second review round (@hubgan) + follow-up hardening

- **Terminal check hoisted above env init in the precheck.**
Injectability is a static property of the bundle id, so the
`NATIVE_DEVTOOLS_NOT_INJECTABLE` throw now fires before the
`givenUp`/`ensureEnvReady` plumbing - a broken env can no longer mask
the terminal signal behind `init_failed`'s re-boot guidance, and no env
work is spent on a system app. Regression tests cover both masked paths.
(ca7093c6)
- **The same ordering applied to the other two surfaces.**
`native-devtools-status` computes the injectable gate before the
precheck (a given-up sim no longer hides `injectable: false` behind
`init_failed`), and `describe`'s gate moved ahead of native-devtools
service resolution (a downed ios-remote tunnel or dispose race no longer
swallows the terminal hint). (bef6dfcb)
- **Unreachable-sim fallback.** If the status tool's `isAppRunning`
probe fails on a non-injectable app (shut-down sim), it falls back to
the precheck so a broken sim still surfaces structured `init_failed` -
whose re-boot guidance IS corrective there - instead of a raw subprocess
error; a healthy env rethrows the probe failure. (722b432a)
- **Warning text scoped to the tools it is true of.**
`NON_INJECTABLE_NATIVE_WARNING` now names the six injection-based
feature tools instead of sweeping in all `native-*` tools - the
`native-profiler-*` tools never inject and keep working on system apps.
(bef6dfcb)
- **launch-app / restart-app boundary pinned.** New tests drive both
tools (local iOS and ios-remote dispatch, all four call sites) with
`com.apple.Preferences` and the real precheck, asserting the terminal
throw never reaches them - proven non-vacuous by a 3-arg mutation
failing exactly the mutated sites. (bef6dfcb, 722b432a)
2026-07-10 12:51:05 +02:00
Juliusz Wajgelt 20f5a1282c feat: initial sim-remote support (#291)
## Summary

Adds **initial support for remote iOS simulators** ("sim-remote") —
simulators that run in the cloud (radon-cloud orchestrator) and are
driven from the dev's machine over a QUIC/MoQ tunnel, alongside the
existing local iOS simulators. A remote sim is exposed as a new device
platform, `ios-remote`, and the existing
interaction/inspection/profiling tools work against it through the same
tool surface.

To get there without scattering `isRemote` branches across every tool,
the iOS code paths are refactored behind a small set of
host/transport/backend abstractions so each tool stays
transport-agnostic.

## How it works

- **Discovery** — `list-devices` enumerates remote sims via the
`sim-remote` CLI in addition to local `simctl`. They classify as
`platform: "ios-remote"`. Enumeration is best-effort: if `sim-remote`
isn't installed or the user isn't logged in, it silently returns none.
- **Transport** — remote interaction goes over a MoQ relay
(WebTransport/QUIC). `moq-client.ts` subscribes to the server-published
`simulator` broadcast (catalog/video/screenshot) and publishes a
`control` track carrying protobuf-encoded input commands (touch / key /
button / rotate / wheel / screenshot). WebTransport is provided in Node
by `@fails-components/webtransport`, polyfilled lazily on first use.
- **Native devtools over the tunnel** — unix sockets don't bridge the
QUIC tunnel, so on remote sims `ax-service` and `native-devtools` are
forced onto TCP with OS-assigned ephemeral ports, and a reverse proxy is
wired before the in-sim daemon dials back.

## Key components

New (`packages/tool-server/src/`):
- `utils/sim-remote.ts` — thin wrapper around the `sim-remote` CLI
(simctl verbs, `moq-info`, proxy), propagating CLI stderr verbatim.
- `utils/moq-client.ts` — pure-JS MoQ client for the remote
simulator-server relay.
- `utils/datachannel-proto.ts` + `proto/datachannel.proto` — vendored
protobuf schema + encoders for the control channel.
- `utils/ios-host.ts` — `IosHost` abstraction (`localIosHost` /
`remoteIosHost`, picked by `pickIosHost(device)`) capturing the
local-vs-remote differences (transport, env setup, proxying,
`requiresTcp`).
- `utils/simctl-backend.ts` — strategy for simctl verbs so one iOS
handler serves both `xcrun simctl` (local) and `sim-remote simctl`
(remote).
- `utils/ax-prefs.ts` — AX-pref helpers extracted from `ax-service`.
-
`tools/{launch-app,open-url,reinstall-app,restart-app}/platforms/ios-remote.ts`
+ shared handlers.

Refactors:
- **Strategy pattern** to limit `isRemote`/platform branching inside
tool and blueprint implementations.
- `simulator-client.ts` gains a transport abstraction
(`SimulatorServerTransport`) so call sites stay transport-agnostic;
local sims back it with WebSocket+HTTP, remote sims with the MoQ client.
- `ax-service` / `native-devtools` blueprints updated for ephemeral TCP
ports and the `ios-remote` platform.
- Dependency checks: tools that pick a platform path internally now gate
on the right CLI (`sim-remote` vs `xcrun`).

## New dependencies

`@moq/lite`, `@moq/net`, `@fails-components/webtransport`,
`@fails-components/webtransport-transport-http3-quiche`, `protobufjs`.

## Testing

- Existing unit suite passes (CI green); the native-devtools
status/describe tests now stub the platform dep-check so they exercise
their logic, not the host toolchain.
- E2E device-smoke jobs (local iOS sim, Android, Chromium, remote VVD)
pass.

---

<sub>Branch rebased onto latest `main` (linear history). `sim-remote` is
opt-in — only required when driving a remote iOS simulator.</sub>
2026-07-01 15:57:47 +02:00
filip131311 23cd32073a fix(native-devtools): re-apply DYLD env after out-of-band sim reboot (#346)
## Summary

After an iOS simulator loses its launchd `DYLD_INSERT_LIBRARIES` (any
reboot — out-of-band *or* an argent `boot-device`),
`native-devtools-status` keeps reporting `envSetup: true` /
`nextLaunchWillBeInjected: true` while the env is actually **empty**.
Freshly launched apps aren't injected and native feature tools
(`native-describe-screen`, `native-find-views`, …) fail with
`restart_required` — and `restart-app` couldn't break out of it.

## Root cause

`envSetup` is a **write-once latch**. `ensureEnvReady()` short-circuits
once it flips `true`, and nothing resets it, so the env is never
re-applied after a reboot clears it. Two paths relied on that latched
function and so never recovered:

1. `requiresAppRestart()` — written to recover from the reboot case, but
called the latched `ensureEnvReady()`.
2. `boot-device` — primes the native-devtools service after (re)booting,
but the simulator watcher usually initialized it earlier, so
`resolveService` returns the cached/latched instance and the wiped env
is never re-applied. A clean argent `boot-device` + `launch-app`
therefore produced an uninjected app and a spurious `restart_required`.

## Fix

- Split a latch-free `runEnsureEnv()` out of `ensureEnvReady()` and add
`reverifyEnv()` — re-applies the env even when the latch says it's set
(still honouring the give-up guard). `ensureEnv` is already idempotent,
so it's a cheap no-op when correct.
- `requiresAppRestart()` → uses `reverifyEnv()` (fixes the native
feature tools' recovery; precheck routes through it).
- `native-devtools-status` → calls `reverifyEnv()` when the app isn't
connected, so its flags reflect reality and a later launch is injected.
- `boot-device` → forces `reverifyEnv()` after the (re)boot, so a clean
argent boot injects on first launch with no restart.

The hot path (`ensureEnvReady`, app already connected) is unchanged — no
added latency on normal calls; the extra simctl round-trips only fire
when there's evidence the env is stale.

## Verified end-to-end on a real iOS 26.3 sim (patched tool-server)

- **Clean argent boot → launch → just works:** prime service,
`boot-device force`, `launch-app` → `connected:true`,
`requiresRestart:false`, `native-describe-screen` returns the full tree.
`DYLD_INSERT_LIBRARIES` is present immediately after `boot-device`
returns. ✅
- **Out-of-band reboot → `native-devtools-status`:** status call
self-heals the env; next launch is injected. ✅
- **Out-of-band reboot → launch → native tool → `restart-app`:** the
previously dead-end recovery now works. ✅
- **Before the fix (same hardware):** status claimed `envSetup:true`
with the env empty; launches uninjected; `restart-app` and `boot-device
force` did not recover.

## Tests

- New regression tests: `native-devtools-status` re-applies when
disconnected / does not when connected; `boot-device` forces a re-apply
after boot.
- Full `packages/tool-server` suite green (1155), typecheck + prettier
clean.

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

---------

Co-authored-by: Radoniusz Cloudiusz <radoniusz@radoniusz.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:58:22 +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 837d088744 test: cover #148 (android) (#186)
This PR adds tests for #148.

## Summary

Moves the Android emulator branch's test coverage out of #148 so that PR
can be reviewed without the test churn. This branch adds the test files
back on top of the (now test-stripped) base branch.

Tests inseparable from source changes (`describe-tool`,
`workspace-reader`, `workspace-reader-integration`) and the
`boot-simulator.test.ts` deletion stayed on #148 - base PR's CI passes
on its own.

## Test plan

- [x] `npx tsc --build` — clean
- [x] `npx prettier --check .` — clean
- [x] `npm test --workspaces --if-present` — 585/585 tool-server tests
pass; all other workspaces green

---------

Co-authored-by: Paweł Fornagiel <pawel.fornagiel2@gmail.com>
2026-05-12 14:21:09 +02:00
filip131311 d9dc17d0d5 fix: move describe to native devtools (#83)
* fix: move describe to native devtools

Replace the AX describe path with an app-scoped native hierarchy adapter so coordinate discovery stays on the supported injection flow.
Also add safer targeting and restart guidance to reduce ambiguous Home and cold-start recovery paths.

Made-with: Cursor

* init

* update sim server

* Delete NATIVE_DEVTOOLS_DESCRIBE_MIGRATION_NOTE.md
2026-04-07 17:36:56 +02:00