premanmcp 1.1.6 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/agent.js CHANGED
@@ -829,6 +829,11 @@ export function createDock({
829
829
  // old rows before painting new ones, and after the event they are no longer
830
830
  // derivable from the terminal -- it has already reflowed.
831
831
  let painted = null;
832
+ // Where the transcript has got to, in screen coordinates. The terminal keeps
833
+ // this too, in its saved cursor, and that copy is the one the writes use --
834
+ // but a resize destroys it, so it is mirrored here to be put back. Null means
835
+ // nobody knows: before `enable`, and after any stretch the dock did not paint.
836
+ let at = null;
832
837
 
833
838
  const size = () => ({
834
839
  columns: stream.columns || 80,
@@ -838,6 +843,82 @@ export function createDock({
838
843
  /** Last row the transcript may use; everything below belongs to the dock. */
839
844
  const floor = () => Math.max(1, size().rows - DOCK_ROWS);
840
845
 
846
+ /**
847
+ * Follow the transcript's cursor through a chunk.
848
+ *
849
+ * The terminal is tracking the same thing in its saved cursor and does it
850
+ * perfectly, so this exists for one moment only: a resize, after which that
851
+ * saved position no longer means anything and the dock has to name a row to
852
+ * carry on from. It used to name the floor, which is why a session that had
853
+ * printed ten lines onto a fifty-row window jumped to the bottom of it and
854
+ * scrolled the banner away to say the eleventh.
855
+ *
856
+ * Only the finals that move the cursor are read. The erases and the colour
857
+ * runs that make up most of a transcript leave it exactly where it was, and
858
+ * a sequence this does not recognise is likelier to be one of those than a
859
+ * jump -- so the unknown case is "no movement" rather than a guess.
860
+ */
861
+ function advance(chunk) {
862
+ if (!at) return;
863
+ const { columns } = size();
864
+ const limit = floor();
865
+ let { row, column } = at;
866
+ // At the floor the region scrolls under the cursor rather than moving it:
867
+ // everything already printed goes up a row and the cursor stays put.
868
+ const down = () => {
869
+ if (row < limit) row += 1;
870
+ };
871
+ const text = String(chunk);
872
+ for (let i = 0; i < text.length; i += 1) {
873
+ const ch = text[i];
874
+ if (ch === "\u001b") {
875
+ const next = text[i + 1];
876
+ if (next === "[") {
877
+ let j = i + 2;
878
+ while (j < text.length && !/[@-~]/.test(text[j])) j += 1;
879
+ const params = text.slice(i + 2, j);
880
+ const final = text[j];
881
+ if (final === "H" || final === "f") {
882
+ const [r, c] = params.split(";");
883
+ row = Math.min(Math.max(Number(r) || 1, 1), limit);
884
+ column = Math.min(Math.max(Number(c) || 1, 1), columns);
885
+ } else if (final === "G") {
886
+ column = Math.min(Math.max(Number(params) || 1, 1), columns);
887
+ }
888
+ i = j;
889
+ continue;
890
+ }
891
+ if (next === "]") {
892
+ while (i < text.length && text[i] !== "\u0007") i += 1;
893
+ continue;
894
+ }
895
+ i += 1;
896
+ continue;
897
+ }
898
+ if (ch === "\r") {
899
+ column = 1;
900
+ continue;
901
+ }
902
+ if (ch === "\n") {
903
+ // ONLCR: stdout to a terminal turns a bare newline into CR+LF, so the
904
+ // column goes back to one. Modelling it as a pure index down would
905
+ // stair-step every line of the transcript to the right.
906
+ column = 1;
907
+ down();
908
+ continue;
909
+ }
910
+ if (ch === "\u0007") continue;
911
+ // Deferred wrap, the way a terminal does it: the glyph in the last column
912
+ // leaves the cursor on that column, and the *next* one moves the line on.
913
+ if (column > columns) {
914
+ column = 1;
915
+ down();
916
+ }
917
+ column += 1;
918
+ }
919
+ at = { row, column };
920
+ }
921
+
841
922
  /**
842
923
  * Is there a window here to dock in at all?
843
924
  *
@@ -983,16 +1064,32 @@ export function createDock({
983
1064
  return {
984
1065
  live,
985
1066
 
986
- /** Reserve the bottom rows and remember where the transcript is. */
987
- enable() {
1067
+ /**
1068
+ * Reserve the bottom rows and remember where the transcript is.
1069
+ *
1070
+ * `row` is where the transcript has got to on a screen the caller knows the
1071
+ * state of -- a session that has just cleared the screen and printed a
1072
+ * banner knows exactly that. Without it the screen is assumed to be full,
1073
+ * which is the honest reading when the dock is coming back up after
1074
+ * something else owned the terminal: scroll to make room for the furniture
1075
+ * and carry on at the floor.
1076
+ */
1077
+ enable({ row } = {}) {
988
1078
  if (!live || open) return;
989
1079
  open = true;
990
1080
  listen();
991
1081
  stream.write(SET_TITLE);
1082
+ // Whatever a previous life left here says nothing about this screen.
1083
+ at = null;
992
1084
  if (!roomy()) return;
993
- stream.write("\n".repeat(DOCK_ROWS));
1085
+ if (row == null) {
1086
+ stream.write("\n".repeat(DOCK_ROWS));
1087
+ at = { row: floor(), column: 1 };
1088
+ } else {
1089
+ at = { row: Math.min(Math.max(1, row), floor()), column: 1 };
1090
+ }
994
1091
  stream.write(`\u001b[1;${floor()}r`);
995
- stream.write(`\u001b[${floor()};1H`);
1092
+ stream.write(`\u001b[${at.row};${at.column}H`);
996
1093
  stream.write("\u001b7");
997
1094
  frame();
998
1095
  },
@@ -1023,12 +1120,18 @@ export function createDock({
1023
1120
  */
1024
1121
  write(chunk) {
1025
1122
  if (!open || !roomy()) {
1123
+ // Nothing is following the cursor down a screen the dock does not own,
1124
+ // so whatever was tracked is now a guess. Said rather than kept: a
1125
+ // window that grows back is better off assuming the transcript filled
1126
+ // it than resuming at a row from before it went blind.
1127
+ at = null;
1026
1128
  stream.write(chunk);
1027
1129
  return;
1028
1130
  }
1029
1131
  // One write rather than three: the restore, the chunk and the save are a
1030
1132
  // single sequence to the terminal, so nothing can land between them.
1031
1133
  stream.write(`\u001b8${chunk}\u001b7`);
1134
+ advance(chunk);
1032
1135
  repaint();
1033
1136
  },
1034
1137
 
@@ -1096,10 +1199,18 @@ export function createDock({
1096
1199
  painted = null;
1097
1200
  if (!roomy()) return;
1098
1201
  stream.write(`\u001b[1;${floor()}r`);
1099
- // The transcript's saved position, re-anchored rather than replaced. A
1100
- // mid-line `delta` resumed at column 1 of the floor row before this, so a
1101
- // sentence in flight during a window drag was orphaned.
1102
- stream.write(`\u001b[${floor()};1H`);
1202
+ // Where the transcript actually is, clamped into the new window -- not
1203
+ // the floor, which is what this used to say. Naming the floor meant every
1204
+ // drag of a window edge moved the transcript to the bottom of it, so the
1205
+ // next line printed scrolled the region and the banner climbed a row
1206
+ // towards the top and off. On a session that had barely started, that is
1207
+ // the whole screen going blank above a reply pinned to the bottom.
1208
+ const { columns } = size();
1209
+ at = {
1210
+ row: Math.min(Math.max(1, at?.row ?? floor()), floor()),
1211
+ column: Math.min(Math.max(1, at?.column ?? 1), columns),
1212
+ };
1213
+ stream.write(`\u001b[${at.row};${at.column}H`);
1103
1214
  stream.write("\u001b7");
1104
1215
  frame();
1105
1216
  },
@@ -1122,6 +1233,7 @@ export function createDock({
1122
1233
  }
1123
1234
  if (!open) return;
1124
1235
  open = false;
1236
+ at = null;
1125
1237
  const { rows } = size();
1126
1238
  stream.write("\u001b[r");
1127
1239
  stream.write(`\u001b[${rows};1H\u001b[2K`);
@@ -1650,8 +1762,6 @@ export async function agentCommand(commandArgs = [], { authenticate = authentica
1650
1762
 
1651
1763
  if (fullScreen) process.stdout.write(`${ENTER_ALT}${CLEAR_SCREEN}`);
1652
1764
 
1653
- process.stdout.write(banner({ paint, workspace, backend: backendUrl(args), conversation }));
1654
-
1655
1765
  const dock = createDock({
1656
1766
  stream: process.stdout,
1657
1767
  paint,
@@ -1660,7 +1770,15 @@ export async function agentCommand(commandArgs = [], { authenticate = authentica
1660
1770
  const prompt = `${paint.caret("\u276f")} `;
1661
1771
  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt });
1662
1772
  dock.attach(rl);
1663
- dock.enable();
1773
+ // The screen was just cleared, so the transcript starts at the top of it and
1774
+ // the dock is told so. The banner then goes through the dock rather than
1775
+ // around it, which is what keeps the two in step: printing it to stdout first
1776
+ // and docking afterwards left the transcript anchored at the bottom of the
1777
+ // window, eleven blank rows below a banner that the first line of the first
1778
+ // reply then scrolled a row closer to the top.
1779
+ dock.enable(fullScreen ? { row: 1 } : undefined);
1780
+
1781
+ dock.write(banner({ paint, workspace, backend: backendUrl(args), conversation }));
1664
1782
  const onResize = () => dock.resize();
1665
1783
  process.stdout.on("resize", onResize);
1666
1784
 
@@ -51,6 +51,32 @@ async function verifyKey(args, apiKey) {
51
51
  return { pairCode: "", pairingId: "", stale: result.status_code === 401 };
52
52
  }
53
53
 
54
+ /**
55
+ * Redeem the pair code from this terminal.
56
+ *
57
+ * The link used to be closed by the agent calling `preman_status`, but the MCP
58
+ * server is a pure bridge now and defines no such tool, so nothing has made this
59
+ * call since. This terminal holds both halves anyway — the key and the code it
60
+ * just minted — so it closes the link itself rather than waiting for a call that
61
+ * no longer happens. Failure is not fatal: the config is already written.
62
+ */
63
+ export async function redeemPairCode(args, agent, apiKey, pairCode) {
64
+ if (!pairCode) return false;
65
+ const result = await callBackendJson(args, "POST", "/workbench/coding-agent/heartbeat", {
66
+ token: apiKey,
67
+ json: {
68
+ pair_code: pairCode,
69
+ agent: agent.id,
70
+ project_path: process.cwd(),
71
+ source: "premanmcp",
72
+ },
73
+ });
74
+ if (!result.ok) {
75
+ process.stdout.write(`Note: could not close the link (${result.status_code}).\n`);
76
+ }
77
+ return Boolean(result.ok);
78
+ }
79
+
54
80
  /** Whichever of the two ways to learn the backend still accepts this key. */
55
81
  export async function checkKeyAndPair(args, agent, apiKey) {
56
82
  return args.has("--no-pair")
package/bin/connect.js CHANGED
@@ -49,7 +49,7 @@ import {
49
49
  renderCodexToml,
50
50
  verifyWrittenConfig,
51
51
  } from "./connect/configs.js";
52
- import { checkKeyAndPair, refreshStaleCredentials } from "./connect/pairing.js";
52
+ import { checkKeyAndPair, redeemPairCode, refreshStaleCredentials } from "./connect/pairing.js";
53
53
  import {
54
54
  autoCheckIn,
55
55
  lastLine,
@@ -258,6 +258,8 @@ export async function connectCommand(commandArgs) {
258
258
  );
259
259
  }
260
260
 
261
+ const redeemed = await redeemPairCode(args, agent, apiKey, pairCode);
262
+
261
263
  // A non-interactive run can still be handed the credential up front, so this
262
264
  // stays reachable; the prompt inside only fires when there is a TTY, and by
263
265
  // then onboarding is done.
@@ -273,6 +275,7 @@ export async function connectCommand(commandArgs) {
273
275
  serverConfig,
274
276
  projectInstall,
275
277
  pairingId,
278
+ preLinked: redeemed,
276
279
  });
277
280
  // The agent goes back to the caller because `onboard` runs steps after this one
278
281
  // that need to know which agent to drive, and asking twice is a question we
@@ -394,7 +397,7 @@ async function establishCheckIn(
394
397
  args,
395
398
  agent,
396
399
  apiKey,
397
- { serverName, written, serverConfig, projectInstall = false, pairingId = "" }
400
+ { serverName, written, serverConfig, projectInstall = false, pairingId = "", preLinked = false }
398
401
  ) {
399
402
  const notes = [];
400
403
  // Both set only when this directory redirects the agent: where it can be
@@ -402,6 +405,9 @@ async function establishCheckIn(
402
405
  let elsewhere = null;
403
406
  let blockedHere = "";
404
407
  const done = (linked) => ({ linked, blockedHere });
408
+ // Already linked by the terminal that wrote the config: nothing to self-test
409
+ // into, no window to open, and no check-in to wait for.
410
+ if (preLinked) return done(true);
405
411
  const ticker = pollTicker();
406
412
  const say = (text) => {
407
413
  ticker.end();
package/bin/eval.js CHANGED
@@ -470,6 +470,18 @@ export function runDir(cwd, job) {
470
470
  return path.join(cwd, "artifacts", "results", String(job.suite), String(job.run));
471
471
  }
472
472
 
473
+ /**
474
+ * Where the suite's own output lands — the parent of every run directory.
475
+ *
476
+ * Only ever joined with a name from `SUITE_ARTIFACTS`. The same directory also
477
+ * holds assert-ai's versioned `artifacts/<stage>/v0001/` tree, which nothing
478
+ * reads back and which a device walking the directory would start uploading;
479
+ * naming the files explicitly is what keeps that out.
480
+ */
481
+ export function suiteDir(cwd, job) {
482
+ return path.join(cwd, "artifacts", "results", String(job.suite));
483
+ }
484
+
473
485
  /**
474
486
  * The four stages as one activity list, current stage active, earlier ones done.
475
487
  *
@@ -522,6 +534,21 @@ export const RUN_ARTIFACTS = [
522
534
  "artifacts.json",
523
535
  ];
524
536
 
537
+ /**
538
+ * The files that describe the bank rather than one run.
539
+ *
540
+ * These are the cases themselves and the rubric they are scored against, and
541
+ * they live one level up from the run directory because every run of the suite
542
+ * shares them. Without them the store holds verdicts with no record of what was
543
+ * asked: the dashboard can say "case 3 failed" and cannot say what case 3 was.
544
+ *
545
+ * No scope is sent with an upload. The server derives it from the filename
546
+ * (`eval_runner_artifacts.scope_of`), files these under the suite rather than
547
+ * the run, and applies its own write-once rule — so a second run of the same
548
+ * suite offers them again and the server declines to rewrite history.
549
+ */
550
+ export const SUITE_ARTIFACTS = ["taxonomy.json", "test_set.jsonl"];
551
+
525
552
  /**
526
553
  * Upload the artifacts that changed since last time.
527
554
  *
@@ -536,10 +563,15 @@ export const RUN_ARTIFACTS = [
536
563
  * success and the next tick retries.
537
564
  */
538
565
  export async function syncArtifacts(job, cwd, sent, { call, log = () => {}, lease } = {}) {
539
- const dir = runDir(cwd, job);
566
+ // Both sets on the same tick, not the suite files at the end: `systematize`
567
+ // and `test_set` finish before the first case does, so a run that dies
568
+ // halfway would otherwise publish scores for cases it never published.
569
+ const sources = [
570
+ ...RUN_ARTIFACTS.map((name) => [name, path.join(runDir(cwd, job), name)]),
571
+ ...SUITE_ARTIFACTS.map((name) => [name, path.join(suiteDir(cwd, job), name)]),
572
+ ];
540
573
  let uploaded = 0;
541
- for (const name of RUN_ARTIFACTS) {
542
- const file = path.join(dir, name);
574
+ for (const [name, file] of sources) {
543
575
  let mark;
544
576
  try {
545
577
  const stat = statSync(file);
@@ -561,7 +593,18 @@ export async function syncArtifacts(job, cwd, sent, { call, log = () => {}, leas
561
593
  form.set("lease_token", lease);
562
594
  form.set("name", name);
563
595
  form.set("file", new Blob([body]), name);
564
- const result = await call(`/workbench/coding-agent/local-runner/evals/${job.id}/artifacts`, form);
596
+ // A refused upload and an unsendable one are the same situation to this
597
+ // loop -- the file is still on disk, unrecorded in `sent`, and the next
598
+ // tick will offer it again. They are not the same to `callBackendJson`,
599
+ // which answers the first and *throws* the second, so without this a
600
+ // backend that blinks mid-run takes the run's process down with it.
601
+ let result;
602
+ try {
603
+ result = await call(`/workbench/coding-agent/local-runner/evals/${job.id}/artifacts`, form);
604
+ } catch (error) {
605
+ log(`upload of ${name} could not be sent: ${error.message}; will retry`);
606
+ continue;
607
+ }
565
608
  if (result.status_code === 409) return { uploaded, lost: true };
566
609
  if (!result.ok) {
567
610
  log(`upload of ${name} answered ${result.status_code}; will retry`);
@@ -629,6 +672,39 @@ export function readProgress(cwd, job) {
629
672
  * Returns "" when there is nothing wrong, so the caller can tell "fine" from
630
673
  * "unreadable".
631
674
  */
675
+ /**
676
+ * What this run alone did, out of counters that belong to the whole invocation.
677
+ *
678
+ * The adapter is opened once and every run in the batch shares it, so its
679
+ * counters are lifetime totals -- which is the honest thing for them to be, and
680
+ * the wrong thing to hand a guard asking about one run. Read as totals they
681
+ * fail a run for the run before it: a batch where the first eval made one tool
682
+ * call and the second legitimately made none reported "the agent made 1 tool
683
+ * call(s) and none of them reached the transcript" against the second, quoting
684
+ * the first one's number. The same arithmetic hides the opposite fault --
685
+ * `reached` never returns to zero after any run reaches the agent, so a later
686
+ * run where every single turn failed cannot be caught at all.
687
+ *
688
+ * Subtracting rather than resetting: `adapterStats()` is the adapter's own
689
+ * lifetime record and other readers rely on it being exactly that. Zeroing
690
+ * shared state from inside one run would also be wrong the day two overlap.
691
+ *
692
+ * `lastFailure` is passed through rather than differenced. It is a string, and
693
+ * the most recent one is the useful one whenever `failures` moved at all.
694
+ */
695
+ export function statsSince(baseline, stats) {
696
+ if (!stats) return stats;
697
+ if (!baseline) return stats;
698
+ const since = (key) => Math.max(0, Number(stats[key] || 0) - Number(baseline[key] || 0));
699
+ return {
700
+ ...stats,
701
+ turns: since("turns"),
702
+ reached: since("reached"),
703
+ failures: since("failures"),
704
+ toolCalls: since("toolCalls"),
705
+ };
706
+ }
707
+
632
708
  export function missingToolEvidence(dir, stats) {
633
709
  const forwarded = Number(stats?.toolCalls);
634
710
  if (!Number.isFinite(forwarded) || forwarded <= 0) return "";
@@ -903,6 +979,10 @@ export async function executeEvalRun(
903
979
  // Before the harness starts, not after it ends: a run that dies mid-way would
904
980
  // otherwise leave its last steps to appear under whichever run came next.
905
981
  stepFeed.reset();
982
+ // Same reason, for the counters that cannot be reset because the adapter they
983
+ // belong to is shared with every other run in this batch. See `statsSince`.
984
+ const adapterBaseline = adapterStats();
985
+ const baselineAt = adapterBaseline ? { ...adapterBaseline } : null;
906
986
 
907
987
  const timeout = Math.min(RUN_TIMEOUT_MS, RUN_TIMEOUT_CAP_MS);
908
988
  const child = spawn(python.command, [...python.argv, HARNESS_PATH, "run", "--config", laid.configPath], {
@@ -965,6 +1045,15 @@ export async function executeEvalRun(
965
1045
  // before it spends an uplink on artifacts nobody will accept.
966
1046
  const synced = await syncArtifacts(job, laid.cwd, sent, { call, log, lease });
967
1047
  if (synced.lost) return lose("lease rejected an upload");
1048
+ } catch (error) {
1049
+ // Nothing this tick reports is worth the run for. It is liveness: the
1050
+ // stages and steps somebody watching sees, and a copy of artifacts the
1051
+ // final sync sends again anyway. Nobody awaits this callback, so an
1052
+ // escaping rejection is an *unhandled* one, and Node's answer to that
1053
+ // is to end the process -- killing a harness mid-stage, skipping the
1054
+ // completion callback, and leaving the run to sit until its lease
1055
+ // lapses. A logged tick and a live run is the better trade.
1056
+ log(`eval ${job.id}: progress tick failed (${error.message}); the run continues`);
968
1057
  } finally {
969
1058
  ticking = false;
970
1059
  }
@@ -1022,7 +1111,7 @@ export async function executeEvalRun(
1022
1111
  // Turns are counted rather than failures, because a run where the agent
1023
1112
  // answered some of the time is a real measurement of a flaky agent and
1024
1113
  // deciding otherwise here would throw away the finding.
1025
- const stats = adapterStats();
1114
+ const stats = statsSince(baselineAt, adapterStats());
1026
1115
  // `reached` rather than `turns`: the adapter counts a turn when it starts
1027
1116
  // one, so a run where every turn failed has as many turns as cases.
1028
1117
  if (stats && (stats.reached ?? stats.turns) === 0 && stats.failures > 0) {
package/bin/runner.js CHANGED
@@ -869,6 +869,19 @@ export async function runnerLoop(
869
869
  try {
870
870
  if (kind === "eval") await runLeasedEval(args, state, job, { log, headless });
871
871
  else await executeJob(args, state, job, { log, fullAccess });
872
+ } catch (error) {
873
+ // Attribution, not recovery. Both executors report their own
874
+ // failures and return; a throw escaping one is a bug in it, and
875
+ // letting it reach the stream's catch below labels that bug
876
+ // `stream dropped` -- blaming the network for something that
877
+ // happened on this machine, which is the wrong place to look and
878
+ // the reason the last one took all evening to find.
879
+ //
880
+ // Counted as an attempt below rather than halting the batch. A
881
+ // crash that stopped the loop silently is what the caller then
882
+ // reports as a finished run, which is the failure this is here
883
+ // to stop being invisible.
884
+ log(`${kind} ${job.id} failed unexpectedly: ${error?.stack || error.message}`);
872
885
  } finally {
873
886
  busy = false;
874
887
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "test": "npm run build && npm run test:proxy",
16
16
  "test:proxy": "node --test scripts/smoke-proxy.mjs",
17
17
  "test:connect": "node scripts/smoke-connect.mjs",
18
+ "test:connect-heartbeat": "node scripts/smoke-connect-heartbeat.mjs",
18
19
  "test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-agent-session.mjs scripts/smoke-agent-dock.mjs scripts/smoke-terminal-lifecycle.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-cli-update.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-eval.mjs scripts/smoke-eval-behaviors.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-onboard-opening.mjs scripts/smoke-local-detect.mjs scripts/smoke-agent-target.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs scripts/smoke-shared-errors.mjs scripts/smoke-process-title.mjs scripts/smoke-predict.mjs",
19
20
  "test:dmg": "node --test --test-timeout=300000 scripts/smoke-install-desktop-volume.mjs"
20
21
  },