@deeeed/metamask-harness 0.84.0 → 0.86.0

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +5 -3
  3. package/adapters/extension/console-tail.mjs +11 -7
  4. package/adapters/extension/ensure-browser.sh +17 -18
  5. package/adapters/extension/refresh-build.sh +1 -1
  6. package/adapters/extension/start-watch.sh +17 -8
  7. package/adapters/manifest.json +1 -1
  8. package/adapters/mobile/reload-app.mjs +2 -1
  9. package/adapters/mobile/start-console-forwarder.sh +8 -0
  10. package/adapters/shared/resolve-slot-ports-core.mjs +48 -6
  11. package/dist/adapters/console-capture.js +3 -1
  12. package/dist/adapters/extension/actions.js +5 -0
  13. package/dist/adapters/extension/console-capture.js +18 -0
  14. package/dist/adapters/extension/fixtures.js +2 -0
  15. package/dist/adapters/extension/overlay.js +5 -4
  16. package/dist/adapters/extension/runtime.js +2 -0
  17. package/dist/adapters/mobile/launch.js +19 -2
  18. package/dist/adapters/mobile/provision.js +4 -2
  19. package/dist/adapters/mobile/run.js +3 -2
  20. package/dist/adapters/mobile/verify.js +15 -7
  21. package/dist/adapters/web-dapp/actions.js +7 -5
  22. package/dist/adapters.js +5 -3
  23. package/dist/command-contract.js +32 -20
  24. package/dist/commands/check.js +5 -7
  25. package/dist/commands/debug.js +2 -2
  26. package/dist/commands/doctor.js +2 -3
  27. package/dist/commands/domain.js +4 -3
  28. package/dist/commands/fixtures.js +3 -3
  29. package/dist/commands/help.js +6 -5
  30. package/dist/commands/launch/extension.js +9 -7
  31. package/dist/commands/logs.js +2 -2
  32. package/dist/commands/prepare.js +4 -4
  33. package/dist/commands/provision.js +2 -2
  34. package/dist/commands/review.js +8 -10
  35. package/dist/commands/runtime-launch.js +2 -0
  36. package/dist/commands/status-probe.js +8 -1
  37. package/dist/commands/status.js +4 -2
  38. package/dist/commands/task-init.js +2 -3
  39. package/dist/console-classifier.js +3 -2
  40. package/dist/doctor.js +6 -2
  41. package/dist/mm-harness-cli.js +19 -6
  42. package/dist/paths.js +9 -4
  43. package/dist/recipe-security.js +1 -1
  44. package/dist/review/knowledge.js +16 -4
  45. package/dist/runner.js +18 -3
  46. package/docs/CONTRIBUTING.md +11 -0
  47. package/docs/QA.md +1 -0
  48. package/docs/RECIPES.md +18 -2
  49. package/docs/cli-contract.md +8 -6
  50. package/library/actions/core/perps/_controller.mjs +2 -2
  51. package/library/actions/extension/app/assert_no_console_errors.mjs +95 -0
  52. package/library/actions/extension/platform/cdp.mjs +72 -2
  53. package/library/actions/extension/ui/navigate.mjs +3 -1
  54. package/library/actions/extension/wallet/select_account.mjs +17 -17
  55. package/library/actions/extension/wallet/state.mjs +75 -21
  56. package/library/actions/mobile/app/assert_no_console_errors.mjs +129 -0
  57. package/library/actions/mobile/wallet/select_account.mjs +34 -18
  58. package/library/actions/shared/console-assert.mjs +174 -0
  59. package/library/actions/shared/console-collector.mjs +10 -3
  60. package/library/actions/shared/console-findings.mjs +108 -7
  61. package/library/actions/web-dapp/app/assert_no_console_errors.mjs +10 -54
  62. package/library/actions/web-dapp/platform/hud.mjs +2 -9
  63. package/library/actions/web-dapp/platform/page.mjs +62 -16
  64. package/library/actions/web-dapp/platform/runtime.mjs +1 -1
  65. package/library/diagnostics/console-allowlist.json +13 -0
  66. package/library/manifests/core.action-manifest.json +6 -6
  67. package/library/manifests/extension.action-manifest.json +37 -1
  68. package/library/manifests/mobile.action-manifest.json +40 -4
  69. package/library/manifests/web-dapp.action-manifest.json +1 -1
  70. package/library/recipes/core/health/check.recipe.json +40 -0
  71. package/library/recipes/extension/health/check.recipe.json +71 -0
  72. package/library/recipes/mobile/health/check.recipe.json +71 -0
  73. package/package.json +9 -9
  74. package/scripts/completions.sh +2 -2
  75. package/site/assets/help-recipes.json +3 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,71 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.86.0 - 2026-10-09
6
+
7
+ ### Added
8
+
9
+ - `MM_HARNESS_SIBLING_LIBRARIES=0` turns off sibling discovery: libraries and reference checkouts beside the checkout or under `~/shared-library` are no longer found.
10
+ - `call` takes `--hud show|hide` like `run`. With recipe-cli 0.11.1 a call draws the HUD by default, and its HUD text is its `intent=` input (`mm-harness call ui.screenshot intent="Show the order filled"`).
11
+ - `MOBILE_RELOAD_VERIFY_TIMEOUT_MS` sets how long `adapters/mobile/reload-app.mjs` waits to verify a reload (default 30 000), and `MM_HARNESS_CONSOLE_ATTACH_TIMEOUT_MS` how long `launch` waits for the Extension console collector to attach (default about 10 s). A value that is not a positive number uses the default. Contract tests use them where no app or extension target can answer.
12
+
13
+ ### Changed
14
+
15
+ - Depend on `@farmslot/recipe-runner` 0.28.1, `@farmslot/recipe-cli` 0.11.1, `@farmslot/adapter-sdk` 0.8.1, `@farmslot/adapter-web` 0.6.5, `@farmslot/adapter-node` 0.1.7 and `@farmslot/adapter-rn` 0.19.4 (were 0.28.0, 0.11.0, 0.8.0, 0.6.4, 0.1.6 and 0.19.3), pinned together; the install holds one `@farmslot/recipe-runner` and one `@farmslot/adapter-sdk`. Farmslot #869 and #870:
16
+ - `run` and `call` draw the HUD for every recipe source, an untrusted or inherited one (a task's `recipe-source.json`) included, unless `--hud hide`. The HUD is part of the plan an approval covers, so an untrusted-source approval made before this release without `--hud hide` (on an adapter that draws the HUD), or with another `--hud` setting, must be approved again; the refusal prints the new digest.
17
+ - The HUD on Extension pages is drawn in a closed shadow root with its text capped at 180 characters, so a node's own intent can no longer satisfy its `ui.wait_for text=` check or catch a text-target press.
18
+ - `call app.hud` drives the HUD itself, with no automatic updates.
19
+ - A Mobile `run` arms and collects the app's issue buffer through `METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT` when it is set, as launch, status and the action library already do (it always used the bundled `cdp-bridge.cjs`). An empty value means the bundled bridge, as in the action library.
20
+ - Extension `start-watch.sh` checks for the webpack compile marker every 0.5 s (was 2 s) and still refreshes the watcher pid every 2 s; its 480 s limit is now wall-clock time. It waits for stale watchers to stop instead of a fixed 2 s.
21
+
22
+ ### Fixed
23
+
24
+ - Mobile `status` and `doctor` pair a physical Android device whose bridge entry carries a manufacturer prefix. A Pixel 6a over USB lists as `model:Pixel_6a` in adb while its bridge entry says `Google Pixel 6a`; it read `liveState: no-bridge`, so `doctor --print-ready` printed `not-ready` and Farmslot slot.check health failed on an unlocked wallet. The prefixed name pairs only the sole physical device (not `emulator-*`) of that model, so a same-model emulator cannot take the phone's entry; bare-model matching is unchanged.
25
+ - Extension `ui.navigate hash=` and `url=` accept a route the Extension router rewrites in the same document. Extension 13.52 canonicalizes Perps symbols (`#/perps/trade/sol` becomes `#/perps/trade/SOL`, `XYZ%3Atsla` becomes `xyz%3ATSLA`, query kept), and readiness waited for the requested URL until it timed out. Readiness for a requested URL now takes the rewritten URL once it holds for two polls, if only the hash changed on the same extension page (same origin, path and query). Another origin, extension page or query, a `chrome-error://` page or a document that never becomes ready still fails; the error names the last URL (`Extension page did not reach <url> within <n>ms (last URL <url>)`). A lock screen that redirects to `#/unlock` is reported, not failed, so assert the route on `href`. `hash=` returns `href` (where the page landed), `requestedHref` and `redirected`; `url=` returns `href`.
26
+ - `metamask.wallet.select_account name=` on Extension and Mobile also matches account group names. Multichain wallets (Extension 13.52) keep fixture names such as `dev1` on the account group while the internal account stays `Account 1`, so `name=dev1` failed with `Requested account was not found in internalAccounts`. A name with no internal account match now selects the EVM account of the account group with that name; a name two account groups share, or a group with no EVM account, fails without selecting anything. Extension, like Mobile, returns `alreadySelected: true` without a write when the requested account is already selected.
27
+
28
+ - Docs, `help` and the Mobile, Extension and Core Perps action descriptions no longer say mainnet is read-only; they state the real mainnet rule (the shared Perps order rule, Core's `CORE_PERPS_ALLOW_MAINNET_WRITES=1`, Mobile cleanup acting on the app's current network, Extension cancel refused on mainnet outside the funded release lane). Core close, ensure and teardown descriptions no longer claim testnet only.
29
+ - Contributors: the feature-guard scripts (`differential.mjs`, `release-smoke.mjs`, `builds.mjs`) reach a remote host with `-o ConnectTimeout=10` on every `ssh` and `scp`, and `BatchMode=yes` on `scp` as on `ssh`. A host name whose first address does not answer (`ssh macpro.local` from macwork: `connect to host macpro.local port 22: Undefined error: 0`) failed or hung without it; with it, ssh moves on to the next address.
30
+ - Extension `refresh-build.sh` writes its build log under `$TMPDIR` as `refresh-build.XXXXXX`, without the `.log` suffix (macOS `mktemp` takes no suffix after the X's); macOS `mktemp -t` ignored `TMPDIR`, so each contract run left two logs in the machine's temp dir.
31
+ - Contributors: a unit run gets one TMPDIR that every worker inherits. When the run ends, interrupted (Ctrl-C, SIGTERM) or not, the processes still naming it are stopped and it is removed; an interrupted run used to leave its per-file temp dirs, vitest's transform dir and detached test browsers behind. Every unit run also left a CDP broker socket in `/tmp` (the console-forwarder test killed the forwarder before it could release it), and the mutation-receipt test now writes under TMPDIR, not the checkout. Two contract tests whose bare `mktemp -d` ignored the runner's TMPDIR on macOS now create under it.
32
+ - Contributors: contract and unit tests no longer see the operator's recipe libraries. `ct_init` and the unit setup point `MM_HARNESS_CONFIG` at a missing file, set `MM_HARNESS_SIBLING_LIBRARIES=0`, point `FARMSLOT_HOME` into the sandbox (the personal library) and clear `RECIPE_LIBRARY_PATH` and any exported `MM_HARNESS_REF_<ADAPTER>`. Port claims go to a sandbox `MM_HARNESS_PORT_REGISTRY`, not `~/.cache/mm-harness/ports`. From a checkout with perps and terminal configured, or checked out beside it, `run-name-resolution` and `extension-perps-g005` failed (`mm-harness-wave2` failed with the config). A test that covers one of these sources sets it up itself.
33
+
34
+ ## 0.85.0 - 2026-10-09
35
+
36
+ ### Added
37
+
38
+ - `health.check` recipes for Mobile, Extension and Core: a read-only readiness check to run before ticket work. A failed precondition stops at a node named after its error (for example `WALLET_LOCKED`, `NOT_ON_HOME` or, on Mobile and Extension, `CONSOLE_ERRORS`), whose intent gives the recovery command. `mm-harness help` lists it under the target step.
39
+ - `metamask.app.assert_no_console_errors` on Mobile and Extension, as on `web-dapp`: it fails on a console error or exception that no console allowlist covers, judging the events since the launch began by their own timestamps, and fails as unavailable unless the capture is proven (the slot's console forwarder or collector is running and a control line logged in the app or MetaMask reaches the log). `launch` records when it starts, and console lines replayed from before then (by an app runtime or browser the launch kept) are left out. Mobile reads `app-console.log`, which every launch now restarts empty along with the forwarder's replay cursor, and judges only the device the bridge selects; Extension reads `extension-console.log` from the offset `launch` records, and its collector now stamps each line with the event's own time (UTC, with milliseconds). Known app noise is accepted only when it also appears on the app's unchanged base build: the bundled library ships `diagnostics/console-allowlist.json` (the Extension unlock page's `UNSAFE_componentWillMount` warning), read ahead of other libraries' by these actions and by run diagnostics, and the classifier carries pattern rules for base-reproduced errors one exact entry cannot cover: Mobile's SES lockdown intrinsic reports and its notifications startup failure (`createOnChainTriggers`), whose message carries the bundle line; Extension's `getBearerToken` while locked (MetaMask/metamask-extension#46688) and the Money query adapter's `mutations` TypeError (TAT-4063), whose stacks name build-specific chunks. Allowlist entries can name the `app` source Mobile lines carry, and Mobile console lines match without the forwarder's time and device prefix.
40
+
41
+ ### Changed
42
+
43
+ - **BREAKING:** `run --hud` takes `show` or `hide`, default `show`. `auto` is gone: the CLI refuses it as an invalid choice (`--hud must be show or hide; received 'auto'.`); drop the flag to get the default. The recipe HUD, each node's intent, now draws on every adapter that implements `app.hud`, headless browsers included. A headless web-dapp (Terminal) browser gets it where `auto` skipped it; Mobile and Extension already drew it by default. A Mobile release artifact still runs without it, since it has no dev bridge to draw with. `METAMASK_RECIPE_AUTO_HUD=0` stays the internal off switch for self-test, verify and the test sandboxes, and `--hud show` wins over it.
44
+ - **BREAKING:** web-dapp `ui.screenshot` keeps the HUD in the capture (it hid it while capturing), so Terminal screenshot evidence shows the node's intent as Mobile and Extension evidence does. `--hud hide` is the way to a HUD-free screenshot.
45
+ - A `surface=wallet` web-dapp `ui.screenshot` (the MetaMask confirmation window or side panel) shows the running node's intent too: the HUD is a bar docked to that MetaMask page's bottom edge for the capture only, which reserves its own height so the request and the footer buttons move up rather than being covered (pointer-events none), and is removed after it.
46
+ - A run with the HUD off (`--hud hide`, a `call`, an untrusted source) first clears the HUD an earlier run left on the slot, so its screenshots never show a previous recipe's intent: web-dapp deletes `hud.json` (the wallet host redraws from it) and the drawn HUD on the app tabs; Extension removes the runner HUD from its pages. Nothing is launched to do it, `METAMASK_RECIPE_AUTO_HUD=0` skips it, and a failed clear prints one `WARN:` line on stderr.
47
+ - Inside a checkout, every command resolves its adapter, target and slot once, through recipe-cli's context resolver, so `status`, `doctor`, `run` and the rest need no `--adapter`. The order is: flag, then the checkout's runtime binding, then its slot, then a unique detect match among the built-ins and the operator's library plugins (matched by their declared `detect`, before any plugin code loads).
48
+ - A checkout that more than one adapter matches stops with `ADAPTER_AMBIGUOUS` (exit 2) and lists the candidates. With no match, a command keeps its "could not detect the MetaMask repo type" error.
49
+ - `status`, `doctor`, `run`, `call` and discovery (`actions`, `run --list`/`--describe`, `call --list`) carry `context` in their `--json` envelopes (each value and its source), as does a command's own `USAGE` error once its context has resolved. Grammar refusals (raised before resolution), the task view (`status --task`/`--watch`) and the other commands' envelopes, `launch` on every adapter included, are unchanged. Human output adds one `context:` line on stderr when the adapter was not given by a flag.
50
+ - Terminal detection needs a recipe-terminal library that declares `detect` (recipe-terminal #8, 8a44aee); with an older library, pass `--adapter terminal`.
51
+ - `status` accepts `--adapter` and `--platform` (it refused them with `CLI_UNKNOWN_OPTION`).
52
+ - The slot comes from mm-harness's own pool resolution (the configured Farmslot root, else the nearest Farmslot checkout's `pool/`) unless `FARMSLOT_POOL_DIR` or `FARMSLOT_ROOT` names one, so a slot checkout beside a Farmslot checkout resolves its slot, and its ports fill `--cdp-port`/`--watcher-port`, with no env.
53
+ - Commands no longer detect the adapter themselves: status, doctor, check, provision, prepare, help, task init, `verify`'s overlay, debug, logs, fixtures, review and domain all read the resolved one. `check` still refuses `--platform ios|android`.
54
+ - `review` and `domain` scope to the resolved adapter when review covers it (mobile, extension, core, terminal), else run unscoped. Only an explicit `--adapter` that names another adapter is refused.
55
+ - Depend on `@farmslot/recipe-cli` 0.11.0 and `@farmslot/adapter-sdk` 0.8.0 (were 0.10.0 and 0.7.1) for the context resolver (deeeed/farmslot#864), on `@farmslot/recipe-runner` 0.28.0, `@farmslot/protocol` 0.35.0 and `@farmslot/agent-runtime` 0.17.1 (were 0.27.0, 0.34.0 and 0.17.0), and on `@farmslot/adapter-web` 0.6.4, `@farmslot/adapter-rn` 0.19.3 and `@farmslot/adapter-node` 0.1.6 (were 0.6.2, 0.19.1 and 0.1.4). With them, a web-dapp launch stops waiting for CDP when its browser dies (deeeed/farmslot#865). The install holds one `@farmslot/adapter-sdk` and one `@farmslot/recipe-runner`.
56
+ - Help lists the selectable adapters (the registered ones, then those the operator's libraries and the invocation's `--library` entries declare) wherever it names `--adapter` or `--platform`, instead of a fixed `mobile|extension|core|terminal`.
57
+ - **BREAKING:** Mobile `verify` takes `--watcher-port` (the public `verify` grammar accepted it; the Mobile leaf refused it) and reads `RECIPE_WATCHER_PORT`: the Metro port is `--watcher-port`, then `RECIPE_WATCHER_PORT`, then `WATCHER_PORT`, then the slot's `.js.env`. With no port env set, a checkout the slot pool maps gets the pool slot's port through `WATCHER_PORT`, so that port now wins over the checkout's `.js.env` `WATCHER_PORT`.
58
+ - **BREAKING:** A `RECIPE_RUNTIME_CONTEXT` that belongs to another checkout no longer sets the Extension slot's ports (`CDP_PORT`, `WATCHER_PORT`, `SLOT_ID`), Extension `verify`'s `RECIPE_SLOT_ID` and `RECIPE_HARNESS_EXTENSION_ID` (it reads the checkout's own context instead), or the Mobile provision slot (simulator, slot id). It counts when its `repoRoot` is the checkout's Git top level (so a clone nested inside another checkout never takes that checkout's context), or, without a `repoRoot`, when the file sits inside the checkout: the rule recipe-cli applies to the runtime binding. A context outside every checkout that names no `repoRoot` used to be authoritative; an orchestrator that keeps the file elsewhere sets `repoRoot`.
59
+ - Contributors: `bash tests/contract/run.sh` runs half the cores' worth of tests at once (`-j N`), each in its own process group, `TMPDIR` and output file, and prints each test's duration and the 15 slowest. A test gets 60 s (`CONTRACT_TEST_TIMEOUT`, or `# contract: timeout=<s>` in its header) and fails as `timed out`; what it leaves running is killed and named on a `LEAK` line; a test that rebuilds `dist/` declares `# contract: serial`. An unfiltered run over `CONTRACT_BUDGET` (180 s) fails, and a stopped run first lets each running test clean up (its EXIT trap reaps the leaves its CLI detached), then kills every process group left in the test's tree. A process a test leaves behind can no longer stall the run.
60
+ - Contributors: `yarn test:goldens` runs its families through `run.sh` and fails over `GOLDENS_BUDGET` (120 s); a golden case gets 60 s (`GOLDENS_CASE_TIMEOUT`, was 600 s). Docs: CONTRIBUTING and cli-contract describe both.
61
+ - Contributors: `AGENTS.md` has a `## Test speed` section: the default and CI suite must take 5 minutes or less (the contract and goldens runs check their own budgets; build and unit are not checked), and a new or changed test uses no fixed waits of 1 s or more, moves to `tests/live/` when it takes over 5 s (a golden case over 30 s), tears down every background process, and never rebuilds `dist/` under concurrent readers.
62
+ - Site: the platform map (`site/platform.html`) is a static page instead of the zoomable canvas. It shows the five layers (Libraries, MetaMask preset, Adapters, CLI, Engine) with their packages, one `mm-harness run` traced through them, and who changes what. It renders from `file://` (no `fetch`), so `site/assets/platform.json` is removed.
63
+
64
+ ### Fixed
65
+
66
+ - A command no longer runs `git rev-parse` on every library it finds before dispatch. Library path hydration ran on every command, `--version` included, and pinned each discovered library (a sibling checkout, a config entry, the fetch cache) to its revision, which it never reads. The revision is now looked up only by what reports it (`status`, `domain`, `review`), as before. In a workspace with recipe-library checkouts beside the product checkout, that was one git process per library on every start.
67
+ - `tests/contract/run.sh`, and `yarn test:goldens` through it, refuse to start (exit 2, `Next: npm run build`) when `dist/` is missing or older than any `src/**/*.ts`. `bin/mm-harness` then runs the TypeScript source on every start, type-stripped each time (about 260 ms more per start, over a thousand starts per run). CI and the full local suite build first, so only a bare `run.sh` or `yarn test:goldens` was affected.
68
+ - Extension reopen (`adapters/extension/ensure-browser.sh`) no longer builds its `node -e` script or its `python3` profile cleanup from the slot id, checkout, runtime, profile, extension and runner paths, CDP port, webpack pid or wallet fixture path. The scripts are fixed text and get those values as argv. Before, a checkout path with an apostrophe (`/tmp/Arthur's checkout`) broke the reopen script's syntax and silently skipped the profile cleanup, and a crafted value could change the code the reopen ran.
69
+
5
70
  ## 0.84.0 - 2026-10-08
6
71
 
7
72
  ### Changed
package/README.md CHANGED
@@ -419,9 +419,11 @@ A team library supplies `review/antipatterns.md` (one `##` section per family),
419
419
  optional `review/antipatterns.<adapter>.md` families appended for that adapter only,
420
420
  `review/parity.md`, and `owned-paths.json`; the harness composes them and never
421
421
  copies them. Locations resolve env (`RECIPE_LIBRARY_PATH`, `MM_HARNESS_REF_<ADAPTER>`)
422
- → `mm-harness config` → sibling directory beside the checkout → shallow fetch into
423
- `<checkout>/.skills-cache/<name>`. A missing reference checkout degrades the parity
424
- step to "not checked" with the reason.
422
+ → `mm-harness config` → sibling directory beside the checkout or under
423
+ `~/shared-library` → shallow fetch into `<checkout>/.skills-cache/<name>`.
424
+ `MM_HARNESS_SIBLING_LIBRARIES=0` turns the sibling step off, for libraries and
425
+ reference checkouts alike (the test sandboxes set it). A missing reference
426
+ checkout degrades the parity step to "not checked" with the reason.
425
427
 
426
428
  ## Recover
427
429
 
@@ -90,10 +90,14 @@ function out(event, destination = null) {
90
90
  }
91
91
  }
92
92
 
93
- function stamp() {
94
- const d = new Date();
95
- const p = (n) => String(n).padStart(2, '0');
96
- return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
93
+ // A record's time: the UTC time of day of the event's own timestamp (CDP
94
+ // Runtime.Timestamp, ms since the epoch), as the Mobile console forwarder
95
+ // writes it, or of the write when the event carries none. Events the browser
96
+ // replays when a collector attaches keep their own time, so
97
+ // metamask.app.assert_no_console_errors can leave out those from before a
98
+ // launch.
99
+ function stamp(timestamp) {
100
+ return new Date(Number.isFinite(timestamp) ? timestamp : Date.now()).toISOString().slice(11, 23);
97
101
  }
98
102
 
99
103
  function shortLabel(target) {
@@ -160,7 +164,7 @@ function emitConsole(label, params, destination) {
160
164
  const level = String(params.type || 'log').toUpperCase();
161
165
  const text = (params.args || []).map(previewArg).join(' ');
162
166
  const [first, ...rest] = text.split('\n');
163
- out(`${stamp()} [${label}] ${level} ${first}${atSuffix(frameText(params.stackTrace?.callFrames?.[0]))}${rest.length ? `\n${rest.join('\n')}` : ''}`, destination);
167
+ out(`${stamp(params.timestamp)} [${label}] ${level} ${first}${atSuffix(frameText(params.stackTrace?.callFrames?.[0]))}${rest.length ? `\n${rest.join('\n')}` : ''}`, destination);
164
168
  }
165
169
 
166
170
  function emitException(label, params, destination) {
@@ -172,7 +176,7 @@ function emitException(label, params, destination) {
172
176
  'uncaught exception';
173
177
  const frame = frameText(d.stackTrace?.callFrames?.[0]) || frameText({ url: d.url, lineNumber: d.lineNumber, columnNumber: d.columnNumber });
174
178
  const [first, ...rest] = String(text).split('\n');
175
- out(`${stamp()} [${label}] EXCEPTION ${first}${atSuffix(frame)}${rest.length ? `\n${rest.join('\n')}` : ''}`, destination);
179
+ out(`${stamp(params.timestamp)} [${label}] EXCEPTION ${first}${atSuffix(frame)}${rest.length ? `\n${rest.join('\n')}` : ''}`, destination);
176
180
  }
177
181
 
178
182
  // Browser log entries (network failures, interventions, deprecations) are
@@ -186,7 +190,7 @@ function emitLogEntry(label, params, destination) {
186
190
  // A Log entry's location is its URL as is: a line number appended to a URL
187
191
  // could not be told apart from the URL's own text.
188
192
  const where = e.url ? e.url : frameText(e.stackTrace?.callFrames?.[0]);
189
- out(`${stamp()} [${label}] [browser:${source}] ${level} ${e.text || ''}${atSuffix(where)}`, destination);
193
+ out(`${stamp(e.timestamp)} [${label}] [browser:${source}] ${level} ${e.text || ''}${atSuffix(where)}`, destination);
190
194
  }
191
195
 
192
196
  function handleMessage(label, destination, data) {
@@ -235,9 +235,10 @@ echo "[cleanup] Reusing profile ${PROFILE}"
235
235
  # shutdowns; --load-extension re-advertises the path but does not flip it back,
236
236
  # leaving MV3 service workers inert).
237
237
  if [ -f "${PROFILE}/Default/Preferences" ]; then
238
- python3 -c "
238
+ python3 - "${PROFILE}/Default/Preferences" 2>/dev/null <<'PY' && echo "[cleanup] Cleared crash recovery state + re-enabled extensions"
239
239
  import json
240
- pf = '${PROFILE}/Default/Preferences'
240
+ import sys
241
+ pf = sys.argv[1]
241
242
  prefs = json.load(open(pf))
242
243
  prefs['profile'] = prefs.get('profile', {})
243
244
  prefs['profile']['exit_type'] = 'Normal'
@@ -248,7 +249,7 @@ for ext_id, ext_prefs in ext_settings.items():
248
249
  ext_prefs['state'] = 1
249
250
  ext_prefs.pop('disable_reasons', None)
250
251
  json.dump(prefs, open(pf, 'w'))
251
- " 2>/dev/null && echo "[cleanup] Cleared crash recovery state + re-enabled extensions"
252
+ PY
252
253
  fi
253
254
 
254
255
  # Readiness gate: wait until webpack emits a loadable background entry before
@@ -289,10 +290,13 @@ echo "[reopen] ${SLOT_ID} — CDP:${CDP_PORT:-off}"
289
290
  cd "${REPO}"
290
291
 
291
292
  trap - EXIT
292
- exec node -e "
293
+ # The reopen script is fixed text; paths, ids and ports reach it as argv, never as source.
294
+ IFS= read -r -d '' REOPEN_SCRIPT <<'NODE' || true
295
+ const [SCRIPT_DIR, SLOT_ID, AGENT_DIR, EXTENSION, REPO, RUNNER_BIN, PROFILE, cdpPortArg, webpackPidArg, WALLET_FIXTURE] =
296
+ process.argv.slice(1);
293
297
  const path = require('path');
294
298
  const fs = require('fs');
295
- const adapterWeb = (name) => require('module').createRequire(path.join('${SCRIPT_DIR}', 'package.json'))('@farmslot/adapter-web/' + name);
299
+ const adapterWeb = (name) => require('module').createRequire(path.join(SCRIPT_DIR, 'package.json'))('@farmslot/adapter-web/' + name);
296
300
  const http = require('http');
297
301
  const { execFileSync } = require('child_process');
298
302
  let chromium; try { chromium = require('@playwright/test').chromium; } catch { chromium = require('playwright').chromium; }
@@ -318,15 +322,9 @@ const {
318
322
  resolveBrowser,
319
323
  } = adapterWeb('browser-resolver');
320
324
 
321
- const SLOT_ID = '${SLOT_ID}';
322
- const AGENT_DIR = '${AGENT_DIR}';
323
- const EXTENSION = '${EXTENSION}';
324
- const REPO = '${REPO}';
325
- const RUNNER_BIN = '${RUNNER_BIN}';
326
- const PROFILE = '${PROFILE}';
327
- const CDP_PORT = '${CDP_PORT}' || null;
328
- const WEBPACK_PID = '${WEBPACK_PID}' || null;
329
- const walletFixture = JSON.parse(fs.readFileSync('${WALLET_FIXTURE}', 'utf8'));
325
+ const CDP_PORT = cdpPortArg || null;
326
+ const WEBPACK_PID = webpackPidArg || null;
327
+ const walletFixture = JSON.parse(fs.readFileSync(WALLET_FIXTURE, 'utf8'));
330
328
  const PASSWORD = walletFixture.password;
331
329
 
332
330
  if (!PASSWORD) {
@@ -522,8 +520,8 @@ const releaseFocusHold = () => {
522
520
  await evaluatePageViaCdp(page, applyPersistentSlotTitle, { slotId: SLOT_ID, defaultTitle: 'MetaMask' });
523
521
 
524
522
  // Wait for runtime state to settle, then unlock if needed.
525
- const unlockSelector = '[data-testid=\"unlock-password\"]';
526
- const homeSelector = '[data-testid=\"account-menu-icon\"]';
523
+ const unlockSelector = '[data-testid="unlock-password"]';
524
+ const homeSelector = '[data-testid="account-menu-icon"]';
527
525
  let detectedState = 'unknown';
528
526
  for (let i = 0; i < 15; i++) {
529
527
  if (await page.locator(homeSelector).count()) {
@@ -544,7 +542,7 @@ const releaseFocusHold = () => {
544
542
  if (detectedState === 'locked') {
545
543
  console.log('[reopen] Unlocking wallet...');
546
544
  await page.fill(unlockSelector, PASSWORD);
547
- await page.click('[data-testid=\"unlock-submit\"]');
545
+ await page.click('[data-testid="unlock-submit"]');
548
546
  await page.waitForSelector(homeSelector, { timeout: 30000 });
549
547
  console.log('[reopen] Unlocked');
550
548
  } else if (detectedState === 'onboarding') {
@@ -611,4 +609,5 @@ const releaseFocusHold = () => {
611
609
  console.error(e);
612
610
  process.exit(1);
613
611
  });
614
- "
612
+ NODE
613
+ exec node -e "$REOPEN_SCRIPT" "$SCRIPT_DIR" "$SLOT_ID" "$AGENT_DIR" "$EXTENSION" "$REPO" "$RUNNER_BIN" "$PROFILE" "$CDP_PORT" "$WEBPACK_PID" "$WALLET_FIXTURE"
@@ -103,7 +103,7 @@ fi
103
103
 
104
104
  echo "[refresh] Using node $(node -v 2>/dev/null || echo 'not found')"
105
105
  echo "[refresh] Starting yarn start (watcher-port=${WATCHER_PORT:-default})..."
106
- LOG="$(mktemp -t refresh-build-XXXXXX.log)"
106
+ LOG="$(mktemp "${TMPDIR:-/tmp}/refresh-build.XXXXXX")"
107
107
  echo "[refresh] Log: $LOG"
108
108
  env ${PORT_ARG} yarn start > "$LOG" 2>&1 &
109
109
  BUILD_PID=$!
@@ -19,7 +19,7 @@
19
19
  # <runtime-dir>/recipe-harness-webpack.{pid,log}; optional --summary file
20
20
  # {feature,status,inputs,outputs,generatedAt}.
21
21
  # Exit 0 — compiled; 1 — slot watcher owns the checkout, build failed, or
22
- # compile-marker timeout (240x2s); 2 — bad args.
22
+ # compile-marker timeout (480 s); 2 — bad args.
23
23
  #
24
24
  # Never touches: the slot-owned webpack.pid watcher process (refusal only,
25
25
  # removes the pid file only when stale); product source files.
@@ -229,8 +229,11 @@ existing_watch_pids="$(watcher_pids || true)"
229
229
  if [ -n "$existing_watch_pids" ]; then
230
230
  echo "[recipe-harness] Stopping stale target-scoped watcher(s): $(printf '%s' "$existing_watch_pids" | tr '\n' ' ')"
231
231
  printf '%s\n' "$existing_watch_pids" | xargs kill 2>/dev/null || true
232
- sleep 2
233
- existing_watch_pids="$(watcher_pids || true)"
232
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
233
+ existing_watch_pids="$(watcher_pids || true)"
234
+ [ -z "$existing_watch_pids" ] && break
235
+ sleep 0.2
236
+ done
234
237
  if [ -n "$existing_watch_pids" ]; then
235
238
  printf '%s\n' "$existing_watch_pids" | xargs kill -9 2>/dev/null || true
236
239
  fi
@@ -290,7 +293,13 @@ elif [ -n "$LIB_DIR" ] && [ "${RECIPE_LOG_TUI_PARENT:-}" != "1" ]; then
290
293
  watch_tail_pid=$!
291
294
  fi
292
295
  compiled=false
293
- for i in {1..240}; do
296
+ # The log is checked every 0.5 s, so a marker is seen at once. The watcher pid
297
+ # needs a full process listing, so it is refreshed every 2 s. Bounded by time:
298
+ # 480 s for a compile marker, 20 s for yarn start's watcher to appear.
299
+ poll_started=$SECONDS
300
+ i=0
301
+ while [ $((SECONDS - poll_started)) -lt 480 ]; do
302
+ i=$((i + 1))
294
303
  if [ -n "$watch_tail_pid" ] && ! kill -0 "$watch_tail_pid" >/dev/null 2>&1; then
295
304
  wait "$watch_tail_pid" 2>/dev/null || true
296
305
  if grep -Eq 'compiled successfully|compiled with [0-9]+ warning|MetaMask .* compiled|Bundle end: service worker|Bundle end:.*app-init' "$watch_log" 2>/dev/null; then
@@ -300,9 +309,9 @@ for i in {1..240}; do
300
309
  echo '[recipe-harness] compact build watcher exited before compile marker; continuing to poll webpack log' >&2
301
310
  watch_tail_pid=""
302
311
  fi
303
- if ! write_canonical_watch_pid; then
304
- if [ "$i" -le 10 ]; then
305
- sleep 2
312
+ if [ $((i % 4)) -eq 1 ] && ! write_canonical_watch_pid; then
313
+ if [ $((SECONDS - poll_started)) -lt 20 ]; then
314
+ sleep 0.5
306
315
  continue
307
316
  fi
308
317
  [ -n "$watch_tail_pid" ] && kill "$watch_tail_pid" >/dev/null 2>&1 || true
@@ -331,7 +340,7 @@ for i in {1..240}; do
331
340
  [ -n "$watch_tail_pid" ] && wait "$watch_tail_pid" 2>/dev/null || true
332
341
  break
333
342
  fi
334
- sleep 2
343
+ sleep 0.5
335
344
  done
336
345
  if [ "$compiled" != true ]; then
337
346
  [ -n "$watch_tail_pid" ] && kill "$watch_tail_pid" >/dev/null 2>&1 || true
@@ -47,7 +47,7 @@
47
47
  "entry": "adapters/mobile/reload-app.mjs",
48
48
  "kind": "node",
49
49
  "purpose": "Request an in-place React Native app reload through Metro's protocol-v2 message endpoint.",
50
- "inputs": "--port --json; env WATCHER_PORT, METRO_PORT",
50
+ "inputs": "--port --json; env WATCHER_PORT, METRO_PORT, MOBILE_RELOAD_VERIFY_TIMEOUT_MS (default 30000)",
51
51
  "outputs": "reload result on stdout; exit 0 requested / 1 unavailable or invalid input"
52
52
  },
53
53
  {
@@ -146,7 +146,8 @@ async function reload(port) {
146
146
  }
147
147
 
148
148
  async function waitForReload(port, targetBefore, processBefore, observer) {
149
- const deadline = Date.now() + 30_000;
149
+ const timeoutMs = Number(process.env.MOBILE_RELOAD_VERIFY_TIMEOUT_MS);
150
+ const deadline = Date.now() + (timeoutMs > 0 ? timeoutMs : 30_000);
150
151
  let lastError;
151
152
  while (Date.now() < deadline) {
152
153
  if (processBefore && readAndroidPid() !== processBefore) {
@@ -72,7 +72,15 @@ if [ -f "$PID_FILE" ]; then
72
72
  rm -f "$PID_FILE"
73
73
  fi
74
74
 
75
+ # Each launch starts a fresh log. The replay cursor goes with it: it marks
76
+ # lines as written that are no longer in the file, such as the relaunched
77
+ # app's first lines the previous forwarder wrote, so the new forwarder replays
78
+ # the app runtime's console buffer into the new log instead of skipping them.
79
+ # A runtime the launch kept replays lines from before the launch too;
80
+ # metamask.app.assert_no_console_errors leaves those out by their own time
81
+ # against the mark each launch attempt records (app-console.launch.json).
75
82
  : > "$APP_LOG_FILE"
83
+ rm -f "$APP_LOG_FILE.forwarder-state.json"
76
84
  pid="$(node "$SCRIPT_DIR/launch-console-forwarder.cjs" \
77
85
  --port "$PORT" \
78
86
  --out "$APP_LOG_FILE" \
@@ -198,15 +198,57 @@ function mergeKvLayers(...layers) {
198
198
  return Object.keys(result).length ? formatKvLines(result) : '';
199
199
  }
200
200
 
201
+ // Whether a runtime context belongs to the checkout at `repo` (recipe-cli's
202
+ // binding rule): its repoRoot is the checkout's Git top level, so a clone
203
+ // nested inside another checkout never takes that checkout's context; without
204
+ // a repoRoot, the file sits inside that top level. An orchestrator may keep the
205
+ // file elsewhere as long as it names the checkout. Twin of recipe-cli's
206
+ // runtimeContextOwned: this module stays dependency-free for the shell paths
207
+ // that load it, so the rule is repeated here rather than imported.
208
+ export function ownsRuntimeContext(file, repo) {
209
+ let repoRoot;
210
+ try {
211
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
212
+ repoRoot = typeof value?.repoRoot === 'string' ? value.repoRoot : undefined;
213
+ } catch {
214
+ return false;
215
+ }
216
+ if (repoRoot === undefined) return within(gitTopLevel(repo), file);
217
+ try {
218
+ return fs.realpathSync(repoRoot) === fs.realpathSync(gitTopLevel(repo));
219
+ } catch {
220
+ // A path that does not exist owns nothing.
221
+ return false;
222
+ }
223
+ }
224
+
225
+ function gitTopLevel(dir) {
226
+ try {
227
+ return execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
228
+ encoding: 'utf8',
229
+ stdio: ['ignore', 'pipe', 'ignore'],
230
+ }).trim();
231
+ } catch {
232
+ // Not a Git checkout: the directory is its own root.
233
+ return dir;
234
+ }
235
+ }
236
+
237
+ function within(outer, inner) {
238
+ try {
239
+ const relative = path.relative(fs.realpathSync(outer), fs.realpathSync(inner));
240
+ return !(relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative));
241
+ } catch {
242
+ // A path that does not exist owns nothing.
243
+ return false;
244
+ }
245
+ }
246
+
201
247
  function extensionRuntimeContextPath(repo) {
202
248
  const repoRoot = path.resolve(repo);
203
249
  const envCtx = process.env.RECIPE_RUNTIME_CONTEXT;
204
- if (envCtx) {
205
- // Farmslot may manage runtime context outside the checkout. Treat the
206
- // explicit environment override as authoritative; containment is not a
207
- // security boundary and silently dropping it selects the wrong port.
208
- return path.resolve(envCtx);
209
- }
250
+ // Another checkout's context would set this checkout's ports.
251
+ if (envCtx && ownsRuntimeContext(path.resolve(envCtx), repoRoot)) return path.resolve(envCtx);
210
252
  return path.join(repoRoot, recipeRuntimeDir(), 'agentic-runtime.json');
211
253
  }
212
254
 
@@ -58,7 +58,9 @@ async function ensureConsoleCollector(options) {
58
58
  fs.writeFileSync(pidFile, `${child.pid}
59
59
  `);
60
60
  const attachmentFrom = { [extensionLog]: fileSize(extensionLog), [pageLog]: fileSize(pageLog) };
61
- for (let attempt = 0; attempt < 100; attempt += 1) {
61
+ const timeoutMs = Number(process.env.MM_HARNESS_CONSOLE_ATTACH_TIMEOUT_MS);
62
+ const attempts = Math.ceil((timeoutMs > 0 ? timeoutMs : 1e4) / 100);
63
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
62
64
  const proc = processArgs(child.pid);
63
65
  if (!proc) break;
64
66
  if (collectorMatches(proc, identity)) {
@@ -43,6 +43,11 @@ const extensionActions = {
43
43
  }
44
44
  })
45
45
  }),
46
+ // The HUD lives only in the extension pages' DOM.
47
+ async clearHud() {
48
+ const { clearExtensionHud } = await import("../../../library/actions/extension/platform/cdp.mjs");
49
+ await clearExtensionHud();
50
+ },
46
51
  trustedMutation: true,
47
52
  // ensure_positions and ensure_orders only check state here.
48
53
  inputFindings: (nodeId, node) => [
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { recipeRuntimePath } from "@farmslot/recipe-cli/harness";
3
4
  import { verifyCapture } from "../../../library/actions/shared/console-collector.mjs";
4
5
  import { ensureConsoleCollector } from "../console-capture.js";
@@ -10,6 +11,21 @@ function extensionCaptureFiles(target) {
10
11
  pidFile: recipeRuntimePath(target, "console-tail.pid")
11
12
  };
12
13
  }
14
+ const EXTENSION_CONSOLE_LAUNCH_FILE = "extension-console.launch.json";
15
+ function recordConsoleLaunchIn(runtimeDir) {
16
+ const log = path.join(runtimeDir, "extension-console.log");
17
+ const stat = fs.existsSync(log) ? fs.statSync(log) : null;
18
+ fs.mkdirSync(runtimeDir, { recursive: true });
19
+ const launchedAtMs = Date.now();
20
+ fs.writeFileSync(
21
+ path.join(runtimeDir, EXTENSION_CONSOLE_LAUNCH_FILE),
22
+ `${JSON.stringify({ offset: stat?.size ?? 0, inode: stat?.ino ?? null, launchedAtMs, recordedAt: new Date(launchedAtMs).toISOString() })}
23
+ `
24
+ );
25
+ }
26
+ function recordExtensionConsoleLaunch(target) {
27
+ recordConsoleLaunchIn(path.dirname(extensionCaptureFiles(target).extensionLog));
28
+ }
13
29
  function extensionCdpPort() {
14
30
  return process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
15
31
  }
@@ -37,8 +53,10 @@ async function verifyExtensionConsoleControl(capture) {
37
53
  return verifyCapture({ cdpPort: capture.cdpPort, log: capture.extensionLog, target: "extension", extensionId });
38
54
  }
39
55
  export {
56
+ EXTENSION_CONSOLE_LAUNCH_FILE,
40
57
  ensureExtensionConsoleCapture,
41
58
  extensionCaptureFiles,
42
59
  extensionCdpPort,
60
+ recordExtensionConsoleLaunch,
43
61
  verifyExtensionConsoleControl
44
62
  };
@@ -4,6 +4,7 @@ import { colorHumanMessage, EXIT, shellQuote, spawnScriptStreaming } from "@farm
4
4
  import { FIXTURE_STREAM_TIMEOUT_MS } from "../../commands/fixtures.js";
5
5
  import { sameFeatureFlags } from "../../feature-flags.js";
6
6
  import { runnerDir } from "../../paths.js";
7
+ import { recordExtensionConsoleLaunch } from "./console-capture.js";
7
8
  import { readExtensionPinnedFlags } from "./feature-flags.js";
8
9
  import { releaseArtifactState } from "./runtime-decision.js";
9
10
  async function applyExtensionFixture({
@@ -96,6 +97,7 @@ async function applyExtensionFixture({
96
97
  }
97
98
  process.stderr.write(`${colorHumanMessage(`\u2192 fixtures ${action} extension \u2014 resetting and seeding the slot-owned profile from the existing build\u2026`)}
98
99
  `);
100
+ recordExtensionConsoleLaunch(target);
99
101
  const liveScript = path.join(runnerDir, "adapters/extension/live.sh");
100
102
  const args = ["--target", target, "--launch-existing-dist", "--launch-only", "--reset-profile"];
101
103
  const flagKeys = Object.keys(fixtureFlags);
@@ -1,13 +1,14 @@
1
- import { readRuntimeContextField, resolveRuntimeContextPath } from "@farmslot/recipe-cli/harness";
1
+ import { readRuntimeContextField, recipeRuntimeDir, resolveRuntimeContextPath } from "@farmslot/recipe-cli/harness";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { ownsRuntimeContext } from "../../../adapters/shared/resolve-slot-ports-core.mjs";
3
5
  import { resolveExtensionSlotPorts } from "../slot-ports.js";
4
6
  function hasArg(args, needle) {
5
7
  return args.some((arg) => arg === needle || arg.startsWith(`${needle}=`));
6
8
  }
7
9
  function applyExtensionRuntimeEnv(target, args) {
8
- const contextPath = resolveRuntimeContextPath(target);
9
- const contextExists = fs.existsSync(contextPath);
10
- if (contextExists) {
10
+ const contextPath = [resolveRuntimeContextPath(target), path.join(target, recipeRuntimeDir(), "agentic-runtime.json")].find((file) => fs.existsSync(file) && ownsRuntimeContext(file, target));
11
+ if (contextPath) {
11
12
  process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
12
13
  if (!process.env.RECIPE_SLOT_ID) {
13
14
  const slotId = readRuntimeContextField(contextPath, "slotId");
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import { createRequire } from "node:module";
5
5
  import path from "node:path";
6
6
  import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, resolveFarmslotPoolDir, runnerDir } from "../../paths.js";
7
+ import { recordExtensionConsoleLaunch } from "./console-capture.js";
7
8
  import { poolFilesForHost } from "../resolve-slot-ports.js";
8
9
  import { macosSessionLocked, SESSION_LOCKED_MESSAGE, SESSION_LOCKED_USER_ACTION } from "../../../adapters/shared/session-lock.mjs";
9
10
  import { profileProcessPids } from "./validation-process-ownership.js";
@@ -666,6 +667,7 @@ async function launchLockedValidationBrowser(options) {
666
667
  fs.mkdirSync(validationProfileRoot, { recursive: true });
667
668
  fs.mkdirSync(validationArtifactsRoot, { recursive: true });
668
669
  assertNoQuarantinedValidationProfiles(validationProfileRoot);
670
+ recordExtensionConsoleLaunch(options.projectRoot);
669
671
  await stopOwnedCdpListener(options.cdpPort, [
670
672
  validationRuntimePath,
671
673
  path.join(options.projectRoot, recipeRuntimeDir())
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { recipeRuntimePath, EXIT, str, writeInteractiveProgress } from "@farmslot/recipe-cli/harness";
3
4
  import { applyDeviceTargeting } from "../../commands/device-target.js";
4
5
  import { launchMobile } from "../../commands/launch/mobile.js";
@@ -49,6 +50,14 @@ function metroLaunchEvidence(target) {
49
50
  if (descriptor !== void 0) fs.closeSync(descriptor);
50
51
  }
51
52
  }
53
+ function recordMobileConsoleLaunch(target) {
54
+ const file = recipeRuntimePath(target, "app-console.launch.json");
55
+ const launchedAtMs = Date.now();
56
+ fs.mkdirSync(path.dirname(file), { recursive: true });
57
+ fs.writeFileSync(file, `${JSON.stringify({ launchedAtMs, recordedAt: new Date(launchedAtMs).toISOString() })}
58
+ `);
59
+ }
60
+ const markedLaunches = /* @__PURE__ */ new WeakSet();
52
61
  const mobileLaunchProfile = {
53
62
  refuseFlags({ platformTarget }) {
54
63
  if (!platformTarget) {
@@ -104,7 +113,14 @@ const mobileLaunchProfile = {
104
113
  },
105
114
  nativeInputs: ["ios", "android", "package.json", "yarn.lock"],
106
115
  rebuildCommand: ({ platformTarget }) => `mm-harness launch ${platformTarget} --build`,
107
- compose: ({ target, platformTarget, machine, remoteFlagRequest }, tier, restartApp, clearMetro) => launchMobile(target, platformTarget, tier, machine, restartApp, clearMetro, remoteFlagRequest?.flags),
116
+ compose: (launch, tier, restartApp, clearMetro) => {
117
+ const { target, platformTarget, machine, remoteFlagRequest } = launch;
118
+ if (restartApp || !markedLaunches.has(launch)) {
119
+ markedLaunches.add(launch);
120
+ recordMobileConsoleLaunch(target);
121
+ }
122
+ return launchMobile(target, platformTarget, tier, machine, restartApp, clearMetro, remoteFlagRequest?.flags);
123
+ },
108
124
  classify(_launch, output) {
109
125
  if (output.includes("open-device: Android device is locked.")) {
110
126
  return {
@@ -187,5 +203,6 @@ const mobileLaunchProfile = {
187
203
  evidence: metroLaunchEvidence
188
204
  };
189
205
  export {
190
- mobileLaunchProfile
206
+ mobileLaunchProfile,
207
+ recordMobileConsoleLaunch
191
208
  };
@@ -4,7 +4,7 @@ import crypto from "node:crypto";
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { resolveSlotPortsByRepo } from "../../../adapters/shared/resolve-slot-ports-core.mjs";
7
+ import { ownsRuntimeContext, resolveSlotPortsByRepo } from "../../../adapters/shared/resolve-slot-ports-core.mjs";
8
8
  const RUNWAY_IOS_METADATA = {
9
9
  artifactName: "ios-app-main-dev-expo",
10
10
  workflow: "expo-dev-build.yml",
@@ -177,8 +177,10 @@ function fail(target, platform, code, message, rerunCommand, slot) {
177
177
  };
178
178
  }
179
179
  function readSlotContext(target, runtimeDir) {
180
+ const envContext = process.env.RECIPE_RUNTIME_CONTEXT;
180
181
  const candidates = [
181
- process.env.RECIPE_RUNTIME_CONTEXT,
182
+ // Another checkout's context would name its slot and simulator.
183
+ envContext && ownsRuntimeContext(envContext, target) ? envContext : void 0,
182
184
  runtimeContextPath(target, runtimeDir),
183
185
  path.join(target, recipeRuntimeDir(), "agentic-runtime.json"),
184
186
  path.join(target, "temp/recipe/runtime/agentic-runtime.json"),
@@ -57,7 +57,8 @@ const mobileRun = {
57
57
  // matches its target against it.
58
58
  RECIPE_RN_EXPLICIT_PLATFORM: process.env.RECIPE_RN_EXPLICIT_PLATFORM
59
59
  }),
60
- // An installed release artifact is opaque: no harness HUD inside it.
60
+ // An installed release artifact is opaque: it has no dev bridge and no
61
+ // AgentStepHud, so there is nothing to draw the HUD with.
61
62
  autoHud: () => process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME !== "1",
62
63
  // Always clear a HUD step the engine left on-device (a failed run strands
63
64
  // the FAIL banner). Best-effort — never masks the run's real outcome.
@@ -157,7 +158,7 @@ const mobileObservation = {
157
158
  }
158
159
  };
159
160
  function runMobileIssueCommand(projectRoot, command) {
160
- const bridge = path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
161
+ const bridge = process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
161
162
  const result = spawnSync(process.execPath, [bridge, command], {
162
163
  cwd: projectRoot,
163
164
  env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },