@deeeed/metamask-harness 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1 - 2026-07-07
4
+
5
+ ### Fixed
6
+ - **Metro survives the launching shell.** `start-metro.sh` spawned Metro without SIGHUP protection, so closing the launching tmux window/shell killed it, leaving a stale `metro.pid` and hanging every subsequent bridge/fixtures call. Metro now starts under `nohup` (argv spawn, no `bash -c` string interpolation), and a stale `metro.pid` naming a dead process is detected and cleaned before start.
7
+ - **`--device <adb serial>` reaches CDP target selection.** The runner's ambiguity gate resolved the serial, but target discovery compared it against Metro's `deviceName` ("Pixel 6 - 16 - API 36") — never a serial — so with an iOS simulator also attached, a recipe pinned to the physical Android device could silently drive the simulator. The serial is now mapped to the Metro identity via `adb -s <serial> shell getprop ro.product.model` with model-prefix matching (scoped to non-simulator targets); an unmatchable or ambiguous pin (two same-model devices) fails fast listing every Metro `/json/list` candidate instead of silently picking one. `--device <serial>` remains the only thing users pass.
8
+ - **Bridge commands always emit valid JSON.** `get-route` printed the literal string `undefined` when the route was transiently unavailable mid-navigation, so `bridgeCommand()` threw on parse and `waitForRoute()` aborted instead of polling. `get-route`/`navigate`/`go-back` now normalise a missing route to `null`, `bridgeCommand()` treats `''`/`undefined` stdout as not-settled-yet only for transient-legitimate commands, and `waitForRoute()` polls through `null` until timeout — the timeout error carries the expected route, last parsed route, last bridge reply and the device pin.
9
+ - **Wallet setup env propagation.** `bridgeEnv()` became async with the serial mapping; the wallet setup action now awaits it instead of spreading a Promise, which would have handed `setup-wallet.sh` an almost-empty environment.
10
+
11
+ ## 0.9.0 - 2026-07-07
12
+
13
+ ### Added
14
+ - **`status` auto-probes live app state per device** — the `yarn a:status` successor. Static info prints instantly; within a strict 2s window each device line is enriched with `screen=` (active route via the `__AGENTIC__` bridge, Route objects normalised), `wallet=locked|unlocked|onboarding`, `account=<label> (0x1234…abcd)` and `fixture=READY|missing`. `--fast` skips all probes (guaranteed-instant for scripts); `--json` carries the enriched `devices[]` in one envelope. All RN targets attached to the checkout's Metro are probed and matched per device (exact id → exact name → platform-uniqueness → leftover 1:1), so android + ios side by side on one Metro both report. Honest degradation: `no-bridge` (nothing matched/answering) vs `bridge-absent` (target attached but the installed build predates `__AGENTIC__`, rendered with a rebuild hint) — surfacing pre-bridge builds required the target discovery probe to become three-state (agentic/responsive/unreachable) instead of silently dropping them.
15
+ - **Every action now carries self-discovery metadata.** `action_metadata` (description + example recipe node, derived from the real handler implementations) covers all official and custom actions across the mobile, extension and core manifests — previously `app.status`, `cdp.target` and all 16 `metamask.wallet.*`/`metamask.perps.*` actions were bare names to agents. A new contract guard enforces coverage (including example structure: `node.action` must match), and locks mobile ↔ extension action-set parity with an explicit platform-only allowlist.
16
+
3
17
  ## 0.8.0 - 2026-07-07
4
18
 
5
19
  ### Added
@@ -104,7 +104,17 @@ const COMMANDS = {
104
104
  client,
105
105
  'globalThis.__AGENTIC__?.getRoute()',
106
106
  );
107
- return { navigated: routeName, params, previousRoute, currentRoute, deviceName, platform };
107
+ // Normalize route values: cdpEval returns undefined when the optional-chain
108
+ // short-circuits (mid-navigation transient or bridge not yet installed).
109
+ // null is valid JSON; undefined produces the literal string "undefined".
110
+ return {
111
+ navigated: routeName,
112
+ params,
113
+ previousRoute: previousRoute ?? null,
114
+ currentRoute: currentRoute ?? null,
115
+ deviceName,
116
+ platform,
117
+ };
108
118
  },
109
119
 
110
120
  async 'get-route'(client) {
@@ -112,7 +122,10 @@ const COMMANDS = {
112
122
  client,
113
123
  'globalThis.__AGENTIC__?.getRoute()',
114
124
  );
115
- return route;
125
+ // cdpEval returns undefined when the optional-chain short-circuits (bridge not
126
+ // yet installed, mid-navigation, or route state transiently missing). Return null
127
+ // so JSON.stringify produces valid JSON ("null") instead of literal "undefined".
128
+ return route ?? null;
116
129
  },
117
130
 
118
131
  async 'get-state'(client, args) {
@@ -168,15 +181,18 @@ const COMMANDS = {
168
181
  client,
169
182
  'globalThis.__AGENTIC__?.getRoute()',
170
183
  );
171
- return { currentRoute: route, deviceName, platform };
184
+ // Same normalization as navigate/get-route: a transiently-missing route must
185
+ // serialize as "currentRoute": null, not have the key silently omitted.
186
+ return { currentRoute: route ?? null, deviceName, platform };
172
187
  },
173
188
 
174
189
  async status(client, _args, { deviceName, platform } = {}) {
175
190
  const expr = `(function() {
191
+ var agenticPresent = typeof globalThis.__AGENTIC__ !== 'undefined';
176
192
  var route = globalThis.__AGENTIC__?.getRoute() || null;
177
193
  var account = null;
178
194
  try { account = globalThis.__AGENTIC__?.getSelectedAccount() || null; } catch(e) {}
179
- return { route: route, account: account };
195
+ return { route: route, account: account, agenticPresent: agenticPresent };
180
196
  })()`;
181
197
  const snapshot = await cdpEval(client, expr);
182
198
  return { ...snapshot, deviceName: deviceName || '', platform: platform || '' };
@@ -658,7 +674,9 @@ Environment:
658
674
  const result = await handler(client, args.slice(1), { deviceName: target.deviceName, platform });
659
675
  results.push(result);
660
676
  } catch {
661
- // Target not responsive skip
677
+ // Recovery is correct: status probes every target advisory; an
678
+ // unresponsive one degrades to absent-from-the-listing rather than
679
+ // failing the whole multi-target command.
662
680
  } finally {
663
681
  if (client) client.close();
664
682
  }
@@ -30,10 +30,22 @@ function loadSimulatorName() {
30
30
  return loadEnvValue('IOS_SIMULATOR') || '';
31
31
  }
32
32
 
33
- /** Read ANDROID_DEVICE serial from .js.env or env (default: none — accept any device) */
33
+ /** Read ANDROID_DEVICE from .js.env or env (default: none — accept any device) */
34
34
  function loadAndroidDevice() {
35
35
  if ('ANDROID_DEVICE' in process.env) return process.env.ANDROID_DEVICE;
36
36
  return loadEnvValue('ANDROID_DEVICE') || '';
37
37
  }
38
38
 
39
- module.exports = { loadEnvValue, loadPort, loadSimulatorName, loadAndroidDevice };
39
+ /**
40
+ * Read ANDROID_TARGET_DEVICE_NAME from env — the Metro-compatible model prefix
41
+ * resolved from an adb serial by bridge.mjs. Used by target-discovery to select
42
+ * the correct Metro CDP target when multiple devices are connected.
43
+ *
44
+ * Set automatically by the bridge when --device <serial> is used. Users never
45
+ * need to set this; pass --device <adb-serial> and the harness maps it internally.
46
+ */
47
+ function loadAndroidTargetDeviceName() {
48
+ return process.env.ANDROID_TARGET_DEVICE_NAME || '';
49
+ }
50
+
51
+ module.exports = { loadEnvValue, loadPort, loadSimulatorName, loadAndroidDevice, loadAndroidTargetDeviceName };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const http = require('node:http');
4
- const { loadSimulatorName, loadAndroidDevice } = require('./config.cjs');
4
+ const { loadSimulatorName, loadAndroidDevice, loadAndroidTargetDeviceName } = require('./config.cjs');
5
5
  const { createWSClient } = require('./ws-client.cjs');
6
6
 
7
7
  const FETCH_TIMEOUT_MS = Number.parseInt(process.env.CDP_TIMEOUT || '30000', 10);
@@ -48,6 +48,18 @@ async function fetchJSON(url) {
48
48
  * Returns true if __AGENTIC__ is installed, false otherwise.
49
49
  */
50
50
  async function probeTarget(wsUrl) {
51
+ return (await probeTargetDetailed(wsUrl)) === 'agentic';
52
+ }
53
+
54
+ /**
55
+ * Three-state probe so callers can tell a pre-__AGENTIC__ build apart from a
56
+ * dead target:
57
+ * 'agentic' — JS runtime answered and __AGENTIC__ is installed.
58
+ * 'responsive' — the target evaluated JS but has no __AGENTIC__ (an app build
59
+ * that predates the bridge, or a non-runtime page).
60
+ * 'unreachable' — connect/eval failed.
61
+ */
62
+ async function probeTargetDetailed(wsUrl) {
51
63
  try {
52
64
  const client = await createWSClient(wsUrl, 3000);
53
65
  try {
@@ -56,13 +68,13 @@ async function probeTarget(wsUrl) {
56
68
  returnByValue: true,
57
69
  awaitPromise: false,
58
70
  });
59
- return result?.result?.value === 'object';
71
+ return result?.result?.value === 'object' ? 'agentic' : 'responsive';
60
72
  } finally {
61
73
  client.close();
62
74
  }
63
75
  } catch {
64
- // Connection failed — target is not the right one
65
- return false;
76
+ // Connection failed — target is not usable at all
77
+ return 'unreachable';
66
78
  }
67
79
  }
68
80
 
@@ -113,14 +125,76 @@ async function discoverTarget(port) {
113
125
  }
114
126
  }
115
127
 
116
- // Filter by device name if ANDROID_DEVICE is set
128
+ // Android device filtering: support two identity layers.
129
+ // ANDROID_TARGET_DEVICE_NAME — Metro-compatible model prefix resolved by bridge.mjs
130
+ // from the adb serial via `adb -s <serial> shell getprop ro.product.model`.
131
+ // Example: "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36".
132
+ // ANDROID_DEVICE — exact Metro deviceName (backward compat / user-specified).
133
+ //
134
+ // When a device is pinned and multiple candidates remain, we MUST NOT silently fall
135
+ // back to another target (e.g. an iOS simulator). If the pin cannot be matched,
136
+ // fail fast with diagnostics so the operator can diagnose the mismatch.
137
+ const androidTargetName = loadAndroidTargetDeviceName();
117
138
  const androidDevice = loadAndroidDevice();
118
- if (androidDevice && candidates.length > 1) {
119
- const deviceFiltered = candidates.filter(
120
- (t) => t.deviceName === androidDevice,
121
- );
122
- if (deviceFiltered.length > 0) {
123
- candidates = deviceFiltered;
139
+ const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
140
+
141
+ if (candidates.length > 1) {
142
+ let androidFiltered = [];
143
+
144
+ if (androidTargetName) {
145
+ // Model prefix match: "Pixel 6" matches "Pixel 6 - 16 - API 36". Metro's
146
+ // /json/list has no explicit platform field, so scope the ANDROID pin by
147
+ // excluding the one iOS identity we do know (the pinned simulator name) —
148
+ // a simulator named to shadow a model string must not satisfy an android pin.
149
+ androidFiltered = candidates.filter(
150
+ (t) => t.deviceName !== simName &&
151
+ (t.deviceName === androidTargetName ||
152
+ (t.deviceName != null && t.deviceName.startsWith(androidTargetName))),
153
+ );
154
+ if (androidFiltered.length > 1) {
155
+ // Two same-model devices produce identical Metro model prefixes — the pin
156
+ // is genuinely ambiguous; picking one silently is exactly the bug this
157
+ // path exists to prevent.
158
+ const ambiguousList = androidFiltered
159
+ .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
160
+ .join('\n');
161
+ throw new Error(
162
+ `Pinned Android device is ambiguous: model '${androidTargetName}' matches ${androidFiltered.length} Metro targets.\n` +
163
+ ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
164
+ ` Matching Metro targets:\n${ambiguousList}\n` +
165
+ ` Set ANDROID_DEVICE to the exact Metro deviceName to disambiguate.`,
166
+ );
167
+ }
168
+ if (androidFiltered.length === 0) {
169
+ // Pinned device could not be matched — never silently pick another target.
170
+ const candidateList = targets
171
+ .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
172
+ .join('\n');
173
+ throw new Error(
174
+ `Pinned Android device did not match any Metro target.\n` +
175
+ ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
176
+ ` Resolved model (ANDROID_TARGET_DEVICE_NAME): ${androidTargetName}\n` +
177
+ ` ANDROID_DEVICE: ${androidDevice || '(not set)'}\n` +
178
+ ` Metro /json/list candidates:\n${candidateList}`,
179
+ );
180
+ }
181
+ candidates = androidFiltered;
182
+ } else if (androidDevice) {
183
+ // Exact Metro deviceName match (user set ANDROID_DEVICE to the Metro name, or
184
+ // legacy path where ANDROID_DEVICE was not resolved from serial).
185
+ androidFiltered = candidates.filter((t) => t.deviceName === androidDevice);
186
+ if (androidFiltered.length === 0) {
187
+ // Pinned by exact Metro name but no candidate matched — fail fast.
188
+ const candidateList = targets
189
+ .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
190
+ .join('\n');
191
+ throw new Error(
192
+ `Pinned Android device (ANDROID_DEVICE='${androidDevice}') did not match any Metro target.\n` +
193
+ ` ADB_SERIAL: ${adbSerial || '(not set)'}\n` +
194
+ ` Metro /json/list candidates:\n${candidateList}`,
195
+ );
196
+ }
197
+ candidates = androidFiltered;
124
198
  }
125
199
  }
126
200
 
@@ -186,18 +260,32 @@ async function discoverAllTargets(port) {
186
260
  return bPage - aPage;
187
261
  });
188
262
 
189
- // Group by deviceName, probe each to find the JS runtime target
190
- const seen = new Set();
191
- const results = [];
263
+ // Group by deviceName, probe each to find the JS runtime target. Prefer the
264
+ // device's __AGENTIC__-bearing target; when a device has none, keep its first
265
+ // RESPONSIVE candidate (candidates are sorted JS-runtime-first) so a build
266
+ // that predates the bridge still surfaces — the status consumer reports it as
267
+ // bridge-absent instead of the device silently vanishing from discovery.
268
+ // Known ambiguity: when a device's JS runtime is attached-but-busy while its
269
+ // native C++ Hermes page answers, that page is also 'responsive' (no
270
+ // __AGENTIC__ in the native context), so the device reports bridge-absent even
271
+ // though the app build may carry the bridge. Low probability, and the degraded
272
+ // report is informative rather than harmful.
273
+ const agenticByDevice = new Map();
274
+ const responsiveByDevice = new Map();
192
275
  for (const candidate of candidates) {
193
276
  const device = candidate.deviceName || candidate.id || candidate.webSocketDebuggerUrl;
194
- if (seen.has(device)) continue;
195
- const hasAgentic = await probeTarget(candidate.webSocketDebuggerUrl);
196
- if (hasAgentic) {
197
- seen.add(device);
198
- results.push({ wsUrl: candidate.webSocketDebuggerUrl, deviceName: device });
277
+ if (agenticByDevice.has(device)) continue;
278
+ const state = await probeTargetDetailed(candidate.webSocketDebuggerUrl);
279
+ if (state === 'agentic') {
280
+ agenticByDevice.set(device, { wsUrl: candidate.webSocketDebuggerUrl, deviceName: device });
281
+ } else if (state === 'responsive' && !responsiveByDevice.has(device)) {
282
+ responsiveByDevice.set(device, { wsUrl: candidate.webSocketDebuggerUrl, deviceName: device });
199
283
  }
200
284
  }
285
+ const results = [...agenticByDevice.values()];
286
+ for (const [device, entry] of responsiveByDevice) {
287
+ if (!agenticByDevice.has(device)) results.push(entry);
288
+ }
201
289
  return results;
202
290
  }
203
291
 
@@ -79,6 +79,18 @@ default_metro_workers() {
79
79
 
80
80
  # --- main ---------------------------------------------------------------------
81
81
 
82
+ # Detect stale metro.pid: if the pid file names a dead process, clean it up.
83
+ # A stale file survives when Metro is killed externally (session close, SIGKILL)
84
+ # and the normal stop path never ran. Cleaning it here keeps metro_ready and the
85
+ # log from attributing a new start to the old process id.
86
+ if [ -f "$PID_FILE" ]; then
87
+ stale_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
88
+ if [ -n "$stale_pid" ] && ! kill -0 "$stale_pid" 2>/dev/null; then
89
+ printf 'start-metro: stale metro.pid (pid %s no longer alive); cleaning up\n' "$stale_pid" >&2
90
+ rm -f "$PID_FILE"
91
+ fi
92
+ fi
93
+
82
94
  # Decide whether to (re)start or skip.
83
95
  if metro_ready; then
84
96
  if [ "$CLEAR" = true ]; then
@@ -97,11 +109,11 @@ fi
97
109
  stop_metro_listener || exit 1
98
110
 
99
111
  METRO_WORKERS="${METRO_MAX_WORKERS:-$(default_metro_workers)}"
100
- CLEAR_SUFFIX=""
101
- [ "$CLEAR" = true ] && CLEAR_SUFFIX=" --clear"
112
+ CLEAR_LABEL=""
113
+ [ "$CLEAR" = true ] && CLEAR_LABEL=", clear"
102
114
 
103
115
  printf 'Starting Metro on port %s (workers=%s%s)\n' \
104
- "$PORT" "$METRO_WORKERS" "${CLEAR:+, clear}" >&2
116
+ "$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
105
117
  printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
106
118
 
107
119
  # Metro runs detached, writing to the log. The tmux window is a read-only tail.
@@ -127,12 +139,17 @@ printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FIL
127
139
  export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
128
140
  export METRO_MAX_WORKERS="${METRO_WORKERS}"
129
141
  # shellcheck disable=SC2094
130
- bash -c "exec yarn expo start --port ${PORT}${CLEAR_SUFFIX}" \
142
+ # nohup sets SIGHUP to ignored (inherited across exec), so Metro survives the
143
+ # launching shell or tmux window closing; when this subshell exits the process
144
+ # is reparented to init. That alone is the detach guarantee — Metro was never
145
+ # in the parent shell's job table, so there is nothing to disown.
146
+ # nohup.out is suppressed (stdout/stderr both go to $LOG_FILE via >>).
147
+ metro_args=(expo start --port "$PORT")
148
+ [ "$CLEAR" = true ] && metro_args+=(--clear)
149
+ nohup yarn "${metro_args[@]}" \
131
150
  </dev/null >> "$LOG_FILE" 2>&1 &
132
151
  echo "$!" > "$PID_FILE"
133
152
  )
134
- # Disown the background process so it outlives this script.
135
- disown "$(cat "$PID_FILE" 2>/dev/null || true)" 2>/dev/null || true
136
153
 
137
154
  start_viewer_window "$LOG_FILE"
138
155
 
@@ -7,7 +7,7 @@ const SPEC = {
7
7
  { name: "help", aliases: ["-h", "--help"], desc: "Show usage" }
8
8
  ],
9
9
  shared: [
10
- { name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json"] },
10
+ { name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json", "--fast"] },
11
11
  { name: "ports", desc: "Slot ports and runtime paths", flags: ["--json"] },
12
12
  { name: "up", desc: "Decide + run minimum work to reach ready", flags: ["--json", "--dry-run"] },
13
13
  { name: "sync", desc: "Refresh harness + canonicalize wallet fixture", flags: ["--json"] },
@@ -29,6 +29,7 @@ function parseArgs(argv, command) {
29
29
  "force",
30
30
  "resolveOnly",
31
31
  "expectLive",
32
+ "fast",
32
33
  "help"
33
34
  ]);
34
35
  for (let i = 0; i < argv.length; i += 1) {
@@ -0,0 +1,167 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fixtureSummary } from "../doctor.js";
5
+ import { runnerDir } from "../paths.js";
6
+ const PROBE_TIMEOUT_MS = 2e3;
7
+ function bridgeScriptPath() {
8
+ return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
9
+ }
10
+ function truncateAddress(address) {
11
+ if (!address || address.length <= 10) return address;
12
+ return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
13
+ }
14
+ function routeToString(route) {
15
+ if (typeof route === "string") return route;
16
+ if (route && typeof route === "object" && "name" in route) {
17
+ const n = route.name;
18
+ if (typeof n === "string") return n;
19
+ }
20
+ return void 0;
21
+ }
22
+ function deriveWalletState(account, route) {
23
+ const routeStr = (routeToString(route) ?? "").toLowerCase();
24
+ if (routeStr.includes("onboard")) return "onboarding";
25
+ if (account !== null && account !== void 0 && typeof account === "object") return "unlocked";
26
+ return "locked";
27
+ }
28
+ function parseBridgeEntries(raw) {
29
+ if (!raw || typeof raw !== "object") return [];
30
+ const items = Array.isArray(raw) ? raw : [raw];
31
+ return items.filter((e) => Boolean(e && typeof e === "object" && !Array.isArray(e))).map((e) => ({
32
+ route: e.route,
33
+ account: e.account,
34
+ deviceName: typeof e.deviceName === "string" ? e.deviceName : "",
35
+ platform: typeof e.platform === "string" ? e.platform : "",
36
+ // agenticPresent is only set when the bridge explicitly sends it (false = absent).
37
+ // Omitted by older bridge versions → leave undefined so caller treats as present.
38
+ ...typeof e.agenticPresent === "boolean" ? { agenticPresent: e.agenticPresent } : {}
39
+ }));
40
+ }
41
+ function matchBridgeEntry(device, entries) {
42
+ const byId = entries.find((e) => e.deviceName === device.id);
43
+ if (byId) return byId;
44
+ if (device.name) {
45
+ const byName = entries.find((e) => e.deviceName === device.name);
46
+ if (byName) return byName;
47
+ }
48
+ const platformEntries = entries.filter((e) => e.platform === device.platform);
49
+ if (platformEntries.length === 1) return platformEntries[0];
50
+ return null;
51
+ }
52
+ function probeMobileBridge(target) {
53
+ const script = bridgeScriptPath();
54
+ if (!fs.existsSync(script)) {
55
+ return Promise.resolve({ ok: false, liveState: "no-bridge" });
56
+ }
57
+ return new Promise((resolve) => {
58
+ const env = {
59
+ ...process.env,
60
+ APP_ROOT: target,
61
+ CDP_TIMEOUT: String(PROBE_TIMEOUT_MS)
62
+ };
63
+ let settled = false;
64
+ let stdout = "";
65
+ const child = spawn(process.execPath, [script, "status"], {
66
+ cwd: target,
67
+ env,
68
+ stdio: ["ignore", "pipe", "ignore"]
69
+ });
70
+ const timer = setTimeout(() => {
71
+ if (settled) return;
72
+ settled = true;
73
+ child.kill("SIGTERM");
74
+ const killTimer = setTimeout(() => child.kill("SIGKILL"), 500);
75
+ child.once("close", () => clearTimeout(killTimer));
76
+ resolve({ ok: false, liveState: "no-bridge" });
77
+ }, PROBE_TIMEOUT_MS);
78
+ child.stdout.on("data", (chunk) => {
79
+ stdout += chunk;
80
+ });
81
+ child.on("close", (code) => {
82
+ if (settled) return;
83
+ settled = true;
84
+ clearTimeout(timer);
85
+ if (code !== 0) {
86
+ resolve({ ok: false, liveState: "no-bridge" });
87
+ return;
88
+ }
89
+ try {
90
+ const raw = JSON.parse(stdout);
91
+ const entries = parseBridgeEntries(raw);
92
+ if (entries.length === 0) {
93
+ resolve({ ok: false, liveState: "no-bridge" });
94
+ return;
95
+ }
96
+ resolve({ ok: true, entries });
97
+ } catch {
98
+ resolve({ ok: false, liveState: "no-bridge" });
99
+ }
100
+ });
101
+ child.on("error", () => {
102
+ if (settled) return;
103
+ settled = true;
104
+ clearTimeout(timer);
105
+ resolve({ ok: false, liveState: "no-bridge" });
106
+ });
107
+ });
108
+ }
109
+ function resolveFixtureStatus(target) {
110
+ const summary = fixtureSummary(target);
111
+ return summary.status === "ready" ? "READY" : "missing";
112
+ }
113
+ async function probeMobileLiveState(target, devices) {
114
+ const result = /* @__PURE__ */ new Map();
115
+ if (devices.length === 0) return result;
116
+ const fixtureStatus = resolveFixtureStatus(target);
117
+ const probeResult = await probeMobileBridge(target);
118
+ if (!probeResult.ok) {
119
+ for (const device of devices) result.set(device.id, { fixtureStatus, liveState: "no-bridge" });
120
+ return result;
121
+ }
122
+ const { entries } = probeResult;
123
+ const matched = /* @__PURE__ */ new Map();
124
+ const usedEntries = /* @__PURE__ */ new Set();
125
+ for (const device of devices) {
126
+ const entry = matchBridgeEntry(device, entries.filter((e) => !usedEntries.has(e)));
127
+ if (entry) {
128
+ matched.set(device.id, entry);
129
+ usedEntries.add(entry);
130
+ }
131
+ }
132
+ const leftoverEntries = entries.filter(
133
+ (e) => !usedEntries.has(e) && e.platform === "" && e.agenticPresent === false
134
+ );
135
+ const unmatchedDevices = devices.filter((d) => !matched.has(d.id));
136
+ if (leftoverEntries.length === 1 && unmatchedDevices.length === 1) {
137
+ matched.set(unmatchedDevices[0].id, leftoverEntries[0]);
138
+ }
139
+ for (const device of devices) {
140
+ const entry = matched.get(device.id);
141
+ if (!entry) {
142
+ result.set(device.id, { fixtureStatus, liveState: "no-bridge" });
143
+ continue;
144
+ }
145
+ if (entry.agenticPresent === false) {
146
+ result.set(device.id, { fixtureStatus, liveState: "bridge-absent" });
147
+ continue;
148
+ }
149
+ const account = entry.account;
150
+ const walletState = deriveWalletState(account, entry.route);
151
+ const currentScreen = routeToString(entry.route);
152
+ const addrRaw = typeof account?.address === "string" ? account.address : void 0;
153
+ const labelRaw = typeof account?.name === "string" ? account.name : void 0;
154
+ const liveEntry = {
155
+ fixtureStatus,
156
+ walletState,
157
+ ...currentScreen !== void 0 ? { currentScreen } : {},
158
+ ...labelRaw !== void 0 && addrRaw !== void 0 ? { selectedAccount: { label: labelRaw, address: truncateAddress(addrRaw) } } : {}
159
+ };
160
+ result.set(device.id, liveEntry);
161
+ }
162
+ return result;
163
+ }
164
+ export {
165
+ probeMobileLiveState,
166
+ resolveFixtureStatus
167
+ };
@@ -4,11 +4,13 @@ import { assertAdapter } from "../paths.js";
4
4
  import { getAdapterSurface } from "../adapters/surface.js";
5
5
  import { listConnectedDevices } from "../devices.js";
6
6
  import { deviceSelected, renderDeviceList } from "./device-target.js";
7
+ import { probeMobileLiveState } from "./status-probe.js";
7
8
  import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
8
9
  import { optionFlag, targetPath } from "./parse-args.js";
9
10
  async function handleStatus({ options }) {
10
11
  const target = targetPath(options);
11
12
  const json = optionFlag(options, "json");
13
+ const fast = optionFlag(options, "fast");
12
14
  const adapter = detectAdapter(target);
13
15
  if (!adapter) {
14
16
  return usageOut(json, "status", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
@@ -17,15 +19,70 @@ async function handleStatus({ options }) {
17
19
  const devices = adapter === "mobile" ? listConnectedDevices().map((device) => ({ ...device, selected: deviceSelected(device) })) : [];
18
20
  const next = getAdapterSurface(adapter).hints.relaunch;
19
21
  if (json) {
20
- console.log(JSON.stringify({ schemaVersion: 1, command: "status", adapter, target, devices, next }, null, 2));
22
+ const doProbe = !fast && adapter === "mobile" && devices.length > 0;
23
+ if (doProbe) {
24
+ const liveMap = await probeMobileLiveState(target, devices);
25
+ const devicesWithLive = devices.map((d) => mergeDeviceLive(d, liveMap.get(d.id)));
26
+ console.log(
27
+ JSON.stringify(
28
+ { schemaVersion: 1, command: "status", adapter, target, devices: devicesWithLive, next },
29
+ null,
30
+ 2
31
+ )
32
+ );
33
+ } else {
34
+ console.log(
35
+ JSON.stringify({ schemaVersion: 1, command: "status", adapter, target, devices, next }, null, 2)
36
+ );
37
+ }
21
38
  return EXIT.ok;
22
39
  }
23
40
  const out = (style, text) => color(style, text, { stream: process.stdout });
24
41
  console.log(`${out("label", "status")} ${out("bold", adapter)} ${out("dim", target)}`);
25
42
  if (adapter === "mobile") renderDeviceList(devices, out);
26
43
  console.log(`${out("label", "Next:")} ${out("cmd", next)}`);
44
+ if (!fast && adapter === "mobile" && devices.length > 0) {
45
+ const liveMap = await probeMobileLiveState(target, devices);
46
+ renderLiveBlock(devices, liveMap, out);
47
+ }
27
48
  return EXIT.ok;
28
49
  }
50
+ function mergeDeviceLive(device, live) {
51
+ if (!live) return device;
52
+ return {
53
+ ...device,
54
+ fixtureStatus: live.fixtureStatus,
55
+ ...live.liveState !== void 0 ? { liveState: live.liveState } : {},
56
+ ...live.currentScreen !== void 0 ? { currentScreen: live.currentScreen } : {},
57
+ ...live.walletState !== void 0 ? { walletState: live.walletState } : {},
58
+ ...live.selectedAccount !== void 0 ? { selectedAccount: live.selectedAccount } : {}
59
+ };
60
+ }
61
+ function renderLiveBlock(devices, liveMap, out) {
62
+ console.log(`${out("label", "live:")}`);
63
+ for (const device of devices) {
64
+ const live = liveMap.get(device.id);
65
+ const prefix = ` ${device.platform} ${out("dim", device.id)}${device.name ? ` (${device.name})` : ""}`;
66
+ if (!live || live.liveState === "no-bridge") {
67
+ console.log(`${prefix}: ${out("dim", "(no-bridge)")}`);
68
+ continue;
69
+ }
70
+ if (live.liveState === "bridge-absent") {
71
+ console.log(`${prefix}: ${out("warn", "(bridge-absent \u2014 app attached, build lacks __AGENTIC__; rebuild/reinstall a dev build)")}`);
72
+ continue;
73
+ }
74
+ const parts = [];
75
+ if (live.currentScreen !== void 0) parts.push(`screen=${out("cmd", live.currentScreen)}`);
76
+ if (live.walletState !== void 0) parts.push(`wallet=${out(live.walletState === "unlocked" ? "ok" : "warn", live.walletState)}`);
77
+ if (live.selectedAccount !== void 0) {
78
+ parts.push(`account=${live.selectedAccount.label} ${out("dim", `(${live.selectedAccount.address})`)}`);
79
+ }
80
+ if (live.fixtureStatus !== void 0) {
81
+ parts.push(`fixture=${out(live.fixtureStatus === "READY" ? "ok" : "warn", live.fixtureStatus)}`);
82
+ }
83
+ console.log(`${prefix}: ${parts.join(" ")}`);
84
+ }
85
+ }
29
86
  export {
30
87
  handleStatus
31
88
  };
@@ -18,18 +18,31 @@ const REAL = [
18
18
  helpText: `mm-harness status [flags]
19
19
 
20
20
  Home dashboard for the current checkout \u2014 the detected adapter, the next command
21
- to run, and (mobile only) the connected devices with which one is the selected
22
- target (matches ADB_SERIAL / IOS_SIMULATOR). Aliases: health, home.
21
+ to run, and (mobile only) the connected devices with live app state. Aliases: health, home.
23
22
 
24
23
  --target <path> Checkout path (default: cwd)
25
24
  --json Machine-readable envelope { adapter, target, devices[], next }
25
+ --fast Skip all live-state probes (instant output, safe for scripts)
26
26
 
27
- The devices[] section carries { platform, id, name, state, selected } per device;
28
- extension/core checkouts report an empty list (no device surface).
27
+ Human output: static device list prints immediately; live-state lines append
28
+ after the ~2s probe window (screen, wallet, account, fixture per device).
29
+
30
+ The devices[] section carries per device:
31
+ { platform, id, name, state, selected } always present
32
+ { currentScreen, walletState, selectedAccount, fixtureStatus } when bridge reachable
33
+ { liveState: 'no-bridge' } when bridge unreachable or --fast
34
+
35
+ All RN targets attached to the checkout's Metro are probed and matched to their
36
+ devices; a device whose target cannot be confidently matched (Metro's device
37
+ description matches neither the serial nor the model name) degrades to
38
+ liveState:'no-bridge'. An attached target whose build predates __AGENTIC__
39
+ reports liveState:'bridge-absent'.
40
+ Extension/core live-state probing is V1-pending (use doctor for runtime status).
29
41
 
30
42
  Example:
31
43
  mm-harness status
32
- mm-harness status --json`
44
+ mm-harness status --json
45
+ mm-harness status --fast`
33
46
  },
34
47
  {
35
48
  name: "actions",