@deeeed/metamask-harness 0.14.4 → 0.14.7

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,6 +1,29 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.14.7 - 2026-07-10
4
+
5
+ ### Changed
6
+ - `doctor --print-ready` now implies the exit-coded live probe — Farmslot `health_check` hooks need only `--print-ready`, not `--expect-live --print-ready`.
7
+ - Mobile `--print-ready` uses bridge `walletState` (unlocked + live bridge), not React Navigation route names; mobile `ready_indicator` is `OK`.
8
+ - Removed `metamask-recipe` install symlinks from adapter inject paths (no compat aliases).
9
+ - `doctor --print-ready` rejects `--json` (stdout is reserved for the Farmslot indicator line).
10
+
11
+ ### Fixed
12
+ - Mobile `surface.runtimeStatus` infers Android from `ADB_SERIAL` / `ANDROID_SERIAL` when present.
13
+
14
+ ## 0.14.6 - 2026-07-10
15
+
16
+ ### Added
17
+ - `mm-harness doctor --print-ready` — Farmslot `health_check` mode: prints `health.ready_indicator` on stdout (`extension`/`mobile`: `OK`; `core`: `ready`).
18
+ - `doctor --expect-live` — exit-coded liveness gate without indicator output (prepare recovery and other pass/fail callers).
19
+
20
+ ## 0.14.5 - 2026-07-10
21
+
22
+ ### Fixed
23
+ - Extension `ensure-browser` closes disposable New Tab / blank / extensions pages after reopen, matching reattach/ensure-ready hygiene.
24
+ - Extension slot browser titles are stamped through one shared helper (`adapters/extension/lib/slot-title.cjs`) with a persistent `MutationObserver`, and re-applied after `ensureExtensionReady` open/prune/reopen so relaunch and recipe paths keep `<slot-id> — MetaMask` instead of a bare MetaMask window.
25
+ - Extension slot-title contract test is hermetic (local CDP/WebSocket stub + fake DOM); Playwright and CDP stamps share one function source so they cannot drift.
26
+ - Extension inject copies `scripts/lib/slot-title.cjs` with the other installed helpers so reattach/readiness/reopen do not `MODULE_NOT_FOUND` on injected checkouts.
4
27
 
5
28
  ## 0.14.4 - 2026-07-09
6
29
 
@@ -358,8 +358,9 @@ const resumeWebpack = () => {
358
358
  const page = ctx.pages().find(p => p.url().includes('chrome-extension://')) || ctx.pages()[0];
359
359
  await page.goto(homeUrl, { waitUntil: 'load', timeout: 15000 });
360
360
 
361
- // Set window title to slot ID
362
- await page.evaluate((id) => { document.title = id + ' \u2014 ' + document.title; }, SLOT_ID);
361
+ // Set window title to slot ID (persists across SPA navigations)
362
+ const { applyPersistentSlotTitle } = require(path.join('${SCRIPT_DIR}', 'lib/slot-title.cjs'));
363
+ await page.evaluate(applyPersistentSlotTitle, SLOT_ID);
363
364
 
364
365
  // Wait for runtime state to settle, then unlock if needed.
365
366
  const unlockSelector = '[data-testid=\"unlock-password\"]';
@@ -393,10 +394,25 @@ const resumeWebpack = () => {
393
394
  console.log('[reopen] Screen: ' + page.url());
394
395
  }
395
396
 
396
- // Close extra extension tabs
397
+ // Close extra extension tabs and disposable Chrome pages (New Tab / blank).
397
398
  for (const p of ctx.pages()) {
398
- if (p !== page && p.url().includes('chrome-extension://')) await p.close().catch(() => {});
399
+ if (p === page) continue;
400
+ const url = p.url();
401
+ if (url.includes('chrome-extension://')) {
402
+ await p.close().catch(() => {});
403
+ continue;
404
+ }
405
+ if (
406
+ url === 'chrome://newtab/' ||
407
+ url === 'chrome://new-tab-page/' ||
408
+ url.startsWith('chrome://new-tab-page') ||
409
+ url === 'about:blank' ||
410
+ url === 'chrome://extensions/'
411
+ ) {
412
+ await p.close().catch(() => {});
413
+ }
399
414
  }
415
+ await page.bringToFront().catch(() => {});
400
416
 
401
417
  fs.writeFileSync(path.join(AGENT_DIR, 'extension.id'), extId);
402
418
  console.log('[reopen] Ready \u2014 ' + SLOT_ID + (CDP_PORT ? ' CDP:' + CDP_PORT : ''));
@@ -130,6 +130,7 @@ copyFile(path.join(runnerDir, 'adapters/shared/tmux-session.sh'), path.join(harn
130
130
  copyFile(path.join(runnerDir, 'adapters/shared/tmux-viewer.sh'), path.join(harnessDir, 'scripts/lib/tmux-viewer.sh'));
131
131
  copyFile(path.join(runnerDir, 'adapters/shared/log-tui.mjs'), path.join(harnessDir, 'scripts/lib/log-tui.mjs'));
132
132
  copyFile(path.join(runnerDir, 'adapters/extension/lib/extension-id.cjs'), path.join(harnessDir, 'scripts/lib/extension-id.cjs'));
133
+ copyFile(path.join(runnerDir, 'adapters/extension/lib/slot-title.cjs'), path.join(harnessDir, 'scripts/lib/slot-title.cjs'));
133
134
  makeExecutableTree(path.join(harnessDir, 'scripts'));
134
135
  fs.writeFileSync(path.join(harnessDir, 'installed-scripts.sha256'), `${dirContentHash(path.join(harnessDir, 'scripts'))}\n`);
135
136
 
@@ -0,0 +1,294 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Operator slot title for headed extension Chrome.
5
+ *
6
+ * Chrome's window label is the active tab's document.title. Farmslot stamps
7
+ * home.html to "<slot-id> — MetaMask" so operators can tell which slot owns a
8
+ * window when several farms run side by side.
9
+ *
10
+ * Stamping is best-effort: missing slot context or an uninspectable tab must
11
+ * never fail launch/readiness. A MutationObserver keeps the prefix across
12
+ * MetaMask SPA title resets.
13
+ */
14
+
15
+ const fs = require('node:fs');
16
+ const http = require('node:http');
17
+ const path = require('node:path');
18
+
19
+ function sanitizeSlotId(value) {
20
+ const slotId = typeof value === 'string' ? value.trim() : '';
21
+ return /^[A-Za-z0-9._:-]{1,64}$/u.test(slotId) ? slotId : '';
22
+ }
23
+
24
+ function readSlotId(target, runtimeDir) {
25
+ for (const key of ['RECIPE_SLOT_ID', 'SLOT_ID', 'FARMSLOT_SLOT_ID']) {
26
+ const value = sanitizeSlotId(process.env[key]);
27
+ if (value) return value;
28
+ }
29
+ if (!target || !runtimeDir) return '';
30
+ try {
31
+ const runtimeContext = JSON.parse(
32
+ fs.readFileSync(path.join(target, runtimeDir, 'agentic-runtime.json'), 'utf8'),
33
+ );
34
+ return sanitizeSlotId(runtimeContext.slotId);
35
+ } catch {
36
+ // Standalone harness users do not need a custom browser title.
37
+ return '';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Playwright page.evaluate callback. Must stay a plain function (no closure)
43
+ * so Playwright can serialize it into the page.
44
+ */
45
+ function applyPersistentSlotTitle(slot) {
46
+ // Inline sanitize — this function is serialized into the page for Playwright
47
+ // and CDP, so it cannot call Node helpers.
48
+ const id =
49
+ typeof slot === 'string' && /^[A-Za-z0-9._:-]{1,64}$/u.test(slot.trim())
50
+ ? slot.trim()
51
+ : '';
52
+ if (!id) return document.title;
53
+ window.__farmslotSlotId = id;
54
+
55
+ const stripSlotPrefixes = (title) => {
56
+ let base = String(title || 'MetaMask');
57
+ // Accept em dash, en dash, or hyphen — historical stampers mixed them.
58
+ for (let i = 0; i < 32; i += 1) {
59
+ const stripped = base.replace(/^.+?\s+[—–-]\s+/u, '');
60
+ if (stripped === base) break;
61
+ base = stripped;
62
+ }
63
+ return base || 'MetaMask';
64
+ };
65
+
66
+ const desiredTitle = () => {
67
+ const id = window.__farmslotSlotId;
68
+ if (!id) return document.title;
69
+ return `${id} — ${stripSlotPrefixes(document.title)}`;
70
+ };
71
+
72
+ const setTitle = () => {
73
+ if (window.__farmslotSlotTitleLock) {
74
+ window.__farmslotSlotTitlePending = true;
75
+ return;
76
+ }
77
+ const next = desiredTitle();
78
+ if (document.title === next) return;
79
+ window.__farmslotSlotTitleLock = true;
80
+ try {
81
+ document.title = next;
82
+ } finally {
83
+ // Release after the title mutation is delivered so the observer cannot
84
+ // re-enter and stack prefixes while React also mutates the DOM. If a
85
+ // mutation arrived while locked, re-apply once after unlock.
86
+ queueMicrotask(() => {
87
+ window.__farmslotSlotTitleLock = false;
88
+ if (window.__farmslotSlotTitlePending) {
89
+ window.__farmslotSlotTitlePending = false;
90
+ setTitle();
91
+ }
92
+ });
93
+ }
94
+ };
95
+
96
+ setTitle();
97
+ if (!window.__farmslotSlotTitleObserver) {
98
+ let node = document.querySelector('title');
99
+ if (!node) {
100
+ node = document.createElement('title');
101
+ (document.head || document.documentElement).appendChild(node);
102
+ }
103
+ window.__farmslotSlotTitleObserver = new MutationObserver(setTitle);
104
+ // Observe only the <title> element — watching head/documentElement with
105
+ // subtree:true re-fires on every React DOM mutation and can livelock CDP.
106
+ window.__farmslotSlotTitleObserver.observe(node, {
107
+ childList: true,
108
+ characterData: true,
109
+ subtree: true,
110
+ });
111
+ }
112
+ return document.title;
113
+ }
114
+
115
+ function buildStampExpression(slotId) {
116
+ const slot = sanitizeSlotId(slotId);
117
+ if (!slot) return 'document.title';
118
+ // Single source of truth: serialize the Playwright callback for CDP evaluate.
119
+ return `(${applyPersistentSlotTitle.toString()})(${JSON.stringify(slot)})`;
120
+ }
121
+
122
+ function assertLocalUrl(url) {
123
+ if (!/^http:\/\/127\.0\.0\.1:\d+\//u.test(url)) {
124
+ throw new Error(`slot-title: refusing non-local URL: ${url}`);
125
+ }
126
+ }
127
+
128
+ function assertLocalWebSocketUrl(url) {
129
+ if (!/^ws:\/\/(?:127\.0\.0\.1|localhost):\d+\//u.test(String(url))) {
130
+ throw new Error(`slot-title: refusing non-local websocket URL: ${url}`);
131
+ }
132
+ }
133
+
134
+ function httpJson(url, timeoutMs = 3000) {
135
+ assertLocalUrl(url);
136
+ return new Promise((resolve, reject) => {
137
+ const req = http.get(url, (res) => {
138
+ let data = '';
139
+ res.setEncoding('utf8');
140
+ res.on('data', (chunk) => {
141
+ data += chunk;
142
+ });
143
+ res.on('end', () => {
144
+ try {
145
+ resolve(JSON.parse(data));
146
+ } catch (err) {
147
+ reject(new Error(`invalid JSON from ${url}: ${err.message}`));
148
+ }
149
+ });
150
+ });
151
+ req.setTimeout(timeoutMs, () => {
152
+ req.destroy(new Error(`timeout from ${url}`));
153
+ });
154
+ req.on('error', reject);
155
+ });
156
+ }
157
+
158
+ function resolveWebSocket(target) {
159
+ try {
160
+ // Prefer the target checkout's ws, then the harness install.
161
+ const { createRequire } = require('node:module');
162
+ const req = createRequire(__filename);
163
+ if (target) {
164
+ try {
165
+ return req(req.resolve('ws', { paths: [target] }));
166
+ } catch {
167
+ // Fall through to harness / global.
168
+ }
169
+ }
170
+ return req('ws');
171
+ } catch {
172
+ return typeof WebSocket === 'function' ? WebSocket : null;
173
+ }
174
+ }
175
+
176
+ async function cdpEvaluate(target, webSocketDebuggerUrl, expression, timeoutMs = 5000) {
177
+ assertLocalWebSocketUrl(webSocketDebuggerUrl);
178
+ const WebSocketImpl = resolveWebSocket(target);
179
+ if (!WebSocketImpl) return { skipped: true, reason: 'WebSocket unavailable' };
180
+ return new Promise((resolve, reject) => {
181
+ const ws = new WebSocketImpl(webSocketDebuggerUrl);
182
+ const timer = setTimeout(() => {
183
+ try {
184
+ ws.close();
185
+ } catch {
186
+ // Best-effort timeout cleanup.
187
+ }
188
+ reject(new Error('timeout evaluating extension page via CDP'));
189
+ }, timeoutMs);
190
+ const onOpen = () => {
191
+ ws.send(
192
+ JSON.stringify({
193
+ id: 1,
194
+ method: 'Runtime.evaluate',
195
+ params: { expression, awaitPromise: true, returnByValue: true },
196
+ }),
197
+ );
198
+ };
199
+ const onMessage = (event) => {
200
+ const raw = event?.data ?? event;
201
+ const msg = JSON.parse(Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw));
202
+ if (msg.id !== 1) return;
203
+ clearTimeout(timer);
204
+ ws.close();
205
+ if (msg.error) {
206
+ reject(new Error(msg.error.message || JSON.stringify(msg.error)));
207
+ return;
208
+ }
209
+ resolve(msg.result?.result?.value ?? null);
210
+ };
211
+ const onError = (err) => {
212
+ clearTimeout(timer);
213
+ reject(new Error(`CDP websocket error while stamping slot title: ${err?.message || err || 'unknown'}`));
214
+ };
215
+ if (typeof ws.on === 'function') {
216
+ ws.on('open', onOpen);
217
+ ws.on('message', onMessage);
218
+ ws.on('error', onError);
219
+ } else {
220
+ ws.addEventListener('open', onOpen);
221
+ ws.addEventListener('message', onMessage);
222
+ ws.addEventListener('error', onError);
223
+ }
224
+ });
225
+ }
226
+
227
+ function isHomePage(target, extensionId) {
228
+ if (!target || target.type !== 'page' || typeof target.url !== 'string') return false;
229
+ if (!target.url.startsWith(`chrome-extension://${extensionId}/`)) return false;
230
+ return target.url.includes('/home.html');
231
+ }
232
+
233
+ /**
234
+ * Stamp every inspectable home.html tab for this extension over CDP.
235
+ * Attached tabs (no webSocketDebuggerUrl) are skipped — another client owns them.
236
+ */
237
+ async function stampHomeTabsViaCdp(options) {
238
+ const {
239
+ target = process.cwd(),
240
+ cdpPort,
241
+ extensionId,
242
+ slotId: slotIdOption,
243
+ runtimeDir,
244
+ } = options || {};
245
+ const slotId = sanitizeSlotId(slotIdOption) || readSlotId(target, runtimeDir);
246
+ const empty = { slotId: slotId || '', stamped: 0, skipped: 0, titles: [] };
247
+ if (!slotId || !cdpPort || !extensionId) return empty;
248
+
249
+ let targets;
250
+ try {
251
+ targets = await httpJson(`http://127.0.0.1:${cdpPort}/json/list`);
252
+ } catch {
253
+ return empty;
254
+ }
255
+ if (!Array.isArray(targets)) return empty;
256
+
257
+ const homes = targets.filter((t) => isHomePage(t, extensionId));
258
+ const titles = [];
259
+ let stamped = 0;
260
+ let skipped = 0;
261
+ const expression = buildStampExpression(slotId);
262
+
263
+ for (const home of homes) {
264
+ if (typeof home.webSocketDebuggerUrl !== 'string') {
265
+ skipped += 1;
266
+ continue;
267
+ }
268
+ try {
269
+ const title = await cdpEvaluate(target, home.webSocketDebuggerUrl, expression);
270
+ if (title && typeof title === 'object' && title.skipped) {
271
+ skipped += 1;
272
+ continue;
273
+ }
274
+ if (typeof title === 'string') {
275
+ titles.push(title);
276
+ stamped += 1;
277
+ } else {
278
+ skipped += 1;
279
+ }
280
+ } catch {
281
+ skipped += 1;
282
+ }
283
+ }
284
+
285
+ return { slotId, stamped, skipped, titles };
286
+ }
287
+
288
+ module.exports = {
289
+ sanitizeSlotId,
290
+ readSlotId,
291
+ applyPersistentSlotTitle,
292
+ buildStampExpression,
293
+ stampHomeTabsViaCdp,
294
+ };
@@ -22,6 +22,10 @@ import { fileURLToPath } from 'node:url';
22
22
 
23
23
  const require = createRequire(import.meta.url);
24
24
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
25
+ const {
26
+ readSlotId: readSlotIdFromLib,
27
+ buildStampExpression,
28
+ } = require(path.join(scriptDir, 'lib/slot-title.cjs'));
25
29
 
26
30
  function pathDefault(key) {
27
31
  for (const candidate of [
@@ -101,25 +105,7 @@ function readExpectedExtensionId(target) {
101
105
  }
102
106
 
103
107
  function readSlotId(target) {
104
- for (const key of ['RECIPE_SLOT_ID', 'SLOT_ID', 'FARMSLOT_SLOT_ID']) {
105
- const value = sanitizeSlotId(process.env[key]);
106
- if (value) return value;
107
- }
108
- try {
109
- const runtimeContext = JSON.parse(fs.readFileSync(path.join(target, recipeRuntimeDir(), 'agentic-runtime.json'), 'utf8'));
110
- const value = sanitizeSlotId(runtimeContext.slotId);
111
- if (value) {
112
- return value;
113
- }
114
- } catch {
115
- // No slot context: standalone harness users do not need a custom browser title.
116
- }
117
- return '';
118
- }
119
-
120
- function sanitizeSlotId(value) {
121
- const slotId = typeof value === 'string' ? value.trim() : '';
122
- return /^[A-Za-z0-9._:-]{1,64}$/u.test(slotId) ? slotId : '';
108
+ return readSlotIdFromLib(target, recipeRuntimeDir());
123
109
  }
124
110
 
125
111
  function writeExtensionId(target, extensionId) {
@@ -407,16 +393,9 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
407
393
  const stampedTitle = await cdpEvaluate(
408
394
  target,
409
395
  pageTarget.webSocketDebuggerUrl,
410
- `(() => {
411
- const slot = ${JSON.stringify(slotId)};
412
- const current = document.title || 'MetaMask';
413
- const base = current.replace(/^.+?\\s+—\\s+/, '') || 'MetaMask';
414
- const next = slot + ' — ' + base;
415
- if (document.title !== next) document.title = next;
416
- return document.title;
417
- })()`,
396
+ buildStampExpression(slotId),
418
397
  );
419
- if (stampedTitle && !stampedTitle.skipped) {
398
+ if (typeof stampedTitle === 'string') {
420
399
  ui.title = stampedTitle;
421
400
  ui.slotTitleApplied = true;
422
401
  }
@@ -123,6 +123,7 @@ let chromium; try { chromium = require('@playwright/test').chromium; } catch { c
123
123
  const fs = require('node:fs');
124
124
  const path = require('node:path');
125
125
  const { extensionIdFromManifestFile } = require(path.join(process.env.SCRIPT_DIR, 'lib/extension-id.cjs'));
126
+ const { readSlotId, applyPersistentSlotTitle } = require(path.join(process.env.SCRIPT_DIR, 'lib/slot-title.cjs'));
126
127
 
127
128
  const port = process.env.CDP_PORT;
128
129
  const target = process.env.TARGET;
@@ -131,22 +132,13 @@ const runtimeDistDir = process.env.RUNTIME_DIST_DIR;
131
132
  const startUrl = process.env.START_URL || '';
132
133
  const settleMs = Number(process.env.SETTLE_MS || '8000') || 8000;
133
134
  const displayMode = process.env.DISPLAY_MODE || 'fullscreen';
134
- const slotId = process.env.RECIPE_SLOT_ID || process.env.SLOT_ID || process.env.FARMSLOT_SLOT_ID || readRuntimeSlotId();
135
+ const slotId = readSlotId(target, runtimeDir);
135
136
 
136
137
  if (!runtimeDir || !runtimeDistDir) {
137
138
  console.error('reattach: resolved runtime paths were not provided.');
138
139
  process.exit(2);
139
140
  }
140
141
 
141
- function readRuntimeSlotId() {
142
- try {
143
- const runtimeContext = JSON.parse(fs.readFileSync(path.join(target, runtimeDir, 'agentic-runtime.json'), 'utf8'));
144
- return typeof runtimeContext.slotId === 'string' ? runtimeContext.slotId.trim() : '';
145
- } catch {
146
- return '';
147
- }
148
- }
149
-
150
142
  function extensionIdFromRuntimeDist() {
151
143
  return extensionIdFromManifestFile(path.join(target, runtimeDir, runtimeDistDir, 'manifest.json'));
152
144
  }
@@ -204,11 +196,7 @@ async function connect() {
204
196
 
205
197
  async function stampSlotTitle(page) {
206
198
  if (!slotId || !page) return;
207
- await page.evaluate((slot) => {
208
- const base = (document.title || 'MetaMask').replace(/^.+?\s+—\s+/, '') || 'MetaMask';
209
- const next = `${slot} — ${base}`;
210
- if (document.title !== next) document.title = next;
211
- }, slotId).catch(() => {
199
+ await page.evaluate(applyPersistentSlotTitle, slotId).catch(() => {
212
200
  // Best-effort operator affordance; reload success is verified by CDP state.
213
201
  });
214
202
  }
@@ -285,6 +273,16 @@ async function closePages(context, predicate) {
285
273
  .catch(() => { /* wallet home is best-effort; the reload already applied */ });
286
274
  }
287
275
  await stampSlotTitle(home);
276
+ for (const page of allPages(context)) {
277
+ const url = page.url();
278
+ if (
279
+ page !== home &&
280
+ url.startsWith(`chrome-extension://${extId}/`) &&
281
+ url.includes('/home.html')
282
+ ) {
283
+ await stampSlotTitle(page);
284
+ }
285
+ }
288
286
  await closeDisposableChromeTabs(context);
289
287
  if (startUrl) {
290
288
  const dapp = allPages(context)
@@ -1,5 +1,11 @@
1
+ import { createRequire } from "node:module";
1
2
  import path from "node:path";
3
+ import { recipeRuntimeDir, runnerDir } from "../../paths.js";
2
4
  import { resolveExtensionId } from "./extension-id.js";
5
+ const requireFromHarness = createRequire(import.meta.url);
6
+ const { stampHomeTabsViaCdp } = requireFromHarness(
7
+ path.join(runnerDir, "adapters/extension/lib/slot-title.cjs")
8
+ );
3
9
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
10
  async function jsonList(port) {
5
11
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -161,12 +167,25 @@ async function ensureExtensionReady(target, options) {
161
167
  health = await checkHealth();
162
168
  }
163
169
  }
170
+ let slotTitle;
171
+ if (after >= 1) {
172
+ const stamp = await stampHomeTabsViaCdp({
173
+ target: resolved,
174
+ cdpPort,
175
+ extensionId,
176
+ runtimeDir: recipeRuntimeDir()
177
+ });
178
+ if (stamp.slotId || stamp.stamped > 0 || stamp.skipped > 0) {
179
+ slotTitle = stamp;
180
+ }
181
+ }
164
182
  const ready = health.status === "PASS" && after === 1;
165
183
  return base({
166
184
  extensionId,
167
185
  opened,
168
186
  action,
169
187
  homeTabs: { before, closed, after },
188
+ ...slotTitle ? { slotTitle } : {},
170
189
  ready,
171
190
  reasonCode: ready ? "ready" : after !== 1 ? "tab-count" : "unhealthy",
172
191
  health
@@ -12,7 +12,8 @@ const mobileSurface = {
12
12
  },
13
13
  async runtimeStatus(target) {
14
14
  const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
15
- const report = await mobileRuntimeStatus(target, { watcherPort });
15
+ const platform = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL ? "android" : "ios";
16
+ const report = await mobileRuntimeStatus(target, { watcherPort, platform });
16
17
  const runwayProvisioned = hasRunwayProvisionBaseline(target);
17
18
  const depsPending = runwayProvisioned && report.checks?.deps?.status !== "current";
18
19
  return {
@@ -16,7 +16,7 @@ const SPEC = {
16
16
  { name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
17
17
  { name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
18
18
  { name: "actions", desc: "List runnable recipe actions", flags: ["--json"] },
19
- { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--cdp-port", "--device"] },
19
+ { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
20
20
  { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
21
21
  { name: "recipe-quality", desc: "Build the recipe-quality artifact from compact JSON", args: ["build"], flags: ["--input", "--output", "--json"] },
22
22
  { name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
@@ -15,6 +15,7 @@ import {
15
15
  renderMobileDeviceList,
16
16
  renderMobileLiveBlock
17
17
  } from "./mobile-device-view.js";
18
+ import { farmslotReadyIndicator } from "./farmslot-ready.js";
18
19
  import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
19
20
  import {
20
21
  actionManifestPathOption,
@@ -40,7 +41,8 @@ async function handleDoctor({ options }) {
40
41
  applyRuntimeDirOption(options);
41
42
  const target = targetPath(options);
42
43
  const json = optionFlag(options, "json");
43
- const expectLive = optionFlag(options, "expectLive");
44
+ const printReady = optionFlag(options, "printReady");
45
+ const expectLive = optionFlag(options, "expectLive") || printReady;
44
46
  const allDevices = optionFlag(options, "allDevices");
45
47
  const platformOption = optionString(options, "platform");
46
48
  const explicitAdapter = optionString(options, "adapter") ?? (platformOption === "ios" || platformOption === "android" ? "mobile" : platformOption);
@@ -49,6 +51,14 @@ async function handleDoctor({ options }) {
49
51
  return usageOut(json, "doctor", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
50
52
  }
51
53
  assertAdapter(adapter);
54
+ if (printReady && json) {
55
+ return usageOut(
56
+ json,
57
+ "doctor",
58
+ "--print-ready owns stdout for Farmslot health_check; drop --json (use --expect-live --json for the doctor envelope)",
59
+ "mm-harness doctor --print-ready --adapter <adapter> --target <path>"
60
+ );
61
+ }
52
62
  const actionManifestPath = actionManifestPathOption(options, adapter);
53
63
  const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
54
64
  const manifestValidation = await validateManifest(manifest);
@@ -80,7 +90,20 @@ async function handleDoctor({ options }) {
80
90
  const liveView = deviceView ? await mobileDeviceLiveView(target, deviceView) : null;
81
91
  const devices = liveView?.devicesWithLive ?? deviceView?.devices ?? [];
82
92
  const additionalReachableDevices = liveView?.additionalReachableDevices ?? [];
83
- if (expectLive) return emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json);
93
+ if (expectLive) {
94
+ return emitExpectLive(
95
+ adapter,
96
+ target,
97
+ runtime,
98
+ result,
99
+ orphanMetros,
100
+ capture,
101
+ devices,
102
+ additionalReachableDevices,
103
+ json,
104
+ printReady
105
+ );
106
+ }
84
107
  if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
85
108
  else {
86
109
  const out = (style, text) => color(style, text, { stream: process.stdout });
@@ -119,8 +142,28 @@ async function handleDoctor({ options }) {
119
142
  }
120
143
  return result.status === "pass" ? 0 : 1;
121
144
  }
122
- function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json) {
145
+ function emitExpectLiveVerdict(adapter, target, runtime, verdict) {
146
+ const out = (style, text) => color(style, text, { stream: process.stderr });
147
+ const detail = runtime ? `${runtime.decision}${runtime.reasonCode ? ` (${runtime.reasonCode})` : ""}` : "runtime probe unavailable";
148
+ console.error(`${out("err", verdict)} ${out("bold", adapter)} ${detail} ${out("dim", target)}`);
149
+ for (const reason of runtime?.reasons ?? []) console.error(` ${out("dim", reason)}`);
150
+ console.error(` Next: ${getAdapterSurface(adapter).hints.relaunch}`);
151
+ }
152
+ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json, printReady) {
123
153
  const live = runtime?.decision === "ready";
154
+ if (printReady) {
155
+ if (live) {
156
+ const indicator = farmslotReadyIndicator(adapter, devices);
157
+ if (indicator) {
158
+ console.log(indicator);
159
+ return EXIT.ok;
160
+ }
161
+ emitExpectLiveVerdict(adapter, target, runtime, "not-ready");
162
+ return EXIT.runtime;
163
+ }
164
+ emitExpectLiveVerdict(adapter, target, runtime, "not-live");
165
+ return EXIT.runtime;
166
+ }
124
167
  if (json) {
125
168
  console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
126
169
  return live ? EXIT.ok : EXIT.runtime;
@@ -130,10 +173,7 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
130
173
  console.log(`${out("ok", "live")} ${out("bold", adapter)} runtime ready`);
131
174
  return EXIT.ok;
132
175
  }
133
- const detail = runtime ? `${runtime.decision}${runtime.reasonCode ? ` (${runtime.reasonCode})` : ""}` : "runtime probe unavailable";
134
- console.error(`${out("err", "not-live")} ${out("bold", adapter)} ${detail} ${out("dim", target)}`);
135
- for (const reason of runtime?.reasons ?? []) console.error(` ${out("dim", reason)}`);
136
- console.error(` Next: ${getAdapterSurface(adapter).hints.relaunch}`);
176
+ emitExpectLiveVerdict(adapter, target, runtime, "not-live");
137
177
  return EXIT.runtime;
138
178
  }
139
179
  function captureHelperHealth() {
@@ -0,0 +1,25 @@
1
+ function selectedDevice(devices) {
2
+ return devices.find((d) => d.selected) ?? devices[0];
3
+ }
4
+ function mobileWalletReady(device) {
5
+ if (device.liveState === "no-bridge" || device.liveState === "bridge-absent") return false;
6
+ return device.walletState === "unlocked";
7
+ }
8
+ function farmslotReadyIndicator(adapter, devices) {
9
+ switch (adapter) {
10
+ case "extension":
11
+ return "OK";
12
+ case "core":
13
+ return "ready";
14
+ case "mobile": {
15
+ const device = selectedDevice(devices);
16
+ if (!device) return "";
17
+ return mobileWalletReady(device) ? "OK" : "";
18
+ }
19
+ default:
20
+ return "";
21
+ }
22
+ }
23
+ export {
24
+ farmslotReadyIndicator
25
+ };
@@ -29,6 +29,7 @@ function parseArgs(argv, command) {
29
29
  "force",
30
30
  "resolveOnly",
31
31
  "expectLive",
32
+ "printReady",
32
33
  "fast",
33
34
  "allDevices",
34
35
  "help"
@@ -171,19 +171,20 @@ Example:
171
171
  sections so there is no hunting for files.
172
172
 
173
173
  --fix Repair the overlay/runtime-context WITHOUT launching (no fixture reseed); --json adds fixed[]/failed[]
174
- --expect-live Exit 0 iff the runtime is live (extension: watcher+CDP; mobile: Metro+bridge; core: deps), non-zero + teaching escape otherwise
174
+ --expect-live Exit-coded liveness gate only (human verdict on stderr). Use without --print-ready when callers need pass/fail without a Farmslot indicator line
175
+ --print-ready Farmslot health_check mode: implies --expect-live and prints health.ready_indicator on stdout (extension/mobile: OK; core: ready)
175
176
  --cdp-port <port> Extension CDP port for the liveness probe (env: CDP_PORT / RECIPE_CDP_PORT)
176
177
  --device <udid|serial|name> Mobile only: target this device (env: IOS_SIMULATOR / ADB_SERIAL). doctor reports the connected devices; it never gates on ambiguity.
177
178
  --all-devices Mobile only: show every connected device instead of the slot-scoped target
178
179
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
179
180
  --target <path> Checkout path (default: cwd)
180
181
  --runtime-dir <dir> Runtime dir containing agentic-runtime.json (relative to target)
181
- --json Machine-readable output
182
+ --json Machine-readable output (incompatible with --print-ready)
182
183
 
183
184
  Example:
184
185
  mm-harness doctor
185
186
  mm-harness doctor --fix --json
186
- mm-harness doctor --adapter extension --target /path/to/checkout --cdp-port 6662 --expect-live
187
+ mm-harness doctor --adapter extension --target /path/to/checkout --cdp-port 6662 --print-ready
187
188
  mm-harness doctor --adapter mobile --target /path/to/checkout`
188
189
  },
189
190
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.14.4",
3
+ "version": "0.14.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -20,7 +20,8 @@
20
20
  "@farmslot/protocol": "^0.7.6",
21
21
  "@farmslot/recipe-harness": "^0.4.3",
22
22
  "commander": "^12.0.0",
23
- "viem": "^2.54.3"
23
+ "viem": "^2.54.3",
24
+ "ws": "8.21.0"
24
25
  },
25
26
  "resolutions": {
26
27
  "esbuild": "0.28.1",