@deeeed/metamask-harness 0.67.1 → 0.69.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.
@@ -1,4 +1,8 @@
1
+ import { trackCheckoutChild } from "../checkout-lock.js";
2
+ import { recordCommandOutput, recordCommandStage } from "../command-journal.js";
1
3
  import { spawn, spawnSync } from "node:child_process";
4
+ import { StringDecoder } from "node:string_decoder";
5
+ import { OperationOutputTail } from "@farmslot/recipe-harness/runtime/operation";
2
6
  import path from "node:path";
3
7
  import { detectAdapter } from "../harness.js";
4
8
  import { colorHumanMessage } from "../cli-color.js";
@@ -81,6 +85,7 @@ function spawnScript(script, args, cwd, json, env) {
81
85
  return { status: 1, output: message };
82
86
  }
83
87
  const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
88
+ recordCommandStage(path.basename(isNodeScript ? args[0] : script));
84
89
  const result = spawnSync(invokeBin, spawnArgs, {
85
90
  cwd,
86
91
  encoding: "utf8",
@@ -97,6 +102,7 @@ function spawnScript(script, args, cwd, json, env) {
97
102
  return { status: 1, output: message };
98
103
  }
99
104
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
105
+ recordCommandOutput(output);
100
106
  if (!json && output) process.stderr.write(output);
101
107
  return { status: result.status ?? 1, output };
102
108
  }
@@ -122,7 +128,7 @@ function spawnInherit(script, args, cwd) {
122
128
  return result.signal === "SIGINT" || result.signal === "SIGTERM" ? 0 : 1;
123
129
  }
124
130
  function spawnScriptStreaming(script, args, cwd, options) {
125
- const spawnOptions = options && ("env" in options || "timeoutMs" in options) ? options : { env: options };
131
+ const spawnOptions = options && ("env" in options || "timeoutMs" in options || "forward" in options || "capture" in options || "stdin" in options || "stage" in options) ? options : { env: options };
126
132
  const isNodeScript = script === process.execPath && args.length > 0;
127
133
  const stem = isNodeScript ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase() : path.basename(script).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase();
128
134
  const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
@@ -131,26 +137,41 @@ function spawnScriptStreaming(script, args, cwd, options) {
131
137
  if (shellLeafMissing(bin)) {
132
138
  const message = `leaf could not start: ${path.basename(bin)} (ENOENT)
133
139
  Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
134
- process.stderr.write(`${message}
140
+ if (spawnOptions.forward !== "none") process.stderr.write(`${message}
135
141
  `);
136
142
  return Promise.resolve({ status: 1, output: message });
137
143
  }
138
144
  const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
139
- return new Promise((resolve) => {
140
- const ownsProcessGroup = process.platform !== "win32" && Number(spawnOptions.timeoutMs) > 0;
145
+ recordCommandStage(spawnOptions.stage ?? path.basename(isNodeScript ? args[0] : script));
146
+ return new Promise((resolve, reject) => {
147
+ const ownsProcessGroup = process.platform !== "win32";
141
148
  const child = spawn(invokeBin, spawnArgs, {
142
149
  cwd,
143
150
  env: spawnOptions.env ? { ...process.env, ...spawnOptions.env } : process.env,
144
- stdio: ["ignore", "pipe", "pipe"],
151
+ stdio: [spawnOptions.stdin ?? "ignore", "pipe", "pipe"],
145
152
  detached: ownsProcessGroup
146
153
  });
147
- let output = "";
154
+ let untrack;
155
+ try {
156
+ untrack = child.pid ? trackCheckoutChild(cwd, child.pid, ownsProcessGroup) : void 0;
157
+ } catch (error) {
158
+ child.once("error", reject);
159
+ child.once("close", () => reject(error));
160
+ try {
161
+ if (ownsProcessGroup && child.pid) process.kill(-child.pid, "SIGKILL");
162
+ else child.kill("SIGKILL");
163
+ } catch (killError) {
164
+ if (killError.code !== "ESRCH") reject(killError);
165
+ }
166
+ return;
167
+ }
168
+ const captured = new OperationOutputTail();
148
169
  let settled = false;
149
170
  let didTimeout = false;
150
171
  let escalation;
151
172
  let forwardedParentSignal;
152
173
  let parentSignalFallback;
153
- const parentSignals = ["SIGINT", "SIGTERM"];
174
+ const parentSignals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
154
175
  const removeParentSignalHandlers = () => {
155
176
  for (const signal of parentSignals) process.removeListener(signal, parentSignalHandlers[signal]);
156
177
  };
@@ -182,7 +203,7 @@ function spawnScriptStreaming(script, args, cwd, options) {
182
203
  forwardedParentSignal = signal;
183
204
  signalChildTree(signal);
184
205
  removeParentSignalHandlers();
185
- parentSignalFallback = setTimeout(() => {
206
+ if (Number(spawnOptions.timeoutMs) > 0) parentSignalFallback = setTimeout(() => {
186
207
  signalChildTree("SIGKILL");
187
208
  exitFromForwardedSignal();
188
209
  }, 1e3);
@@ -194,31 +215,35 @@ function spawnScriptStreaming(script, args, cwd, options) {
194
215
  const timer = Number.isFinite(timeoutMs) && Number(timeoutMs) > 0 ? setTimeout(() => {
195
216
  didTimeout = true;
196
217
  const message = `leaf timed out after ${String(timeoutMs)}ms: ${path.basename(script)}`;
197
- output += `${output.endsWith("\n") || output.length === 0 ? "" : "\n"}${message}
198
- `;
199
- process.stderr.write(`${message}
218
+ captured.append(`
219
+ ${message}
220
+ `);
221
+ if (spawnOptions.forward !== "none") process.stderr.write(`${message}
200
222
  `);
201
223
  signalChildTree("SIGTERM");
202
224
  escalation = setTimeout(() => {
203
225
  signalChildTree("SIGKILL");
204
- finish({ status: 1, output, timedOut: true, timeoutMs: Number(timeoutMs) });
226
+ finish({ status: 1, output: captured.toString(), timedOut: true, timeoutMs: Number(timeoutMs) });
205
227
  }, 1e3);
206
228
  }, Number(timeoutMs)) : void 0;
207
- const tee = (chunk) => {
208
- const text = chunk.toString("utf8");
209
- output += text;
210
- process.stderr.write(text);
229
+ const decoders = [new StringDecoder("utf8"), new StringDecoder("utf8")];
230
+ const tee = (chunk, stdout = false) => {
231
+ if (spawnOptions.capture !== false) captured.append(decoders[stdout ? 0 : 1].write(chunk));
232
+ recordCommandOutput(chunk);
233
+ if (spawnOptions.forward !== "none") {
234
+ (stdout && spawnOptions.forward === "original" ? process.stdout : process.stderr).write(chunk);
235
+ }
211
236
  };
212
- child.stdout?.on("data", tee);
237
+ child.stdout?.on("data", (chunk) => tee(chunk, true));
213
238
  child.stderr?.on("data", tee);
214
239
  child.on("error", (error) => {
215
240
  const leaf = isNodeScript ? path.basename(args[0]) : path.basename(script);
216
241
  const code = error.code ?? "ESPAWN";
217
242
  const message = `leaf could not start: ${leaf} (${code})
218
243
  Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
219
- process.stderr.write(`${message}
244
+ if (spawnOptions.forward !== "none") process.stderr.write(`${message}
220
245
  `);
221
- finish({ status: 1, output: message });
246
+ finish({ status: 1, output: message, error });
222
247
  });
223
248
  child.on("exit", () => {
224
249
  if (forwardedParentSignal) {
@@ -227,13 +252,15 @@ function spawnScriptStreaming(script, args, cwd, options) {
227
252
  }
228
253
  if (!didTimeout) return;
229
254
  signalChildTree("SIGKILL");
230
- finish({ status: 1, output, timedOut: true, timeoutMs: Number(timeoutMs) });
255
+ finish({ status: 1, output: captured.toString(), timedOut: true, timeoutMs: Number(timeoutMs) });
231
256
  });
232
257
  child.on("close", (status) => {
258
+ if (status !== null) untrack?.();
259
+ if (spawnOptions.capture !== false) captured.append(decoders.map((decoder) => decoder.end()).join(""));
233
260
  if (didTimeout) signalChildTree("SIGKILL");
234
261
  finish({
235
262
  status: didTimeout ? 1 : status ?? 1,
236
- output,
263
+ output: captured.toString(),
237
264
  ...didTimeout ? { timedOut: true, timeoutMs: Number(timeoutMs) } : {}
238
265
  });
239
266
  });
@@ -118,6 +118,7 @@ function renderTaskView(view, title, explicitTask = false) {
118
118
  last.ageMs !== void 0 && `${formatDuration(last.ageMs)} ago`
119
119
  ]));
120
120
  }
121
+ lines.push(...renderOperations(view));
121
122
  lines.push("");
122
123
  if (!view.activity.ongoing) {
123
124
  lines.push(out("warn", "no ongoing task detected"));
@@ -190,6 +191,12 @@ function changedLines(previous, next) {
190
191
  ];
191
192
  }
192
193
  const lines = [];
194
+ for (const operation of next.operations) {
195
+ const before2 = previous.operations.find((entry) => entry.id === operation.id);
196
+ if (!before2 || before2.status !== operation.status || before2.stage !== operation.stage || before2.ownerAlive !== operation.ownerAlive) {
197
+ lines.push(`operation ${operation.command} ${operation.status}${operation.stage ? ` \xB7 ${operation.stage}` : ""} \xB7 log ${operation.logPath}`);
198
+ }
199
+ }
193
200
  for (const step of next.prepare?.steps ?? []) {
194
201
  if (!previous.prepare?.steps.some((prior) => prior.id === step.id && prior.status === step.status)) {
195
202
  lines.push(`prepare ${step.id} ${step.status} (${formatDuration(step.durationMs)})`);
@@ -285,6 +292,22 @@ function shortUdid(udid) {
285
292
  function joinParts(parts, separator = " \xB7 ") {
286
293
  return parts.filter((part) => Boolean(part)).join(separator);
287
294
  }
295
+ function renderOperations(view) {
296
+ const active = view.operations.filter((operation) => operation.status === "running" && operation.ownerAlive);
297
+ const selected = active.length ? active : view.operations.slice(-1);
298
+ return selected.flatMap((operation) => {
299
+ const elapsed = Date.parse(operation.finishedAt ?? (/* @__PURE__ */ new Date()).toISOString()) - Date.parse(operation.startedAt);
300
+ const state = operation.status === "running" ? operation.ownerAlive ? "owner alive" : "owner exited; outcome unknown" : operation.status;
301
+ const age = operation.lastOutputAt ? formatDuration(Date.now() - Date.parse(operation.lastOutputAt)) + " ago" : "none recorded";
302
+ const quoted = "'" + operation.logPath.replace(/'/g, "'\\''") + "'";
303
+ return [
304
+ `${operation.parentId ? " " : ""}operation ${operation.command} \xB7 ${state} \xB7 ${formatDuration(elapsed)} elapsed`,
305
+ ...operation.stage ? [` stage ${operation.stage}`] : [],
306
+ ` last output ${age}`,
307
+ ` follow: tail -n 50 -F ${quoted}`
308
+ ];
309
+ });
310
+ }
288
311
  export {
289
312
  changedLines,
290
313
  formatDuration,
@@ -215,7 +215,7 @@ function gitSourceFingerprint(topLevel, pathspecs, head) {
215
215
  }
216
216
  function sourcePathspecs(git, excludedRoots, includedRoots) {
217
217
  const exclusions = excludedRoots.flatMap((root) => {
218
- const relative = path.relative(git.topLevel, path.resolve(root));
218
+ const relative = path.relative(git.topLevel, fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root));
219
219
  return relative !== ".." && !relative.startsWith(`..${path.sep}`) ? [`:(exclude)${relative}`] : [];
220
220
  });
221
221
  const inclusions = includedRoots.length > 0 ? includedRoots.map((root) => path.join(git.pathspec === "." ? "" : git.pathspec, root)) : [git.pathspec];
package/dist/harness.js CHANGED
@@ -1,7 +1,8 @@
1
- import { execFileSync, spawnSync } from "node:child_process";
1
+ import { spawnScriptStreaming } from "./commands/shared.js";
2
+ import { execFileSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
- import { resolveLeafInvoke, shellLeafMissing, missingShellLeafMessage } from "./leaf-invoke.js";
5
+ import { shellLeafMissing, missingShellLeafMessage } from "./leaf-invoke.js";
5
6
  import { resolveExtensionSlotPorts } from "./adapters/slot-ports.js";
6
7
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "./paths.js";
7
8
  import { colorHumanMessage } from "./cli-color.js";
@@ -367,15 +368,10 @@ async function handleHarness(argv) {
367
368
  }
368
369
  return 1;
369
370
  }
370
- const { bin: dispatchBin, args: dispatchArgs } = resolveLeafInvoke(dispatch.command, [
371
- ...dispatch.prefixArgs,
372
- ...forwardArgs
373
- ]);
374
- const result = spawnSync(dispatchBin, dispatchArgs, {
375
- // Default: stream child output verbatim (byte-identical to the skill path).
376
- // --json: route child output to our stderr so stdout carries only the summary.
377
- stdio: quiet ? "ignore" : json ? ["inherit", 2, "inherit"] : "inherit",
378
- env: process.env
371
+ const result = await spawnScriptStreaming(dispatch.command, [...dispatch.prefixArgs, ...forwardArgs], target, {
372
+ forward: quiet ? "none" : json ? "stderr" : "original",
373
+ stdin: quiet ? "ignore" : "inherit",
374
+ capture: false
379
375
  });
380
376
  if (result.error) {
381
377
  if (quiet) {
@@ -1,3 +1,4 @@
1
+ import { recordCommandStage } from "./command-journal.js";
1
2
  class JsonStreamWriter {
2
3
  enabled;
3
4
  command;
@@ -32,9 +33,11 @@ class JsonStreamWriter {
32
33
  }));
33
34
  }
34
35
  phase(phase, fields = {}) {
36
+ recordCommandStage(phase);
35
37
  this.emit("phase", { phase, ...fields });
36
38
  }
37
39
  node(nodeId, action, status) {
40
+ if (status === "running") recordCommandStage(`${nodeId}: ${action}`);
38
41
  this.emit("node", { nodeId, action, status });
39
42
  }
40
43
  mutation(mutation) {
@@ -1,3 +1,5 @@
1
+ import { recordCommandOutput } from "./command-journal.js";
2
+ import { trackCheckoutChild } from "./checkout-lock.js";
1
3
  import { createHash } from "node:crypto";
2
4
  import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
3
5
  import { existsSync, readFileSync } from "node:fs";
@@ -95,6 +97,7 @@ function runProcess(command, args, options) {
95
97
  env: options.env,
96
98
  stdio: [options.stdin == null ? "ignore" : "pipe", "pipe", "pipe"]
97
99
  });
100
+ if (child.pid) trackCheckoutChild(options.cwd, child.pid);
98
101
  if (options.stdin != null) {
99
102
  child.stdin.on("error", (error) => {
100
103
  if (error.code === "EPIPE" || settled) return;
@@ -107,9 +110,11 @@ function runProcess(command, args, options) {
107
110
  let stderr = "";
108
111
  child.stdout.on("data", (chunk) => {
109
112
  stdout += chunk;
113
+ recordCommandOutput(chunk);
110
114
  });
111
115
  child.stderr.on("data", (chunk) => {
112
116
  stderr += chunk;
117
+ recordCommandOutput(chunk);
113
118
  });
114
119
  const timeout = options.timeoutMs ? setTimeout(() => {
115
120
  if (settled) return;
@@ -245,6 +245,8 @@ Example:
245
245
  Example:
246
246
  mm-harness checklist mark temp/tasks/recipe-cook/<task> start
247
247
  mm-harness checklist mark temp/tasks/recipe-cook/<task> 1
248
+ mm-harness checklist mark temp/tasks/recipe-cook/<task> sub start qa-cases --step 2 --from inputs/qa-cases.md
249
+ mm-harness checklist mark temp/tasks/recipe-cook/<task> sub qa-cases 1
248
250
  mm-harness checklist mark temp/tasks/recipe-cook/<task> complete --mark-last
249
251
  mm-harness checklist closeout temp/tasks/recipe-cook/<task> --share`
250
252
  },
@@ -296,6 +298,7 @@ Example:
296
298
  Task:
297
299
  --title <t> Task title (required)
298
300
  --task-text <s> | --task-file <p> Task description, inline or from a file
301
+ --acceptance <criterion> Authored acceptance criterion (repeatable, preserved in order)
299
302
  --ticket <key> Ticket key
300
303
  --source-ref <url> Where the task came from
301
304
 
@@ -428,6 +431,9 @@ Example:
428
431
  mm-harness actions.
429
432
 
430
433
  --list List actions accepted by call for this adapter; no <action> needed
434
+ --domain <name> With --list: filter by action domain, e.g. perps or swap-bridge
435
+ --source <name> With --list: filter by origin, e.g. metamask, perps, or official
436
+ --sort <domain|name|library> With --list: grouping/order (default: domain; JSON: name)
431
437
  --arg k=v Action field value (repeatable; equivalent to key=value shorthand)
432
438
  --device <udid|serial|name> Mobile only: target this device (sets IOS_SIMULATOR / ADB_SERIAL and updates agentic-runtime.json). Without it, >1 connected mobile device fails fast and lists them. Do not shut down other devices to force selection.
433
439
  --cdp-port <port> Extension only: bind the action to this live browser runtime.
@@ -462,6 +468,9 @@ Remote feature flags (mobile applies at runtime; extension pins at launch):
462
468
 
463
469
  Discovery:
464
470
  --list List complete recipes accepted by run for this adapter; no <recipe> needed
471
+ --domain <name> With --list: filter by recipe domain, e.g. perps or swap-bridge
472
+ --source <name> With --list: filter by library origin, e.g. metamask or perps
473
+ --sort <domain|name|library> With --list: grouping/order (default: domain; JSON: name)
465
474
  --describe Show one recipe's parameters, composed actions/recipes, and runnable command
466
475
  --plan Validate + print execution plan, touching nothing. Exit 5 if invalid.
467
476
  --proof Preflight behavioral proof bindings before planning/execution; runtime evidence is still required.
@@ -506,7 +515,7 @@ Example:
506
515
  replace it, and an interrupted process remains recorded as verdict=running.
507
516
 
508
517
  --target <path> Checkout path (default: cwd)
509
- --runtime-dir <dir> Runtime dir containing last-command.json
518
+ --runtime-dir <dir> Runtime dir containing command operation records
510
519
  --json Machine-readable envelope
511
520
 
512
521
  Example:
package/dist/task-view.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readOperations, processIdentity } from "@farmslot/recipe-harness/runtime/operation";
1
2
  import { spawnSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -31,7 +32,12 @@ function findTaskDir(target) {
31
32
  return latestTaskDir(path.join(target, "temp", "tasks"));
32
33
  }
33
34
  function collectTaskView(target, taskDir, now) {
35
+ target = fs.existsSync(target) ? fs.realpathSync(target) : path.resolve(target);
36
+ taskDir = taskDir && (fs.existsSync(taskDir) ? fs.realpathSync(taskDir) : path.resolve(taskDir));
37
+ const taskIsRunning = taskDir && readJsonObject(path.join(taskDir, WORKER_SIGNAL_FILE))?.status === "running";
38
+ const operations = readOperations(path.join(target, recipeRuntimeDir(), "operations")).filter((operation) => !taskIsRunning || path.resolve(operation.logPath).startsWith(path.resolve(taskDir, "artifacts", "operations") + path.sep)).map((operation) => ({ ...operation, ownerAlive: operation.status === "running" && processIdentity(operation.pid) === operation.processStartedAt }));
34
39
  const base = {
40
+ operations,
35
41
  checkout: readCheckout(target),
36
42
  isolation: readIsolation(target),
37
43
  libraries: parseLibraryPathEnv(process.env.RECIPE_LIBRARY_PATH).map(
@@ -77,7 +83,8 @@ function collectTaskView(target, taskDir, now) {
77
83
  parseTime(pickString(signal, "timestamp")),
78
84
  ...readMarkEvents(signal).map((event) => Date.parse(event.checkedAt)),
79
85
  commandCounts ? commandAt : void 0,
80
- prepare ? parseTime(prepare.updatedAt) : void 0
86
+ prepare ? parseTime(prepare.updatedAt) : void 0,
87
+ ...operations.flatMap((operation) => [parseTime(operation.lastOutputAt), parseTime(operation.stageStartedAt), parseTime(operation.startedAt)])
81
88
  ],
82
89
  branch: base.checkout.branch,
83
90
  ticket,
@@ -149,6 +149,43 @@ bash tests/contract/run.sh
149
149
  Runtime changes also require real affected-platform QA and a packed installation
150
150
  test. Update `CHANGELOG.md` under `Unreleased` for user-visible behavior.
151
151
 
152
+ When upgrading the protocol or runtime, align their transitive consumers before
153
+ pinning them here. Farmslot's `workspace:*` dependencies become exact versions
154
+ when packed, so `agent-runtime` and `expo-recipe` can retain older copies until
155
+ republished. Verify the complete install with
156
+ `bash tests/contract/packaging-dist-install.test.sh` before releasing the harness.
157
+
152
158
  Docs describe only current behavior. History belongs in the changelog and Git;
153
159
  do not add handovers, audits, speculative roadmaps, or duplicate command guides
154
160
  under `docs/`.
161
+
162
+ ## Long command observations
163
+
164
+ `mm-harness status --watch` shows command ownership, stage, elapsed time, last
165
+ output, and a `tail -n 50 -F` command for each active operation. Records and logs
166
+ live under `temp/recipe/runtime/operations/`. During a running task, records and
167
+ logs also appear under its `artifacts/operations/` directory for Command Center.
168
+
169
+ Use `mm-harness last --json` to resolve the current journal path. Each invocation
170
+ writes its own journal; new commands no longer write `last-command.json`. The
171
+ reader still accepts that legacy file when no operation records exist. A running
172
+ parent remains visible while its child or a rejected competing command finishes.
173
+
174
+ An owner process being alive, recent log output, and a stage change are separate
175
+ observations. None proves application readiness. Reading status or following logs
176
+ does not refresh activity or take the checkout mutation lock.
177
+
178
+ The checkout keeps the latest 100 completed invocation records. Task-local mirrors
179
+ remain with their task artifacts. Streaming leaves keep full output on disk;
180
+ commands that need captured output for classification keep a bounded tail.
181
+ `fixtures sync --json` streams progress to stderr while reserving stdout for JSON.
182
+ Interactive harness leaves inherit stdin; their stdout/stderr are piped for logs,
183
+ so child TTY formatting may differ from a direct launch.
184
+
185
+ Streaming leaves run in tracked process groups. Ordinary interrupt, termination,
186
+ hangup and quit signals are forwarded. Non-timed leaves may finish their cleanup
187
+ without a new forced-kill deadline. SIGKILL cannot be forwarded: if the caller's
188
+ group is forcibly killed, its detached build may survive and the checkout remains
189
+ reserved until that recorded group exits. Inspect the recorded group before
190
+ manual cleanup. Group-ID reuse can conservatively retain an old crash lock; the
191
+ lock's recovery hint describes what to inspect.
package/docs/RECIPES.md CHANGED
@@ -542,12 +542,19 @@ selected deterministically from `recipes/<adapter>/<domain>/`. Identical
542
542
  cross-adapter graphs live once in `recipes/shared/<domain>/`; an adapter-specific
543
543
  variant takes precedence. Both resolve as `<domain>.<recipe>`, without a platform
544
544
  or `shared` prefix. Keep domains below `core`, `extension`, `mobile`, or `shared`.
545
- Legacy
546
- `*.<adapter>.recipe.json` paths remain readable during migration, but new and
547
- migrated libraries use the adapter-first layout. A file cannot declare its
545
+
546
+ Legacy `*.<adapter>.recipe.json` paths remain readable during migration, but new
547
+ and migrated libraries use the adapter-first layout. A file cannot declare its
548
548
  adapter through both forms. Every run records the root recipe, exact resolved
549
549
  dependency documents, their digests, call edges, selected sources, and shadows.
550
550
 
551
+ `mm-harness run --list` and `mm-harness call --list` group recipes/actions by domain
552
+ and show their library origins separately.
553
+ Use `--domain swap-bridge` or `--source perps` to filter, and `--sort name|domain|library`
554
+ to change the order. These options also apply to `--json` output, which keeps name
555
+ ordering unless `--sort` is supplied. Domain filters accept hyphens or underscores,
556
+ so `--domain swap-bridge` includes `metamask.swap_bridge.*` actions.
557
+
551
558
  ## Direct product access
552
559
 
553
560
  The bundled library is intentionally small. A task may use a verified controller
@@ -159,6 +159,18 @@ export async function navigateOpaqueMobile(input, destination) {
159
159
  }
160
160
  }
161
161
 
162
+ // Release builds may omit the mode toggle when Pro is disabled. The distinct
163
+ // visible market screen still proves its mode; a chooser or conflicting screen does not.
164
+ export function visiblePerpsModeScreen(ids, mode) {
165
+ const expectedScreen = mode === 'pro' ? IDS.proMarket : IDS.market;
166
+ const oppositeScreen = mode === 'pro' ? IDS.market : IDS.proMarket;
167
+ const expectedToggle = mode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
168
+ const oppositeToggle = mode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
169
+ if (ids.has(IDS.modeLite) || ids.has(IDS.modePro) || ids.has(oppositeScreen) || ids.has(oppositeToggle)) return undefined;
170
+ if (ids.has(expectedScreen)) return expectedScreen;
171
+ return ids.has(expectedToggle) ? expectedToggle : undefined;
172
+ }
173
+
162
174
  export async function ensureVisibleAndroidPerpsMode(input, perpsMode) {
163
175
  if (!['lite', 'pro'].includes(perpsMode)) {
164
176
  throw new Error('Opaque Mobile Perps mode must be lite or pro.');
@@ -218,7 +230,6 @@ export async function ensureVisibleAndroidPerpsMode(input, perpsMode) {
218
230
 
219
231
  try {
220
232
  const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
221
- const expectedToggle = perpsMode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
222
233
  const currentToggle = perpsMode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
223
234
  const option = perpsMode === 'pro' ? IDS.modePro : IDS.modeLite;
224
235
  let expectedStreak = 0;
@@ -240,12 +251,13 @@ export async function ensureVisibleAndroidPerpsMode(input, perpsMode) {
240
251
  await new Promise((resolve) => setTimeout(resolve, 250));
241
252
  continue;
242
253
  }
243
- if (ids.has(expectedToggle)) {
254
+ const observedScreen = visiblePerpsModeScreen(ids, perpsMode);
255
+ if (observedScreen) {
244
256
  expectedStreak += 1;
245
257
  if (expectedStreak >= 2) {
246
258
  return {
247
259
  mode: perpsMode,
248
- observedScreen: expectedToggle,
260
+ observedScreen,
249
261
  provider: 'android-native-accessibility',
250
262
  proofPath: 'visible-native-navigation',
251
263
  };
@@ -294,7 +306,6 @@ export async function ensureVisibleIosPerpsMode(input, perpsMode) {
294
306
  if (!['lite', 'pro'].includes(perpsMode)) {
295
307
  throw new Error('iOS Perps mode must be lite or pro.');
296
308
  }
297
- const expectedToggle = perpsMode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
298
309
  const currentToggle = perpsMode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
299
310
  const option = perpsMode === 'pro' ? IDS.modePro : IDS.modeLite;
300
311
  const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
@@ -324,12 +335,13 @@ export async function ensureVisibleIosPerpsMode(input, perpsMode) {
324
335
  const items = observed.observations?.['ui.visible']?.items ?? [];
325
336
  const ids = new Set(items.map((item) => String(item.test_id ?? '')));
326
337
  lastObserved = [...ids].filter(Boolean).slice(0, 20);
327
- if (ids.has(expectedToggle)) {
338
+ const observedScreen = visiblePerpsModeScreen(ids, perpsMode);
339
+ if (observedScreen) {
328
340
  expectedStreak += 1;
329
341
  if (expectedStreak >= 2) {
330
342
  return {
331
343
  mode: perpsMode,
332
- observedScreen: expectedToggle,
344
+ observedScreen,
333
345
  provider: 'ios-native-accessibility',
334
346
  proofPath: 'visible-native-navigation',
335
347
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.67.1",
3
+ "version": "0.69.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -20,11 +20,11 @@
20
20
  "check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
21
21
  },
22
22
  "dependencies": {
23
- "@farmslot/agent-runtime": "0.15.0",
24
- "@farmslot/expo-recipe": "0.12.1",
23
+ "@farmslot/agent-runtime": "0.15.1",
24
+ "@farmslot/expo-recipe": "0.12.2",
25
25
  "@farmslot/handoff": "^0.3.1",
26
- "@farmslot/protocol": "0.32.0",
27
- "@farmslot/recipe-harness": "0.20.0",
26
+ "@farmslot/protocol": "0.33.0",
27
+ "@farmslot/recipe-harness": "0.21.1",
28
28
  "agent-device": "0.19.3",
29
29
  "commander": "^12.0.0",
30
30
  "es-module-lexer": "2.3.1",
@@ -44,8 +44,8 @@ _mmh_flags_for() {
44
44
  execution-template) printf '%s' "--dir --domain-dir --project-worker --project-name --package-templates --package-id --flow --run-mode --platform --domain --id --provenance --include-shadowed --no-include-shadowed --title --force --json" ;;
45
45
  recipe-quality) printf '%s' "--input --output --recipe --task --evidence --params --adapter --library --advisor --advisor-model --advisor-stages --json" ;;
46
46
  actions) printf '%s' "--adapter --action --kind --raw --action-manifest --target --json" ;;
47
- call) printf '%s' "--list --arg --adapter --target --artifacts-dir --action-manifest --heal --json" ;;
48
- run) printf '%s' "--list --describe --plan --adapter --artifacts-dir --target --library --cdp-port --heal --record-video --json" ;;
47
+ call) printf '%s' "--list --domain --source --sort --arg --adapter --target --artifacts-dir --action-manifest --heal --json" ;;
48
+ run) printf '%s' "--list --domain --source --sort --describe --plan --adapter --artifacts-dir --target --library --cdp-port --heal --record-video --json" ;;
49
49
  doctor) printf '%s' "--adapter --target --fix --json" ;;
50
50
  install|verify|cleanup) printf '%s' "--adapter --target --json" ;;
51
51
  *) printf '%s' "" ;;