@deeeed/metamask-harness 0.19.1 → 0.20.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 (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/adapters/extension/ensure-browser.sh +6 -4
  3. package/adapters/extension/launch-browser.cjs +6 -1
  4. package/adapters/extension/lib/chrome-args.cjs +11 -1
  5. package/adapters/extension/start-watch.sh +1 -1
  6. package/adapters/extension/verify.sh +17 -0
  7. package/adapters/mobile/open-device.sh +12 -1
  8. package/adapters/mobile/wait-for-bridge.sh +1 -1
  9. package/dist/adapters/extension/ensure-ready.js +7 -2
  10. package/dist/adapters/extension/runtime-decision.js +4 -6
  11. package/dist/adapters/extension/runtime.js +114 -17
  12. package/dist/adapters/mobile/prepare.js +40 -6
  13. package/dist/command-contract.js +58 -5
  14. package/dist/commands/call.js +6 -3
  15. package/dist/commands/check.js +9 -2
  16. package/dist/commands/checklist.js +117 -14
  17. package/dist/commands/launch/extension.js +8 -3
  18. package/dist/commands/launch/index.js +38 -25
  19. package/dist/commands/launch/mobile.js +2 -2
  20. package/dist/commands/manifest.js +72 -5
  21. package/dist/commands/run-engine.js +7 -2
  22. package/dist/heal-bounds.js +1 -1
  23. package/dist/metamask-action-validation.js +45 -0
  24. package/dist/mm-harness-cli.js +7 -4
  25. package/docs/CONTRIBUTING.md +8 -0
  26. package/library/actions/extension/platform/cdp.mjs +8 -3
  27. package/library/actions/extension/ui/navigate.mjs +239 -16
  28. package/library/actions/mobile/ui/navigate.mjs +1 -1
  29. package/library/manifests/core.action-manifest.json +60 -7
  30. package/library/manifests/extension.action-manifest.json +55 -13
  31. package/library/manifests/mobile.action-manifest.json +52 -12
  32. package/library/recipes/perps/lifecycle.recipe.json +3 -9
  33. package/library/recipes/runner/action-validation.extension.recipe.json +5 -4
  34. package/package.json +5 -4
  35. package/scripts/completions.sh +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.20.0 - 2026-07-24
6
+
7
+ ### Added
8
+
9
+ - `mm-harness checklist closeout` delegates learning capture to `@farmslot/handoff`; successful terminal marks stage automatically while sharing stays explicit.
10
+
11
+ ### Fixed
12
+
13
+ - Extension readiness bounds both CDP connection setup and page calls instead of hanging.
14
+ - Extension readiness reports a suspended UI compositor instead of claiming a reachable CDP page is interactive.
15
+ - Extension hash navigation stays inside the running app so SPA routes are not reset during startup.
16
+ - `ui.navigate page=perps` discovers and activates current Perps controls, including bounded back navigation from detail screens, and verifies the destination; bundled recipes no longer embed stale product routes.
17
+ - Semantic Extension navigation honors the caller timeout while avoiding repeated control activation.
18
+ - Invalid enum recovery commands use the closest valid value instead of repeating the rejected input.
19
+ - Perps mutation plans fail early when required market, side, or amount inputs are missing.
20
+ - `check diff` honors the checkout's configured ESLint config file.
21
+ - Extension builds no longer certify dependency state; Yarn `node-modules` freshness follows the installed tree and only successful installs record its baseline.
22
+
5
23
  ## 0.19.1 - 2026-07-23
6
24
 
7
25
  ### Added
@@ -277,7 +277,11 @@ const fs = require('fs');
277
277
  const http = require('http');
278
278
  const { execSync, execFileSync } = require('child_process');
279
279
  let chromium; try { chromium = require('@playwright/test').chromium; } catch { chromium = require('playwright').chromium; }
280
- const { isolatedProfileArgs, remoteDebuggingArgs } = require(path.join('${SCRIPT_DIR}', 'lib/chrome-args.cjs'));
280
+ const {
281
+ automationRuntimeArgs,
282
+ isolatedProfileArgs,
283
+ remoteDebuggingArgs,
284
+ } = require(path.join('${SCRIPT_DIR}', 'lib/chrome-args.cjs'));
281
285
  const { captureMacFrontmostProcess, restoreMacFrontmostProcess } = require(path.join('${SCRIPT_DIR}', 'lib/macos-focus.cjs'));
282
286
 
283
287
  const SLOT_ID = '${SLOT_ID}';
@@ -304,9 +308,7 @@ const resumeWebpack = () => {
304
308
  '--user-data-dir=' + PROFILE,
305
309
  '--disable-extensions-except=' + EXTENSION,
306
310
  '--load-extension=' + EXTENSION,
307
- '--disable-background-timer-throttling',
308
- '--disable-backgrounding-occluded-windows',
309
- '--disable-renderer-backgrounding',
311
+ ...automationRuntimeArgs(),
310
312
  '--no-first-run',
311
313
  '--no-default-browser-check',
312
314
  ...isolatedProfileArgs(),
@@ -24,7 +24,11 @@ const os = require('node:os');
24
24
  const path = require('node:path');
25
25
  const { execFileSync, spawn, spawnSync } = require('node:child_process');
26
26
  const { extensionIdFromExtensionDir } = require('./lib/extension-id.cjs');
27
- const { isolatedProfileArgs, remoteDebuggingArgs } = require('./lib/chrome-args.cjs');
27
+ const {
28
+ automationRuntimeArgs,
29
+ isolatedProfileArgs,
30
+ remoteDebuggingArgs,
31
+ } = require('./lib/chrome-args.cjs');
28
32
  const { captureMacFrontmostProcess, restoreMacFrontmostProcess } = require('./lib/macos-focus.cjs');
29
33
 
30
34
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
@@ -99,6 +103,7 @@ const chromeArgs = [
99
103
  ...remoteDebuggingArgs(cdpPort),
100
104
  '--no-first-run',
101
105
  '--disable-first-run-ui',
106
+ ...automationRuntimeArgs(),
102
107
  ...isolatedProfileArgs(),
103
108
  '--disable-default-apps',
104
109
  '--disable-popup-blocking',
@@ -28,4 +28,14 @@ function isolatedProfileArgs(platform = process.platform) {
28
28
  return platform === 'darwin' ? ['--use-mock-keychain'] : [];
29
29
  }
30
30
 
31
- module.exports = { isolatedProfileArgs, remoteDebuggingArgs };
31
+ // Keep requestAnimationFrame, transitions, and input hit testing alive when the
32
+ // slot-owned browser is occluded by the operator's other windows.
33
+ function automationRuntimeArgs() {
34
+ return [
35
+ '--disable-background-timer-throttling',
36
+ '--disable-backgrounding-occluded-windows',
37
+ '--disable-renderer-backgrounding',
38
+ ];
39
+ }
40
+
41
+ module.exports = { automationRuntimeArgs, isolatedProfileArgs, remoteDebuggingArgs };
@@ -338,6 +338,6 @@ fi
338
338
  echo '[recipe-harness] yarn start compiled successfully'
339
339
  if [ -n "$RUNNER_BIN" ]; then
340
340
  "$RUNNER_BIN" runtime-decision --adapter extension --target . --record-baseline --json >/dev/null 2>&1 || true
341
- echo '[recipe-harness] recorded deps/cache baseline (runtime-decision --record-baseline)'
341
+ echo '[recipe-harness] recorded webpack cache baseline (runtime-decision --record-baseline)'
342
342
  fi
343
343
  status=pass
@@ -407,6 +407,23 @@ if [ "$STATIC_ONLY" = false ]; then
407
407
  refresh_extension_id
408
408
  else
409
409
  checks+=("{\"name\":\"home-tab convergence\",\"status\":\"fail\",\"detail\":\"see logs/extension-ensure-ready.json\"}")
410
+ node - "$ARTIFACTS/logs/extension-ensure-ready.json" <<'NODE' >&2
411
+ const fs = require('fs');
412
+ const file = process.argv[2];
413
+ try {
414
+ const report = JSON.parse(fs.readFileSync(file, 'utf8'));
415
+ const code = report.health?.errorCode;
416
+ const findings = Array.isArray(report.health?.findings) ? report.health.findings : [];
417
+ if (code) {
418
+ const finding = findings[0] ?? 'Extension UI is not interactive.';
419
+ console.error(finding.startsWith(`${code}:`) ? finding : `${code}: ${finding}`);
420
+ }
421
+ else if (findings[0]) console.error(findings[0]);
422
+ if (report.health?.userAction) console.error(`Next: ${report.health.userAction}`);
423
+ } catch {
424
+ // The summary retains the raw helper output for diagnosis.
425
+ }
426
+ NODE
410
427
  status="fail"
411
428
  fi
412
429
 
@@ -13,6 +13,7 @@
13
13
  # --simulator <udid|name|"booted"> iOS simulator (default: IOS_SIMULATOR env, else "booted")
14
14
  # --adb-serial <serial> Android device serial (default: ADB_SERIAL env)
15
15
  # --preflight-mode fast|auto|rebuild-native|clean (default: fast; env MOBILE_PREFLIGHT_MODE)
16
+ # --restart terminate the selected app before opening it
16
17
  # --ui-only iOS only: ensure Simulator.app is visible; do not relaunch the app
17
18
  # Outputs:
18
19
  # Progress on stderr; exit 0 pass; 1 open failed; 2 bad args.
@@ -27,6 +28,7 @@ ADB_SERIAL_ARG="${ADB_SERIAL:-${ANDROID_SERIAL:-}}"
27
28
  PREFLIGHT_MODE="${MOBILE_PREFLIGHT_MODE:-fast}"
28
29
  PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
29
30
  UI_ONLY=false
31
+ RESTART=false
30
32
 
31
33
  while [ "$#" -gt 0 ]; do
32
34
  case "$1" in
@@ -36,11 +38,12 @@ while [ "$#" -gt 0 ]; do
36
38
  --adb-serial) ADB_SERIAL_ARG="$2"; shift 2 ;;
37
39
  --preflight-mode) PREFLIGHT_MODE="$2"; shift 2 ;;
38
40
  --port) PORT="$2"; shift 2 ;;
41
+ --restart) RESTART=true; shift ;;
39
42
  --ui-only) UI_ONLY=true; shift ;;
40
43
  -h|--help)
41
44
  printf 'Usage: open-device.sh [--platform ios|android] [--target <dir>]\n'
42
45
  printf ' [--simulator <udid|name|booted>] [--adb-serial <serial>]\n'
43
- printf ' [--preflight-mode fast|auto|rebuild-native|clean] [--port <n>] [--ui-only]\n'
46
+ printf ' [--preflight-mode fast|auto|rebuild-native|clean] [--port <n>] [--restart] [--ui-only]\n'
44
47
  exit 0
45
48
  ;;
46
49
  *) printf 'open-device: unknown arg: %s\n' "$1" >&2; exit 2 ;;
@@ -265,6 +268,10 @@ if [ "$PLATFORM" = "ios" ]; then
265
268
  LAUNCHED=false
266
269
  for BUNDLE_ID in "${BUNDLE_IDS[@]}"; do
267
270
  ensure_ios_app_for_mode "$SIM_TARGET" "$BUNDLE_ID" "$PREFLIGHT_MODE" || continue
271
+ if $RESTART; then
272
+ xcrun simctl terminate "$SIM_TARGET" "$BUNDLE_ID" >/dev/null 2>&1 || true
273
+ printf 'Restarting iOS dev client %s on %s\n' "$BUNDLE_ID" "$SIM_TARGET" >&2
274
+ fi
268
275
 
269
276
  # Dismiss Expo dev-menu onboarding for clean headless runs.
270
277
  xcrun simctl spawn "$SIM_TARGET" defaults write "$BUNDLE_ID" \
@@ -314,6 +321,10 @@ else
314
321
  LAUNCHED=false
315
322
  for PKG in "${ANDROID_PKGS[@]}"; do
316
323
  ensure_android_app_for_mode "$PKG" "$PREFLIGHT_MODE" "${ADB_ARGS[@]}" || continue
324
+ if $RESTART; then
325
+ adb "${ADB_ARGS[@]}" shell am force-stop "$PKG" >/dev/null 2>&1 || true
326
+ printf 'Restarting Android dev client %s\n' "$PKG" >&2
327
+ fi
317
328
 
318
329
  ENCODED_URL="$(urlencode "http://localhost:${PORT}?disableOnboarding=1" 2>/dev/null \
319
330
  || printf 'http%%3A%%2F%%2Flocalhost%%3A%s%%3FdisableOnboarding%%3D1' "$PORT")"
@@ -158,5 +158,5 @@ printf 'wait-for-bridge: no bridge target matched the requested %s on port %s (%
158
158
  "$(requested_target)" "$PORT" "$MAX_POLLS" "$((MAX_POLLS * 2))" >&2
159
159
  printf ' requested: %s\n' "$(requested_target)" >&2
160
160
  printf ' answered: %s\n' "$(answered_targets)" >&2
161
- printf ' Next: check %s — or run mm-harness app-status\n' "$STATUS_LOG" >&2
161
+ printf ' Next: check %s — or run mm-harness status --json\n' "$STATUS_LOG" >&2
162
162
  exit 1
@@ -135,7 +135,12 @@ async function ensureExtensionReady(target, options) {
135
135
  try {
136
136
  const { checkExtensionRuntimeHealth } = await import("./runtime.js");
137
137
  const report = await checkExtensionRuntimeHealth(resolved, cdpPort);
138
- return { status: report.status, findings: report.findings };
138
+ return {
139
+ status: report.status,
140
+ ...report.errorCode ? { errorCode: report.errorCode } : {},
141
+ ...report.userAction ? { userAction: report.userAction } : {},
142
+ findings: report.findings
143
+ };
139
144
  } catch (error) {
140
145
  return { status: "FAIL", findings: [error instanceof Error ? error.message : String(error)] };
141
146
  }
@@ -180,7 +185,7 @@ async function ensureExtensionReady(target, options) {
180
185
  homeTabs: { before, closed, after },
181
186
  ...slotTitle ? { slotTitle } : {},
182
187
  ready,
183
- reasonCode: ready ? "ready" : after !== 1 ? "tab-count" : "unhealthy",
188
+ reasonCode: ready ? "ready" : after !== 1 ? "tab-count" : health.errorCode ?? "unhealthy",
184
189
  health
185
190
  });
186
191
  }
@@ -4,8 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import {
6
6
  depsCheck,
7
- INSTALL_MARKERS,
8
- recordDepsBaseline
7
+ INSTALL_MARKERS
9
8
  } from "@farmslot/recipe-harness/runtime/deps-readiness";
10
9
  import { recipeHarnessPath, recipeRuntimeDir, recipeWatchLogCandidates } from "../../paths.js";
11
10
  const WEBPACK_DIRECT_INPUTS = ["package.json", "yarn.lock", ".yarnrc.yml", ".tool-versions"];
@@ -236,14 +235,13 @@ async function cdpCheck(target, cdpPort, pageMode) {
236
235
  return { status: "fail", findings: [error instanceof Error ? error.message : String(error)] };
237
236
  }
238
237
  }
239
- function recordReadinessBaseline(target) {
240
- recordDepsBaseline(target);
238
+ function recordWebpackBaseline(target) {
241
239
  const cache = webpackFingerprint(target);
242
240
  writeBaseline(target, "webpack-cache-state.json", cache);
243
241
  }
244
242
  async function decideExtensionReadiness(target, options = {}) {
245
243
  const resolved = path.resolve(target);
246
- if (options.record) recordReadinessBaseline(resolved);
244
+ if (options.record) recordWebpackBaseline(resolved);
247
245
  const deps = depsCheck(resolved);
248
246
  const webpackCache = webpackCacheCheck(resolved);
249
247
  const buildLog = buildLogCheck(resolved, options.watchLog);
@@ -355,6 +353,6 @@ function buildAction(target, clean) {
355
353
  export {
356
354
  decideExtensionReadiness,
357
355
  isExtensionDistStale,
358
- recordReadinessBaseline,
356
+ recordWebpackBaseline,
359
357
  runtimeDistCheck
360
358
  };
@@ -2,7 +2,12 @@ import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, recipeRuntimeDir, resolveLocalProtocolRoot, resolveRequiredLocalProtocolRoot, runnerDir } from "../../paths.js";
5
- const { CdpSession, jsonGet, sleep } = await importRecipeHarnessRuntimeCdp();
5
+ const {
6
+ CdpSession,
7
+ jsonGet,
8
+ probeCdpCompositorInteractivity,
9
+ sleep
10
+ } = await importRecipeHarnessRuntimeCdp();
6
11
  const { extensionIdFromTarget } = await importRecipeHarnessRuntimeBrowserExtension();
7
12
  async function prepareExtensionRuntime(options) {
8
13
  const projectRoot = path.resolve(options.projectRoot);
@@ -39,6 +44,9 @@ async function assertHealthyExtensionRuntime(options) {
39
44
  pageMode: options.pageMode
40
45
  });
41
46
  if (lastReport.status === "PASS") return lastReport;
47
+ if (lastReport.errorCode === "UI_COMPOSITOR_SUSPENDED") {
48
+ throw new Error(formatHealthFailure(lastReport, options.projectRoot));
49
+ }
42
50
  await sleep(500);
43
51
  }
44
52
  const report = lastReport ?? failReport(options.cdpPort, ["CDP runtime was not probed."], {});
@@ -48,9 +56,14 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
48
56
  const findings = [];
49
57
  const warnings = [];
50
58
  let targets = [];
59
+ const cdpCallTimeoutMs = options.cdpCallTimeoutMs ?? 5e3;
51
60
  try {
52
- await jsonGet(`http://127.0.0.1:${cdpPort}/json/version`);
53
- const rawTargets = await jsonGet(`http://127.0.0.1:${cdpPort}/json/list`);
61
+ await jsonGet(`http://127.0.0.1:${cdpPort}/json/version`, {
62
+ timeoutMs: cdpCallTimeoutMs
63
+ });
64
+ const rawTargets = await jsonGet(`http://127.0.0.1:${cdpPort}/json/list`, {
65
+ timeoutMs: cdpCallTimeoutMs
66
+ });
54
67
  targets = Array.isArray(rawTargets) ? rawTargets : [];
55
68
  } catch (error) {
56
69
  return failReport(cdpPort, [`CDP is not reachable on port ${cdpPort}: ${messageOf(error)}`], {});
@@ -76,11 +89,57 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
76
89
  if (String(target.url ?? "").startsWith("chrome-error://")) {
77
90
  findings.push(`Extension target resolved to chrome-error page: ${target.url}`);
78
91
  }
79
- const session = await CdpSession.connect(target.webSocketDebuggerUrl);
92
+ let session;
80
93
  try {
81
- await session.call("Runtime.enable");
82
- await session.call("Page.enable");
83
- const runtime = await evaluateHealth(session);
94
+ session = await CdpSession.connect(target.webSocketDebuggerUrl, {
95
+ timeoutMs: cdpCallTimeoutMs
96
+ });
97
+ await boundedCdpCall(session, "Runtime.enable", {}, cdpCallTimeoutMs);
98
+ await boundedCdpCall(session, "Page.enable", {}, cdpCallTimeoutMs);
99
+ const compositor = await probeCdpCompositorInteractivity(
100
+ session,
101
+ Math.min(1e3, cdpCallTimeoutMs)
102
+ );
103
+ if (compositor.status === "suspended") {
104
+ const userAction = compositorRecoveryAction(projectRoot);
105
+ return {
106
+ status: "FAIL",
107
+ errorCode: "UI_COMPOSITOR_SUSPENDED",
108
+ userAction,
109
+ cdpPort,
110
+ targetUrl: target.url,
111
+ extensionId: safeExtensionId(target),
112
+ extensionPageTargets: extensionTargets.length,
113
+ findings: [
114
+ `UI_COMPOSITOR_SUSPENDED: the Extension page is reachable over CDP but its render compositor is not advancing (${compositor.reason ?? "unknown reason"}).`
115
+ ],
116
+ details: {
117
+ projectRoot,
118
+ targetUrls: targets.map((entry) => entry.url).filter(Boolean),
119
+ compositor
120
+ }
121
+ };
122
+ }
123
+ if (compositor.status === "not-interactive") {
124
+ return {
125
+ status: "FAIL",
126
+ errorCode: "UI_NOT_INTERACTIVE",
127
+ userAction: `Inspect the visible Extension page for an overlay or blocked control, then rerun: mm-harness verify --target ${projectRoot}`,
128
+ cdpPort,
129
+ targetUrl: target.url,
130
+ extensionId: safeExtensionId(target),
131
+ extensionPageTargets: extensionTargets.length,
132
+ findings: [
133
+ `UI_NOT_INTERACTIVE: the Extension page is rendering, but its visible controls failed browser hit testing (${compositor.reason ?? "unknown reason"}).`
134
+ ],
135
+ details: {
136
+ projectRoot,
137
+ targetUrls: targets.map((entry) => entry.url).filter(Boolean),
138
+ compositor
139
+ }
140
+ };
141
+ }
142
+ const runtime = await evaluateHealth(session, cdpCallTimeoutMs);
84
143
  if (runtime.href && !String(runtime.href).startsWith("chrome-extension://")) {
85
144
  findings.push(`Extension page href is not an extension URL: ${runtime.href}`);
86
145
  }
@@ -114,21 +173,48 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
114
173
  runtime
115
174
  }
116
175
  };
176
+ } catch (error) {
177
+ return failReport(
178
+ cdpPort,
179
+ [`Extension page CDP probe failed: ${messageOf(error)}`],
180
+ {
181
+ targetUrl: target.url,
182
+ targetUrls: targets.map((entry) => entry.url).filter(Boolean)
183
+ },
184
+ extensionTargets.length
185
+ );
117
186
  } finally {
118
- session.close();
187
+ session?.close();
119
188
  }
120
189
  }
121
- function failReport(cdpPort, findings, details) {
190
+ function failReport(cdpPort, findings, details, extensionPageTargets = 0) {
122
191
  return {
123
192
  status: "FAIL",
124
193
  cdpPort,
125
- extensionPageTargets: 0,
194
+ extensionPageTargets,
126
195
  findings,
127
196
  details
128
197
  };
129
198
  }
130
- async function evaluateHealth(session) {
131
- const result = await session.call("Runtime.evaluate", {
199
+ async function boundedCdpCall(session, method, params, timeoutMs) {
200
+ let timer;
201
+ try {
202
+ return await Promise.race([
203
+ session.call(method, params),
204
+ new Promise((_resolve, reject) => {
205
+ timer = setTimeout(
206
+ () => reject(new Error(`${method} timed out after ${timeoutMs}ms.`)),
207
+ timeoutMs
208
+ );
209
+ })
210
+ ]);
211
+ } finally {
212
+ if (timer) clearTimeout(timer);
213
+ }
214
+ }
215
+ async function evaluateHealth(session, timeoutMs) {
216
+ const backgroundProbeTimeoutMs = extensionBackgroundProbeTimeoutMs(timeoutMs);
217
+ const result = await boundedCdpCall(session, "Runtime.evaluate", {
132
218
  expression: `(() => {
133
219
  const hooks = globalThis.stateHooks || {};
134
220
  const bodyText = document.body?.innerText || '';
@@ -177,13 +263,13 @@ async function evaluateHealth(session) {
177
263
  hasPerpsStreamManager: typeof hooks.getPerpsStreamManager === 'function',
178
264
  backgroundUnresponsive: bodyText.includes('Background connection unresponsive') || bodyText.includes('MetaMask had trouble starting'),
179
265
  backgroundProbeOk: false,
180
- backgroundProbeError: 'perpsGetAccountState timed out after 5000ms',
181
- }), 5000)),
266
+ backgroundProbeError: 'perpsGetAccountState timed out after ${backgroundProbeTimeoutMs}ms',
267
+ }), ${backgroundProbeTimeoutMs})),
182
268
  ]);
183
269
  })()`,
184
270
  awaitPromise: true,
185
271
  returnByValue: true
186
- });
272
+ }, timeoutMs);
187
273
  if (result.exceptionDetails) {
188
274
  throw new Error(
189
275
  result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Extension runtime health evaluation failed."
@@ -191,15 +277,24 @@ async function evaluateHealth(session) {
191
277
  }
192
278
  return result.result?.value ?? {};
193
279
  }
280
+ function extensionBackgroundProbeTimeoutMs(cdpCallTimeoutMs) {
281
+ return Math.max(1, Math.floor(cdpCallTimeoutMs * 0.8));
282
+ }
194
283
  function formatHealthFailure(report, projectRoot) {
195
284
  return [
196
285
  `Extension runtime health check failed for ${projectRoot} on CDP port ${report.cdpPort}.`,
197
286
  ...report.findings.map((finding) => `- ${finding}`),
198
- "Use the host-managed validation launcher, for example:",
199
- ` mm-harness run <recipe.json> --adapter extension --target ${projectRoot} --slot <slot-id> --cdp-port ${report.cdpPort} --launch-existing-dist --artifacts-dir <artifacts-dir>`,
287
+ ...report.userAction ? ["Next:", ` ${report.userAction}`] : [
288
+ "Use the host-managed validation launcher, for example:",
289
+ ` mm-harness run <recipe.json> --adapter extension --target ${projectRoot} --slot <slot-id> --cdp-port ${report.cdpPort} --launch-existing-dist --artifacts-dir <artifacts-dir>`
290
+ ],
200
291
  `Health details: ${JSON.stringify(report.details)}`
201
292
  ].join("\n");
202
293
  }
294
+ function compositorRecoveryAction(projectRoot) {
295
+ const recovery = `mm-harness launch --verify --target ${JSON.stringify(projectRoot)}`;
296
+ return process.platform === "darwin" ? `Unlock the macOS session, then rerun: ${recovery}` : `Restore an active display compositor, then rerun: ${recovery}`;
297
+ }
203
298
  function resolveCdpPort(rawPort, slot) {
204
299
  const raw = rawPort ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT ?? slot?.cdpPort;
205
300
  const port = Number(raw);
@@ -329,6 +424,8 @@ function messageOf(error) {
329
424
  export {
330
425
  assertHealthyExtensionRuntime,
331
426
  checkExtensionRuntimeHealth,
427
+ compositorRecoveryAction,
428
+ extensionBackgroundProbeTimeoutMs,
332
429
  formatHealthFailure,
333
430
  prepareExtensionRuntime
334
431
  };
@@ -32,6 +32,7 @@ async function prepareMobile(target, opts = {}) {
32
32
  const platform = opts.platform ?? "ios";
33
33
  const preflightMode = opts.preflightMode ?? "fast";
34
34
  const clearMetro = opts.clearMetro ?? false;
35
+ const restartApp = opts.restartApp ?? false;
35
36
  const report = await decideMobileReadiness(target, {
36
37
  watcherPort: opts.watcherPort,
37
38
  metroLog: opts.metroLog,
@@ -49,6 +50,16 @@ async function prepareMobile(target, opts = {}) {
49
50
  return { status: EXIT.runtime, output: msg };
50
51
  }
51
52
  if (report.decision === "ready") {
53
+ if (restartApp) {
54
+ const launch2 = await dispatchActionSequence(
55
+ withAppRestart(launchActions(path.resolve(target)), true),
56
+ target,
57
+ platform,
58
+ json,
59
+ preflightMode
60
+ );
61
+ return launch2.status === 0 ? startMobileConsoleForwarder(target, platform, json) : launch2;
62
+ }
52
63
  const surface = await ensureMobileHumanSurface(target, platform, json);
53
64
  if (surface.status !== 0) return surface;
54
65
  const confirm = await dispatchAction(
@@ -66,7 +77,7 @@ async function prepareMobile(target, opts = {}) {
66
77
  );
67
78
  }
68
79
  const launch = await dispatchActionSequence(
69
- launchActions(path.resolve(target)),
80
+ withAppRestart(launchActions(path.resolve(target)), restartApp),
70
81
  target,
71
82
  platform,
72
83
  json,
@@ -80,9 +91,12 @@ async function prepareMobile(target, opts = {}) {
80
91
  `);
81
92
  return { status: EXIT.runtime, output: msg };
82
93
  }
83
- const actions = clearMetro ? report.actions.map(
84
- (a) => a.id === "start-metro" && !a.argv?.includes("--clear") ? { ...a, argv: [...a.argv ?? [], "--clear"] } : a
85
- ) : report.actions;
94
+ const actions = withAppRestart(
95
+ clearMetro ? report.actions.map(
96
+ (a) => a.id === "start-metro" && !a.argv?.includes("--clear") ? { ...a, argv: [...a.argv ?? [], "--clear"] } : a
97
+ ) : report.actions,
98
+ restartApp
99
+ );
86
100
  const actionResult = await dispatchActionSequence(actions, target, platform, json, preflightMode);
87
101
  if (actionResult.status !== 0) return actionResult;
88
102
  if (report.decision === "install" && !process.env["RECIPE_UP_INSTALL_ATTEMPTED"]) {
@@ -109,7 +123,13 @@ async function prepareMobile(target, opts = {}) {
109
123
  break;
110
124
  }
111
125
  case "launch": {
112
- const result = await dispatchActionSequence(postInstall.actions, target, platform, json, preflightMode);
126
+ const result = await dispatchActionSequence(
127
+ withAppRestart(postInstall.actions, restartApp),
128
+ target,
129
+ platform,
130
+ json,
131
+ preflightMode
132
+ );
113
133
  if (result.status !== 0) return result;
114
134
  break;
115
135
  }
@@ -136,6 +156,12 @@ function ensureMobileHumanSurface(target, platform, json) {
136
156
  if (platform !== "ios") return Promise.resolve({ status: 0, output: "" });
137
157
  return dispatchAction({ id: "ensure-device-ui", cwd: target }, target, platform, json);
138
158
  }
159
+ function withAppRestart(actions, restartApp) {
160
+ if (!restartApp) return actions;
161
+ return actions.map(
162
+ (action) => action.id === "launch-mobile-runtime" ? { ...action, argv: [...action.argv ?? [], "--restart"] } : action
163
+ );
164
+ }
139
165
  async function dispatchActionSequence(requestedActions, target, platform, json, preflightMode) {
140
166
  const resolved = path.resolve(target);
141
167
  const actions = applyMobileMetroEnvPolicy(resolved, requestedActions);
@@ -187,7 +213,15 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
187
213
  const leaf = path.join(runnerDir, "adapters/mobile/open-device.sh");
188
214
  return spawnScriptStreaming(
189
215
  leaf,
190
- ["--platform", platform, "--target", cwd, "--preflight-mode", preflightMode],
216
+ [
217
+ "--platform",
218
+ platform,
219
+ "--target",
220
+ cwd,
221
+ "--preflight-mode",
222
+ preflightMode,
223
+ ...action.argv ?? []
224
+ ],
191
225
  target,
192
226
  POD_PROBE_ENV
193
227
  );
@@ -58,8 +58,8 @@ const PUBLIC_COMMAND_CONTRACTS = {
58
58
  })
59
59
  },
60
60
  checklist: {
61
- usage: "mm-harness checklist mark <task-dir> <step> [options]",
62
- options: options(HELP, {
61
+ usage: "mm-harness checklist <mark <task-dir> <step> | closeout <task-dir>> [options]",
62
+ options: options(HELP, JSON, {
63
63
  "--mark-last": bool(),
64
64
  "--already-fixed": bool(),
65
65
  "--no-self-review": bool(),
@@ -67,10 +67,14 @@ const PUBLIC_COMMAND_CONTRACTS = {
67
67
  "--skip-checklist": bool(),
68
68
  "--reason": value(),
69
69
  "--checklist": value(),
70
- "--signal": value()
70
+ "--signal": value(),
71
+ "--share": bool(),
72
+ "--config": value(),
73
+ "--destination": value(),
74
+ "--metadata": value()
71
75
  }),
72
76
  positionals: [
73
- { label: "action", choices: ["mark"] },
77
+ { label: "action", choices: ["mark", "closeout"] },
74
78
  { label: "task-dir" },
75
79
  {
76
80
  label: "step",
@@ -82,7 +86,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
82
86
  validDescription: "start|complete|no-change|blocked|a positive numeric step"
83
87
  }
84
88
  ],
85
- minimumPositionals: 3
89
+ minimumPositionals: 2
86
90
  },
87
91
  actions: {
88
92
  options: options(HELP, JSON, TARGET, ADAPTER, ADAPTER_OR_MOBILE_PLATFORM, {
@@ -307,6 +311,55 @@ function validatePublicInvocation(argv, examples = {}) {
307
311
  }
308
312
  }
309
313
  if (seenOptions.has("--help") || seenOptions.has("-h")) return null;
314
+ if (name === "checklist") {
315
+ const action = positionals[0];
316
+ const markOnly = [
317
+ "--mark-last",
318
+ "--already-fixed",
319
+ "--no-self-review",
320
+ "--skip-learnings",
321
+ "--skip-checklist",
322
+ "--reason",
323
+ "--checklist",
324
+ "--signal"
325
+ ];
326
+ const closeoutOnly = [
327
+ "--json",
328
+ "--share",
329
+ "--config",
330
+ "--destination",
331
+ "--metadata"
332
+ ];
333
+ const invalidOption = (action === "mark" ? closeoutOnly : markOnly).find(
334
+ (option) => seenOptions.has(option)
335
+ );
336
+ if (invalidOption && (action === "mark" || action === "closeout")) {
337
+ return usageFailure(
338
+ "CLI_UNKNOWN_OPTION",
339
+ name,
340
+ `${invalidOption} is not valid with checklist ${action}.`,
341
+ contract,
342
+ examples
343
+ );
344
+ }
345
+ if (action === "mark" && positionals.length < 3) {
346
+ return missingPositionalFailure(
347
+ name,
348
+ "checklist mark requires <step>.",
349
+ contract,
350
+ examples
351
+ );
352
+ }
353
+ if (action === "closeout" && positionals.length > 2) {
354
+ return usageFailure(
355
+ "CLI_EXCESS_POSITIONAL",
356
+ name,
357
+ `unexpected positional '${positionals[2]}'; checklist closeout accepts only <task-dir>.`,
358
+ contract,
359
+ examples
360
+ );
361
+ }
362
+ }
310
363
  if (contract.leadingPositionals && !contract.requiredUnless?.some((option) => seenOptions.has(option)) && tokens.slice(0, contract.leadingPositionals).some((argument) => !argument || argument.startsWith("-"))) {
311
364
  const missing = contract.positionals?.[0]?.label ?? "argument";
312
365
  return missingPositionalFailure(name, `${name} requires <${missing}> first.`, contract, examples);