@haven_ai/connect 0.1.33-alpha.0 → 0.1.35-alpha.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.
package/dist/cli.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import crypto from 'crypto';
2
+ import crypto, { createHash } from 'crypto';
3
3
  import { Wallet } from 'ethers';
4
4
  import { mkdir, rm, stat, readdir, readFile, writeFile, chmod, access, unlink, rename } from 'fs/promises';
5
5
  import { homedir, platform } from 'os';
6
6
  import { join, resolve, dirname, basename } from 'path';
7
7
  import { ensureConsent, computeConsentHash, loadCredentials, consentInputFromClient, registeredToolNames, MCP_VERSION } from '@haven_ai/mcp';
8
8
  import { ensureSignerConsent, computeSignerConsentHash, loadSignerCredentials, createEdgeSigner, toolSchemas } from '@haven_ai/signer';
9
- import { isSupportedNodeVersion, SKILL_FOLDER_NAME, resolveTokenFromAddress, HAVEN_SKILL_MD, HAVEN_SKILL_BODY_MD, HAVEN_MINIMUM_NODE_VERSION, unsupportedNodeVersionMessage } from '@haven_ai/sdk';
9
+ import { connectorRerunCommand, isSupportedNodeVersion, SKILL_FOLDER_NAME, resolveTokenFromAddress, HAVEN_SKILL_MD, HAVEN_SKILL_BODY_MD, HAVEN_MINIMUM_NODE_VERSION, unsupportedNodeVersionMessage } from '@haven_ai/sdk';
10
10
  import { parseDocument, isMap, stringify } from 'yaml';
11
- import { execFile, spawn } from 'child_process';
11
+ import { spawn, execFile } from 'child_process';
12
12
  import { promisify } from 'util';
13
13
  import { realpathSync } from 'fs';
14
14
  import { fileURLToPath, pathToFileURL } from 'url';
@@ -48,6 +48,7 @@ function createConnectApiClient(baseUrl, fetchImpl = fetch) {
48
48
  runtime: input.runtime,
49
49
  connector_version: input.connectorVersion,
50
50
  mcp_server_name: input.mcpServerName,
51
+ run_mode: input.runMode,
51
52
  connector_context: input.connectorContext,
52
53
  install_capabilities: input.installCapabilities && {
53
54
  can_write_runtime_config: input.installCapabilities.canWriteRuntimeConfig,
@@ -83,7 +84,21 @@ function createConnectApiClient(baseUrl, fetchImpl = fetch) {
83
84
  restart_required: input.restartRequired,
84
85
  next_user_action: input.nextUserAction,
85
86
  error_code: input.errorCode ?? null,
86
- environment_label: input.environmentLabel
87
+ environment_label: input.environmentLabel,
88
+ // Three states on the wire, and ABSENT is a fourth (#2561 review).
89
+ // `?? null` alone collapsed the fourth into the third: a report that
90
+ // simply had nothing to say — the early config-written ping, which
91
+ // fires before the scan has started — asserted "the scan could not
92
+ // run" instead of leaving the key alone. Inert today, because the
93
+ // complete report overwrites it seconds later and nothing reads the
94
+ // row in between; a landmine the moment any caller relies on
95
+ // "absent = unchanged", which is what the backend's jsonb merge
96
+ // means and what this field's own contract says.
97
+ //
98
+ // So: a caller that passes the field says something (`null` included,
99
+ // deliberately, since "could not run" is a claim worth making); a
100
+ // caller that omits it says nothing.
101
+ ...input.supersededAgentIds !== void 0 ? { superseded_agent_ids: input.supersededAgentIds } : {}
87
102
  })
88
103
  });
89
104
  }
@@ -528,9 +543,9 @@ var init_runtime_manifest = __esm({
528
543
  mcpPackage: "@haven_ai/mcp",
529
544
  mcpVersion: MCP_VERSION,
530
545
  sdkPackage: "@haven_ai/sdk",
531
- sdkVersion: "0.1.33-alpha.0",
546
+ sdkVersion: "0.1.35-alpha.0",
532
547
  signerPackage: "@haven_ai/signer",
533
- signerVersion: "0.1.33-alpha.0",
548
+ signerVersion: "0.1.35-alpha.0",
534
549
  // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
535
550
  // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
536
551
  // so the guard that was supposed to enforce the floor waved Node v23 through
@@ -896,7 +911,7 @@ async function writeJsonRuntimeConfig(input, target, serverRoot) {
896
911
  restartRequired: true,
897
912
  messages: unreadable ? [
898
913
  `Could not update ${configTargetLabel(input.runtime)}: ${err.message}.`,
899
- `Nothing was written to ${err.configPath}. Fix the JSON there (or move the file aside), then run \`${REPAIR_COMMAND_PREFIX} ${input.runtime}\` to write the Haven entries from the credentials already stored on this machine. Do not re-run the setup command: its token is already used.`
914
+ `Nothing was written to ${err.configPath}. Fix the JSON there (or move the file aside), then run \`${REPAIR_COMMAND_PREFIX} ${input.runtime}\` to write the Haven entries from the credentials already stored on this machine. Do not re-run the connector command: its setup token is already used.`
900
915
  ] : [`Could not update ${configTargetLabel(input.runtime)}: ${err instanceof Error ? err.message : String(err)}`],
901
916
  errorCode: unreadable ? "runtime_config_unreadable" : "runtime_config_write_failed"
902
917
  };
@@ -961,7 +976,7 @@ async function writeHermesConfig(input, deps) {
961
976
  restartRequired: true,
962
977
  messages: unreadable ? [
963
978
  `Could not update Hermes Agent config: ${err.message}.`,
964
- `Nothing was written to ${err.configPath}. Fix the YAML there (or move the file aside), then run \`${REPAIR_COMMAND_PREFIX} hermes\` to write the Haven entries from the credentials already stored on this machine. Do not re-run the setup command: its token is already used.`
979
+ `Nothing was written to ${err.configPath}. Fix the YAML there (or move the file aside), then run \`${REPAIR_COMMAND_PREFIX} hermes\` to write the Haven entries from the credentials already stored on this machine. Do not re-run the connector command: its setup token is already used.`
965
980
  ] : [recoveryIncomplete ? "Could not update Hermes Agent config. Recovery did not complete; inspect the Hermes configuration before retrying." : "Could not update Hermes Agent config. Existing configuration was left unchanged."],
966
981
  errorCode: unreadable ? "runtime_config_unreadable" : "runtime_config_write_failed"
967
982
  };
@@ -1351,7 +1366,7 @@ var init_config_writers = __esm({
1351
1366
  this.name = "InvalidCodexTomlError";
1352
1367
  }
1353
1368
  };
1354
- REPAIR_COMMAND_PREFIX = "npx @haven_ai/connect@alpha --doctor --repair --runtime";
1369
+ REPAIR_COMMAND_PREFIX = connectorRerunCommand("--doctor --repair --runtime");
1355
1370
  UnreadableRuntimeConfigError = class extends Error {
1356
1371
  configPath;
1357
1372
  constructor(configPath, detail) {
@@ -1601,32 +1616,129 @@ var init_probes = __esm({
1601
1616
  "src/probes.ts"() {
1602
1617
  }
1603
1618
  });
1619
+ function resolveRuntimeSpecOverride(env) {
1620
+ const override = {};
1621
+ for (const pkg of Object.keys(RUNTIME_SPEC_ENV)) {
1622
+ const variable = RUNTIME_SPEC_ENV[pkg];
1623
+ const raw = env[variable];
1624
+ if (raw === void 0) continue;
1625
+ override[pkg] = validateSpec(variable, raw);
1626
+ }
1627
+ return Object.keys(override).length > 0 ? override : void 0;
1628
+ }
1629
+ function validateSpec(variable, raw) {
1630
+ if (raw.length === 0 || raw.trim().length === 0) {
1631
+ throw new RuntimeSpecOverrideError(variable, "it is empty");
1632
+ }
1633
+ if (/\s/.test(raw)) {
1634
+ throw new RuntimeSpecOverrideError(variable, "it contains whitespace");
1635
+ }
1636
+ if (/[\u0000-\u001f\u007f]/.test(raw)) {
1637
+ throw new RuntimeSpecOverrideError(variable, "it contains a control character");
1638
+ }
1639
+ const meta = SHELL_METACHARACTERS.exec(raw);
1640
+ if (meta) {
1641
+ throw new RuntimeSpecOverrideError(variable, `it contains the shell metacharacter ${JSON.stringify(meta[0])}`);
1642
+ }
1643
+ return raw;
1644
+ }
1645
+ function runtimeSpecOverrideDirectoryKey(resolvedSpecs) {
1646
+ const digest = createHash("sha256").update(resolvedSpecs.join("\n")).digest("hex");
1647
+ return `override-${digest.slice(0, 12)}`;
1648
+ }
1649
+ function overrideApplies(override, packages) {
1650
+ return override !== void 0 && packages.some((pkg) => override[pkg] !== void 0);
1651
+ }
1652
+ function runtimeSpecOverrideNotice(runtimeLabel, override, pinned, runtimeDirectory) {
1653
+ const lines = [`RUNTIME SPEC OVERRIDE ACTIVE for the local Haven ${runtimeLabel} \u2014 this is NOT the pinned manifest.`];
1654
+ for (const pkg of Object.keys(RUNTIME_SPEC_ENV)) {
1655
+ const spec = override[pkg];
1656
+ if (spec === void 0) continue;
1657
+ const pin = pinned[pkg];
1658
+ lines.push(` ${RUNTIME_SPEC_ENV[pkg]}=${spec}${pin ? ` (instead of ${pin})` : ""}`);
1659
+ }
1660
+ lines.push(` Installing into ${runtimeDirectory} \u2014 the pinned runtime directory is untouched.`);
1661
+ lines.push(" Unset the variable(s) and re-run to return to the pinned manifest.");
1662
+ return lines;
1663
+ }
1664
+ function describeRuntimeSpecOverride(override) {
1665
+ return Object.keys(RUNTIME_SPEC_ENV).filter((pkg) => override[pkg] !== void 0).map((pkg) => `${RUNTIME_SPEC_ENV[pkg]}=${override[pkg]}`).join(" ");
1666
+ }
1667
+ var RUNTIME_SPEC_ENV, SHELL_METACHARACTERS, RuntimeSpecOverrideError;
1668
+ var init_runtime_spec_override = __esm({
1669
+ "src/runtime-spec-override.ts"() {
1670
+ RUNTIME_SPEC_ENV = {
1671
+ signer: "HAVEN_SIGNER_SPEC",
1672
+ sdk: "HAVEN_SDK_SPEC",
1673
+ mcp: "HAVEN_MCP_SPEC"
1674
+ };
1675
+ SHELL_METACHARACTERS = /[;&|<>$`'"()!#*?[\]{}\\]/;
1676
+ RuntimeSpecOverrideError = class extends Error {
1677
+ code = "runtime_spec_override_invalid";
1678
+ variable;
1679
+ constructor(variable, reason) {
1680
+ super(`${variable} is set but not usable as an npm package spec: ${reason}. Unset it, or set it to a spec npm install accepts (file:/abs/path, a .tgz, or @haven_ai/<pkg>@<version>).`);
1681
+ this.name = "RuntimeSpecOverrideError";
1682
+ this.variable = variable;
1683
+ }
1684
+ };
1685
+ }
1686
+ });
1604
1687
  async function prepareSignerRuntime(input, deps = {}) {
1605
1688
  const homeDir = input.homeDir ?? homedir();
1606
- const runtimeDirectory = resolve(homeDir, ".haven", "signer-runtime", MCP_RUNTIME_MANIFEST.signerVersion);
1689
+ const override = resolveSignerRuntimeOverride(deps.env ?? process.env);
1690
+ const signerSpec = override?.signer ?? signerPackageSpec();
1691
+ const sdkSpec = override?.sdk ?? sdkPackageSpec();
1692
+ const resolvedSpecs = [signerSpec, sdkSpec];
1693
+ const overrideRecord = override ? { specs: override, resolved_specs: resolvedSpecs, directory_key: runtimeSpecOverrideDirectoryKey(resolvedSpecs) } : void 0;
1694
+ const runtimeDirectory = resolve(
1695
+ homeDir,
1696
+ ".haven",
1697
+ "signer-runtime",
1698
+ overrideRecord ? overrideRecord.directory_key : MCP_RUNTIME_MANIFEST.signerVersion
1699
+ );
1607
1700
  const npmCacheDirectory = resolve(homeDir, ".haven", "npm-cache");
1608
1701
  const cliPath = join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "dist", "cli.js");
1609
1702
  const messages = [];
1703
+ if (override) {
1704
+ messages.push(...runtimeSpecOverrideNotice(
1705
+ "signer runtime",
1706
+ override,
1707
+ { signer: signerPackageSpec(), sdk: sdkPackageSpec() },
1708
+ runtimeDirectory
1709
+ ));
1710
+ }
1610
1711
  await mkdir(runtimeDirectory, { recursive: true, mode: 448 });
1611
1712
  await chmod(runtimeDirectory, 448).catch(() => void 0);
1612
1713
  await mkdir(npmCacheDirectory, { recursive: true, mode: 448 });
1613
1714
  await chmod(npmCacheDirectory, 448).catch(() => void 0);
1614
- if (await installedRuntimeMatches(runtimeDirectory, cliPath)) {
1715
+ if (override) {
1716
+ await installRuntimePackages(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
1717
+ messages.push(`Installed local Haven signer runtime from override (${resolvedSpecs.join(" ")}).`);
1718
+ } else if (await installedRuntimeMatches(runtimeDirectory, cliPath)) {
1615
1719
  messages.push(`Using existing local Haven signer runtime ${signerPackageSpec()}.`);
1616
1720
  } else {
1617
- await installRuntimePackages(runtimeDirectory, npmCacheDirectory, deps);
1721
+ await installRuntimePackages(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
1618
1722
  messages.push(`Installed local Haven signer runtime ${signerPackageSpec()}.`);
1619
1723
  }
1620
1724
  await assertFileExists(cliPath, "local Haven signer CLI");
1725
+ const installedVersions = override ? await readInstalledVersions(runtimeDirectory) : void 0;
1621
1726
  const wrapperPath = join(input.credentialDirectory, "bin", "haven-signer.mjs");
1622
- await writeWrapper({ wrapperPath, cliPath, signerPath: input.signerPath });
1727
+ await writeWrapper({
1728
+ wrapperPath,
1729
+ cliPath,
1730
+ signerPath: input.signerPath,
1731
+ overrideComment: override ? describeRuntimeSpecOverride(override) : void 0
1732
+ });
1623
1733
  await writeRuntimeSidecar({
1624
1734
  path: join(input.credentialDirectory, "signer-runtime.json"),
1625
1735
  wrapperPath,
1626
1736
  runtimeDirectory,
1627
1737
  npmCacheDirectory,
1628
1738
  cliPath,
1629
- serverName: input.serverName
1739
+ serverName: input.serverName,
1740
+ override: overrideRecord,
1741
+ installedVersions
1630
1742
  });
1631
1743
  messages.push(`Prepared stable local Haven signer wrapper: ${wrapperPath}`);
1632
1744
  return {
@@ -1636,10 +1748,17 @@ async function prepareSignerRuntime(input, deps = {}) {
1636
1748
  runtimeDirectory,
1637
1749
  npmCacheDirectory,
1638
1750
  cliPath,
1639
- messages
1751
+ messages,
1752
+ ...overrideRecord ? { runtimeSpecOverride: overrideRecord } : {}
1640
1753
  };
1641
1754
  }
1642
- async function installRuntimePackages(runtimeDirectory, npmCacheDirectory, deps) {
1755
+ function resolveSignerRuntimeOverride(env) {
1756
+ const override = resolveRuntimeSpecOverride(env);
1757
+ if (!overrideApplies(override, ["signer", "sdk"])) return void 0;
1758
+ const { signer, sdk } = override;
1759
+ return { ...signer !== void 0 ? { signer } : {}, ...sdk !== void 0 ? { sdk } : {} };
1760
+ }
1761
+ async function installRuntimePackages(runtimeDirectory, npmCacheDirectory, packageSpecs, deps) {
1643
1762
  const { runCommand, onProgress } = deps;
1644
1763
  const baseArgs = [
1645
1764
  "install",
@@ -1649,8 +1768,7 @@ async function installRuntimePackages(runtimeDirectory, npmCacheDirectory, deps)
1649
1768
  "--no-fund",
1650
1769
  "--omit=dev",
1651
1770
  "--prefer-offline",
1652
- signerPackageSpec(),
1653
- sdkPackageSpec()
1771
+ ...packageSpecs
1654
1772
  ];
1655
1773
  const run = async (args) => {
1656
1774
  const startedAt = Date.now();
@@ -1673,23 +1791,33 @@ async function installRuntimePackages(runtimeDirectory, npmCacheDirectory, deps)
1673
1791
  await run([...baseArgs, "--cache", npmCacheDirectory]);
1674
1792
  } catch (err) {
1675
1793
  throw new Error(
1676
- `Could not install local Haven signer runtime ${signerPackageSpec()}: ${err instanceof Error ? err.message : String(err)}`
1794
+ `Could not install local Haven signer runtime ${packageSpecs.join(" ")}: ${err instanceof Error ? err.message : String(err)}`
1677
1795
  );
1678
1796
  }
1679
1797
  }
1680
1798
  }
1681
1799
  async function installedRuntimeMatches(runtimeDirectory, cliPath) {
1800
+ return installedRuntimeMatchesVersions(runtimeDirectory, cliPath, {
1801
+ signerVersion: MCP_RUNTIME_MANIFEST.signerVersion,
1802
+ sdkVersion: MCP_RUNTIME_MANIFEST.sdkVersion
1803
+ });
1804
+ }
1805
+ async function installedRuntimeMatchesVersions(runtimeDirectory, cliPath, expected) {
1682
1806
  try {
1683
1807
  await assertFileExists(cliPath, "local Haven signer CLI");
1684
- const [signerPackage, sdkPackage] = await Promise.all([
1685
- readPackageJson(join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "package.json")),
1686
- readPackageJson(join(runtimeDirectory, "node_modules", "@haven_ai", "sdk", "package.json"))
1687
- ]);
1688
- return signerPackage.version === MCP_RUNTIME_MANIFEST.signerVersion && sdkPackage.version === MCP_RUNTIME_MANIFEST.sdkVersion;
1808
+ const installed = await readInstalledVersions(runtimeDirectory);
1809
+ return installed.signerVersion === expected.signerVersion && installed.sdkVersion === expected.sdkVersion;
1689
1810
  } catch {
1690
1811
  return false;
1691
1812
  }
1692
1813
  }
1814
+ async function readInstalledVersions(runtimeDirectory) {
1815
+ const [signerPackage, sdkPackage] = await Promise.all([
1816
+ readPackageJson(join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "package.json")),
1817
+ readPackageJson(join(runtimeDirectory, "node_modules", "@haven_ai", "sdk", "package.json"))
1818
+ ]);
1819
+ return { signerVersion: signerPackage.version ?? "", sdkVersion: sdkPackage.version ?? "" };
1820
+ }
1693
1821
  async function readPackageJson(path) {
1694
1822
  return JSON.parse(await readFile(path, "utf8"));
1695
1823
  }
@@ -1698,6 +1826,7 @@ async function writeWrapper(input) {
1698
1826
  await chmod(dirname(input.wrapperPath), 448).catch(() => void 0);
1699
1827
  const source = [
1700
1828
  "#!/usr/bin/env node",
1829
+ ...input.overrideComment ? [`// HAVEN RUNTIME SPEC OVERRIDE (#2424): ${input.overrideComment} \u2014 this wrapper launches a NON-pinned signer build.`] : [],
1701
1830
  "import { spawn } from 'node:child_process'",
1702
1831
  "",
1703
1832
  `const cliPath = ${JSON.stringify(input.cliPath)}`,
@@ -1729,13 +1858,14 @@ async function writeRuntimeSidecar(input) {
1729
1858
  const value = {
1730
1859
  ...input.serverName ? { server_name: input.serverName } : {},
1731
1860
  signer_package: MCP_RUNTIME_MANIFEST.signerPackage,
1732
- signer_version: MCP_RUNTIME_MANIFEST.signerVersion,
1861
+ signer_version: input.installedVersions?.signerVersion ?? MCP_RUNTIME_MANIFEST.signerVersion,
1733
1862
  sdk_package: MCP_RUNTIME_MANIFEST.sdkPackage,
1734
- sdk_version: MCP_RUNTIME_MANIFEST.sdkVersion,
1863
+ sdk_version: input.installedVersions?.sdkVersion ?? MCP_RUNTIME_MANIFEST.sdkVersion,
1735
1864
  wrapper_path: input.wrapperPath,
1736
1865
  runtime_directory: input.runtimeDirectory,
1737
1866
  npm_cache_directory: input.npmCacheDirectory,
1738
- cli_path: input.cliPath
1867
+ cli_path: input.cliPath,
1868
+ ...input.override ? { runtime_spec_override: input.override } : {}
1739
1869
  };
1740
1870
  await writeFile(input.path, `${JSON.stringify(value, null, 2)}
1741
1871
  `, { mode: 384 });
@@ -1752,6 +1882,7 @@ var execFileAsync, SIGNER_INSTALL_TIMEOUT_MS, SIGNER_INSTALL_HEARTBEAT_MS;
1752
1882
  var init_signer_runtime = __esm({
1753
1883
  "src/signer-runtime.ts"() {
1754
1884
  init_runtime_manifest();
1885
+ init_runtime_spec_override();
1755
1886
  execFileAsync = promisify(execFile);
1756
1887
  SIGNER_INSTALL_TIMEOUT_MS = 6e5;
1757
1888
  SIGNER_INSTALL_HEARTBEAT_MS = 15e3;
@@ -1760,27 +1891,50 @@ var init_signer_runtime = __esm({
1760
1891
  async function prepareLocalMcpRuntime(input, deps = {}) {
1761
1892
  assertSupportedNodeVersion(input.nodeVersion);
1762
1893
  const homeDir = input.homeDir ?? homedir();
1763
- const runtimeDirectory = resolve(homeDir, ".haven", "mcp-runtime", MCP_RUNTIME_MANIFEST.mcpVersion);
1894
+ const override = resolveLocalMcpRuntimeOverride(deps.env ?? process.env);
1895
+ const mcpSpec = override?.mcp ?? mcpPackageSpec();
1896
+ const sdkSpec = override?.sdk ?? sdkPackageSpec();
1897
+ const resolvedSpecs = [mcpSpec, sdkSpec];
1898
+ const overrideRecord = override ? { specs: override, resolved_specs: resolvedSpecs, directory_key: runtimeSpecOverrideDirectoryKey(resolvedSpecs) } : void 0;
1899
+ const runtimeDirectory = resolve(
1900
+ homeDir,
1901
+ ".haven",
1902
+ "mcp-runtime",
1903
+ overrideRecord ? overrideRecord.directory_key : MCP_RUNTIME_MANIFEST.mcpVersion
1904
+ );
1764
1905
  const npmCacheDirectory = resolve(homeDir, ".haven", "npm-cache");
1765
1906
  const cliPath = join(runtimeDirectory, "node_modules", "@haven_ai", "mcp", "dist", "cli.js");
1766
1907
  const messages = [];
1908
+ if (override) {
1909
+ messages.push(...runtimeSpecOverrideNotice(
1910
+ "MCP runtime",
1911
+ override,
1912
+ { mcp: mcpPackageSpec(), sdk: sdkPackageSpec() },
1913
+ runtimeDirectory
1914
+ ));
1915
+ }
1767
1916
  await mkdir(runtimeDirectory, { recursive: true, mode: 448 });
1768
1917
  await chmod(runtimeDirectory, 448).catch(() => void 0);
1769
1918
  await mkdir(npmCacheDirectory, { recursive: true, mode: 448 });
1770
1919
  await chmod(npmCacheDirectory, 448).catch(() => void 0);
1771
- if (await installedRuntimeMatches2(runtimeDirectory, cliPath)) {
1920
+ if (override) {
1921
+ await installRuntimePackages2(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
1922
+ messages.push(`Installed local Haven MCP runtime from override (${resolvedSpecs.join(" ")}).`);
1923
+ } else if (await installedRuntimeMatches2(runtimeDirectory, cliPath)) {
1772
1924
  messages.push(`Using existing local Haven MCP runtime ${mcpPackageSpec()}.`);
1773
1925
  } else {
1774
- await installRuntimePackages2(runtimeDirectory, npmCacheDirectory, deps);
1926
+ await installRuntimePackages2(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
1775
1927
  messages.push(`Installed local Haven MCP runtime ${mcpPackageSpec()}.`);
1776
1928
  }
1777
1929
  await assertFileExists2(cliPath, "local Haven MCP CLI");
1930
+ const installedVersions = override ? await readInstalledVersions2(runtimeDirectory) : void 0;
1778
1931
  const wrapperPath = join(input.credentialDirectory, "bin", "haven-mcp");
1779
1932
  await writeWrapper2({
1780
1933
  wrapperPath,
1781
1934
  cliPath,
1782
1935
  identityPath: input.identityPath,
1783
- signerPath: input.signerPath
1936
+ signerPath: input.signerPath,
1937
+ overrideComment: override ? describeRuntimeSpecOverride(override) : void 0
1784
1938
  });
1785
1939
  await writeRuntimeSidecar2({
1786
1940
  path: join(input.credentialDirectory, "mcp-runtime.json"),
@@ -1788,7 +1942,9 @@ async function prepareLocalMcpRuntime(input, deps = {}) {
1788
1942
  runtimeDirectory,
1789
1943
  npmCacheDirectory,
1790
1944
  cliPath,
1791
- serverName: input.serverName
1945
+ serverName: input.serverName,
1946
+ override: overrideRecord,
1947
+ installedVersions
1792
1948
  });
1793
1949
  messages.push(`Prepared stable local Haven MCP wrapper: ${wrapperPath}`);
1794
1950
  return {
@@ -1798,7 +1954,8 @@ async function prepareLocalMcpRuntime(input, deps = {}) {
1798
1954
  runtimeDirectory,
1799
1955
  npmCacheDirectory,
1800
1956
  cliPath,
1801
- messages
1957
+ messages,
1958
+ ...overrideRecord ? { runtimeSpecOverride: overrideRecord } : {}
1802
1959
  };
1803
1960
  }
1804
1961
  function assertSupportedNodeVersion(nodeVersion = process.versions.node, minimumNodeVersion = MCP_RUNTIME_MANIFEST.minimumNodeVersion, subject = "Haven setup") {
@@ -1806,7 +1963,13 @@ function assertSupportedNodeVersion(nodeVersion = process.versions.node, minimum
1806
1963
  throw new UnsupportedNodeVersionError(nodeVersion, minimumNodeVersion, subject);
1807
1964
  }
1808
1965
  }
1809
- async function installRuntimePackages2(runtimeDirectory, npmCacheDirectory, deps) {
1966
+ function resolveLocalMcpRuntimeOverride(env) {
1967
+ const override = resolveRuntimeSpecOverride(env);
1968
+ if (!overrideApplies(override, ["mcp", "sdk"])) return void 0;
1969
+ const { mcp, sdk } = override;
1970
+ return { ...mcp !== void 0 ? { mcp } : {}, ...sdk !== void 0 ? { sdk } : {} };
1971
+ }
1972
+ async function installRuntimePackages2(runtimeDirectory, npmCacheDirectory, packageSpecs, deps) {
1810
1973
  const { runCommand, onProgress } = deps;
1811
1974
  const baseArgs = [
1812
1975
  "install",
@@ -1816,8 +1979,7 @@ async function installRuntimePackages2(runtimeDirectory, npmCacheDirectory, deps
1816
1979
  "--no-fund",
1817
1980
  "--omit=dev",
1818
1981
  "--prefer-offline",
1819
- mcpPackageSpec(),
1820
- sdkPackageSpec()
1982
+ ...packageSpecs
1821
1983
  ];
1822
1984
  const run = async (args) => {
1823
1985
  const startedAt = Date.now();
@@ -1839,22 +2001,26 @@ async function installRuntimePackages2(runtimeDirectory, npmCacheDirectory, deps
1839
2001
  try {
1840
2002
  await run([...baseArgs, "--cache", npmCacheDirectory]);
1841
2003
  } catch (err) {
1842
- throw new Error(`Could not install local Haven MCP runtime ${mcpPackageSpec()}: ${err instanceof Error ? err.message : String(err)}`);
2004
+ throw new Error(`Could not install local Haven MCP runtime ${packageSpecs.join(" ")}: ${err instanceof Error ? err.message : String(err)}`);
1843
2005
  }
1844
2006
  }
1845
2007
  }
1846
2008
  async function installedRuntimeMatches2(runtimeDirectory, cliPath) {
1847
2009
  try {
1848
2010
  await assertFileExists2(cliPath, "local Haven MCP CLI");
1849
- const [mcpPackage, sdkPackage] = await Promise.all([
1850
- readPackageJson2(join(runtimeDirectory, "node_modules", "@haven_ai", "mcp", "package.json")),
1851
- readPackageJson2(join(runtimeDirectory, "node_modules", "@haven_ai", "sdk", "package.json"))
1852
- ]);
1853
- return mcpPackage.version === MCP_RUNTIME_MANIFEST.mcpVersion && sdkPackage.version === MCP_RUNTIME_MANIFEST.sdkVersion;
2011
+ const installed = await readInstalledVersions2(runtimeDirectory);
2012
+ return installed.mcpVersion === MCP_RUNTIME_MANIFEST.mcpVersion && installed.sdkVersion === MCP_RUNTIME_MANIFEST.sdkVersion;
1854
2013
  } catch {
1855
2014
  return false;
1856
2015
  }
1857
2016
  }
2017
+ async function readInstalledVersions2(runtimeDirectory) {
2018
+ const [mcpPackage, sdkPackage] = await Promise.all([
2019
+ readPackageJson2(join(runtimeDirectory, "node_modules", "@haven_ai", "mcp", "package.json")),
2020
+ readPackageJson2(join(runtimeDirectory, "node_modules", "@haven_ai", "sdk", "package.json"))
2021
+ ]);
2022
+ return { mcpVersion: mcpPackage.version ?? "", sdkVersion: sdkPackage.version ?? "" };
2023
+ }
1858
2024
  async function readPackageJson2(path) {
1859
2025
  return JSON.parse(await readFile(path, "utf8"));
1860
2026
  }
@@ -1863,6 +2029,7 @@ async function writeWrapper2(input) {
1863
2029
  await chmod(dirname(input.wrapperPath), 448).catch(() => void 0);
1864
2030
  const source = [
1865
2031
  "#!/usr/bin/env node",
2032
+ ...input.overrideComment ? [`// HAVEN RUNTIME SPEC OVERRIDE (#2424): ${input.overrideComment} \u2014 this wrapper launches a NON-pinned MCP build.`] : [],
1866
2033
  "import { spawn } from 'node:child_process'",
1867
2034
  "",
1868
2035
  `const cliPath = ${JSON.stringify(input.cliPath)}`,
@@ -1886,14 +2053,15 @@ async function writeRuntimeSidecar2(input) {
1886
2053
  const value = {
1887
2054
  ...input.serverName ? { server_name: input.serverName } : {},
1888
2055
  mcp_package: MCP_RUNTIME_MANIFEST.mcpPackage,
1889
- mcp_version: MCP_RUNTIME_MANIFEST.mcpVersion,
2056
+ mcp_version: input.installedVersions?.mcpVersion ?? MCP_RUNTIME_MANIFEST.mcpVersion,
1890
2057
  sdk_package: MCP_RUNTIME_MANIFEST.sdkPackage,
1891
- sdk_version: MCP_RUNTIME_MANIFEST.sdkVersion,
2058
+ sdk_version: input.installedVersions?.sdkVersion ?? MCP_RUNTIME_MANIFEST.sdkVersion,
1892
2059
  minimum_node_version: MCP_RUNTIME_MANIFEST.minimumNodeVersion,
1893
2060
  wrapper_path: input.wrapperPath,
1894
2061
  runtime_directory: input.runtimeDirectory,
1895
2062
  npm_cache_directory: input.npmCacheDirectory,
1896
- cli_path: input.cliPath
2063
+ cli_path: input.cliPath,
2064
+ ...input.override ? { runtime_spec_override: input.override } : {}
1897
2065
  };
1898
2066
  await writeFile(input.path, `${JSON.stringify(value, null, 2)}
1899
2067
  `, { mode: 384 });
@@ -1911,6 +2079,7 @@ var init_local_mcp_runtime = __esm({
1911
2079
  "src/local-mcp-runtime.ts"() {
1912
2080
  init_signer_runtime();
1913
2081
  init_runtime_manifest();
2082
+ init_runtime_spec_override();
1914
2083
  execFileAsync2 = promisify(execFile);
1915
2084
  UnsupportedNodeVersionError = class extends Error {
1916
2085
  code = "local_mcp_unsupported_node_version";
@@ -2411,7 +2580,7 @@ async function installRuntime(input, deps = {}) {
2411
2580
  localMcpConfigured: false,
2412
2581
  probeResult: "signer_runtime_install_failed",
2413
2582
  restartRequired: false,
2414
- nextUserAction: "The local Haven signer runtime could not be installed, so no configuration was written. Check your network (a cold install downloads the signer package set) and re-run: npx @haven_ai/connect@alpha",
2583
+ nextUserAction: `The local Haven signer runtime could not be installed, so no configuration was written. Check your network (a cold install downloads the signer package set) and re-run: ${connectorRerunCommand()}`,
2415
2584
  errorCode: "signer_runtime_install_failed",
2416
2585
  configTarget: profile.label,
2417
2586
  signerAcknowledged: signerConsent?.acknowledged,
@@ -2422,7 +2591,7 @@ async function installRuntime(input, deps = {}) {
2422
2591
  ...consentMessages,
2423
2592
  `Could not pre-install the local Haven signer: ${err instanceof Error ? err.message : String(err)}`,
2424
2593
  "No runtime configuration was written (fail-closed): a config pointing at an uninstalled signer looks wired but cannot start.",
2425
- "Re-run `npx @haven_ai/connect@alpha` to retry the setup."
2594
+ `Re-run \`${connectorRerunCommand()}\` to retry the setup.`
2426
2595
  ]
2427
2596
  };
2428
2597
  }
@@ -2485,7 +2654,7 @@ async function installRuntime(input, deps = {}) {
2485
2654
  const hostedProbeMessages = configResult.hostedConfigured && hostedProbe.status !== "ok" ? [`Hosted Haven MCP probe failed: ${hostedProbe.status}.`] : configResult.hostedConfigured ? ["Verified hosted Haven MCP tools with a read-only handshake."] : [];
2486
2655
  const signerProbeMessages = signerProbe ? signerProbe.status === "ok" ? ["Verified local Haven signer with a stdio handshake."] : [
2487
2656
  `Local Haven signer handshake failed: ${signerProbe.status}.`,
2488
- "Re-run `npx @haven_ai/connect@alpha` to repair the signer setup."
2657
+ `Re-run \`${connectorRerunCommand()}\` to repair the signer setup.`
2489
2658
  ] : [];
2490
2659
  const localProbeMessages = localMcpProbe && localMcpProbe.status !== "ok" ? [`Local Haven MCP handshake failed: ${localMcpProbe.status}.`] : localMcpProbe?.status === "ok" ? ["Verified local Haven MCP tools with a stdio handshake."] : [];
2491
2660
  const skillInstall = !configResult.errorCode ? await installSkillForRuntime(runtime, { homeDir: deps.homeDir, env: deps.env }) : void 0;
@@ -2556,7 +2725,7 @@ async function configureClaudeCode(deps, localMcpCommand, serverName) {
2556
2725
  restartRequired: true,
2557
2726
  messages: [
2558
2727
  `Could not update Claude Code MCP config: ${err instanceof Error ? err.message : String(err)}`,
2559
- "Install Claude Code or rerun the Haven setup command inside a Claude Code terminal."
2728
+ "Install Claude Code or rerun the Haven connector command inside a Claude Code terminal."
2560
2729
  ],
2561
2730
  errorCode: "claude_code_config_failed"
2562
2731
  };
@@ -2623,7 +2792,7 @@ async function configureClaudeCodeHosted(deps, input, signerCommand) {
2623
2792
  restartRequired: true,
2624
2793
  messages: [
2625
2794
  `Could not update Claude Code MCP config: ${err instanceof Error ? err.message : String(err)}`,
2626
- "Install Claude Code or rerun the Haven setup command inside a Claude Code terminal."
2795
+ "Install Claude Code or rerun the Haven connector command inside a Claude Code terminal."
2627
2796
  ],
2628
2797
  errorCode: "claude_code_config_failed"
2629
2798
  };
@@ -2697,7 +2866,7 @@ function supportsLocalMcp(runtime) {
2697
2866
  return runtime === "codex-cli" || runtime === "codex-desktop" || runtime === "claude-code";
2698
2867
  }
2699
2868
  async function prepareRuntimeForLocalMcp(input, deps) {
2700
- const prepare = deps.prepareLocalMcpRuntime ?? ((runtimeInput) => prepareLocalMcpRuntime(runtimeInput, { runCommand: deps.runCommand, onProgress: deps.onProgress }));
2869
+ const prepare = deps.prepareLocalMcpRuntime ?? ((runtimeInput) => prepareLocalMcpRuntime(runtimeInput, { runCommand: deps.runCommand, onProgress: deps.onProgress, env: deps.env }));
2701
2870
  return prepare({
2702
2871
  credentialDirectory: input.credentialDirectory,
2703
2872
  identityPath: input.identityPath,
@@ -2712,7 +2881,7 @@ async function prepareSignerForRuntime(input, deps) {
2712
2881
  // install heartbeat was dead code in production and the console still
2713
2882
  // went silent for the whole cold install — the exact symptom the issue
2714
2883
  // set out to remove, at a longer timeout.
2715
- prepareSignerRuntime(runtimeInput, { runCommand: deps.runCommand, onProgress: deps.onProgress })
2884
+ prepareSignerRuntime(runtimeInput, { runCommand: deps.runCommand, onProgress: deps.onProgress, env: deps.env })
2716
2885
  ));
2717
2886
  return prepare({
2718
2887
  credentialDirectory: input.credentialDirectory,
@@ -2752,14 +2921,6 @@ var init_runtime_install = __esm({
2752
2921
  }
2753
2922
  });
2754
2923
 
2755
- // src/rekey-messages.ts
2756
- var REKEY_FINISH_NEEDS_API_KEY;
2757
- var init_rekey_messages = __esm({
2758
- "src/rekey-messages.ts"() {
2759
- REKEY_FINISH_NEEDS_API_KEY = "--rekey-finish needs --api-key <key> \u2014 the one the Haven agent page showed once.";
2760
- }
2761
- });
2762
-
2763
2924
  // src/tombstone.ts
2764
2925
  var tombstone_exports = {};
2765
2926
  __export(tombstone_exports, {
@@ -2811,7 +2972,7 @@ function tombstoneScript(info) {
2811
2972
  "one of them: each holds the snapshot from its own start time, so after",
2812
2973
  "a chain of recreations each can be parked on a DIFFERENT old agent.",
2813
2974
  "",
2814
- "Then verify with: npx @haven_ai/connect@alpha --doctor --runtime <runtime>"
2975
+ `Then verify with: ${connectorRerunCommand("--doctor --runtime <runtime>")}`
2815
2976
  ];
2816
2977
  return [
2817
2978
  "#!/usr/bin/env node",
@@ -2877,6 +3038,9 @@ var init_tombstone = __esm({
2877
3038
  // src/unwire.ts
2878
3039
  var unwire_exports = {};
2879
3040
  __export(unwire_exports, {
3041
+ readIdentityFile: () => readIdentityFile,
3042
+ teardownLocalKeyMaterial: () => teardownLocalKeyMaterial,
3043
+ tombstoneDirectoryIfAbsent: () => tombstoneDirectoryIfAbsent,
2880
3044
  unwireAgent: () => unwireAgent
2881
3045
  });
2882
3046
  function identityAt(directory) {
@@ -2916,18 +3080,13 @@ async function unwireAgent(input) {
2916
3080
  const slug = input.slug ?? sidecar?.server_name;
2917
3081
  const names = serverNamesFor(slug);
2918
3082
  const runtimes = [];
2919
- let tombstoned = false;
2920
- const tombstonePath = join(input.directory, TOMBSTONE_FILENAME);
2921
- if (await readOptionalText(tombstonePath) === null) {
2922
- await writeAgentTombstone({
2923
- directory: input.directory,
2924
- agentId,
2925
- reason: input.reason ?? "unwired via --unwire",
2926
- replacedBy: input.replacedBy,
2927
- tombstonesDir: input.tombstonesDir
2928
- });
2929
- tombstoned = true;
2930
- }
3083
+ const tombstoned = await tombstoneDirectoryIfAbsent({
3084
+ directory: input.directory,
3085
+ agentId,
3086
+ reason: input.reason ?? "unwired via --unwire",
3087
+ replacedBy: input.replacedBy,
3088
+ tombstonesDir: input.tombstonesDir
3089
+ });
2931
3090
  for (const model of RUNTIMES) {
2932
3091
  const path = runtimeConfigPathFor(model.runtime, homeDir);
2933
3092
  if (path === null) continue;
@@ -3012,16 +3171,27 @@ async function unwireAgent(input) {
3012
3171
  }
3013
3172
  }
3014
3173
  }
3174
+ await teardownLocalKeyMaterial(input.directory, identity);
3175
+ return { directory: input.directory, agentId, slug, tombstoned, runtimes };
3176
+ }
3177
+ async function tombstoneDirectoryIfAbsent(input) {
3178
+ if (await readOptionalText(join(input.directory, TOMBSTONE_FILENAME)) !== null) return false;
3179
+ await writeAgentTombstone(input);
3180
+ return true;
3181
+ }
3182
+ async function teardownLocalKeyMaterial(directory, identity) {
3015
3183
  await Promise.all([
3016
- rm(join(input.directory, "signer.json"), { force: true }),
3017
- rm(join(input.directory, REKEY_PENDING_FILENAME), { force: true })
3184
+ rm(join(directory, "signer.json"), { force: true }),
3185
+ rm(join(directory, REKEY_PENDING_FILENAME), { force: true })
3018
3186
  ]);
3019
3187
  if (identity && identity.api_key !== void 0) {
3020
3188
  const { api_key: _dropped, ...rest } = identity;
3021
- await writeFile(join(input.directory, "identity.json"), `${JSON.stringify(rest, null, 2)}
3189
+ await writeFile(join(directory, "identity.json"), `${JSON.stringify(rest, null, 2)}
3022
3190
  `, { mode: 384 });
3023
3191
  }
3024
- return { directory: input.directory, agentId, slug, tombstoned, runtimes };
3192
+ }
3193
+ async function readIdentityFile(directory) {
3194
+ return identityAt(directory);
3025
3195
  }
3026
3196
  var RUNTIMES;
3027
3197
  var init_unwire = __esm({
@@ -3042,6 +3212,14 @@ var init_unwire = __esm({
3042
3212
  }
3043
3213
  });
3044
3214
 
3215
+ // src/rekey-messages.ts
3216
+ var REKEY_FINISH_NEEDS_API_KEY;
3217
+ var init_rekey_messages = __esm({
3218
+ "src/rekey-messages.ts"() {
3219
+ REKEY_FINISH_NEEDS_API_KEY = "--rekey-finish needs --api-key <key> \u2014 the one the Haven agent page showed once.";
3220
+ }
3221
+ });
3222
+
3045
3223
  // src/rekey.ts
3046
3224
  var rekey_exports = {};
3047
3225
  __export(rekey_exports, {
@@ -3069,7 +3247,7 @@ async function startRekey(options, deps = {}) {
3069
3247
  expires_at: expiresAt
3070
3248
  });
3071
3249
  const finishCommand = [
3072
- "npx @haven_ai/connect@alpha --rekey-finish",
3250
+ connectorRerunCommand("--rekey-finish"),
3073
3251
  options.serverName ? `--name ${options.serverName}` : void 0,
3074
3252
  "--api-key <the key the dashboard showed you>",
3075
3253
  options.runtime ? `--runtime ${options.runtime}` : "--runtime <your runtime>"
@@ -3160,7 +3338,7 @@ async function finishRekey(options, deps = {}) {
3160
3338
  signerPath: `${stored.directory}/signer.json`,
3161
3339
  homeDir: options.homeDir
3162
3340
  },
3163
- { runCommand: deps.runCommand }
3341
+ { runCommand: deps.runCommand, env: deps.env }
3164
3342
  );
3165
3343
  const result = await (deps.writeConfig ?? writeHostedRuntimeConfig)(
3166
3344
  { runCommand: deps.runCommand, homeDir: options.homeDir },
@@ -3176,6 +3354,11 @@ async function finishRekey(options, deps = {}) {
3176
3354
  { command: prepared.command, args: prepared.args }
3177
3355
  );
3178
3356
  configRewritten = result.hostedConfigured;
3357
+ if (prepared.runtimeSpecOverride) {
3358
+ messages.push(
3359
+ ` Signer runtime: RUNTIME SPEC OVERRIDE ACTIVE \u2014 ${describeRuntimeSpecOverride(prepared.runtimeSpecOverride.specs)} (NOT the pinned manifest; installed into ${prepared.runtimeDirectory})`
3360
+ );
3361
+ }
3179
3362
  messages.push(` Config: ${result.target}`);
3180
3363
  messages.push(...result.messages.map((line) => ` ${line}`));
3181
3364
  if (!configRewritten) {
@@ -3242,6 +3425,7 @@ var init_rekey = __esm({
3242
3425
  init_api();
3243
3426
  init_runtime_install();
3244
3427
  init_signer_runtime();
3428
+ init_runtime_spec_override();
3245
3429
  init_key();
3246
3430
  init_redact();
3247
3431
  init_rekey_messages();
@@ -3418,6 +3602,46 @@ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
3418
3602
  detail: `A re-key started ${started} is still open (expires ${status.expiresAt ?? "unknown"}). Paste this address into "Replace signing key" on the Haven agent page: ${address}. Parked at ${status.path}. ` + wedgeNote
3419
3603
  };
3420
3604
  }
3605
+ async function runtimeSpecOverrideCheck(directory, sidecar, env) {
3606
+ const facts = [];
3607
+ const installed = sidecar?.runtime_spec_override;
3608
+ if (installed) {
3609
+ facts.push(
3610
+ `signer runtime installed under ${describeRuntimeSpecOverride(installed.specs)} (resolved: ${installed.resolved_specs.join(" ")}; directory key ${installed.directory_key})`
3611
+ );
3612
+ }
3613
+ const mcpInstalled = await readMcpSidecarOverride(directory);
3614
+ if (mcpInstalled) {
3615
+ facts.push(
3616
+ `local MCP runtime installed under ${describeRuntimeSpecOverride(mcpInstalled.specs)} (resolved: ${mcpInstalled.resolved_specs.join(" ")}; directory key ${mcpInstalled.directory_key})`
3617
+ );
3618
+ }
3619
+ let shell;
3620
+ try {
3621
+ const active = resolveRuntimeSpecOverride(env);
3622
+ if (active) shell = `set in this shell: ${describeRuntimeSpecOverride(active)} \u2014 a --repair from here installs these, not the pin`;
3623
+ } catch (err) {
3624
+ shell = err instanceof RuntimeSpecOverrideError ? `set in this shell but REFUSED: ${err.message}` : `set in this shell but unreadable: ${err instanceof Error ? err.message : String(err)}`;
3625
+ }
3626
+ if (shell) facts.push(shell);
3627
+ if (facts.length === 0) return void 0;
3628
+ const variables = Object.values(RUNTIME_SPEC_ENV).join(" / ");
3629
+ return {
3630
+ id: "runtime_spec_override",
3631
+ label: "Runtime spec override",
3632
+ ok: false,
3633
+ detail: `runtime spec overridden \u2014 not the pinned manifest (${MCP_RUNTIME_MANIFEST.signerPackage}@${MCP_RUNTIME_MANIFEST.signerVersion}, ${MCP_RUNTIME_MANIFEST.sdkPackage}@${MCP_RUNTIME_MANIFEST.sdkVersion}). ${facts.join(". ")}.`,
3634
+ repair: `Developer override (#2424). To return to the pinned manifest: unset ${variables}, then run ${RERUN} --doctor --repair --runtime <runtime>. If the override is intentional, this finding is the record of it.`
3635
+ };
3636
+ }
3637
+ async function readMcpSidecarOverride(directory) {
3638
+ try {
3639
+ const parsed = JSON.parse(await readFile(join(directory, "mcp-runtime.json"), "utf8"));
3640
+ return parsed.runtime_spec_override;
3641
+ } catch {
3642
+ return void 0;
3643
+ }
3644
+ }
3421
3645
  async function readIdentity(directory) {
3422
3646
  try {
3423
3647
  return JSON.parse(await readFile(join(directory, "identity.json"), "utf8"));
@@ -3452,6 +3676,18 @@ async function checksForAgent(entry, input, deps) {
3452
3676
  detail: "No signer-runtime.json sidecar \u2014 the pinned signer runtime was never prepared (or a pre-#1586 npx config).",
3453
3677
  repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}`
3454
3678
  });
3679
+ } else if (sidecar.runtime_spec_override) {
3680
+ const matches = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
3681
+ signerVersion: sidecar.signer_version,
3682
+ sdkVersion: sidecar.sdk_version
3683
+ });
3684
+ checks.push({
3685
+ id: "signer_runtime",
3686
+ label: "Signer runtime (preinstalled wrapper)",
3687
+ ok: matches,
3688
+ detail: matches ? `Installed ${sidecar.signer_package}@${sidecar.signer_version} at ${sidecar.runtime_directory} (override install \u2014 see runtime_spec_override)` : `Override runtime directory is stale or empty (${sidecar.runtime_directory}) \u2014 the CLI or package versions are missing.`,
3689
+ ...matches ? {} : { repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime} with the same HAVEN_*_SPEC variables set.` }
3690
+ });
3455
3691
  } else {
3456
3692
  const matches = await installedRuntimeMatches(sidecar.runtime_directory, sidecar.cli_path);
3457
3693
  const versionOk = sidecar.signer_version === MCP_RUNTIME_MANIFEST.signerVersion;
@@ -3464,6 +3700,8 @@ async function checksForAgent(entry, input, deps) {
3464
3700
  ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}` }
3465
3701
  });
3466
3702
  }
3703
+ const overrideCheck = await runtimeSpecOverrideCheck(directory, sidecar, deps.env ?? process.env);
3704
+ if (overrideCheck) checks.push(overrideCheck);
3467
3705
  const hostedUrl = identity?.hosted_mcp_url ?? (identity?.api_url ? `${identity.api_url}/mcp` : void 0);
3468
3706
  if (identity?.api_key && hostedUrl) {
3469
3707
  const probe = await (deps.probeHosted ?? probeHostedMcpTools)(identity.api_key, hostedUrl, deps.fetch);
@@ -3535,7 +3773,7 @@ async function checksForAgent(entry, input, deps) {
3535
3773
  label: "Signer stdio handshake",
3536
3774
  ok: false,
3537
3775
  detail: "The local-tools consent is not acknowledged, so the signer refuses to start (by design).",
3538
- repair: `Run: ${RERUN} --ack-local-tools --setup <token> (or re-run your original setup command with --ack-local-tools).`
3776
+ repair: `Run: ${RERUN} --ack-local-tools --setup <token> (or re-run your original connector command with --ack-local-tools).`
3539
3777
  });
3540
3778
  } else {
3541
3779
  const probe = await (deps.probeSignerTools ?? probeLocalMcpTools)(
@@ -3658,7 +3896,7 @@ async function runDoctor(input, deps = {}) {
3658
3896
  signerCapabilities = result.signerCapabilities;
3659
3897
  for (const check of result.checks) primaryChecksById.set(check.id, check);
3660
3898
  }
3661
- for (const id of ["credentials", "signer_runtime"]) {
3899
+ for (const id of ["credentials", "signer_runtime", "runtime_spec_override"]) {
3662
3900
  const check = primaryChecksById.get(id);
3663
3901
  if (check) checks.push(check);
3664
3902
  }
@@ -3809,7 +4047,7 @@ async function runRepair(input, deps = {}) {
3809
4047
  ok: false,
3810
4048
  messages: [
3811
4049
  `The config at ${configPath} is the LOCAL-stdio topology (--local). Repair currently rewrites only the hosted+signer shape and will not touch it.`,
3812
- "Re-run your original setup command (with --local) to repair a local-stdio install."
4050
+ "Re-run your original connector command (with --local) to repair a local-stdio install."
3813
4051
  ]
3814
4052
  };
3815
4053
  }
@@ -3821,7 +4059,7 @@ async function runRepair(input, deps = {}) {
3821
4059
  const signerPath = join(directory, "signer.json");
3822
4060
  const prepared = await prepareSignerRuntime(
3823
4061
  { credentialDirectory: directory, signerPath, homeDir, serverName },
3824
- { runCommand: deps.runCommand }
4062
+ { runCommand: deps.runCommand, env: deps.env }
3825
4063
  );
3826
4064
  messages.push(...prepared.messages);
3827
4065
  const names = serverNamesFor(serverName);
@@ -3848,6 +4086,7 @@ var init_doctor = __esm({
3848
4086
  init_runtime_manifest();
3849
4087
  init_probes();
3850
4088
  init_signer_runtime();
4089
+ init_runtime_spec_override();
3851
4090
  init_config_writers();
3852
4091
  init_runtime_registry();
3853
4092
  init_signer_consent();
@@ -3855,7 +4094,7 @@ var init_doctor = __esm({
3855
4094
  init_server_names();
3856
4095
  init_storage();
3857
4096
  init_redact();
3858
- RERUN = "npx @haven_ai/connect@alpha";
4097
+ RERUN = connectorRerunCommand();
3859
4098
  }
3860
4099
  });
3861
4100
 
@@ -4014,7 +4253,7 @@ function noInstalledClientsError() {
4014
4253
  function promptAbortedError(reason) {
4015
4254
  return new ConnectError(
4016
4255
  "runtime_prompt_aborted",
4017
- `Runtime not chosen (${reason}). Nothing was written: no agent was created, no credentials were stored, and the Haven setup token is still unused. Run the setup command again, or pass --runtime <name> to skip the prompt.`,
4256
+ `Runtime not chosen (${reason}). Nothing was written: no agent was created, no credentials were stored, and the Haven setup token is still unused. Run the connector command again, or pass --runtime <name> to skip the prompt.`,
4018
4257
  "rerun_connect_and_choose_a_runtime"
4019
4258
  );
4020
4259
  }
@@ -4045,10 +4284,119 @@ function defaultPromptIo() {
4045
4284
  };
4046
4285
  }
4047
4286
 
4287
+ // src/wiring-collision.ts
4288
+ init_server_names();
4289
+ init_signer_runtime();
4290
+ init_storage();
4291
+ init_tombstone();
4292
+ async function detectWiringCollision(input) {
4293
+ const root = defaultCredentialRoot(input.credentialsDir);
4294
+ let entries = [];
4295
+ try {
4296
+ entries = await readdir(root);
4297
+ } catch {
4298
+ return null;
4299
+ }
4300
+ const superseded = [];
4301
+ const taken = /* @__PURE__ */ new Set();
4302
+ for (const entry of entries) {
4303
+ const directory = join(root, entry);
4304
+ let identityRaw;
4305
+ try {
4306
+ identityRaw = await readFile(join(directory, "identity.json"), "utf8");
4307
+ } catch {
4308
+ continue;
4309
+ }
4310
+ taken.add(entry);
4311
+ let identity;
4312
+ try {
4313
+ identity = JSON.parse(identityRaw);
4314
+ } catch {
4315
+ identity = void 0;
4316
+ }
4317
+ if (!identity?.api_key) continue;
4318
+ if (await pathExists2(join(directory, TOMBSTONE_FILENAME))) continue;
4319
+ const sidecar = await readRuntimeSidecar(directory);
4320
+ if (sidecar?.server_name) continue;
4321
+ superseded.push({ directory, agentId: identity.agent_id ?? entry });
4322
+ }
4323
+ if (superseded.length === 0) return null;
4324
+ return {
4325
+ superseded,
4326
+ suggestedServerName: proposeServerSlug(input.agentName, taken)
4327
+ };
4328
+ }
4329
+ function proposeServerSlug(agentName, taken) {
4330
+ let base = agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 32).replace(/-+$/g, "");
4331
+ if (!slugIsValid(base)) base = "agent";
4332
+ if (!taken.has(base)) return base;
4333
+ for (let n = 2; n < 1e3; n += 1) {
4334
+ const suffix = `-${n}`;
4335
+ const candidate = `${base.slice(0, 32 - suffix.length).replace(/-+$/g, "")}${suffix}`;
4336
+ if (slugIsValid(candidate) && !taken.has(candidate)) return candidate;
4337
+ }
4338
+ throw new Error(`Could not propose an unused server name for ${JSON.stringify(agentName)}.`);
4339
+ }
4340
+ function slugIsValid(slug) {
4341
+ try {
4342
+ assertValidServerSlug(slug);
4343
+ return true;
4344
+ } catch {
4345
+ return false;
4346
+ }
4347
+ }
4348
+ var MAX_PROMPT_ATTEMPTS2 = 3;
4349
+ async function promptWiringCollisionResolution(collision, agentName, io) {
4350
+ const ids = collision.superseded.map((entry) => entry.agentId).join(", ");
4351
+ io.write(`This machine is already wired to a Haven agent with a live key: ${ids}.
4352
+ `);
4353
+ io.write(`Setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring.
4354
+ `);
4355
+ io.write(" r) replace \u2014 re-point haven / haven-signer at the new agent and retire the previous directory locally\n");
4356
+ io.write(" (tombstoned, local key files removed; you still revoke it on the Haven agent page)\n");
4357
+ io.write(` a) alongside \u2014 install as a named agent (suggested: ${collision.suggestedServerName}) with its own
4358
+ `);
4359
+ io.write(` haven-<name> / haven-signer-<name> pair, leaving the current wiring untouched
4360
+ `);
4361
+ io.write(" q) quit \u2014 nothing is written and the setup token stays unused\n");
4362
+ for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS2; attempt += 1) {
4363
+ const answer = await io.question("Replace, install alongside, or quit? [r/a/q]: ");
4364
+ if (answer === null) return { action: "abort" };
4365
+ const trimmed = answer.trim().toLowerCase();
4366
+ if (trimmed === "r" || trimmed === "replace") return { action: "replace" };
4367
+ if (trimmed === "q" || trimmed === "quit") return { action: "abort" };
4368
+ if (trimmed === "a" || trimmed === "alongside") {
4369
+ const typed = await io.question(`Server name [${collision.suggestedServerName}]: `);
4370
+ if (typed === null) return { action: "abort" };
4371
+ const serverName = typed.trim() === "" ? collision.suggestedServerName : typed.trim();
4372
+ try {
4373
+ assertValidServerSlug(serverName);
4374
+ } catch (err) {
4375
+ io.write(`${err instanceof Error ? err.message : String(err)}
4376
+ `);
4377
+ continue;
4378
+ }
4379
+ return { action: "alongside", serverName };
4380
+ }
4381
+ io.write(`"${trimmed}" is not one of r, a, q.
4382
+ `);
4383
+ }
4384
+ return { action: "abort" };
4385
+ }
4386
+ async function pathExists2(path) {
4387
+ try {
4388
+ await stat(path);
4389
+ return true;
4390
+ } catch {
4391
+ return false;
4392
+ }
4393
+ }
4394
+
4048
4395
  // src/runtime.ts
4396
+ init_unwire();
4049
4397
  init_local_mcp_runtime();
4050
4398
  init_runtime_manifest();
4051
- var CONNECTOR_VERSION = "0.1.33-alpha.0";
4399
+ var CONNECTOR_VERSION = "0.1.35-alpha.0";
4052
4400
  var CONNECT_OUTCOME_SCHEMA_VERSION = 1;
4053
4401
  var failureOutcomesByError = /* @__PURE__ */ new WeakMap();
4054
4402
  function failureOutcomeFor(runtimeHint, error) {
@@ -4153,6 +4501,28 @@ async function executeConnect(options, deps, trace) {
4153
4501
  await assertServerSlugAvailable(options.serverName, options.credentialsDir);
4154
4502
  }
4155
4503
  log("Checked local credential storage \u2014 all clear.");
4504
+ let serverName = options.serverName;
4505
+ let replacing;
4506
+ if (!serverName) {
4507
+ const collision = await detectWiringCollision({
4508
+ credentialsDir: options.credentialsDir,
4509
+ agentName: setup.agent.name
4510
+ });
4511
+ if (collision) {
4512
+ const resolution = await resolveWiringCollision(collision, setup.agent.name, options, deps);
4513
+ if (resolution.action === "replace") {
4514
+ replacing = collision;
4515
+ log(
4516
+ `Replacing the existing haven / haven-signer wiring (previous agent(s): ${supersededIds(collision)}). The previous directory is retired locally once the new wiring is written; nothing is revoked.`
4517
+ );
4518
+ } else {
4519
+ serverName = resolution.serverName;
4520
+ assertValidServerSlug(serverName);
4521
+ await assertServerSlugAvailable(serverName, options.credentialsDir);
4522
+ log(`Installing alongside the existing wiring as a named agent: ${serverNamesFor(serverName).hosted} / ${serverNamesFor(serverName).signer}.`);
4523
+ }
4524
+ }
4525
+ }
4156
4526
  const localKey = generateKey();
4157
4527
  const localApiKey = generateLocalApiKey();
4158
4528
  log("Minting a fresh signing key and API key \u2014 both stay on this machine.");
@@ -4173,7 +4543,10 @@ async function executeConnect(options, deps, trace) {
4173
4543
  // the dashboard can name it. Derived here rather than sent as the raw
4174
4544
  // slug — `serverNamesFor` is the one place the naming rule lives, and
4175
4545
  // the hosted name is what a user pastes into an MCP config.
4176
- mcpServerName: serverNamesFor(options.serverName).hosted,
4546
+ mcpServerName: serverNamesFor(serverName).hosted,
4547
+ // #2528: 'prose' is the default because it is what a caller who says
4548
+ // nothing is doing — the library entry point is not a --json run.
4549
+ runMode: options.runMode ?? "prose",
4177
4550
  connectorContext: {
4178
4551
  environment_label: options.environmentLabel ?? "Local workspace",
4179
4552
  config_target: installCapabilities.canWriteRuntimeConfig ? "agent runtime MCP config" : "local credential files"
@@ -4185,7 +4558,7 @@ async function executeConnect(options, deps, trace) {
4185
4558
  if (dead) throw dead;
4186
4559
  if (isExpiredSetupChallenge(err)) {
4187
4560
  throw new Error(
4188
- "The Haven setup challenge expired while connecting. Return to Haven, start a fresh connection, and run its new Connect command. Do not reuse or paste credentials."
4561
+ "The Haven setup challenge expired while connecting. Return to Haven, start a fresh connection, and run its new connector command. Do not reuse or paste credentials."
4189
4562
  );
4190
4563
  }
4191
4564
  throw err;
@@ -4195,7 +4568,7 @@ async function executeConnect(options, deps, trace) {
4195
4568
  const credentialPaths = await writeCredentials({
4196
4569
  baseDir: options.credentialsDir,
4197
4570
  agentId: registration.agent_id,
4198
- serverName: options.serverName,
4571
+ serverName,
4199
4572
  apiKey: localApiKey,
4200
4573
  delegateKey: localKey.privateKey,
4201
4574
  delegateAddress: localKey.address,
@@ -4234,7 +4607,7 @@ async function executeConnect(options, deps, trace) {
4234
4607
  ackSigner: options.ackSigner,
4235
4608
  ackLocalTools: options.ackLocalTools,
4236
4609
  localMcp: options.localMcp,
4237
- serverName: options.serverName
4610
+ serverName
4238
4611
  }, {
4239
4612
  onProgress: log,
4240
4613
  // #1543: report "runtime configured" the moment the config write settles,
@@ -4261,7 +4634,7 @@ async function executeConnect(options, deps, trace) {
4261
4634
  environmentLabel: options.environmentLabel ?? "Local workspace"
4262
4635
  });
4263
4636
  if (!early.errorCode || early.errorCode === "manual_runtime_setup_required") {
4264
- log("\u2192 Action needed: approve this agent's budget in the Haven dashboard \u2014 the approval button is live now. Setup continues here in the meantime.");
4637
+ log(approveBudgetCta(setup, registration.approval_url));
4265
4638
  }
4266
4639
  } catch {
4267
4640
  }
@@ -4273,10 +4646,45 @@ async function executeConnect(options, deps, trace) {
4273
4646
  } else {
4274
4647
  log("Haven setup on this machine is complete.");
4275
4648
  }
4649
+ let supersededAgentsRetiredLocally;
4650
+ const retiredAgentIds = [];
4651
+ if (replacing) {
4652
+ if (runtimeInstall.errorCode) {
4653
+ supersededAgentsRetiredLocally = false;
4654
+ log(
4655
+ `Previous agent(s) ${supersededIds(replacing)} were NOT retired: the runtime install did not complete, so their wiring may still be the only working one. Resolve the install, then retire them with ${RERUN_HINT} --unwire <dir>.`
4656
+ );
4657
+ } else {
4658
+ supersededAgentsRetiredLocally = true;
4659
+ for (const entry of replacing.superseded) {
4660
+ try {
4661
+ await tombstoneDirectoryIfAbsent({
4662
+ directory: entry.directory,
4663
+ agentId: entry.agentId,
4664
+ reason: "replaced by a new setup (--replace)",
4665
+ replacedBy: registration.agent_id
4666
+ });
4667
+ await teardownLocalKeyMaterial(entry.directory, await readIdentityFile(entry.directory));
4668
+ retiredAgentIds.push(entry.agentId);
4669
+ log(`Retired previous agent ${entry.agentId} locally: tombstoned, local key files removed.`);
4670
+ } catch (err) {
4671
+ supersededAgentsRetiredLocally = false;
4672
+ log(`Could not retire previous agent ${entry.agentId} locally: ${err instanceof Error ? err.message : String(err)}`);
4673
+ }
4674
+ }
4675
+ }
4676
+ }
4677
+ let supersededScan = null;
4276
4678
  let supersededAgentIds = [];
4277
4679
  try {
4278
- supersededAgentIds = await listOtherAgentIds(options.credentialsDir, credentialPaths.directory);
4279
- if (supersededAgentIds.length > 0) {
4680
+ supersededScan = await listOtherAgentIds(options.credentialsDir, credentialPaths.directory);
4681
+ supersededAgentIds = supersededScan ?? [];
4682
+ if (supersededAgentsRetiredLocally === true) {
4683
+ log("");
4684
+ log(
4685
+ `Replaced: previous agent(s) ${supersededIds(replacing)} are retired on this machine but NOT revoked \u2014 revoke them on the Haven agent page, then restart EVERY long-lived host (gateways, TUI workers, editors): each holds the MCP wiring snapshot from its own start time, and the tombstone speaks only when a stale host next probes the old path.`
4686
+ );
4687
+ } else if (supersededAgentIds.length > 0) {
4280
4688
  log("");
4281
4689
  log(
4282
4690
  `Heads-up: this setup created a NEW agent. Your previous agent(s) \u2014 ${supersededAgentIds.join(", ")} \u2014 still exist with their own keys, and any host that was already running keeps acting as them.`
@@ -4304,23 +4712,31 @@ async function executeConnect(options, deps, trace) {
4304
4712
  restartRequired: runtimeInstall.restartRequired,
4305
4713
  nextUserAction: runtimeInstall.nextUserAction,
4306
4714
  errorCode: runtimeInstall.errorCode,
4307
- environmentLabel: options.environmentLabel ?? "Local workspace"
4715
+ environmentLabel: options.environmentLabel ?? "Local workspace",
4716
+ // The raw scan result, `null` and all — NOT the flattened
4717
+ // `supersededAgentIds` the outcome carries. Flattening here would hand
4718
+ // the dashboard the same ambiguity this field exists to remove (#2561).
4719
+ supersededAgentIds: supersededScan
4308
4720
  });
4309
4721
  } catch (err) {
4310
4722
  log(`Could not report install status to Haven: ${err instanceof Error ? err.message : String(err)}`);
4311
4723
  }
4312
4724
  let approval;
4313
- if (options.waitForApproval !== false && !runtimeInstall.errorCode) {
4725
+ const narrateApprovalWait = options.waitForApproval === true || options.waitForApproval === void 0 && (deps.isStdoutTty ?? Boolean(process.stdout.isTTY));
4726
+ if (narrateApprovalWait && !runtimeInstall.errorCode) {
4314
4727
  approval = await waitForBudgetApproval(api, registration.setup_id, localApiKey, log, options.approvalWait);
4315
4728
  }
4316
- printNextSteps(runtimeInstall, log, approval);
4729
+ printNextSteps(runtimeInstall, log, approval, registration.approval_url);
4317
4730
  const outcome = completionOutcome({
4318
4731
  runtimeInstall,
4319
4732
  delegateAddress: registration.delegate_address,
4320
4733
  hostedMcpUrl: registration.hosted_mcp_url,
4321
4734
  supersededAgentIds,
4735
+ supersededAgentsRetiredLocally,
4736
+ ...replacing ? { retiredAgentIds } : {},
4322
4737
  setupChallengeExpiresAt: setup.challenge.expires_at,
4323
- approvalRequired: registration.agent_status === "pending_approval"
4738
+ approvalRequired: registration.agent_status === "pending_approval",
4739
+ approvalUrl: registration.approval_url
4324
4740
  });
4325
4741
  if (await recordConnectOutcome(deps, credentialPaths.directory, outcome)) {
4326
4742
  log(`Saved this run's outcome to ${CONNECT_OUTCOME_FILENAME} in the agent's credential directory.`);
@@ -4353,7 +4769,16 @@ function completionOutcome(input) {
4353
4769
  instruction: manualSetup ? "Finish the manual MCP setup using the secret-free references shown in normal Connect output, then start a fresh session." : runtimeProfile(runtimeInstall.runtime).activationInstruction
4354
4770
  },
4355
4771
  next_action: nextAction2,
4356
- approval: { required: input.approvalRequired, expires_at: null },
4772
+ approval: {
4773
+ required: input.approvalRequired,
4774
+ expires_at: null,
4775
+ // Only when there is something to approve AND the backend supplied a
4776
+ // link. Never synthesised here: the connector does not know the
4777
+ // dashboard's origin, and a guessed URL is worse than none — it is the
4778
+ // "do not invent one" rule the agent runbook states, applied to the
4779
+ // tool rather than the agent.
4780
+ ...input.approvalRequired && input.approvalUrl ? { url: input.approvalUrl } : {}
4781
+ },
4357
4782
  verification: {
4358
4783
  tools: ["haven_get_agent", "haven_get_allowances"],
4359
4784
  instruction: runtimeVerificationInstruction(runtimeInstall.runtime)
@@ -4371,6 +4796,8 @@ function completionOutcome(input) {
4371
4796
  // agents" is a fact a caller needs, and an omitted key would be
4372
4797
  // indistinguishable from an older connector that never reported it.
4373
4798
  superseded_agent_ids: input.supersededAgentIds ?? [],
4799
+ ...input.supersededAgentsRetiredLocally !== void 0 ? { superseded_agents_retired_locally: input.supersededAgentsRetiredLocally } : {},
4800
+ ...input.retiredAgentIds ? { retired_agent_ids: input.retiredAgentIds } : {},
4374
4801
  ...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
4375
4802
  ...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
4376
4803
  };
@@ -4392,10 +4819,38 @@ function installedClientProse(hint) {
4392
4819
  return `Haven can see these agent clients installed here, likeliest first: ${found.join(", ")}.${suggestion} `;
4393
4820
  }
4394
4821
  function runtimeSelectionPrompt(options, deps) {
4395
- if (options.interactive !== true) return void 0;
4396
- if (!(deps.isTty ?? Boolean(process.stdin.isTTY))) return void 0;
4822
+ if (!interactivePromptAllowed(options, deps)) return void 0;
4397
4823
  return deps.promptRuntime ?? (() => resolveRuntimeByInstalledClientPrompt());
4398
4824
  }
4825
+ function interactivePromptAllowed(options, deps) {
4826
+ if (options.interactive !== true) return false;
4827
+ return deps.isTty ?? Boolean(process.stdin.isTTY);
4828
+ }
4829
+ function supersededIds(collision) {
4830
+ return collision.superseded.map((entry) => entry.agentId).join(", ");
4831
+ }
4832
+ async function resolveWiringCollision(collision, agentName, options, deps) {
4833
+ if (options.replaceExistingWiring) return { action: "replace" };
4834
+ if (interactivePromptAllowed(options, deps)) {
4835
+ const prompt = deps.promptWiringCollision ?? ((c, name) => promptWiringCollisionResolution(c, name, defaultPromptIo()));
4836
+ const resolution = await prompt(collision, agentName);
4837
+ if (resolution.action === "abort") {
4838
+ throw new ConnectError(
4839
+ "wiring_collision_declined",
4840
+ `Setup stopped at your request: this machine is already wired to ${supersededIds(collision)} and you chose neither to replace that wiring nor to install alongside it. Nothing was written: no agent was created, no credentials were stored, and the Haven setup token is still unused. Run the connector command again with --replace to re-point haven / haven-signer at the new agent, or with --name <slug> (e.g. --name ${collision.suggestedServerName}) to install alongside.`,
4841
+ "rerun_connect_with_replace_or_name",
4842
+ { supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
4843
+ );
4844
+ }
4845
+ return resolution;
4846
+ }
4847
+ throw new ConnectError(
4848
+ "wiring_collision",
4849
+ `This machine is already wired to a Haven agent with a live key (${supersededIds(collision)}), and setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring. Nothing was written and the Haven setup token is still unused. If you are an AI agent running this command: do NOT add a flag yourself \u2014 relay this to your user and stop. Your user decides: REPLACE the existing wiring (re-run the same command with --replace added, which re-points the pair at the new agent and retires the previous directory locally \u2014 they still revoke the old agent on the Haven agent page), or install ALONGSIDE it (re-run with --name <slug> added, e.g. --name ${collision.suggestedServerName}, which gives the new agent its own haven-<slug> / haven-signer-<slug> pair). Re-run only with the flag your user chooses.`,
4850
+ "relay_wiring_collision_to_user",
4851
+ { supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
4852
+ );
4853
+ }
4399
4854
  function failedConnectOutcome(runtimeHint, error) {
4400
4855
  const message = error instanceof Error ? error.message : "";
4401
4856
  const code = error instanceof ConnectError ? error.code : /Node\.js >=/i.test(message) ? "unsupported_node_version" : /setup challenge.*expired|expired or invalid/i.test(message) ? "setup_challenge_expired_or_invalid" : /only available for Claude Code and Codex/i.test(message) ? "local_mcp_unsupported_runtime" : "connect_failed";
@@ -4426,7 +4881,9 @@ function failedConnectOutcome(runtimeHint, error) {
4426
4881
  ...error instanceof ConnectError && message ? { message: redactForAutomation(message) } : {},
4427
4882
  ...error instanceof ConnectError && error.details.allowedRuntimes ? { allowed_runtimes: error.details.allowedRuntimes } : {},
4428
4883
  ...error instanceof ConnectError && error.details.installedClients?.length ? { installed_clients: error.details.installedClients } : {},
4429
- ...error instanceof ConnectError && error.details.suggestedRuntime ? { suggested_runtime: error.details.suggestedRuntime } : {}
4884
+ ...error instanceof ConnectError && error.details.suggestedRuntime ? { suggested_runtime: error.details.suggestedRuntime } : {},
4885
+ ...error instanceof ConnectError && error.details.supersededAgentIds ? { superseded_agent_ids: error.details.supersededAgentIds } : {},
4886
+ ...error instanceof ConnectError && error.details.suggestedServerName ? { suggested_name: error.details.suggestedServerName } : {}
4430
4887
  }
4431
4888
  };
4432
4889
  }
@@ -4442,6 +4899,22 @@ function printSetupSummary(setup, log) {
4442
4899
  }
4443
4900
  log(`Setup challenge expires at ${setup.challenge.expires_at}. If it expires, return to Haven for a fresh setup and rerun Connect \u2014 do not reuse or paste credentials.`);
4444
4901
  }
4902
+ function approveBudgetCta(setup, approvalUrl) {
4903
+ const where = approvalUrl ? `at ${approvalUrl}` : "in the Haven dashboard";
4904
+ const budgetPhrase = setup.agent_budget.length > 0 ? setup.agent_budget.map((budget) => `up to ${describeSetupBudget(budget)}`).join(", ") : void 0;
4905
+ if (budgetPhrase) {
4906
+ return `\u2192 Action needed: approve this agent's budget \u2014 ${budgetPhrase} from ${setup.haven_wallet.name} on ${setup.haven_wallet.network} \u2014 ${where}. The approval button is live now; setup continues here in the meantime.`;
4907
+ }
4908
+ return `\u2192 Action needed: approve this agent's budget ${where} \u2014 the approval button is live now. Setup continues here in the meantime.`;
4909
+ }
4910
+ function describeSetupBudget(budget) {
4911
+ return describeApprovedBudget({
4912
+ token_symbol: budget.token_symbol,
4913
+ token_address: budget.token_address,
4914
+ amount: budget.allowance_amount,
4915
+ reset_period_min: budget.reset_period_min
4916
+ });
4917
+ }
4445
4918
  function assertSetupChallengeIsUsable(expiresAt) {
4446
4919
  const expiresAtMs = Date.parse(expiresAt);
4447
4920
  if (!Number.isNaN(expiresAtMs) && expiresAtMs > Date.now()) return;
@@ -4453,7 +4926,7 @@ function deadSetupTokenError(err) {
4453
4926
  if (!(err instanceof ConnectRequestError) || err.status !== 410 && err.status !== 401) return null;
4454
4927
  return new ConnectError(
4455
4928
  "setup_challenge_expired_or_invalid",
4456
- "This Haven setup token is expired or invalid \u2014 tokens are single-use and expire 30 minutes after the dashboard issues them, and a mistyped token reads the same way. Return to Haven, start a fresh connection, and run its new Connect command. No local credentials were written.",
4929
+ "This Haven setup token is expired or invalid \u2014 tokens are single-use and expire 30 minutes after the dashboard issues them, and a mistyped token reads the same way. Return to Haven, start a fresh connection, and run its new connector command. No local credentials were written.",
4457
4930
  "return_to_haven_for_fresh_setup"
4458
4931
  );
4459
4932
  }
@@ -4554,18 +5027,19 @@ async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
4554
5027
  );
4555
5028
  return "pending";
4556
5029
  }
4557
- function completionHandoffLines(result, approval) {
5030
+ function completionHandoffLines(result, approval, approvalUrl) {
5031
+ const approveStep = approvalUrl ? `Approve the budget at ${approvalUrl}. Approval \u2014 not restarting \u2014 unlocks Haven tools.` : "Return to Haven and approve the budget. Approval \u2014 not restarting \u2014 unlocks Haven tools.";
4558
5032
  if (result.errorCode === "manual_runtime_setup_required") {
4559
5033
  return [
4560
5034
  "Next steps:",
4561
- "1. Return to Haven and approve the budget. Approval \u2014 not restarting \u2014 unlocks Haven tools.",
5035
+ `1. ${approveStep}`,
4562
5036
  "2. Finish the manual MCP setup using the secret-free file references printed above, then start a fresh session in your runtime.",
4563
5037
  `3. ${runtimeVerificationInstruction(result.runtime)}`
4564
5038
  ];
4565
5039
  }
4566
5040
  if (result.errorCode) {
4567
5041
  return [
4568
- "Recovery: runtime setup is not complete. Resolve the reported problem, then return to Haven for a fresh connection and run its new Connect command. Do not manually edit runtime config or paste credentials into prompts, logs, or config."
5042
+ "Recovery: runtime setup is not complete. Resolve the reported problem, then return to Haven for a fresh connection and run its new connector command. Do not manually edit runtime config or paste credentials into prompts, logs, or config."
4569
5043
  ];
4570
5044
  }
4571
5045
  if (approval === "ended") {
@@ -4584,7 +5058,7 @@ function completionHandoffLines(result, approval) {
4584
5058
  }
4585
5059
  return [
4586
5060
  "Next steps:",
4587
- "1. Return to Haven and approve the budget. Approval \u2014 not restarting \u2014 unlocks Haven tools.",
5061
+ `1. ${approveStep}`,
4588
5062
  `2. ${activation}`,
4589
5063
  `3. ${runtimeVerificationInstruction(result.runtime)}`
4590
5064
  ];
@@ -4598,14 +5072,14 @@ function activationInstructionWithWhy(profile) {
4598
5072
  }
4599
5073
  return profile.activationInstruction;
4600
5074
  }
4601
- var RERUN_HINT = "npx @haven_ai/connect@alpha";
5075
+ var RERUN_HINT = connectorRerunCommand();
4602
5076
  async function listOtherAgentIds(baseDir, currentDirectory) {
4603
5077
  const root = defaultCredentialRoot(baseDir);
4604
5078
  let entries = [];
4605
5079
  try {
4606
5080
  entries = await readdir(root);
4607
5081
  } catch {
4608
- return [];
5082
+ return null;
4609
5083
  }
4610
5084
  const ids = [];
4611
5085
  for (const entry of entries) {
@@ -4625,8 +5099,8 @@ async function listOtherAgentIds(baseDir, currentDirectory) {
4625
5099
  }
4626
5100
  return ids;
4627
5101
  }
4628
- function printNextSteps(result, log, approval) {
4629
- for (const line of completionHandoffLines(result, approval)) log(line);
5102
+ function printNextSteps(result, log, approval, approvalUrl) {
5103
+ for (const line of completionHandoffLines(result, approval, approvalUrl)) log(line);
4630
5104
  }
4631
5105
 
4632
5106
  // src/args.ts
@@ -4648,6 +5122,7 @@ function parseArgs(argv, env = process.env) {
4648
5122
  let tombstoneReplacedBy;
4649
5123
  let unwire;
4650
5124
  let unwireDir;
5125
+ let replace = false;
4651
5126
  for (let i = 0; i < argv.length; i += 1) {
4652
5127
  const arg = argv[i];
4653
5128
  if (arg === "--help" || arg === "-h") {
@@ -4687,6 +5162,8 @@ function parseArgs(argv, env = process.env) {
4687
5162
  options.runtimeForce = requireValue(argv, ++i, arg);
4688
5163
  } else if (arg === "--credentials-dir") {
4689
5164
  options.credentialsDir = requireValue(argv, ++i, arg);
5165
+ } else if (arg === "--replace") {
5166
+ replace = true;
4690
5167
  } else if (arg === "--name") {
4691
5168
  options.serverName = requireValue(argv, ++i, arg);
4692
5169
  assertValidServerSlug(options.serverName);
@@ -4712,6 +5189,17 @@ function parseArgs(argv, env = process.env) {
4712
5189
  if (help) {
4713
5190
  return { options, help, json, doctor, repair, tombstone, rekey };
4714
5191
  }
5192
+ if (replace) {
5193
+ if (rekeyPhase || tombstoneDir || unwire || doctor || repair) {
5194
+ throw new Error("--replace belongs to a --setup run: it says what to do when the bare haven / haven-signer pair is already wired to another agent.");
5195
+ }
5196
+ if (options.serverName) {
5197
+ throw new Error(
5198
+ "--replace and --name contradict each other: --replace overwrites the bare haven / haven-signer wiring, --name installs alongside it under its own pair. Pass one of them."
5199
+ );
5200
+ }
5201
+ options.replaceExistingWiring = true;
5202
+ }
4715
5203
  if (rekey) {
4716
5204
  if (options.setupToken) {
4717
5205
  throw new Error("--rekey replaces an existing agent's key; it does not take --setup. Drop one of them.");
@@ -4790,6 +5278,12 @@ function helpText() {
4790
5278
  " --runtime-force <name> Escape hatch: use exactly this runtime, ignoring environment detection.",
4791
5279
  " --credentials-dir <path> Credential directory fallback. Defaults to ~/.haven/agents.",
4792
5280
  " --environment-label <text> Non-sensitive label shown in Haven setup review.",
5281
+ " --replace When this machine is already wired to a different Haven agent on the bare",
5282
+ " haven / haven-signer pair, re-point that pair at the new agent and retire the",
5283
+ " previous agent directory locally (tombstoned, local key files removed).",
5284
+ " Nothing is revoked \u2014 revoke the old agent on the Haven agent page. Without",
5285
+ " this flag a non-interactive run REFUSES such a collision (wiring_collision)",
5286
+ " and an interactive terminal is asked; --name installs alongside instead.",
4793
5287
  " --name <slug> Wiring slug for a NAMED agent: writes haven-<slug> / haven-signer-<slug>",
4794
5288
  " MCP entries and stores credentials at ~/.haven/agents/<slug>/, so several",
4795
5289
  " agents can run side by side in one runtime. 1-32 lowercase letters, digits,",
@@ -4893,13 +5387,13 @@ async function runCli(argv, io = {
4893
5387
  }
4894
5388
  if (parsed.tombstone) {
4895
5389
  const { writeAgentTombstone: writeAgentTombstone2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
4896
- const { readFile: readFile13 } = await import('fs/promises');
4897
- const { join: join11 } = await import('path');
5390
+ const { readFile: readFile14 } = await import('fs/promises');
5391
+ const { join: join12 } = await import('path');
4898
5392
  try {
4899
5393
  let agentId = "unknown";
4900
5394
  try {
4901
5395
  const identity = JSON.parse(
4902
- await readFile13(join11(parsed.tombstone.directory, "identity.json"), "utf8")
5396
+ await readFile14(join12(parsed.tombstone.directory, "identity.json"), "utf8")
4903
5397
  );
4904
5398
  agentId = identity.agent_id ?? "unknown";
4905
5399
  } catch {
@@ -4938,10 +5432,10 @@ async function runCli(argv, io = {
4938
5432
  if (parsed.unwire) {
4939
5433
  const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
4940
5434
  const { homedir: homedir10 } = await import('os');
4941
- const { join: join11 } = await import('path');
5435
+ const { join: join12 } = await import('path');
4942
5436
  const homeDir = homedir10();
4943
- const root = parsed.options.credentialsDir ?? join11(homeDir, ".haven", "agents");
4944
- const directory = parsed.unwireDir ?? (parsed.options.serverName ? join11(root, parsed.options.serverName) : root);
5437
+ const root = parsed.options.credentialsDir ?? join12(homeDir, ".haven", "agents");
5438
+ const directory = parsed.unwireDir ?? (parsed.options.serverName ? join12(root, parsed.options.serverName) : root);
4945
5439
  try {
4946
5440
  const result = await unwireAgent2({
4947
5441
  directory,
@@ -5108,12 +5602,23 @@ async function runCli(argv, io = {
5108
5602
  const result = await runConnect(
5109
5603
  {
5110
5604
  ...parsed.options,
5111
- waitForApproval: !parsed.json,
5605
+ // #1377 D / #2484: leave prose runs UNSPECIFIED (undefined) so
5606
+ // runConnect's stdout-TTY narration gate decides whether there is a
5607
+ // watching human to narrate to — an agent invoking prose as a tool
5608
+ // call has none and must not sit opaque in the wait. --json stays an
5609
+ // explicit false (skip, emit promptly).
5610
+ waitForApproval: parsed.json ? false : void 0,
5112
5611
  // #1719: only a human-facing run may be asked which installed client to
5113
5612
  // configure. --json is the automation contract — it must fail with a
5114
5613
  // machine-readable code, never block on stdin. runConnect additionally
5115
5614
  // requires a real TTY before it prompts.
5116
- interactive: !parsed.json
5615
+ interactive: !parsed.json,
5616
+ // #2528: reported to the backend at register, so the funnel can tell a
5617
+ // machine-readable run from a narrated one. Read from the SAME
5618
+ // `parsed.json` the three flags above use, rather than inferred later
5619
+ // from `waitForApproval` — that flag is already false for a prose run
5620
+ // with no TTY (#2484), so inferring would mislabel real prose runs.
5621
+ runMode: parsed.json ? "json" : "prose"
5117
5622
  },
5118
5623
  {
5119
5624
  log: (message) => (parsed.json ? io.stderr : io.stdout)(`${message}