premanmcp 0.10.1 → 0.10.2

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/README.md CHANGED
@@ -48,13 +48,19 @@ to go restart anything, in cheapest-first order:
48
48
  1. **Self-test.** It starts the MCP server exactly as your agent will and calls
49
49
  `preman_status` over stdio. That both completes the link and proves the whole chain —
50
50
  launcher, package, key, backend. `--no-self-test` turns it off.
51
- 2. **Headless agent run** (`claude -p`, `cursor-agent -p`, `codex exec`), which also
52
- proves your agent can load what was written. `--no-auto-checkin` turns it off.
51
+ 2. **Agent run**, which also proves your agent can load what was written. It opens your
52
+ agent interactively in a new terminal window the session you go on to use — and falls
53
+ back to a headless run (`claude -p`, `cursor-agent -p`, `codex exec`) where no window can
54
+ be opened, such as CI or SSH. `--no-auto-checkin` turns it off, `PREMAN_NO_TERMINAL=1`
55
+ keeps it headless.
53
56
  3. **Wait**, if neither is possible: restart your agent and it links on its first call.
54
57
 
55
58
  A self-test that answers from an unexpected backend is reported with the file that
56
59
  redirected it — a repo-local `preman-mcp.config.json` with `"PREMAN_CONFIG_OVERRIDE": true`
57
- wins over the MCP config env, and otherwise only fills in what the env leaves unset.
60
+ wins over the MCP config env, and otherwise only fills in what the env leaves unset. When
61
+ that file overrides `PREMAN_BACKEND`, `connect` stops there rather than waiting: an agent
62
+ started in that directory reads the same file and checks in somewhere else, so it names the
63
+ file and tells you how to connect to either backend.
58
64
 
59
65
  Once linked, `connect` finishes onboarding without handing you homework:
60
66
 
package/bin/connect.js CHANGED
@@ -509,6 +509,9 @@ export async function waitForConnection(
509
509
  intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
510
510
  timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
511
511
  stopWhen = null,
512
+ // Called once per unsuccessful poll, so a wait measured in minutes can show
513
+ // that it is still a wait rather than a hang.
514
+ onPoll = null,
512
515
  } = {}
513
516
  ) {
514
517
  const deadline = Date.now() + timeoutMs;
@@ -525,6 +528,7 @@ export async function waitForConnection(
525
528
  });
526
529
  if (status.ok && status.connected) return true;
527
530
  if (stopWhen && stopWhen()) return false;
531
+ if (onPoll) onPoll();
528
532
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
529
533
  }
530
534
  } finally {
@@ -533,6 +537,22 @@ export async function waitForConnection(
533
537
  return false;
534
538
  }
535
539
 
540
+ /** A dot per poll, and the newline that closes the run of them. */
541
+ function pollTicker() {
542
+ let dots = 0;
543
+ return {
544
+ tick() {
545
+ dots += 1;
546
+ process.stdout.write(".");
547
+ },
548
+ end() {
549
+ if (!dots) return;
550
+ dots = 0;
551
+ process.stdout.write("\n");
552
+ },
553
+ };
554
+ }
555
+
536
556
  // ── Auto check-in ───────────────────────────────────────────────────────
537
557
 
538
558
  /**
@@ -544,7 +564,7 @@ export async function waitForConnection(
544
564
  * prompting.
545
565
  */
546
566
  export function headlessCheckIn(agent, serverName) {
547
- const prompt = `Call the ${serverName} MCP tool preman_status and report the result.`;
567
+ const prompt = checkInPrompt(serverName);
548
568
  if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
549
569
  if (agent.id === "claude_code") {
550
570
  return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
@@ -553,13 +573,122 @@ export function headlessCheckIn(agent, serverName) {
553
573
  return null;
554
574
  }
555
575
 
576
+ function checkInPrompt(serverName) {
577
+ return `Call the ${serverName} MCP tool preman_status and report the result.`;
578
+ }
579
+
580
+ /**
581
+ * How to start each agent as the session the user actually works in.
582
+ *
583
+ * Print mode (`-p`, `codex exec`) answers once and exits: it is a batch call, not
584
+ * the agent anybody goes on to use, and its failures are invisible because
585
+ * nobody is looking at it. The interactive form is the same prompt without that
586
+ * flag — it needs a terminal of its own, which is what openInNewTerminal is for.
587
+ */
588
+ export function interactiveCheckIn(agent, serverName) {
589
+ const prompt = checkInPrompt(serverName);
590
+ if (agent.id === "cursor") return { bin: "cursor-agent", args: [prompt] };
591
+ if (agent.id === "claude_code") {
592
+ return { bin: "claude", args: ["--allowedTools", `mcp__${serverName}`, prompt] };
593
+ }
594
+ if (agent.id === "codex") return { bin: "codex", args: [prompt] };
595
+ return null;
596
+ }
597
+
598
+ /** A command line for a spec, quoted for the shell the terminal will start. */
599
+ export function commandLine(spec) {
600
+ return [spec.bin, ...spec.args].map(shellQuote).join(" ");
601
+ }
602
+
603
+ /** Escape a shell line into an AppleScript string literal. */
604
+ function appleScriptString(value) {
605
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
606
+ }
607
+
608
+ /**
609
+ * The terminal emulator to open a window in, or null when we know of none.
610
+ *
611
+ * `sync` marks the launchers that hand the work to an app and return — those
612
+ * report their own failure through an exit code, which is worth waiting for.
613
+ * The rest *are* the window, so they are spawned detached and outlive us.
614
+ */
615
+ function terminalLauncher(line) {
616
+ if (process.platform === "darwin") {
617
+ const iterm = existsSync("/Applications/iTerm.app");
618
+ const script = iterm
619
+ ? `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${appleScriptString(line)}\nend tell`
620
+ : `tell application "Terminal"\nactivate\ndo script ${appleScriptString(line)}\nend tell`;
621
+ return { bin: "osascript", args: ["-e", script], label: iterm ? "iTerm" : "Terminal", sync: true };
622
+ }
623
+
624
+ if (process.platform === "win32") {
625
+ return {
626
+ bin: "cmd.exe",
627
+ args: ["/c", "start", "cmd.exe", "/k", line],
628
+ label: "Command Prompt",
629
+ sync: true,
630
+ };
631
+ }
632
+
633
+ // Keep the shell alive afterwards: an agent that exits immediately would
634
+ // otherwise take its own error message off the screen with it.
635
+ const body = `${line}; exec ${process.env.SHELL || "sh"}`;
636
+ const candidates = [
637
+ { bin: "x-terminal-emulator", args: ["-e", "sh", "-c", body], label: "terminal" },
638
+ { bin: "gnome-terminal", args: ["--", "sh", "-c", body], label: "GNOME Terminal" },
639
+ { bin: "konsole", args: ["-e", "sh", "-c", body], label: "Konsole" },
640
+ { bin: "xterm", args: ["-e", "sh", "-c", body], label: "xterm" },
641
+ ];
642
+ return candidates.find((candidate) => onPath(candidate.bin)) || null;
643
+ }
644
+
645
+ /**
646
+ * Open `command` in a new terminal window, best effort.
647
+ *
648
+ * Same contract as openUrl: never throws, and declines rather than guessing when
649
+ * nobody is watching — `PREMAN_NO_TERMINAL` for an explicit no, and a non-TTY
650
+ * stdout for CI, piped output and test runs. Callers fall back to a headless run.
651
+ */
652
+ export function openInNewTerminal(command, options = {}) {
653
+ const cwd = options?.cwd || process.cwd();
654
+ const optOut = (process.env.PREMAN_NO_TERMINAL || "").trim().toLowerCase();
655
+ if (optOut && !["0", "false", "no"].includes(optOut)) {
656
+ return { opened: false, reason: "PREMAN_NO_TERMINAL is set" };
657
+ }
658
+ if (!process.stdout.isTTY) return { opened: false, reason: "not running in a terminal" };
659
+
660
+ const launcher = terminalLauncher(`cd ${shellQuote(cwd)} && ${command}`);
661
+ if (!launcher) return { opened: false, reason: "no terminal emulator found" };
662
+
663
+ try {
664
+ if (launcher.sync) {
665
+ const done = spawnSync(launcher.bin, launcher.args, { stdio: "ignore", timeout: 20000 });
666
+ if (done.error) return { opened: false, reason: done.error.message };
667
+ if (done.status !== 0) {
668
+ return { opened: false, reason: `${launcher.bin} exited with code ${done.status}` };
669
+ }
670
+ } else {
671
+ const child = spawn(launcher.bin, launcher.args, { stdio: "ignore", detached: true });
672
+ child.unref();
673
+ }
674
+ return { opened: true, terminal: launcher.label };
675
+ } catch (error) {
676
+ return { opened: false, reason: error.message };
677
+ }
678
+ }
679
+
556
680
  /**
557
681
  * Finish the link ourselves instead of asking the user to go restart their agent.
558
682
  *
559
683
  * The config on disk is already correct at this point; all that is missing is
560
684
  * one call from the agent, and telling someone to make it from another terminal
561
- * is a dead end in the one terminal they are sitting in. So run the agent
562
- * headlessly and poll for the check-in it produces.
685
+ * is a dead end in the one terminal they are sitting in.
686
+ *
687
+ * So start the agent. Interactively, in a window of its own, because that is the
688
+ * session the user keeps: a print-mode run answers once, exits, and leaves them
689
+ * exactly where they started. Only when no window can be opened — CI, SSH, a
690
+ * machine with no terminal emulator — does this fall back to the headless run,
691
+ * which still finishes the link even though nobody sees it happen.
563
692
  *
564
693
  * Returns `ran: false` when the agent's binary is absent or will not start, and
565
694
  * the caller falls back to the printed instructions.
@@ -577,8 +706,23 @@ export async function autoCheckIn(
577
706
  ),
578
707
  serverName = "preman",
579
708
  intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
709
+ onLaunch = () => {},
710
+ onPoll = null,
580
711
  } = {}
581
712
  ) {
713
+ const session = interactiveCheckIn(agent, serverName);
714
+ if (session && onPath(session.bin)) {
715
+ const { opened, terminal } = openInNewTerminal(commandLine(session), { cwd: process.cwd() });
716
+ if (opened) {
717
+ onLaunch({ mode: "interactive", bin: session.bin, terminal });
718
+ // Detached, so there is no exit to watch for and no output to quote: the
719
+ // agent is in front of the user now, and the deadline is all that bounds
720
+ // this. Whoever is watching the window can Ctrl+C out of the wait.
721
+ const connected = await waitForConnection(args, apiKey, { intervalMs, timeoutMs, onPoll });
722
+ return { ran: true, connected, command: session.bin, interactive: true, terminal };
723
+ }
724
+ }
725
+
582
726
  const spec = headlessCheckIn(agent, serverName);
583
727
  if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
584
728
  if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
@@ -592,6 +736,7 @@ export async function autoCheckIn(
592
736
  } catch (error) {
593
737
  return { ran: false, connected: false, reason: error.message };
594
738
  }
739
+ onLaunch({ mode: "headless", bin: spec.bin });
595
740
 
596
741
  let spawnError = null;
597
742
  let exitedAt = 0;
@@ -618,6 +763,7 @@ export async function autoCheckIn(
618
763
  const connected = await waitForConnection(args, apiKey, {
619
764
  intervalMs,
620
765
  timeoutMs,
766
+ onPoll,
621
767
  stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
622
768
  });
623
769
  if (spawnError && !connected) {
@@ -1523,20 +1669,52 @@ export async function connectCommand(commandArgs) {
1523
1669
  await captureDispatchCredential(args, agent, apiKey);
1524
1670
  }
1525
1671
 
1672
+ /**
1673
+ * A repo config that sends every agent started here to a backend the key we just
1674
+ * wrote was not issued for, or null.
1675
+ *
1676
+ * Only an override can do this: without the flag the MCP config's env wins, and
1677
+ * a self-test that disagrees with it is a transient, not a redirect. It matters
1678
+ * because it is the one failure waiting cannot fix — the agent starts in the
1679
+ * same directory, reads the same file, and checks in somewhere else forever.
1680
+ */
1681
+ function backendRedirect(repo, status, serverConfig) {
1682
+ if (!repo?.override || !(repo.applied || []).includes("PREMAN_BACKEND")) return null;
1683
+ const trim = (url) => String(url || "").replace(/\/+$/, "");
1684
+ const actual = trim(status.backend_url);
1685
+ const wanted = trim(serverConfig?.env?.PREMAN_BACKEND);
1686
+ if (!actual || !wanted || actual === wanted) return null;
1687
+ return { path: repo.path, actual, wanted };
1688
+ }
1689
+
1526
1690
  /**
1527
1691
  * Finish the link here, by whatever means work, in cheapest-first order.
1528
1692
  *
1529
1693
  * 1. Run the MCP server ourselves and call one tool. No agent, no tokens, a few
1530
1694
  * seconds, and it proves the launcher/key/backend chain the agent will use.
1531
- * 2. Failing that, run the agent headlessly which also proves the agent can
1532
- * load the config we wrote.
1695
+ * 2. Failing that, start the agent — in its own window when there is a desktop
1696
+ * to open one on, headlessly otherwise — which also proves the agent can load
1697
+ * the config we wrote.
1533
1698
  * 3. Failing that, ask them to restart it and wait, which is all this ever did.
1534
1699
  *
1700
+ * Every diagnosis is printed the moment it is known rather than saved for the
1701
+ * end: the steps below are measured in minutes, and a note that explains what is
1702
+ * happening is worth nothing after it has stopped happening.
1703
+ *
1535
1704
  * Returns whether the check-in landed, and prints the troubleshooting block
1536
1705
  * itself when it did not.
1537
1706
  */
1538
1707
  async function establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }) {
1539
1708
  const notes = [];
1709
+ const ticker = pollTicker();
1710
+ const say = (text) => {
1711
+ ticker.end();
1712
+ process.stdout.write(text);
1713
+ };
1714
+ const note = (text) => {
1715
+ notes.push(text);
1716
+ say(`Note: ${text}\n`);
1717
+ };
1540
1718
 
1541
1719
  if (!args.has("--no-self-test") && serverConfig && selfTestBudgetMs() > 0) {
1542
1720
  process.stdout.write("\nChecking the connection…\n");
@@ -1546,61 +1724,81 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written, serv
1546
1724
  if (repo?.override && repo.applied?.length) {
1547
1725
  // The one failure that reads as a bad key: a working server talking to a
1548
1726
  // backend nobody chose. Name the file before it costs anyone an hour.
1549
- notes.push(
1727
+ note(
1550
1728
  `${repo.path} overrides ${repo.applied.join(", ")} for anything started in this directory, ` +
1551
1729
  `so your agent will use ${status.backend_url}.`
1552
1730
  );
1553
1731
  }
1554
1732
  if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
1555
- for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1556
1733
  return true;
1557
1734
  }
1558
- notes.push(
1735
+ note(
1559
1736
  test.ok
1560
1737
  ? `the MCP server answered from ${status.backend_url || "an unknown backend"} but was not authenticated`
1561
1738
  : `the MCP server could not be started (${test.reason || "unknown"}${test.stderr ? `: ${test.stderr}` : ""})`
1562
1739
  );
1740
+
1741
+ const redirect = backendRedirect(repo, status, serverConfig);
1742
+ if (redirect) {
1743
+ const flag = `--agent ${agent.id.replace("_", "-")}`;
1744
+ say(
1745
+ `\nNot linked: ${redirect.path} forces PREMAN_BACKEND=${redirect.actual} for anything\n` +
1746
+ `started in this directory, so ${agent.label} cannot check in against ${redirect.wanted}.\n` +
1747
+ ` - Run '${cliInvocation()} connect' from your own project instead of this directory.\n` +
1748
+ ` - Or connect to that backend: ${cliInvocation()} connect ${flag} --backend ${redirect.actual}\n` +
1749
+ ` (needs a key issued by it, and that API running).\n` +
1750
+ ` - Config written to: ${written.path}\n`
1751
+ );
1752
+ return false;
1753
+ }
1563
1754
  }
1564
1755
 
1565
1756
  const blocker = agentBlocker(agent.id);
1757
+ let launch = {};
1566
1758
  if (!args.has("--no-auto-checkin") && !blocker) {
1567
- const spec = headlessCheckIn(agent, serverName);
1568
- if (spec && onPath(spec.bin)) {
1569
- process.stdout.write(`\nStarting ${agent.label} to finish the link…\n`);
1570
- }
1571
- const auto = await autoCheckIn(args, agent, apiKey, { serverName });
1572
- if (auto.connected) {
1573
- for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1759
+ launch = await autoCheckIn(args, agent, apiKey, {
1760
+ serverName,
1761
+ onPoll: ticker.tick,
1762
+ onLaunch: ({ mode, terminal }) =>
1763
+ say(
1764
+ mode === "interactive"
1765
+ ? `\nOpened a new ${terminal} window running ${agent.label}.\n`
1766
+ : `\nStarting ${agent.label} to finish the link…\n`
1767
+ ),
1768
+ });
1769
+ if (launch.connected) {
1770
+ ticker.end();
1574
1771
  return true;
1575
1772
  }
1576
- if (auto.ran) {
1577
- const why = lastLine(auto.output);
1578
- process.stdout.write(
1579
- `${agent.label} ran but did not check in${why ? `: ${why}` : "."}\n`
1580
- );
1581
- } else if (auto.reason) {
1582
- process.stdout.write(`Could not run ${agent.label}: ${auto.reason}\n`);
1773
+ if (launch.ran && !launch.interactive) {
1774
+ const why = lastLine(launch.output);
1775
+ say(`${agent.label} ran but did not check in${why ? `: ${why}` : "."}\n`);
1776
+ } else if (!launch.ran && launch.reason) {
1777
+ say(`Could not run ${agent.label}: ${launch.reason}\n`);
1583
1778
  }
1584
1779
  } else if (blocker) {
1585
1780
  // Starting an agent that cannot authenticate spends two minutes to learn
1586
1781
  // what one status probe already knows.
1587
- process.stdout.write(`${MARK.skip()} ${blocker}\n`);
1782
+ say(`${MARK.skip()} ${blocker}\n`);
1588
1783
  notes.push(blocker);
1589
1784
  }
1590
1785
 
1591
- process.stdout.write(
1592
- `\nRestart ${agent.label} and ask it: "run preman_status"\n` +
1593
- "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
1786
+ say(
1787
+ launch.interactive
1788
+ ? `\nAnswer it in the ${agent.label} window it links on its first PreMan call.\n` +
1789
+ "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
1790
+ : `\nRestart ${agent.label} and ask it: "run preman_status"\n` +
1791
+ "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
1594
1792
  );
1595
1793
 
1596
- if (await waitForConnection(args, apiKey)) {
1597
- for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1794
+ if (await waitForConnection(args, apiKey, { onPoll: ticker.tick })) {
1795
+ ticker.end();
1598
1796
  return true;
1599
1797
  }
1600
1798
 
1601
- process.stdout.write(
1799
+ say(
1602
1800
  "No check-in yet. Troubleshooting:\n" +
1603
- notes.map((note) => ` - ${note}\n`).join("") +
1801
+ notes.map((entry) => ` - ${entry}\n`).join("") +
1604
1802
  ` - ${agent.restartHint}\n` +
1605
1803
  ` - Config written to: ${written.path}\n` +
1606
1804
  ` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +
package/bin/runner.js CHANGED
@@ -612,6 +612,32 @@ function defaultLog(message) {
612
612
  process.stdout.write(`[preman runner] ${new Date().toISOString()} ${message}\n`);
613
613
  }
614
614
 
615
+ /**
616
+ * One heartbeat. Never throws.
617
+ *
618
+ * It runs on a timer, and a rejected fetch inside a timer callback has nobody to
619
+ * catch it — Node turns that unhandled rejection into process exit, so a single
620
+ * connect timeout to the backend used to take the whole daemon down mid-run and
621
+ * leave a machine that reads as paired and never runs anything again. A missed
622
+ * heartbeat only costs one TTL window: the next one puts this runner back online.
623
+ */
624
+ export async function sendHeartbeat(args, state, { busy = false, log = () => {} } = {}) {
625
+ try {
626
+ // Reported honestly because the matcher prefers idle runners: a busy device
627
+ // claiming to be idle wins work it will only sit on until the lease expires.
628
+ const result = await callBackendJson(
629
+ args,
630
+ "POST",
631
+ "/workbench/coding-agent/local-runner/heartbeat",
632
+ { token: state.runner_token, json: { state: busy ? "busy" : "idle" } }
633
+ );
634
+ return { revoked: result.status_code === 401 };
635
+ } catch (error) {
636
+ log(`heartbeat failed: ${error.message}`);
637
+ return { revoked: false };
638
+ }
639
+ }
640
+
615
641
  /**
616
642
  * Hold the stream and run what it leases, until stopped.
617
643
  *
@@ -633,17 +659,12 @@ export async function runnerLoop(
633
659
  `${state.backend_url || backendUrl(args)}/`
634
660
  );
635
661
 
636
- const heartbeat = setInterval(async () => {
637
- // Reported honestly because the matcher prefers idle runners: a busy device
638
- // claiming to be idle wins work it will only sit on until the lease expires.
639
- const result = await callBackendJson(args, "POST", "/workbench/coding-agent/local-runner/heartbeat", {
640
- token: state.runner_token,
641
- json: { state: busy ? "busy" : "idle" },
642
- });
643
- if (result.status_code === 401) {
662
+ const heartbeat = setInterval(() => {
663
+ void sendHeartbeat(args, state, { busy, log }).then(({ revoked }) => {
664
+ if (!revoked) return;
644
665
  log("runner registration is no longer active; stopping");
645
666
  stopped = true;
646
- }
667
+ });
647
668
  }, HEARTBEAT_MS);
648
669
 
649
670
  try {
@@ -842,6 +863,16 @@ async function startForeground(args, commandArgs) {
842
863
  process.on("SIGINT", () => void shutdown("SIGINT"));
843
864
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
844
865
 
866
+ // A daemon that dies must not leave its pid file behind claiming otherwise.
867
+ // Nothing is swallowed: the reason is logged and the exit code says it failed.
868
+ const die = (kind) => (error) => {
869
+ log(`${kind}: ${error?.stack || error}`);
870
+ rmSync(RUNNER_PID_FILE, { force: true });
871
+ process.exit(1);
872
+ };
873
+ process.on("uncaughtException", die("uncaught exception"));
874
+ process.on("unhandledRejection", die("unhandled rejection"));
875
+
845
876
  try {
846
877
  const result = await runnerLoop(args, state, {
847
878
  log,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "open-mcp-preview-in-cursor": "node scripts/open-cursor-preview.mjs",
16
16
  "test": "npm run build && npm run test:connect && npm run test:node",
17
17
  "test:connect": "node scripts/smoke-connect.mjs",
18
- "test:node": "node --test --test-timeout=30000 scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.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"
18
+ "test:node": "node --test --test-timeout=30000 scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-local-detect.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"
19
19
  },
20
20
  "dependencies": {
21
21
  "@modelcontextprotocol/ext-apps": "^0.1.0",