@alfe.ai/integrations 0.1.5 → 0.2.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +314 -4
  2. package/dist/index.js +742 -104
  3. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ import { homedir, platform, tmpdir } from "node:os";
5
5
  import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { buildConfigValidationSchema, parseManifestFile } from "@alfe.ai/integration-manifest";
7
7
  import { createLogger } from "@auriclabs/logger";
8
+ import { parseDocument } from "yaml";
9
+ import { toServerConfig } from "@alfe.ai/mcp-bundler";
8
10
  //#region src/registry.ts
9
11
  /** Default cache TTL — refetch the registry index after this many ms. */
10
12
  const DEFAULT_REGISTRY_TTL_MS = 6e4;
@@ -149,8 +151,8 @@ var Resolver = class {
149
151
  * are installed at the root integrations directory so all hook scripts
150
152
  * can resolve them via Node's upward module resolution.
151
153
  */
152
- const execFileAsync$1 = promisify(execFile);
153
- const log$2 = createLogger("Installer");
154
+ const execFileAsync$2 = promisify(execFile);
155
+ const log$4 = createLogger("Installer");
154
156
  const INTEGRATIONS_DIR = join(homedir(), ".alfe", "integrations");
155
157
  const GIT_TIMEOUT_MS = 6e4;
156
158
  const NPM_TIMEOUT_MS = 6e4;
@@ -211,12 +213,12 @@ var Installer = class {
211
213
  */
212
214
  async cloneDirect(resolved, installPath) {
213
215
  try {
214
- await execFileAsync$1("git", [
216
+ await execFileAsync$2("git", [
215
217
  "clone",
216
218
  resolved.repository,
217
219
  installPath
218
220
  ], { timeout: GIT_TIMEOUT_MS });
219
- await execFileAsync$1("git", ["checkout", resolved.commit], {
221
+ await execFileAsync$2("git", ["checkout", resolved.commit], {
220
222
  cwd: installPath,
221
223
  timeout: GIT_TIMEOUT_MS
222
224
  });
@@ -237,12 +239,12 @@ var Installer = class {
237
239
  const subdir = resolved.subdir;
238
240
  const tempDir = mkdtempSync(join(tmpdir(), `alfe-clone-${resolved.id}-`));
239
241
  try {
240
- await execFileAsync$1("git", [
242
+ await execFileAsync$2("git", [
241
243
  "clone",
242
244
  resolved.repository,
243
245
  tempDir
244
246
  ], { timeout: GIT_TIMEOUT_MS });
245
- await execFileAsync$1("git", ["checkout", resolved.commit], {
247
+ await execFileAsync$2("git", ["checkout", resolved.commit], {
246
248
  cwd: tempDir,
247
249
  timeout: GIT_TIMEOUT_MS
248
250
  });
@@ -312,7 +314,7 @@ var Installer = class {
312
314
  dependencies: { ...SHARED_PACKAGES }
313
315
  };
314
316
  writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n", "utf-8");
315
- log$2.info("Installing shared @alfe.ai packages for integration hooks");
317
+ log$4.info("Installing shared @alfe.ai packages for integration hooks");
316
318
  await this.runNpmInstall(this.basePath);
317
319
  this.sharedPackagesReady = true;
318
320
  }
@@ -322,12 +324,12 @@ var Installer = class {
322
324
  */
323
325
  async installLocalDependencies(installPath) {
324
326
  if (!existsSync(join(installPath, "package.json"))) return;
325
- log$2.info({ path: installPath }, "Installing integration-specific npm dependencies");
327
+ log$4.info({ path: installPath }, "Installing integration-specific npm dependencies");
326
328
  await this.runNpmInstall(installPath);
327
329
  }
328
330
  async runNpmInstall(cwd) {
329
331
  try {
330
- await execFileAsync$1("npm", [
332
+ await execFileAsync$2("npm", [
331
333
  "install",
332
334
  "--production",
333
335
  "--no-audit",
@@ -564,12 +566,17 @@ var LockManager = class {
564
566
  }
565
567
  /**
566
568
  * Add entries for an integration activation in a specific runtime.
569
+ *
570
+ * Pass `opts.configApplied` when the integration applied runtime config so
571
+ * the contribution is recorded even if it ships no plugins/skills — this is
572
+ * what lets a config-only integration be torn down on deactivate.
567
573
  */
568
- addEntries(runtime, integrationId, version, plugins, skills, installPath) {
574
+ addEntries(runtime, integrationId, version, plugins, skills, installPath, opts) {
569
575
  const lock = this.read();
570
576
  if (!(runtime in lock.runtimes)) lock.runtimes[runtime] = {
571
577
  plugins: [],
572
- skills: []
578
+ skills: [],
579
+ config: []
573
580
  };
574
581
  const state = lock.runtimes[runtime];
575
582
  for (const plugin of plugins) if (!state.plugins.some((p) => p.package === plugin.package && p.sourceIntegration === integrationId)) state.plugins.push({
@@ -587,11 +594,22 @@ var LockManager = class {
587
594
  integrationVersion: version
588
595
  });
589
596
  }
597
+ if (opts?.configApplied) {
598
+ state.config ??= [];
599
+ if (!state.config.some((c) => c.sourceIntegration === integrationId)) state.config.push({
600
+ sourceIntegration: integrationId,
601
+ integrationVersion: version
602
+ });
603
+ }
590
604
  this.write(lock);
591
605
  }
592
606
  /**
593
607
  * Remove all entries for a given integration across all runtimes.
594
608
  * Returns what was removed, keyed by runtime.
609
+ *
610
+ * A runtime is included in the result when the integration contributed
611
+ * plugins, skills, OR config there — so `deactivate` drives
612
+ * `applier.removeConfig` even for a config-only integration.
595
613
  */
596
614
  removeEntries(integrationId) {
597
615
  const lock = this.read();
@@ -599,12 +617,15 @@ var LockManager = class {
599
617
  for (const [runtime, state] of Object.entries(lock.runtimes)) {
600
618
  const removedPlugins = state.plugins.filter((p) => p.sourceIntegration === integrationId);
601
619
  const removedSkills = state.skills.filter((s) => s.sourceIntegration === integrationId);
602
- if (removedPlugins.length > 0 || removedSkills.length > 0) removed[runtime] = {
620
+ const removedConfig = (state.config ?? []).filter((c) => c.sourceIntegration === integrationId);
621
+ if (removedPlugins.length > 0 || removedSkills.length > 0 || removedConfig.length > 0) removed[runtime] = {
603
622
  plugins: removedPlugins,
604
- skills: removedSkills
623
+ skills: removedSkills,
624
+ config: removedConfig
605
625
  };
606
626
  state.plugins = state.plugins.filter((p) => p.sourceIntegration !== integrationId);
607
627
  state.skills = state.skills.filter((s) => s.sourceIntegration !== integrationId);
628
+ if (state.config) state.config = state.config.filter((c) => c.sourceIntegration !== integrationId);
608
629
  }
609
630
  this.write(lock);
610
631
  return removed;
@@ -1113,7 +1134,7 @@ var IntegrationManager = class {
1113
1134
  message: "Already active"
1114
1135
  }
1115
1136
  };
1116
- if (entry.status !== "configured" && entry.status !== "installed") return this.err("INVALID_STATE", `Cannot activate integration in "${entry.status}" state`);
1137
+ if (entry.status !== "configured" && entry.status !== "installed" && entry.status !== "error") return this.err("INVALID_STATE", `Cannot activate integration in "${entry.status}" state`);
1117
1138
  this.log.info(`Activating integration: ${integrationId}`);
1118
1139
  try {
1119
1140
  const installPath = this.installer.getInstallPath(integrationId);
@@ -1136,6 +1157,11 @@ var IntegrationManager = class {
1136
1157
  continue;
1137
1158
  }
1138
1159
  const { plugins, skills, config: runtimeConfig } = resolveInstallsForRuntime(manifest, runtimeName);
1160
+ if (applier.ensurePluginsAllowed && plugins.length > 0) try {
1161
+ await applier.ensurePluginsAllowed(plugins.map((p) => p.package));
1162
+ } catch (err) {
1163
+ this.log.warn(`Failed to pre-trust plugins for ${integrationId} on ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
1164
+ }
1139
1165
  const pluginFailures = [];
1140
1166
  for (const plugin of plugins) {
1141
1167
  this.log.info(`Applying plugin ${plugin.package} to ${runtimeName}`);
@@ -1158,18 +1184,22 @@ var IntegrationManager = class {
1158
1184
  this.log.info(`Applying skill ${skillName} to ${runtimeName}`);
1159
1185
  await applier.applySkill(skillName, srcPath);
1160
1186
  }
1187
+ let runtimeConfigApplied = false;
1161
1188
  if (runtimeConfig && Object.keys(runtimeConfig).length > 0) {
1162
1189
  const agentConfig = entry.config;
1163
1190
  const interpolatedConfig = interpolateSelfConfig(runtimeConfig, agentConfig);
1164
1191
  this.log.info(`Applying config for ${integrationId} to ${runtimeName}`);
1165
1192
  await applier.applyConfig(integrationId, interpolatedConfig);
1166
1193
  configApplied = true;
1194
+ runtimeConfigApplied = true;
1167
1195
  }
1168
1196
  const appliedPlugins = plugins.filter((p) => !pluginFailures.includes(p.package));
1169
- if (appliedPlugins.length > 0 || skills.length > 0) this.lockManager.addEntries(runtimeName, integrationId, manifest.version, appliedPlugins, skills, installPath);
1197
+ if (appliedPlugins.length > 0 || skills.length > 0 || runtimeConfigApplied) this.lockManager.addEntries(runtimeName, integrationId, manifest.version, appliedPlugins, skills, installPath, { configApplied: runtimeConfigApplied });
1170
1198
  }
1171
1199
  const mcpServers = manifest.mcp_servers ?? [];
1172
- if (mcpServers.length > 0) if (!this.mcpApplier) this.log.warn(`Integration "${integrationId}" declares ${String(mcpServers.length)} mcp_server(s) but no mcpApplier is wired — skipping MCP registration`);
1200
+ const runtimeSupportsMcp = !supportedAgents || supportedAgents.length === 0 || [...this.runtimeAppliers.keys()].some((r) => supportedAgents.includes(r));
1201
+ if (mcpServers.length > 0 && !runtimeSupportsMcp) this.log.warn(`Integration "${integrationId}" declares ${String(mcpServers.length)} mcp_server(s) but no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${supportedAgents.join(", ")}) — skipping MCP registration`);
1202
+ else if (mcpServers.length > 0) if (!this.mcpApplier) this.log.warn(`Integration "${integrationId}" declares ${String(mcpServers.length)} mcp_server(s) but no mcpApplier is wired — skipping MCP registration`);
1173
1203
  else {
1174
1204
  const secretEntries = this.secrets.get(integrationId);
1175
1205
  const mergedConfig = {
@@ -1587,32 +1617,7 @@ var IntegrationManager = class {
1587
1617
  }
1588
1618
  };
1589
1619
  //#endregion
1590
- //#region src/appliers/openclaw-applier.ts
1591
- /**
1592
- * OpenClawApplier — applies plugins, skills, and config to the OpenClaw runtime.
1593
- *
1594
- * Plugins are installed via `openclaw plugins install`.
1595
- * Skills are copied to ~/.alfe/skills/{name}.
1596
- * Config is applied via `openclaw config set` so OpenClaw manages its own
1597
- * config file (openclaw.json) without clobbering. Per-integration tracking
1598
- * is stored in a separate tracking file (config.json) for clean removal.
1599
- */
1600
- const execFileAsync = promisify(execFile);
1601
- const log$1 = createLogger("OpenClawApplier");
1602
- const DEFAULT_SKILLS_DIR = join(homedir(), ".alfe", "skills");
1603
- /**
1604
- * Bundled OpenClaw plugins that ship inside the runtime (not installed as npm
1605
- * `@alfe.ai/openclaw-*` packages) and must always be present in `plugins.allow`.
1606
- *
1607
- * OpenClaw 2026.6.8+ treats a non-empty `plugins.allow` as an EXCLUSIVE allowlist:
1608
- * any plugin absent from it is gated off even when its manifest is
1609
- * `enabledByDefault`. Because we build `plugins.allow` incrementally from the npm
1610
- * plugins we `applyPlugin`, bundled plugins never land in it and are silently
1611
- * disabled. Keep this list minimal — only bundled plugins Alfe actually relies on.
1612
- * Providers (anthropic/openai/elevenlabs/…) are intentionally excluded: Alfe routes
1613
- * LLM/voice traffic through its own AI proxy, so leaving them gated off is correct.
1614
- */
1615
- const BUNDLED_BASELINE_ALLOW = ["browser"];
1620
+ //#region src/appliers/config-flatten.ts
1616
1621
  function flattenConfig(obj, prefix = "") {
1617
1622
  const entries = [];
1618
1623
  for (const [key, val] of Object.entries(obj)) {
@@ -1646,7 +1651,7 @@ function partitionEntries(entries) {
1646
1651
  });
1647
1652
  continue;
1648
1653
  }
1649
- if (!entry.parentPath) throw new Error(`OpenClawApplier: top-level config keys containing dots are not supported (key: "${entry.dottedKey}")`);
1654
+ if (!entry.parentPath) throw new Error(`config-flatten: top-level config keys containing dots are not supported (key: "${entry.dottedKey}")`);
1650
1655
  let bucket = subtreesByParent.get(entry.parentPath);
1651
1656
  if (!bucket) {
1652
1657
  bucket = /* @__PURE__ */ new Map();
@@ -1659,9 +1664,75 @@ function partitionEntries(entries) {
1659
1664
  subtreesByParent
1660
1665
  };
1661
1666
  }
1667
+ //#endregion
1668
+ //#region src/appliers/openclaw-applier.ts
1669
+ /**
1670
+ * OpenClawApplier — applies plugins, skills, and config to the OpenClaw runtime.
1671
+ *
1672
+ * Plugins are installed via `openclaw plugins install`.
1673
+ * Skills are copied to ~/.alfe/skills/{name}.
1674
+ * Config is applied via `openclaw config set` so OpenClaw manages its own
1675
+ * config file (openclaw.json) without clobbering. Per-integration tracking
1676
+ * is stored in a separate tracking file (config.json) for clean removal.
1677
+ */
1678
+ const execFileAsync$1 = promisify(execFile);
1679
+ const log$3 = createLogger("OpenClawApplier");
1680
+ const DEFAULT_SKILLS_DIR = join(homedir(), ".alfe", "skills");
1681
+ /**
1682
+ * Bundled OpenClaw plugins that ship inside the runtime (not installed as npm
1683
+ * `@alfe.ai/openclaw-*` packages) and must always be present in `plugins.allow`.
1684
+ *
1685
+ * OpenClaw 2026.6.8+ treats a non-empty `plugins.allow` as an EXCLUSIVE allowlist:
1686
+ * any plugin absent from it is gated off even when its manifest is
1687
+ * `enabledByDefault`. Because we build `plugins.allow` incrementally from the npm
1688
+ * plugins we `applyPlugin`, bundled plugins never land in it and are silently
1689
+ * disabled. Keep this list minimal — only bundled plugins Alfe actually relies on.
1690
+ * Providers (anthropic/openai/elevenlabs/…) are intentionally excluded: Alfe routes
1691
+ * LLM/voice traffic through its own AI proxy, so leaving them gated off is correct.
1692
+ */
1693
+ const BUNDLED_BASELINE_ALLOW = ["browser"];
1694
+ /**
1695
+ * Every `openclaw config set` triggers an async hot-reload of the running
1696
+ * OpenClaw runtime, which itself rewrites openclaw.json. If the next
1697
+ * `config set` fires before that reload settles it collides with the
1698
+ * in-flight write (OpenClaw's clobber protection) and exits non-zero with a
1699
+ * bare "Command failed". We serialize all config writes through one promise
1700
+ * chain and retry transient failures with a short backoff so the reload has
1701
+ * time to settle between writes.
1702
+ */
1703
+ const CONFIG_SET_RETRIES$1 = 3;
1704
+ const CONFIG_SET_RETRY_DELAY_MS$1 = 750;
1705
+ const delay$1 = (ms) => new Promise((resolve) => {
1706
+ setTimeout(resolve, ms);
1707
+ });
1708
+ /**
1709
+ * Describe a `config set` target WITHOUT leaking values. The value argument is
1710
+ * the JSON payload (model config, the gateway loopback token via the alfe hook,
1711
+ * etc.) and must never reach the integration's `errorMessage`, which projects
1712
+ * to the user-facing dashboard. Keep only the program + the path/flag.
1713
+ */
1714
+ function redactConfigSetTarget(args) {
1715
+ return `openclaw config ${args.slice(0, 2).join(" ")}`.trim();
1716
+ }
1717
+ /**
1718
+ * Build a diagnosable BUT scrubbed message from an execFile rejection. Node's
1719
+ * execFile error `message` is "Command failed: <full argv>" — which for
1720
+ * `config set` embeds the value payload — so we never surface it. We keep the
1721
+ * redacted target, the exit code, and `stderr` (openclaw's own error text).
1722
+ */
1723
+ function configSetErrorMessage(err, args) {
1724
+ const target = redactConfigSetTarget(args);
1725
+ if (err instanceof Error) {
1726
+ const e = err;
1727
+ const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
1728
+ const code = e.code !== void 0 ? ` (exit ${e.code})` : "";
1729
+ return stderr ? `${target} failed${code}: ${stderr}` : `${target} failed${code}`;
1730
+ }
1731
+ return `${target} failed: ${String(err)}`;
1732
+ }
1662
1733
  async function readParentObject(parentPath) {
1663
1734
  try {
1664
- const { stdout } = await execFileAsync("openclaw", [
1735
+ const { stdout } = await execFileAsync$1("openclaw", [
1665
1736
  "config",
1666
1737
  "get",
1667
1738
  parentPath
@@ -1677,6 +1748,10 @@ var OpenClawApplier = class {
1677
1748
  agentWorkspace;
1678
1749
  skillsDir;
1679
1750
  trackingPath;
1751
+ configSetRetries;
1752
+ configSetRetryDelayMs;
1753
+ /** Serializes all `openclaw config set` writes so they never interleave with each other or the hot-reload they trigger. */
1754
+ configSetQueue = Promise.resolve();
1680
1755
  constructor(options) {
1681
1756
  const home = options.home ?? options.workspace;
1682
1757
  if (!home) throw new Error("OpenClawApplier requires `home` (or legacy `workspace`) option");
@@ -1684,20 +1759,53 @@ var OpenClawApplier = class {
1684
1759
  this.agentWorkspace = options.agentWorkspace ?? join(home, "workspace");
1685
1760
  this.skillsDir = options.skillsDir ?? DEFAULT_SKILLS_DIR;
1686
1761
  this.trackingPath = options.configPath ?? join(this.home, "config.json");
1762
+ this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES$1;
1763
+ this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS$1;
1764
+ }
1765
+ /** Convenience: `openclaw config set <args>`, serialized + retried. */
1766
+ runConfigSet(setArgs, opts = {}) {
1767
+ return this.runConfigCommand(["set", ...setArgs], opts);
1768
+ }
1769
+ /**
1770
+ * Run `openclaw config <args>` (set/unset), serialized against every other
1771
+ * config write and retried with backoff. See CONFIG_SET_RETRIES for why:
1772
+ * each write triggers a runtime hot-reload that rewrites openclaw.json, and a
1773
+ * follow-up command that races the reload fails with a bare "Command failed".
1774
+ *
1775
+ * Throws an Error whose (scrubbed) message includes stderr after retries are
1776
+ * exhausted, so the real cause propagates to the integration errorMessage
1777
+ * without leaking the value payload.
1778
+ */
1779
+ runConfigCommand(args, opts = {}) {
1780
+ const timeout = opts.timeout ?? 1e4;
1781
+ const run = async () => {
1782
+ let lastErr;
1783
+ for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
1784
+ await execFileAsync$1("openclaw", ["config", ...args], { timeout });
1785
+ return;
1786
+ } catch (err) {
1787
+ lastErr = err;
1788
+ if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay$1(this.configSetRetryDelayMs * 2 ** attempt);
1789
+ }
1790
+ throw new Error(configSetErrorMessage(lastErr, args));
1791
+ };
1792
+ const result = this.configSetQueue.then(run, run);
1793
+ this.configSetQueue = result.catch(() => void 0);
1794
+ return result;
1687
1795
  }
1688
1796
  async applyPlugin(spec, _installPath, opts) {
1689
1797
  const pkg = stripPluginVersion(spec);
1690
1798
  await this.ensurePluginsAllow(pkg);
1691
1799
  this.cleanupUntrackedExtensionInstall(pkg);
1692
1800
  if (opts?.force && this.isPluginInstalled(pkg)) {
1693
- log$1.info({
1801
+ log$3.info({
1694
1802
  pkg,
1695
1803
  spec
1696
1804
  }, "Force mode — uninstalling plugin before reinstall");
1697
1805
  try {
1698
1806
  await this.removePlugin(pkg);
1699
1807
  } catch (err) {
1700
- log$1.warn({
1808
+ log$3.warn({
1701
1809
  pkg,
1702
1810
  spec,
1703
1811
  err: err instanceof Error ? err.message : String(err)
@@ -1715,12 +1823,12 @@ var OpenClawApplier = class {
1715
1823
  const args = useUnsafeFlag ? [...baseArgs, "--dangerously-force-unsafe-install"] : baseArgs;
1716
1824
  try {
1717
1825
  try {
1718
- await execFileAsync("openclaw", args, { timeout: 6e4 });
1826
+ await execFileAsync$1("openclaw", args, { timeout: 6e4 });
1719
1827
  } catch (err) {
1720
1828
  const errText = err instanceof Error ? `${err.message}\n${err.stderr ?? ""}` : String(err);
1721
1829
  if (useUnsafeFlag && errText.includes("unknown option")) {
1722
- log$1.info({ pkg }, "OpenClaw does not support --dangerously-force-unsafe-install, retrying without");
1723
- await execFileAsync("openclaw", baseArgs, { timeout: 6e4 });
1830
+ log$3.info({ pkg }, "OpenClaw does not support --dangerously-force-unsafe-install, retrying without");
1831
+ await execFileAsync$1("openclaw", baseArgs, { timeout: 6e4 });
1724
1832
  } else throw err;
1725
1833
  }
1726
1834
  } catch (err) {
@@ -1728,7 +1836,7 @@ var OpenClawApplier = class {
1728
1836
  setTimeout(r, 500);
1729
1837
  });
1730
1838
  if (!this.isPluginInstalled(pkg)) throw err;
1731
- log$1.warn({
1839
+ log$3.warn({
1732
1840
  pkg,
1733
1841
  spec,
1734
1842
  err: err instanceof Error ? err.message : String(err)
@@ -1740,13 +1848,18 @@ var OpenClawApplier = class {
1740
1848
  }
1741
1849
  }
1742
1850
  /**
1743
- * Ensure the plugin is in plugins.allow in openclaw.json.
1851
+ * Ensure one or more plugins are in plugins.allow in openclaw.json.
1744
1852
  * Uses `openclaw config set` to avoid clobbering OpenClaw's own file format.
1853
+ *
1854
+ * Prefer passing the FULL set of plugins for an integration in a single call
1855
+ * (see `ensurePluginsAllowed`): each write triggers a runtime hot-reload, so
1856
+ * one write for N plugins is one reload instead of N.
1745
1857
  */
1746
- async ensurePluginsAllow(pkg) {
1858
+ async ensurePluginsAllow(pkgs) {
1859
+ const wanted = Array.isArray(pkgs) ? pkgs : [pkgs];
1747
1860
  let currentAllow = [];
1748
1861
  try {
1749
- const { stdout } = await execFileAsync("openclaw", [
1862
+ const { stdout } = await execFileAsync$1("openclaw", [
1750
1863
  "config",
1751
1864
  "get",
1752
1865
  "plugins.allow"
@@ -1754,24 +1867,30 @@ var OpenClawApplier = class {
1754
1867
  const parsed = JSON.parse(stdout.trim());
1755
1868
  if (Array.isArray(parsed)) currentAllow = parsed;
1756
1869
  } catch {}
1757
- const missing = [...new Set([pkg, ...BUNDLED_BASELINE_ALLOW])].filter((id) => !currentAllow.includes(id));
1870
+ const missing = [...new Set([...wanted, ...BUNDLED_BASELINE_ALLOW])].filter((id) => !currentAllow.includes(id));
1758
1871
  if (missing.length === 0) return;
1759
1872
  const updated = [...currentAllow, ...missing];
1760
1873
  try {
1761
- await execFileAsync("openclaw", [
1762
- "config",
1763
- "set",
1764
- "plugins.allow",
1765
- JSON.stringify(updated)
1766
- ], { timeout: 1e4 });
1874
+ await this.runConfigSet(["plugins.allow", JSON.stringify(updated)]);
1767
1875
  } catch (err) {
1768
- log$1.warn({
1876
+ log$3.warn({
1769
1877
  err: err instanceof Error ? err.message : String(err),
1770
- pkg
1878
+ pkgs: wanted
1771
1879
  }, "Failed to set plugins.allow via openclaw config set");
1772
1880
  }
1773
1881
  }
1774
1882
  /**
1883
+ * Pre-trust every plugin an integration ships in a SINGLE plugins.allow
1884
+ * write, before any are installed. Called once by the manager ahead of the
1885
+ * per-plugin install loop so activation triggers one hot-reload for the
1886
+ * allowlist instead of one per plugin. `applyPlugin`'s own per-plugin
1887
+ * `ensurePluginsAllow` then finds nothing missing and is a no-op.
1888
+ */
1889
+ async ensurePluginsAllowed(specs) {
1890
+ if (specs.length === 0) return;
1891
+ await this.ensurePluginsAllow(specs.map(stripPluginVersion));
1892
+ }
1893
+ /**
1775
1894
  * Check if a plugin is already installed.
1776
1895
  *
1777
1896
  * OpenClaw 2026.4 stored plugins under `~/.openclaw/extensions/{pkg-name-with-dashes}-{hash}`.
@@ -1818,12 +1937,12 @@ var OpenClawApplier = class {
1818
1937
  recursive: true,
1819
1938
  force: true
1820
1939
  });
1821
- log$1.info({
1940
+ log$3.info({
1822
1941
  pkg,
1823
1942
  removed: fullPath
1824
1943
  }, "Removed untracked extensions/ install — will reinstall via npm path");
1825
1944
  } catch (err) {
1826
- log$1.warn({
1945
+ log$3.warn({
1827
1946
  pkg,
1828
1947
  removed: fullPath,
1829
1948
  err: err instanceof Error ? err.message : String(err)
@@ -1832,7 +1951,7 @@ var OpenClawApplier = class {
1832
1951
  }
1833
1952
  }
1834
1953
  async removePlugin(spec) {
1835
- await execFileAsync("openclaw", [
1954
+ await execFileAsync$1("openclaw", [
1836
1955
  "plugins",
1837
1956
  "uninstall",
1838
1957
  "--force",
@@ -1846,21 +1965,21 @@ var OpenClawApplier = class {
1846
1965
  return Promise.resolve();
1847
1966
  }
1848
1967
  async applyClawHubSkill(slug) {
1849
- log$1.info({ slug }, "Installing skill from ClawHub");
1968
+ log$3.info({ slug }, "Installing skill from ClawHub");
1850
1969
  try {
1851
- await execFileAsync("openclaw", [
1970
+ await execFileAsync$1("openclaw", [
1852
1971
  "skills",
1853
1972
  "install",
1854
1973
  slug
1855
1974
  ], { timeout: 6e4 });
1856
- log$1.info({ slug }, "ClawHub skill installed");
1975
+ log$3.info({ slug }, "ClawHub skill installed");
1857
1976
  } catch (err) {
1858
1977
  const msg = err instanceof Error ? err.message : String(err);
1859
1978
  if (msg.includes("already exists") || msg.includes("Skill already exists")) {
1860
- log$1.info({ slug }, "ClawHub skill already installed (skipped)");
1979
+ log$3.info({ slug }, "ClawHub skill already installed (skipped)");
1861
1980
  return;
1862
1981
  }
1863
- log$1.error({
1982
+ log$3.error({
1864
1983
  slug,
1865
1984
  err: msg
1866
1985
  }, "ClawHub skill install failed");
@@ -1873,7 +1992,7 @@ var OpenClawApplier = class {
1873
1992
  recursive: true,
1874
1993
  force: true
1875
1994
  });
1876
- log$1.info({ slug }, "ClawHub skill removed");
1995
+ log$3.info({ slug }, "ClawHub skill removed");
1877
1996
  }
1878
1997
  return Promise.resolve();
1879
1998
  }
@@ -1902,14 +2021,9 @@ var OpenClawApplier = class {
1902
2021
  const merged = { ...await readParentObject(parentPath) };
1903
2022
  for (const [k, v] of dottedKvs) merged[k] = v;
1904
2023
  try {
1905
- await execFileAsync("openclaw", [
1906
- "config",
1907
- "set",
1908
- parentPath,
1909
- JSON.stringify(merged)
1910
- ], { timeout: 1e4 });
2024
+ await this.runConfigSet([parentPath, JSON.stringify(merged)]);
1911
2025
  } catch (err) {
1912
- log$1.error({
2026
+ log$3.error({
1913
2027
  err: err instanceof Error ? err.message : String(err),
1914
2028
  parentPath
1915
2029
  }, "Failed to set config subtree via openclaw config set");
@@ -1917,14 +2031,9 @@ var OpenClawApplier = class {
1917
2031
  }
1918
2032
  }
1919
2033
  if (leaves.length > 0) try {
1920
- await execFileAsync("openclaw", [
1921
- "config",
1922
- "set",
1923
- "--batch-json",
1924
- JSON.stringify(leaves)
1925
- ], { timeout: 1e4 });
2034
+ await this.runConfigSet(["--batch-json", JSON.stringify(leaves)]);
1926
2035
  } catch (err) {
1927
- log$1.error({
2036
+ log$3.error({
1928
2037
  err: err instanceof Error ? err.message : String(err),
1929
2038
  batch: leaves
1930
2039
  }, "Failed to set config via openclaw config set --batch-json");
@@ -1944,13 +2053,9 @@ var OpenClawApplier = class {
1944
2053
  const integrationConfig = integrations[integrationId];
1945
2054
  const { leaves, subtreesByParent } = partitionEntries(flattenConfig(integrationConfig));
1946
2055
  for (const { path } of leaves) try {
1947
- await execFileAsync("openclaw", [
1948
- "config",
1949
- "unset",
1950
- path
1951
- ], { timeout: 1e4 });
2056
+ await this.runConfigCommand(["unset", path]);
1952
2057
  } catch (err) {
1953
- log$1.warn({
2058
+ log$3.warn({
1954
2059
  err: err instanceof Error ? err.message : String(err),
1955
2060
  path
1956
2061
  }, "Failed to unset config via openclaw config unset");
@@ -1960,19 +2065,10 @@ var OpenClawApplier = class {
1960
2065
  const remaining = Object.fromEntries(Object.entries(existing).filter(([k]) => !dottedKvs.has(k)));
1961
2066
  if (Object.keys(existing).length === 0) continue;
1962
2067
  try {
1963
- if (Object.keys(remaining).length === 0) await execFileAsync("openclaw", [
1964
- "config",
1965
- "unset",
1966
- parentPath
1967
- ], { timeout: 1e4 });
1968
- else await execFileAsync("openclaw", [
1969
- "config",
1970
- "set",
1971
- parentPath,
1972
- JSON.stringify(remaining)
1973
- ], { timeout: 1e4 });
2068
+ if (Object.keys(remaining).length === 0) await this.runConfigCommand(["unset", parentPath]);
2069
+ else await this.runConfigSet([parentPath, JSON.stringify(remaining)]);
1974
2070
  } catch (err) {
1975
- log$1.warn({
2071
+ log$3.warn({
1976
2072
  err: err instanceof Error ? err.message : String(err),
1977
2073
  parentPath
1978
2074
  }, "Failed to update parent config during remove");
@@ -1981,6 +2077,19 @@ var OpenClawApplier = class {
1981
2077
  tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
1982
2078
  this.writeTracking(tracking);
1983
2079
  }
2080
+ /**
2081
+ * Raw single-key config write — `openclaw config set <key> <value>`.
2082
+ *
2083
+ * Deliberately bypasses the `_integrations` tracking that `applyConfig`
2084
+ * maintains: this is a fire-and-forget write for the `alfe.config_set`
2085
+ * cloud-command, not an integration contribution, so it must NOT be
2086
+ * recorded for later `removeConfig` teardown. Reuses the serialized +
2087
+ * retried queue so it can't interleave with the hot-reload that integration
2088
+ * config writes trigger.
2089
+ */
2090
+ setConfigRaw(key, value) {
2091
+ return this.runConfigSet([key, value]);
2092
+ }
1984
2093
  isAvailable() {
1985
2094
  return Promise.resolve(existsSync(this.home));
1986
2095
  }
@@ -1998,6 +2107,535 @@ var OpenClawApplier = class {
1998
2107
  }
1999
2108
  };
2000
2109
  //#endregion
2110
+ //#region src/appliers/hermes-applier.ts
2111
+ /**
2112
+ * HermesApplier — applies integration config (and, later, native plugins) to
2113
+ * the Hermes runtime (Nous Research's Python agent).
2114
+ *
2115
+ * Hermes config lives in `~/.hermes/config.yaml` (YAML) and is mutated via the
2116
+ * `hermes config set/unset <dotted.key> <value>` CLI — we never write the YAML
2117
+ * file directly (mirrors the OpenClaw rule of letting the runtime own its own
2118
+ * config format). Per-integration contributions are tracked in a separate file
2119
+ * (`~/.hermes/.alfe-integrations.json`) so removal is precise.
2120
+ *
2121
+ * Scope (Phase 1, MCP-first hybrid):
2122
+ * - config: SUPPORTED — consumes `installs.runtimes.hermes.config` (the AI-proxy
2123
+ * routing keys: model.provider/base_url/api_key/model, etc.).
2124
+ * - plugins: the `@alfe.ai/openclaw-*` npm plugins are OpenClaw-specific and are
2125
+ * log-and-skipped here; a real Hermes-native plugin spec (git/pip/dir) would
2126
+ * `hermes plugins install`/`enable`, but no manifest declares one yet.
2127
+ * - skills: no-op (Hermes ships built-in skills; ClawHub is OpenClaw-only) —
2128
+ * deferred to a later per-capability phase.
2129
+ * - MCP: NOT handled here. MCP delivery for Hermes is a later phase (config-only
2130
+ * `mcp_servers:` via the runtime-agnostic store consumer). This applier owns
2131
+ * config + plugins only.
2132
+ */
2133
+ const execFileAsync = promisify(execFile);
2134
+ const log$2 = createLogger("HermesApplier");
2135
+ const DEFAULT_HERMES_HOME$1 = join(homedir(), ".hermes");
2136
+ /**
2137
+ * Hermes config writes are serialized through one promise chain so concurrent
2138
+ * `applyConfig` calls (the manager can fan out across integrations) never
2139
+ * interleave their `hermes config set` writes against the same config.yaml.
2140
+ *
2141
+ * Whether Hermes hot-reloads config.yaml on each write — the way OpenClaw does,
2142
+ * which forced a retry/backoff there — is NOT yet spike-confirmed. We keep a
2143
+ * modest retry as a precaution; if the spike proves Hermes writes are
2144
+ * synchronous and race-free, the retry count can drop to 0 without API change.
2145
+ */
2146
+ const CONFIG_SET_RETRIES = 3;
2147
+ const CONFIG_SET_RETRY_DELAY_MS = 750;
2148
+ const delay = (ms) => new Promise((resolve) => {
2149
+ setTimeout(resolve, ms);
2150
+ });
2151
+ /** `@alfe.ai/openclaw-*` packages are OpenClaw-specific plugins — not Hermes. */
2152
+ function isOpenClawPlugin(spec) {
2153
+ return stripPluginVersion(spec).startsWith("@alfe.ai/openclaw-");
2154
+ }
2155
+ /**
2156
+ * Serialize a config value for `hermes config set <key> <value>`. Strings pass
2157
+ * through verbatim (the proxy routing keys — provider/base_url/api_key/model —
2158
+ * are all strings, including `${ALFE_API_KEY}` env refs). Non-strings are
2159
+ * JSON-encoded so booleans/numbers/objects survive the CLI round-trip.
2160
+ */
2161
+ function stringifyConfigValue(value) {
2162
+ return typeof value === "string" ? value : JSON.stringify(value);
2163
+ }
2164
+ /**
2165
+ * Describe a `config set/unset` target WITHOUT leaking values. The value
2166
+ * argument can be a secret (`model.api_key`), so it must never reach the
2167
+ * integration's user-facing `errorMessage`. Keep only the program + verb + key.
2168
+ */
2169
+ function redactConfigTarget(args) {
2170
+ return `hermes config ${args.slice(0, 2).join(" ")}`.trim();
2171
+ }
2172
+ /**
2173
+ * Build a diagnosable BUT scrubbed message from an execFile rejection. Node's
2174
+ * execFile error `message` is "Command failed: <full argv>" — which for
2175
+ * `config set` embeds the value payload — so we never surface it. We keep the
2176
+ * redacted target, the exit code, and `stderr` (hermes' own error text).
2177
+ */
2178
+ function configErrorMessage(err, args) {
2179
+ const target = redactConfigTarget(args);
2180
+ if (err instanceof Error) {
2181
+ const e = err;
2182
+ const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
2183
+ const code = e.code !== void 0 ? ` (exit ${e.code})` : "";
2184
+ return stderr ? `${target} failed${code}: ${stderr}` : `${target} failed${code}`;
2185
+ }
2186
+ return `${target} failed: ${String(err)}`;
2187
+ }
2188
+ var HermesApplier = class {
2189
+ runtime = "hermes";
2190
+ home;
2191
+ trackingPath;
2192
+ configSetRetries;
2193
+ configSetRetryDelayMs;
2194
+ /** Serializes all `hermes config` writes so they never interleave. */
2195
+ configSetQueue = Promise.resolve();
2196
+ constructor(options = {}) {
2197
+ this.home = options.home ?? options.workspace ?? DEFAULT_HERMES_HOME$1;
2198
+ this.trackingPath = options.configPath ?? join(this.home, ".alfe-integrations.json");
2199
+ this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES;
2200
+ this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS;
2201
+ }
2202
+ /**
2203
+ * Apply integration config to the Hermes runtime via `hermes config set`.
2204
+ *
2205
+ * Each leaf is applied as `hermes config set <dotted.key> <value>`. Each
2206
+ * integration's config contribution is tracked in the tracking file so it can
2207
+ * be cleanly removed later (mirrors OpenClawApplier's `_integrations`).
2208
+ */
2209
+ async applyConfig(integrationId, config) {
2210
+ const tracking = this.readTracking();
2211
+ const integrations = tracking._integrations ?? {};
2212
+ integrations[integrationId] = config;
2213
+ tracking._integrations = integrations;
2214
+ this.writeTracking(tracking);
2215
+ const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
2216
+ if (subtreesByParent.size > 0) log$2.warn({
2217
+ integrationId,
2218
+ parents: [...subtreesByParent.keys()]
2219
+ }, "Hermes applyConfig: skipping dotted-key subtree(s) — OpenClaw-plugin-shaped config does not apply to Hermes");
2220
+ for (const { path, value } of leaves) try {
2221
+ await this.runConfigSet([path, stringifyConfigValue(value)]);
2222
+ } catch (err) {
2223
+ log$2.error({
2224
+ err: err instanceof Error ? err.message : String(err),
2225
+ key: path
2226
+ }, "Failed to set config via hermes config set");
2227
+ throw err;
2228
+ }
2229
+ }
2230
+ /**
2231
+ * Remove config previously applied by an integration.
2232
+ *
2233
+ * Reads the tracking file to find which keys this integration set, then
2234
+ * removes each via `hermes config unset`. Clears the tracking entry.
2235
+ */
2236
+ async removeConfig(integrationId) {
2237
+ const tracking = this.readTracking();
2238
+ const integrations = tracking._integrations ?? {};
2239
+ if (!(integrationId in integrations)) return;
2240
+ const integrationConfig = integrations[integrationId];
2241
+ const { leaves } = partitionEntries(flattenConfig(integrationConfig));
2242
+ for (const { path } of leaves) try {
2243
+ await this.unsetConfigKey(path);
2244
+ } catch (err) {
2245
+ log$2.warn({
2246
+ err: err instanceof Error ? err.message : String(err),
2247
+ key: path
2248
+ }, "Failed to unset config via hermes config unset");
2249
+ }
2250
+ tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
2251
+ this.writeTracking(tracking);
2252
+ }
2253
+ /**
2254
+ * Raw single-key config write — `hermes config set <key> <value>`.
2255
+ *
2256
+ * Deliberately bypasses the `_integrations` tracking that `applyConfig`
2257
+ * maintains: this is a fire-and-forget write for the `alfe.config_set`
2258
+ * cloud-command, NOT an integration contribution, so it must not be recorded
2259
+ * for later `removeConfig` teardown (doing so would corrupt the
2260
+ * per-integration removal accounting). Reuses the serialized queue.
2261
+ */
2262
+ setConfigRaw(key, value) {
2263
+ return this.runConfigSet([key, value]);
2264
+ }
2265
+ /**
2266
+ * Apply a plugin. The `@alfe.ai/openclaw-*` npm plugins are OpenClaw-specific
2267
+ * (they carry the `openclaw` peer dep) and do NOT apply to Hermes — log + skip
2268
+ * them. The manager catches per-plugin, so a skip here is a correct no-op.
2269
+ *
2270
+ * A genuine Hermes-native plugin spec (git URL / pip / local dir) would
2271
+ * `hermes plugins install` then `hermes plugins enable`; no manifest declares
2272
+ * one in Phase 1, so this path is a real attempt (not faked) but untrodden.
2273
+ */
2274
+ async applyPlugin(spec) {
2275
+ if (isOpenClawPlugin(spec)) {
2276
+ log$2.info({ spec }, "Hermes applyPlugin: skipping OpenClaw-specific npm plugin (not a Hermes plugin)");
2277
+ return;
2278
+ }
2279
+ log$2.info({ spec }, "Hermes applyPlugin: installing native Hermes plugin");
2280
+ await execFileAsync("hermes", [
2281
+ "plugins",
2282
+ "install",
2283
+ spec
2284
+ ], { timeout: 6e4 });
2285
+ await execFileAsync("hermes", [
2286
+ "plugins",
2287
+ "enable",
2288
+ spec
2289
+ ], { timeout: 3e4 });
2290
+ }
2291
+ /**
2292
+ * Remove a plugin. OpenClaw-specific npm plugins were never installed into
2293
+ * Hermes (applyPlugin skipped them), so removal is a no-op log. A native
2294
+ * Hermes plugin would be disabled via `hermes plugins`.
2295
+ */
2296
+ async removePlugin(spec) {
2297
+ if (isOpenClawPlugin(spec)) {
2298
+ log$2.info({ spec }, "Hermes removePlugin: skipping OpenClaw-specific npm plugin (was never installed on Hermes)");
2299
+ return;
2300
+ }
2301
+ log$2.info({ spec }, "Hermes removePlugin: disabling native Hermes plugin");
2302
+ await execFileAsync("hermes", [
2303
+ "plugins",
2304
+ "disable",
2305
+ spec
2306
+ ], { timeout: 3e4 });
2307
+ }
2308
+ applySkill(name) {
2309
+ log$2.info({ name }, "Hermes applySkill: no-op (Hermes built-in skills; deferred)");
2310
+ return Promise.resolve();
2311
+ }
2312
+ removeSkill(name) {
2313
+ log$2.info({ name }, "Hermes removeSkill: no-op (Hermes built-in skills; deferred)");
2314
+ return Promise.resolve();
2315
+ }
2316
+ applyClawHubSkill(slug) {
2317
+ log$2.info({ slug }, "Hermes applyClawHubSkill: no-op (ClawHub is OpenClaw-only)");
2318
+ return Promise.resolve();
2319
+ }
2320
+ removeClawHubSkill(slug) {
2321
+ log$2.info({ slug }, "Hermes removeClawHubSkill: no-op (ClawHub is OpenClaw-only)");
2322
+ return Promise.resolve();
2323
+ }
2324
+ isAvailable() {
2325
+ return Promise.resolve(existsSync(this.home));
2326
+ }
2327
+ /** Convenience: `hermes config set <args>`, serialized + retried. */
2328
+ runConfigSet(setArgs) {
2329
+ return this.runConfigCommand(["set", ...setArgs]);
2330
+ }
2331
+ /**
2332
+ * Unset a single config key. The unset verb is isolated HERE so there is one
2333
+ * place to change if the spike proves `hermes config unset` is unavailable.
2334
+ *
2335
+ * FALLBACK (do NOT pre-build): if `hermes config unset` does not exist, the
2336
+ * substitute is `hermes config set <key> ""` (clear the value) or a
2337
+ * read-merge-write of config.yaml. Not implemented now — `unset` is the
2338
+ * documented verb; confirm in the Phase-0 spike before adding a fallback.
2339
+ * TODO(phase-0 spike): confirm `hermes config unset <key>` exists.
2340
+ */
2341
+ unsetConfigKey(key) {
2342
+ return this.runConfigCommand(["unset", key]);
2343
+ }
2344
+ /**
2345
+ * Run `hermes config <args>` (set/unset), serialized against every other
2346
+ * config write and retried with backoff. Throws an Error whose (scrubbed)
2347
+ * message includes stderr after retries are exhausted, so the real cause
2348
+ * propagates to the integration errorMessage without leaking the value.
2349
+ */
2350
+ runConfigCommand(args) {
2351
+ const run = async () => {
2352
+ let lastErr;
2353
+ for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
2354
+ await execFileAsync("hermes", ["config", ...args], { timeout: 1e4 });
2355
+ return;
2356
+ } catch (err) {
2357
+ lastErr = err;
2358
+ if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay(this.configSetRetryDelayMs * 2 ** attempt);
2359
+ }
2360
+ throw new Error(configErrorMessage(lastErr, args));
2361
+ };
2362
+ const result = this.configSetQueue.then(run, run);
2363
+ this.configSetQueue = result.catch(() => void 0);
2364
+ return result;
2365
+ }
2366
+ readTracking() {
2367
+ if (!existsSync(this.trackingPath)) return {};
2368
+ try {
2369
+ return JSON.parse(readFileSync(this.trackingPath, "utf-8"));
2370
+ } catch {
2371
+ return {};
2372
+ }
2373
+ }
2374
+ writeTracking(config) {
2375
+ mkdirSync(join(this.trackingPath, ".."), { recursive: true });
2376
+ writeFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
2377
+ }
2378
+ };
2379
+ //#endregion
2380
+ //#region src/appliers/hermes-mcp-sync.ts
2381
+ /**
2382
+ * HermesMcpSync — Hermes-only CONSUMER of the runtime-agnostic MCP store
2383
+ * (Approach B: config.yaml mirror).
2384
+ *
2385
+ * Background: `McpApplier` writes resolved MCP servers (command/args/env, with
2386
+ * `{{config}}`/`{{credentials}}` already interpolated at apply time) into the
2387
+ * runtime-agnostic bundler store at `~/.alfe/mcp/servers.json`. OpenClaw consumes
2388
+ * that store over IPC (the daemon hosts the bundler children and the openclaw
2389
+ * plugin reaches them). Hermes cannot consume over IPC — it reads MCP servers
2390
+ * from its own `~/.hermes/config.yaml` under the top-level `mcp_servers:` key and
2391
+ * spawns the children itself. So Hermes needs its own consumer that mirrors the
2392
+ * store into `config.yaml`.
2393
+ *
2394
+ * This class is the parallel to OpenClaw's IPC consumption: it subscribes to
2395
+ * `manager.onChange()` and, **only when the active runtime is hermes** (the
2396
+ * daemon only constructs it for hermes agents), read-merge-writes the store into
2397
+ * `~/.hermes/config.yaml`, preserving user-authored `mcp_servers` and every other
2398
+ * config key.
2399
+ *
2400
+ * Two cross-cutting responsibilities make the mirrored servers actually work:
2401
+ *
2402
+ * 1. `ALFE_API_KEY` injection. The Alfe MCP servers (`@alfe.ai/<provider>-mcp`)
2403
+ * fetch their real provider credentials from the Alfe API at startup using
2404
+ * `ALFE_API_KEY`. For OpenClaw the daemon spawns the children, so they
2405
+ * inherit `ALFE_API_KEY` from the daemon's own `process.env`. Hermes spawns
2406
+ * the children itself in a separate process tree, so they would NOT inherit
2407
+ * it — without it they start in a silent zero-accounts degraded mode. We
2408
+ * therefore inject an `ALFE_API_KEY` reference into every mirrored stdio
2409
+ * server's env and write the actual secret into `~/.hermes/.env`
2410
+ * (read-merge-write, never clobbering other keys).
2411
+ *
2412
+ * 2. Restart. Writing `config.yaml` is assumed to require a runtime reload (vs a
2413
+ * hot-reload) — SPIKE-PENDING — so after a real change we trigger the EXISTING
2414
+ * runtime-restart path (the same callback the daemon's
2415
+ * `setRuntimeRestartNeededHandler` uses), never a second restart mechanism.
2416
+ *
2417
+ * The daemon gates its own bundler OFF for hermes (skips `loadIntoBundler` +
2418
+ * `warmup`) so the store is a pure ledger and the MCP children are spawned ONLY
2419
+ * by Hermes — avoiding a double-spawn of every server.
2420
+ */
2421
+ const log$1 = createLogger("HermesMcpSync");
2422
+ const DEFAULT_HERMES_HOME = join(homedir(), ".hermes");
2423
+ /**
2424
+ * SPIKE-PENDING SEAM #1 — `${VAR}` interpolation from `~/.hermes/.env`.
2425
+ *
2426
+ * We inject `ALFE_API_KEY=${ALFE_API_KEY}` into every Alfe-owned stdio server's
2427
+ * env and put the real value in `~/.hermes/.env`. This assumes Hermes
2428
+ * interpolates `${VAR}` references in `mcp_servers.<id>.env` from `.env` at spawn
2429
+ * time. TODO(phase-0 spike): confirm. If Hermes does NOT interpolate, the
2430
+ * fallback (NOT built here) is to write the literal `ALFE_API_KEY` VALUE inline
2431
+ * into each `mcp_servers.<id>.env` and skip the `.env` file entirely — change
2432
+ * `withAlfeApiKey()` + `ensureEnvApiKey()` together in that one case.
2433
+ */
2434
+ const ALFE_API_KEY_ENV_REF = "${ALFE_API_KEY}";
2435
+ const DEFAULT_DEBOUNCE_MS = 250;
2436
+ /**
2437
+ * Read-merge-write mirror of the MCP store into `~/.hermes/config.yaml`.
2438
+ *
2439
+ * Ownership / removal model: every entry returned by `manager.listServers()`
2440
+ * comes from the Alfe store (its `owner` is `integration:<id>` / `cli` /
2441
+ * `manual`) and is therefore Alfe-owned — these are the ids we write. User
2442
+ * authored `mcp_servers` entries live ONLY in config.yaml and never appear in
2443
+ * the store, so we never touch them. To know which config.yaml ids to REMOVE
2444
+ * when a server leaves the store, we persist the set of ids we last wrote to a
2445
+ * sidecar (`.alfe-mcp-synced.json`) and only ever delete from that set — exactly
2446
+ * how the bundler store tracks `_ownedOpenclawKeys` for its openclaw.json mirror.
2447
+ */
2448
+ var HermesMcpSync = class {
2449
+ manager;
2450
+ home;
2451
+ apiKey;
2452
+ requestRestart;
2453
+ configPath;
2454
+ envPath;
2455
+ trackingPath;
2456
+ debounceMs;
2457
+ /** Ids of `mcp_servers` entries this sync last wrote — the removal set. */
2458
+ syncedIds = /* @__PURE__ */ new Set();
2459
+ /** Serializes syncs so two onChange-driven runs can't interleave file writes. */
2460
+ queue = Promise.resolve();
2461
+ unsubscribe;
2462
+ debounceTimer;
2463
+ started = false;
2464
+ constructor(opts) {
2465
+ this.manager = opts.manager;
2466
+ this.home = opts.home ?? DEFAULT_HERMES_HOME;
2467
+ this.apiKey = opts.apiKey;
2468
+ this.requestRestart = opts.requestRestart;
2469
+ this.configPath = opts.configPath ?? join(this.home, "config.yaml");
2470
+ this.envPath = opts.envPath ?? join(this.home, ".env");
2471
+ this.trackingPath = opts.trackingPath ?? join(this.home, ".alfe-mcp-synced.json");
2472
+ this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
2473
+ }
2474
+ /**
2475
+ * Begin mirroring: load the prior removal set, run one immediate sync (so
2476
+ * config.yaml reflects the current store before Hermes first starts), then
2477
+ * subscribe to store changes (debounced). Idempotent.
2478
+ */
2479
+ start() {
2480
+ if (this.started) return;
2481
+ this.started = true;
2482
+ this.loadSyncedIds();
2483
+ this.syncOnce().catch((err) => {
2484
+ log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync: initial sync failed");
2485
+ });
2486
+ this.unsubscribe = this.manager.onChange(() => {
2487
+ this.schedule();
2488
+ });
2489
+ log$1.info({ configPath: this.configPath }, "Hermes MCP sync started — mirroring store into config.yaml");
2490
+ }
2491
+ /** Stop subscribing and cancel any pending debounced sync. Idempotent. */
2492
+ stop() {
2493
+ if (this.unsubscribe) {
2494
+ this.unsubscribe();
2495
+ this.unsubscribe = void 0;
2496
+ }
2497
+ if (this.debounceTimer) {
2498
+ clearTimeout(this.debounceTimer);
2499
+ this.debounceTimer = void 0;
2500
+ }
2501
+ this.started = false;
2502
+ }
2503
+ /**
2504
+ * Mirror the current store into config.yaml + .env exactly once. Public so the
2505
+ * daemon (and tests) can await a deterministic sync. Serialized against any
2506
+ * other in-flight sync.
2507
+ */
2508
+ syncOnce() {
2509
+ const run = () => {
2510
+ this.syncNow();
2511
+ return Promise.resolve();
2512
+ };
2513
+ const result = this.queue.then(run, run);
2514
+ this.queue = result.catch(() => void 0);
2515
+ return result;
2516
+ }
2517
+ schedule() {
2518
+ if (this.debounceMs <= 0) {
2519
+ this.syncOnce().catch((err) => {
2520
+ log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync failed");
2521
+ });
2522
+ return;
2523
+ }
2524
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
2525
+ this.debounceTimer = setTimeout(() => {
2526
+ this.debounceTimer = void 0;
2527
+ this.syncOnce().catch((err) => {
2528
+ log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync failed");
2529
+ });
2530
+ }, this.debounceMs);
2531
+ this.debounceTimer.unref();
2532
+ }
2533
+ syncNow() {
2534
+ const desired = this.computeDesired();
2535
+ const desiredIds = new Set(desired.keys());
2536
+ const doc = parseDocument(existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "");
2537
+ const before = doc.toString();
2538
+ for (const [id, entry] of desired) doc.setIn(["mcp_servers", id], entry);
2539
+ for (const id of this.syncedIds) if (!desiredIds.has(id)) doc.deleteIn(["mcp_servers", id]);
2540
+ const after = doc.toString();
2541
+ let changed = before !== after;
2542
+ if (changed) {
2543
+ mkdirSync(dirname(this.configPath), { recursive: true });
2544
+ writeFileSync(this.configPath, after, "utf-8");
2545
+ log$1.info({
2546
+ added: [...desiredIds],
2547
+ removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
2548
+ }, "Hermes MCP sync: config.yaml mcp_servers updated");
2549
+ }
2550
+ this.syncedIds = desiredIds;
2551
+ this.persistSyncedIds();
2552
+ if (desiredIds.size > 0 && this.ensureEnvApiKey()) changed = true;
2553
+ if (changed) this.requestRestart?.();
2554
+ }
2555
+ computeDesired() {
2556
+ const desired = /* @__PURE__ */ new Map();
2557
+ for (const { id, entry } of this.manager.listServers()) desired.set(id, this.toHermesEntry(entry));
2558
+ return desired;
2559
+ }
2560
+ /**
2561
+ * Transform a stored entry into a Hermes `mcp_servers` entry. SPIKE-PENDING
2562
+ * SEAM #3 lives here — the schema shape. stdio entries get `ALFE_API_KEY`
2563
+ * injected; remote entries pass through (Hermes supports `url`-based MCP).
2564
+ */
2565
+ toHermesEntry(entry) {
2566
+ const cfg = toServerConfig(entry);
2567
+ if ("command" in cfg) {
2568
+ const out = { command: cfg.command };
2569
+ if (cfg.args && cfg.args.length > 0) out.args = cfg.args;
2570
+ out.env = this.withAlfeApiKey(cfg.env);
2571
+ if (cfg.cwd) out.cwd = cfg.cwd;
2572
+ return out;
2573
+ }
2574
+ const out = { url: cfg.url };
2575
+ if (cfg.transport) out.transport = cfg.transport;
2576
+ if (cfg.headers) out.headers = cfg.headers;
2577
+ return out;
2578
+ }
2579
+ withAlfeApiKey(env) {
2580
+ const merged = { ...env ?? {} };
2581
+ if (!("ALFE_API_KEY" in merged)) merged.ALFE_API_KEY = ALFE_API_KEY_ENV_REF;
2582
+ return merged;
2583
+ }
2584
+ /** Read-merge-write `~/.hermes/.env` to set ALFE_API_KEY without clobbering other keys. */
2585
+ ensureEnvApiKey() {
2586
+ if (!this.apiKey) {
2587
+ log$1.warn("Hermes MCP sync: no ALFE_API_KEY available — mirrored MCP servers will start in zero-accounts degraded mode");
2588
+ return false;
2589
+ }
2590
+ return upsertEnvVar(this.envPath, "ALFE_API_KEY", this.apiKey);
2591
+ }
2592
+ loadSyncedIds() {
2593
+ if (!existsSync(this.trackingPath)) return;
2594
+ try {
2595
+ const data = JSON.parse(readFileSync(this.trackingPath, "utf-8"));
2596
+ if (Array.isArray(data.syncedIds)) this.syncedIds = new Set(data.syncedIds.filter((x) => typeof x === "string"));
2597
+ } catch {}
2598
+ }
2599
+ persistSyncedIds() {
2600
+ try {
2601
+ mkdirSync(dirname(this.trackingPath), { recursive: true });
2602
+ writeFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n", "utf-8");
2603
+ } catch (err) {
2604
+ log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync: failed to persist synced-id sidecar");
2605
+ }
2606
+ }
2607
+ };
2608
+ /**
2609
+ * Set `KEY=value` in a `.env`-style file, preserving every other line (comments,
2610
+ * blank lines, unrelated keys) and order. Returns whether the file changed. Not
2611
+ * a full dotenv parser — it only matches simple `KEY=` lines, which is all
2612
+ * `~/.hermes/.env` ever holds.
2613
+ */
2614
+ function upsertEnvVar(envPath, key, value) {
2615
+ const desiredLine = `${key}=${value}`;
2616
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
2617
+ const lines = existing.length > 0 ? existing.replace(/\n$/, "").split("\n") : [];
2618
+ const keyRe = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/;
2619
+ const idx = lines.findIndex((line) => keyRe.exec(line)?.[1] === key);
2620
+ let out;
2621
+ let changed;
2622
+ if (idx === -1) {
2623
+ out = [...lines, desiredLine];
2624
+ changed = true;
2625
+ } else {
2626
+ changed = lines[idx] !== desiredLine;
2627
+ out = lines.map((line, i) => i === idx ? desiredLine : line);
2628
+ }
2629
+ if (changed) {
2630
+ mkdirSync(dirname(envPath), { recursive: true });
2631
+ writeFileSync(envPath, out.join("\n") + "\n", "utf-8");
2632
+ }
2633
+ return changed;
2634
+ }
2635
+ function errMsg$1(err) {
2636
+ return err instanceof Error ? err.message : String(err);
2637
+ }
2638
+ //#endregion
2001
2639
  //#region src/appliers/mcp-applier.ts
2002
2640
  const log = createLogger("McpApplier");
2003
2641
  const CONFIG_TEMPLATE_RE = /\{\{config\.([a-zA-Z0-9_]+)\}\}/g;
@@ -2177,4 +2815,4 @@ var IntegrationManagerAdapter = class {
2177
2815
  }
2178
2816
  };
2179
2817
  //#endregion
2180
- export { DEFAULT_REGISTRY_TTL_MS, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, OpenClawApplier, Registry, RegistryResolveError, Resolver, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext };
2818
+ export { DEFAULT_REGISTRY_TTL_MS, HermesApplier, HermesMcpSync, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, OpenClawApplier, Registry, RegistryResolveError, Resolver, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };