@deeeed/metamask-harness 0.67.1 → 0.68.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.
@@ -12,6 +12,7 @@ const MARK_SCRIPT = require2.resolve(
12
12
  const { resolveChecklistPaths } = require2(
13
13
  "@farmslot/agent-runtime/scripts/checklist-target.cjs"
14
14
  );
15
+ const { readSubtaskIndex, SubtaskRefusal } = require2("@farmslot/agent-runtime/scripts/subtask-unit.cjs");
15
16
  const USAGE = "mm-harness checklist <mark <task-dir> <step> | closeout <task-dir>> [options]";
16
17
  const CHECKLIST_VALUE_OPTIONS = /* @__PURE__ */ new Set([
17
18
  "--reason",
@@ -129,10 +130,10 @@ function checklistStepRequiresDiffPass(checklist, stepNumber) {
129
130
  const step = enumerateChecklistCheckboxes(checklist).find((item) => item.stepNumber === stepNumber);
130
131
  return step !== void 0 && /mm-harness check diff/u.test(step.rawLabel);
131
132
  }
132
- function checkDiffGateReady(taskDir, step) {
133
+ function checkDiffGateReady(taskDir, step, parentChecklist) {
133
134
  const stepNumber = numericMarkStep(step);
134
135
  if (stepNumber === null) return true;
135
- const { taskPath } = resolveChecklistPaths(taskDir);
136
+ const taskPath = parentChecklist ? path.join(taskDir, parentChecklist) : resolveChecklistPaths(taskDir).taskPath;
136
137
  let checklist = "";
137
138
  try {
138
139
  checklist = fs.readFileSync(taskPath, "utf8");
@@ -203,6 +204,16 @@ async function handleChecklist(argv) {
203
204
  return EXIT.usage;
204
205
  }
205
206
  if (preserveTerminalSignal(resolvedTaskDir, step)) return EXIT.ok;
207
+ if (step === "sub" && forwarded[2] === "complete") {
208
+ try {
209
+ const unit = readSubtaskIndex(resolvedTaskDir)?.units.find((unit2) => unit2.id === forwarded[1]);
210
+ if (unit && !checkDiffGateReady(resolvedTaskDir, String(unit.parent.stepNumber), unit.parent.checklist)) return EXIT.runtime;
211
+ } catch (error) {
212
+ if (!(error instanceof SubtaskRefusal)) throw error;
213
+ console.error(error.message);
214
+ return error.code;
215
+ }
216
+ }
206
217
  if (!checkDiffGateReady(resolvedTaskDir, step)) return EXIT.runtime;
207
218
  teachRecipeCompletion(resolvedTaskDir, step);
208
219
  const result = spawnSync(
@@ -559,13 +559,12 @@ async function fixturesFinalize(adapter, target, options, json) {
559
559
  }
560
560
  return exitCode;
561
561
  }
562
- function fixturesSync(adapter, target, json) {
562
+ async function fixturesSync(adapter, target, json) {
563
563
  const mmHarnessBin = path.join(runnerDir, "bin/mm-harness");
564
- const installResult = spawnScript(
564
+ const installResult = await spawnScriptStreaming(
565
565
  mmHarnessBin,
566
566
  ["install", "--adapter", adapter, "--target", target],
567
- target,
568
- json
567
+ target
569
568
  );
570
569
  if (installResult.status !== 0) {
571
570
  if (!json) {
@@ -578,7 +577,7 @@ function fixturesSync(adapter, target, json) {
578
577
  const syncArgs = ["--target", target];
579
578
  if (process.env.CDP_PORT) syncArgs.push("--cdp-port", process.env.CDP_PORT);
580
579
  if (process.env.RECIPE_SLOT_ID) syncArgs.push("--slot-id", process.env.RECIPE_SLOT_ID);
581
- const syncResult = spawnScript(syncFixtureSh, syncArgs, target, json);
580
+ const syncResult = await spawnScriptStreaming(syncFixtureSh, syncArgs, target);
582
581
  if (syncResult.status !== 0 || fixtureSummary(target, adapter).status !== "ready") {
583
582
  if (!json) console.error("\u2717 no usable wallet fixture source was found.\n Next: mm-harness fixtures init --from <path> or mm-harness fixtures init --dev");
584
583
  return EXIT.runtime;
@@ -1,4 +1,7 @@
1
+ import { acquireCheckoutLock, trackCheckoutChild } from "../checkout-lock.js";
2
+ import { recordCommandStage } from "../command-journal.js";
1
3
  import { spawn } from "node:child_process";
4
+ import { OperationOutputTail } from "@farmslot/recipe-harness/runtime/operation";
2
5
  import fs from "node:fs";
3
6
  import path from "node:path";
4
7
  import {
@@ -168,18 +171,19 @@ function spawnStep(executable, args, target) {
168
171
  // stream a recorded readiness step captures.
169
172
  env: { ...process.env, MM_HARNESS_NO_UPDATE_CHECK: "1" }
170
173
  });
174
+ if (child.pid) trackCheckoutChild(target, child.pid);
171
175
  let stdout = "";
172
- let stderr = "";
176
+ const stderr = new OperationOutputTail();
173
177
  child.stdout?.setEncoding("utf8");
174
178
  child.stderr?.setEncoding("utf8");
175
179
  child.stdout?.on("data", (chunk) => {
176
180
  stdout += chunk;
177
181
  });
178
182
  child.stderr?.on("data", (chunk) => {
179
- stderr += chunk;
183
+ stderr.append(chunk);
180
184
  });
181
- child.on("error", (error) => resolve({ error, status: null, stdout, stderr }));
182
- child.on("close", (status) => resolve({ status, stdout, stderr }));
185
+ child.on("error", (error) => resolve({ error, status: null, stdout, stderr: stderr.toString() }));
186
+ child.on("close", (status) => resolve({ status, stdout, stderr: stderr.toString() }));
183
187
  });
184
188
  }
185
189
  async function runStep(executable, target, artifactsDir, id, args, { json, onSpawn }) {
@@ -305,6 +309,22 @@ function resolvePlatform(requested, target) {
305
309
  );
306
310
  }
307
311
  async function handlePrepare(argv) {
312
+ const { options } = parseArgs(argv, "prepare");
313
+ const target = targetPath(options);
314
+ if (!fs.existsSync(target)) return handlePrepareLocked(argv);
315
+ const lock = acquireCheckoutLock(target, "prepare");
316
+ if ("message" in lock) {
317
+ if (optionFlag(options, "json")) console.log(JSON.stringify({ status: "fail", error: { code: "SANDBOX_BUSY", message: lock.message }, exitCode: EXIT.runtime }));
318
+ else console.error(`${lock.message} Next: inspect the owning operation with mm-harness status --watch`);
319
+ return EXIT.runtime;
320
+ }
321
+ try {
322
+ return await handlePrepareLocked(argv);
323
+ } finally {
324
+ lock.release();
325
+ }
326
+ }
327
+ async function handlePrepareLocked(argv) {
308
328
  const { options } = parseArgs(argv, "prepare");
309
329
  const json = optionFlag(options, "json");
310
330
  const target = targetPath(options);
@@ -372,6 +392,7 @@ async function handlePrepare(argv) {
372
392
  const step = await runStep(executable, target, artifactsDir, id, args, {
373
393
  json,
374
394
  onSpawn: (stepStartedAt) => {
395
+ recordCommandStage(id);
375
396
  activeStep = { id, startedAt: new Date(stepStartedAt).toISOString() };
376
397
  writeProgress();
377
398
  view?.start(id, stepStartedAt);
@@ -20,7 +20,8 @@ import {
20
20
  import {
21
21
  importRecipeHarness,
22
22
  importRecipeProtocol,
23
- runnerDir
23
+ runnerDir,
24
+ recipeRuntimePath
24
25
  } from "../paths.js";
25
26
  import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
26
27
  import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
@@ -277,7 +278,11 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
277
278
  recipeDocument: recipeDocument ?? recipe,
278
279
  ...absoluteRecipePath ? { recipePath: absoluteRecipePath } : {},
279
280
  ...librarySources ? { librarySources } : {},
280
- excludedProductRoots: [absoluteArtifactsDir],
281
+ excludedProductRoots: [
282
+ absoluteArtifactsDir,
283
+ recipeRuntimePath(projectRoot, "operations"),
284
+ recipeRuntimePath(projectRoot, "lock-members")
285
+ ],
281
286
  helperPaths: commandHelperPaths(recipeDocument ?? recipe, projectRoot)
282
287
  };
283
288
  const startProvenance = await captureExecutionProvenance(
@@ -416,6 +421,7 @@ function runtimeRecipeDocument(recipe, absoluteRecipePath) {
416
421
  function recipeProcessEnvironment() {
417
422
  const env = { ...process.env };
418
423
  delete env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
424
+ delete env.MM_HARNESS_OPERATION_ID;
419
425
  return env;
420
426
  }
421
427
  function executionLibrarySources(sources, invocationTrust) {
@@ -1,4 +1,5 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { checkoutBusyOut, spawnScriptStreaming } from "./shared.js";
2
+ import { acquireCheckoutLock } from "../checkout-lock.js";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import {
@@ -23,7 +24,18 @@ import {
23
24
  usageError
24
25
  } from "./parse-args.js";
25
26
  import { FeatureFlagSpecError, parseRemoteFlagSpec } from "../feature-flags.js";
26
- async function handleRuntimeLaunch({ options }) {
27
+ async function handleRuntimeLaunch(args) {
28
+ const target = targetPath(args.options);
29
+ if (optionFlag(args.options, "help") || !fs.existsSync(target)) return handleRuntimeLaunchLocked(args);
30
+ const lock = acquireCheckoutLock(target, "runtime-launch");
31
+ if ("message" in lock) return checkoutBusyOut(optionFlag(args.options, "json"), "runtime-launch", lock.message, lock.path);
32
+ try {
33
+ return await handleRuntimeLaunchLocked(args);
34
+ } finally {
35
+ lock.release();
36
+ }
37
+ }
38
+ async function handleRuntimeLaunchLocked({ options }) {
27
39
  if (optionFlag(options, "help")) {
28
40
  console.log(`mm-harness runtime-launch --adapter <extension|mobile> --target <checkout> [flags]
29
41
 
@@ -186,11 +198,11 @@ async function handleRuntimeLaunch({ options }) {
186
198
  cdpPort
187
199
  }));
188
200
  }
189
- const result = spawnSync(command[0], command.slice(1), {
190
- cwd: target,
191
- encoding: "utf8",
192
- maxBuffer: 5 * 1024 * 1024,
193
- stdio: jsonMode ? ["ignore", "pipe", "pipe"] : "inherit"
201
+ const result = await spawnScriptStreaming(command[0], command.slice(1), target, {
202
+ forward: jsonMode ? "stderr" : "original",
203
+ stdin: jsonMode ? "ignore" : "inherit",
204
+ capture: false,
205
+ stage: mode
194
206
  });
195
207
  const summaryPath = path.join(artifactsDir, "summary.json");
196
208
  const summary = readJsonIfExists(summaryPath);
@@ -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
  },
@@ -506,7 +508,7 @@ Example:
506
508
  replace it, and an interrupted process remains recorded as verdict=running.
507
509
 
508
510
  --target <path> Checkout path (default: cwd)
509
- --runtime-dir <dir> Runtime dir containing last-command.json
511
+ --runtime-dir <dir> Runtime dir containing command operation records
510
512
  --json Machine-readable envelope
511
513
 
512
514
  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.
@@ -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
  };