@alfe.ai/integrations 0.1.4 → 0.1.6

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/index.d.ts CHANGED
@@ -8,6 +8,14 @@ import { Manager } from "@alfe.ai/mcp-bundler";
8
8
  *
9
9
  * Calls GET /integrations/registry (public, no auth required) and caches the result.
10
10
  * The API URL can be passed explicitly or set via ALFE_API_URL env var.
11
+ *
12
+ * The cache is time-bounded by a TTL (default 60s). Long-running consumers — chiefly
13
+ * the agent daemon, which constructs the Registry once at startup and never restarts —
14
+ * would otherwise be pinned to the registry snapshot taken at process boot, and could
15
+ * never resolve a version published after the daemon came up (the "stale cache" bug).
16
+ * With a TTL, every consumer self-heals within a bounded window. The resolve-for-install
17
+ * path additionally forces a fresh read (see `load({ fresh: true })`) because installing
18
+ * a specific version is a rare, correctness-critical action that must never race the TTL.
11
19
  */
12
20
  interface RegistryEntry {
13
21
  /** Human-readable display name (e.g. 'Alfe Voice') */
@@ -69,26 +77,51 @@ interface RegistryIndex {
69
77
  type RegistryFetcher = () => Promise<(RegistryEntry & {
70
78
  id: string;
71
79
  })[]>;
80
+ /** Default cache TTL — refetch the registry index after this many ms. */
81
+ declare const DEFAULT_REGISTRY_TTL_MS = 60000;
82
+ interface RegistryOptions {
83
+ /**
84
+ * How long (ms) a loaded index is considered fresh before `load()` refetches.
85
+ * Defaults to {@link DEFAULT_REGISTRY_TTL_MS} (60s). A non-positive value
86
+ * disables time-based caching (every `load()` refetches).
87
+ */
88
+ ttlMs?: number;
89
+ }
90
+ /** Options for a single `load()` call. */
91
+ interface LoadOptions {
92
+ /** Bypass the cache and refetch the index even if it's still within TTL. */
93
+ fresh?: boolean;
94
+ }
72
95
  declare class Registry {
73
96
  private index;
97
+ private loadedAt;
74
98
  private fetcher;
99
+ private ttlMs;
75
100
  /**
76
101
  * @param fetcher - Function that fetches the integrations array from the registry API.
77
102
  * Typically backed by api-client's IntegrationsService.getRegistry().
103
+ * @param options - Optional cache configuration (TTL).
78
104
  */
79
- constructor(fetcher: RegistryFetcher);
105
+ constructor(fetcher: RegistryFetcher, options?: RegistryOptions);
106
+ /** True when the cached index is absent or older than the TTL. */
107
+ private isStale;
80
108
  /**
81
- * Load the registry index. Uses cache if already loaded.
109
+ * Load the registry index. Returns the cached index while it's still within
110
+ * TTL; refetches once the cache is stale (or when `{ fresh: true }` is passed).
82
111
  */
83
- load(): Promise<RegistryIndex>;
112
+ load(options?: LoadOptions): Promise<RegistryIndex>;
84
113
  /**
85
- * Force reload the index (bypass cache).
114
+ * Force reload the index (bypass cache). Equivalent to `load({ fresh: true })`.
86
115
  */
87
116
  reload(): Promise<RegistryIndex>;
88
117
  /**
89
118
  * Get a specific integration entry by name.
119
+ *
120
+ * @param fresh - When true, force a fresh fetch before reading (used by the
121
+ * resolve-for-install path so a stale version list never blocks
122
+ * a just-published version).
90
123
  */
91
- get(id: string): Promise<RegistryEntry | undefined>;
124
+ get(id: string, fresh?: boolean): Promise<RegistryEntry | undefined>;
92
125
  /**
93
126
  * List all integrations in the registry.
94
127
  */
@@ -115,6 +148,20 @@ interface ResolvedIntegration {
115
148
  subdir?: string;
116
149
  description: string;
117
150
  }
151
+ /** Options for a single `resolve()` call. */
152
+ interface ResolveOptions {
153
+ /**
154
+ * Bypass the registry cache and read a fresh index before resolving.
155
+ *
156
+ * The install/reconcile path sets this: installing a specific version is a
157
+ * rare, user-triggered, correctness-critical action that must never resolve
158
+ * against a stale cached version list (the cause of the
159
+ * `Version "x" not found … Available versions: …` install failure on a
160
+ * long-running daemon). High-frequency read-only uses (marketplace
161
+ * listing/search) leave it unset and keep the TTL cache.
162
+ */
163
+ fresh?: boolean;
164
+ }
118
165
  declare class RegistryResolveError extends Error {
119
166
  constructor(message: string);
120
167
  }
@@ -126,9 +173,11 @@ declare class Resolver {
126
173
  *
127
174
  * @param name - Integration name (e.g. "discord")
128
175
  * @param version - Specific version (e.g. "1.0.0") or undefined for latest
176
+ * @param options - Pass `{ fresh: true }` to bypass the registry cache
177
+ * (used by the install/reconcile path).
129
178
  * @returns Resolved integration with repo URL and commit hash
130
179
  */
131
- resolve(id: string, version?: string): Promise<ResolvedIntegration>;
180
+ resolve(id: string, version?: string, options?: ResolveOptions): Promise<ResolvedIntegration>;
132
181
  /**
133
182
  * Check if an integration exists in the registry.
134
183
  */
@@ -238,6 +287,13 @@ interface RuntimeApplier {
238
287
  applyPlugin(pkg: string, integrationInstallPath: string, opts?: {
239
288
  force?: boolean;
240
289
  }): Promise<void>;
290
+ /**
291
+ * Optional: pre-trust an integration's full plugin set in one write before
292
+ * the per-plugin install loop. Runtimes whose config writes trigger a reload
293
+ * (OpenClaw) implement this to collapse N allowlist writes into one. Absent
294
+ * → the manager relies on `applyPlugin` to allowlist each plugin itself.
295
+ */
296
+ ensurePluginsAllowed?(pkgs: string[]): Promise<void>;
241
297
  /** Remove a plugin package from this runtime */
242
298
  removePlugin(pkg: string): Promise<void>;
243
299
  /** Copy a skill directory into this runtime's skills location */
@@ -699,6 +755,10 @@ interface OpenClawApplierOptions {
699
755
  skillsDir?: string;
700
756
  /** Path to the integration tracking file (defaults to {home}/config.json) */
701
757
  configPath?: string;
758
+ /** Max retries for a transient `openclaw config set` failure (default 3) */
759
+ configSetRetries?: number;
760
+ /** Backoff between `openclaw config set` retries, ms (default 750; set 0 in tests) */
761
+ configSetRetryDelayMs?: number;
702
762
  }
703
763
  declare class OpenClawApplier implements RuntimeApplier {
704
764
  readonly runtime = "openclaw";
@@ -706,15 +766,44 @@ declare class OpenClawApplier implements RuntimeApplier {
706
766
  private agentWorkspace;
707
767
  private skillsDir;
708
768
  private trackingPath;
769
+ private configSetRetries;
770
+ private configSetRetryDelayMs;
771
+ /** Serializes all `openclaw config set` writes so they never interleave with each other or the hot-reload they trigger. */
772
+ private configSetQueue;
709
773
  constructor(options: OpenClawApplierOptions);
774
+ /** Convenience: `openclaw config set <args>`, serialized + retried. */
775
+ private runConfigSet;
776
+ /**
777
+ * Run `openclaw config <args>` (set/unset), serialized against every other
778
+ * config write and retried with backoff. See CONFIG_SET_RETRIES for why:
779
+ * each write triggers a runtime hot-reload that rewrites openclaw.json, and a
780
+ * follow-up command that races the reload fails with a bare "Command failed".
781
+ *
782
+ * Throws an Error whose (scrubbed) message includes stderr after retries are
783
+ * exhausted, so the real cause propagates to the integration errorMessage
784
+ * without leaking the value payload.
785
+ */
786
+ private runConfigCommand;
710
787
  applyPlugin(spec: string, _installPath?: string, opts?: {
711
788
  force?: boolean;
712
789
  }): Promise<void>;
713
790
  /**
714
- * Ensure the plugin is in plugins.allow in openclaw.json.
791
+ * Ensure one or more plugins are in plugins.allow in openclaw.json.
715
792
  * Uses `openclaw config set` to avoid clobbering OpenClaw's own file format.
793
+ *
794
+ * Prefer passing the FULL set of plugins for an integration in a single call
795
+ * (see `ensurePluginsAllowed`): each write triggers a runtime hot-reload, so
796
+ * one write for N plugins is one reload instead of N.
716
797
  */
717
798
  private ensurePluginsAllow;
799
+ /**
800
+ * Pre-trust every plugin an integration ships in a SINGLE plugins.allow
801
+ * write, before any are installed. Called once by the manager ahead of the
802
+ * per-plugin install loop so activation triggers one hot-reload for the
803
+ * allowlist instead of one per plugin. `applyPlugin`'s own per-plugin
804
+ * `ensurePluginsAllow` then finds nothing missing and is a no-op.
805
+ */
806
+ ensurePluginsAllowed(specs: string[]): Promise<void>;
718
807
  /**
719
808
  * Check if a plugin is already installed.
720
809
  *
@@ -875,4 +964,4 @@ declare class IntegrationManagerAdapter implements IIntegrationManager {
875
964
  resetReinstallAttempts(integrationId: string): void;
876
965
  }
877
966
  //#endregion
878
- export { type CredentialsResolver, type HookEnvOptions, type HookResult, type IIntegrationManager, type InstalledInfo, Installer, InstallerError, type IntegrationConfigureParams, type IntegrationHealthParams, type IntegrationInfo, type IntegrationInstallParams, IntegrationManager, IntegrationManagerAdapter, type IntegrationManagerOptions, type IntegrationRemoveParams, LockManager, McpApplier, type McpApplierOptions, OpenClawApplier, type OpenClawApplierOptions, type PlatformContext, Registry, type RegistryEntry, type RegistryFetcher, type RegistryIndex, RegistryResolveError, type ResolvedIntegration, Resolver, type RuntimeApplier, type RuntimeDesiredState, type RuntimeLockFile, type RuntimePluginEntry, type RuntimeSkillEntry, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext };
967
+ export { type CredentialsResolver, DEFAULT_REGISTRY_TTL_MS, type HookEnvOptions, type HookResult, type IIntegrationManager, type InstalledInfo, Installer, InstallerError, type IntegrationConfigureParams, type IntegrationHealthParams, type IntegrationInfo, type IntegrationInstallParams, IntegrationManager, IntegrationManagerAdapter, type IntegrationManagerOptions, type IntegrationRemoveParams, type LoadOptions, LockManager, McpApplier, type McpApplierOptions, OpenClawApplier, type OpenClawApplierOptions, type PlatformContext, Registry, type RegistryEntry, type RegistryFetcher, type RegistryIndex, type RegistryOptions, RegistryResolveError, type ResolveOptions, type ResolvedIntegration, Resolver, type RuntimeApplier, type RuntimeDesiredState, type RuntimeLockFile, type RuntimePluginEntry, type RuntimeSkillEntry, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext };
package/dist/index.js CHANGED
@@ -6,21 +6,34 @@ import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, op
6
6
  import { buildConfigValidationSchema, parseManifestFile } from "@alfe.ai/integration-manifest";
7
7
  import { createLogger } from "@auriclabs/logger";
8
8
  //#region src/registry.ts
9
+ /** Default cache TTL — refetch the registry index after this many ms. */
10
+ const DEFAULT_REGISTRY_TTL_MS = 6e4;
9
11
  var Registry = class {
10
12
  index = null;
13
+ loadedAt = 0;
11
14
  fetcher;
15
+ ttlMs;
12
16
  /**
13
17
  * @param fetcher - Function that fetches the integrations array from the registry API.
14
18
  * Typically backed by api-client's IntegrationsService.getRegistry().
19
+ * @param options - Optional cache configuration (TTL).
15
20
  */
16
- constructor(fetcher) {
21
+ constructor(fetcher, options = {}) {
17
22
  this.fetcher = fetcher;
23
+ this.ttlMs = options.ttlMs ?? 6e4;
24
+ }
25
+ /** True when the cached index is absent or older than the TTL. */
26
+ isStale() {
27
+ if (!this.index) return true;
28
+ if (this.ttlMs <= 0) return true;
29
+ return Date.now() - this.loadedAt >= this.ttlMs;
18
30
  }
19
31
  /**
20
- * Load the registry index. Uses cache if already loaded.
32
+ * Load the registry index. Returns the cached index while it's still within
33
+ * TTL; refetches once the cache is stale (or when `{ fresh: true }` is passed).
21
34
  */
22
- async load() {
23
- if (this.index) return this.index;
35
+ async load(options = {}) {
36
+ if (!options.fresh && this.index && !this.isStale()) return this.index;
24
37
  const raw = await this.fetcher();
25
38
  const integrations = {};
26
39
  for (const entry of raw) {
@@ -31,20 +44,25 @@ var Registry = class {
31
44
  version: 1,
32
45
  integrations
33
46
  };
47
+ this.loadedAt = Date.now();
34
48
  return this.index;
35
49
  }
36
50
  /**
37
- * Force reload the index (bypass cache).
51
+ * Force reload the index (bypass cache). Equivalent to `load({ fresh: true })`.
38
52
  */
39
53
  async reload() {
40
54
  this.index = null;
41
- return this.load();
55
+ return this.load({ fresh: true });
42
56
  }
43
57
  /**
44
58
  * Get a specific integration entry by name.
59
+ *
60
+ * @param fresh - When true, force a fresh fetch before reading (used by the
61
+ * resolve-for-install path so a stale version list never blocks
62
+ * a just-published version).
45
63
  */
46
- async get(id) {
47
- return (await this.load()).integrations[id];
64
+ async get(id, fresh = false) {
65
+ return (await this.load({ fresh })).integrations[id];
48
66
  }
49
67
  /**
50
68
  * List all integrations in the registry.
@@ -83,10 +101,12 @@ var Resolver = class {
83
101
  *
84
102
  * @param name - Integration name (e.g. "discord")
85
103
  * @param version - Specific version (e.g. "1.0.0") or undefined for latest
104
+ * @param options - Pass `{ fresh: true }` to bypass the registry cache
105
+ * (used by the install/reconcile path).
86
106
  * @returns Resolved integration with repo URL and commit hash
87
107
  */
88
- async resolve(id, version) {
89
- const entry = await this.registry.get(id);
108
+ async resolve(id, version, options = {}) {
109
+ const entry = await this.registry.get(id, options.fresh);
90
110
  if (!entry) throw new RegistryResolveError(`Integration "${id}" not found in registry`);
91
111
  const resolvedVersion = version && version.length > 0 ? version : entry.latest;
92
112
  if (!entry.versions.includes(resolvedVersion)) throw new RegistryResolveError(`Version "${resolvedVersion}" not found for integration "${id}". Available versions: ${entry.versions.join(", ")}`);
@@ -966,7 +986,7 @@ var IntegrationManager = class {
966
986
  resolved = buildCustomResolved(name, customSource);
967
987
  this.log.info(`Custom Connection install: ${name} from ${resolved.repository}@${resolved.commit}`);
968
988
  } else {
969
- resolved = await this.resolver.resolve(name, version);
989
+ resolved = await this.resolver.resolve(name, version, { fresh: true });
970
990
  this.log.info(`Resolved ${name}@${resolved.version} from ${resolved.repository}`);
971
991
  }
972
992
  const installPath = await this.installer.install(resolved);
@@ -1093,7 +1113,7 @@ var IntegrationManager = class {
1093
1113
  message: "Already active"
1094
1114
  }
1095
1115
  };
1096
- if (entry.status !== "configured" && entry.status !== "installed") return this.err("INVALID_STATE", `Cannot activate integration in "${entry.status}" state`);
1116
+ if (entry.status !== "configured" && entry.status !== "installed" && entry.status !== "error") return this.err("INVALID_STATE", `Cannot activate integration in "${entry.status}" state`);
1097
1117
  this.log.info(`Activating integration: ${integrationId}`);
1098
1118
  try {
1099
1119
  const installPath = this.installer.getInstallPath(integrationId);
@@ -1116,6 +1136,11 @@ var IntegrationManager = class {
1116
1136
  continue;
1117
1137
  }
1118
1138
  const { plugins, skills, config: runtimeConfig } = resolveInstallsForRuntime(manifest, runtimeName);
1139
+ if (applier.ensurePluginsAllowed && plugins.length > 0) try {
1140
+ await applier.ensurePluginsAllowed(plugins.map((p) => p.package));
1141
+ } catch (err) {
1142
+ this.log.warn(`Failed to pre-trust plugins for ${integrationId} on ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
1143
+ }
1119
1144
  const pluginFailures = [];
1120
1145
  for (const plugin of plugins) {
1121
1146
  this.log.info(`Applying plugin ${plugin.package} to ${runtimeName}`);
@@ -1593,6 +1618,45 @@ const DEFAULT_SKILLS_DIR = join(homedir(), ".alfe", "skills");
1593
1618
  * LLM/voice traffic through its own AI proxy, so leaving them gated off is correct.
1594
1619
  */
1595
1620
  const BUNDLED_BASELINE_ALLOW = ["browser"];
1621
+ /**
1622
+ * Every `openclaw config set` triggers an async hot-reload of the running
1623
+ * OpenClaw runtime, which itself rewrites openclaw.json. If the next
1624
+ * `config set` fires before that reload settles it collides with the
1625
+ * in-flight write (OpenClaw's clobber protection) and exits non-zero with a
1626
+ * bare "Command failed". We serialize all config writes through one promise
1627
+ * chain and retry transient failures with a short backoff so the reload has
1628
+ * time to settle between writes.
1629
+ */
1630
+ const CONFIG_SET_RETRIES = 3;
1631
+ const CONFIG_SET_RETRY_DELAY_MS = 750;
1632
+ const delay = (ms) => new Promise((resolve) => {
1633
+ setTimeout(resolve, ms);
1634
+ });
1635
+ /**
1636
+ * Describe a `config set` target WITHOUT leaking values. The value argument is
1637
+ * the JSON payload (model config, the gateway loopback token via the alfe hook,
1638
+ * etc.) and must never reach the integration's `errorMessage`, which projects
1639
+ * to the user-facing dashboard. Keep only the program + the path/flag.
1640
+ */
1641
+ function redactConfigSetTarget(args) {
1642
+ return `openclaw config ${args.slice(0, 2).join(" ")}`.trim();
1643
+ }
1644
+ /**
1645
+ * Build a diagnosable BUT scrubbed message from an execFile rejection. Node's
1646
+ * execFile error `message` is "Command failed: <full argv>" — which for
1647
+ * `config set` embeds the value payload — so we never surface it. We keep the
1648
+ * redacted target, the exit code, and `stderr` (openclaw's own error text).
1649
+ */
1650
+ function configSetErrorMessage(err, args) {
1651
+ const target = redactConfigSetTarget(args);
1652
+ if (err instanceof Error) {
1653
+ const e = err;
1654
+ const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
1655
+ const code = e.code !== void 0 ? ` (exit ${e.code})` : "";
1656
+ return stderr ? `${target} failed${code}: ${stderr}` : `${target} failed${code}`;
1657
+ }
1658
+ return `${target} failed: ${String(err)}`;
1659
+ }
1596
1660
  function flattenConfig(obj, prefix = "") {
1597
1661
  const entries = [];
1598
1662
  for (const [key, val] of Object.entries(obj)) {
@@ -1657,6 +1721,10 @@ var OpenClawApplier = class {
1657
1721
  agentWorkspace;
1658
1722
  skillsDir;
1659
1723
  trackingPath;
1724
+ configSetRetries;
1725
+ configSetRetryDelayMs;
1726
+ /** Serializes all `openclaw config set` writes so they never interleave with each other or the hot-reload they trigger. */
1727
+ configSetQueue = Promise.resolve();
1660
1728
  constructor(options) {
1661
1729
  const home = options.home ?? options.workspace;
1662
1730
  if (!home) throw new Error("OpenClawApplier requires `home` (or legacy `workspace`) option");
@@ -1664,6 +1732,39 @@ var OpenClawApplier = class {
1664
1732
  this.agentWorkspace = options.agentWorkspace ?? join(home, "workspace");
1665
1733
  this.skillsDir = options.skillsDir ?? DEFAULT_SKILLS_DIR;
1666
1734
  this.trackingPath = options.configPath ?? join(this.home, "config.json");
1735
+ this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES;
1736
+ this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS;
1737
+ }
1738
+ /** Convenience: `openclaw config set <args>`, serialized + retried. */
1739
+ runConfigSet(setArgs, opts = {}) {
1740
+ return this.runConfigCommand(["set", ...setArgs], opts);
1741
+ }
1742
+ /**
1743
+ * Run `openclaw config <args>` (set/unset), serialized against every other
1744
+ * config write and retried with backoff. See CONFIG_SET_RETRIES for why:
1745
+ * each write triggers a runtime hot-reload that rewrites openclaw.json, and a
1746
+ * follow-up command that races the reload fails with a bare "Command failed".
1747
+ *
1748
+ * Throws an Error whose (scrubbed) message includes stderr after retries are
1749
+ * exhausted, so the real cause propagates to the integration errorMessage
1750
+ * without leaking the value payload.
1751
+ */
1752
+ runConfigCommand(args, opts = {}) {
1753
+ const timeout = opts.timeout ?? 1e4;
1754
+ const run = async () => {
1755
+ let lastErr;
1756
+ for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
1757
+ await execFileAsync("openclaw", ["config", ...args], { timeout });
1758
+ return;
1759
+ } catch (err) {
1760
+ lastErr = err;
1761
+ if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay(this.configSetRetryDelayMs * 2 ** attempt);
1762
+ }
1763
+ throw new Error(configSetErrorMessage(lastErr, args));
1764
+ };
1765
+ const result = this.configSetQueue.then(run, run);
1766
+ this.configSetQueue = result.catch(() => void 0);
1767
+ return result;
1667
1768
  }
1668
1769
  async applyPlugin(spec, _installPath, opts) {
1669
1770
  const pkg = stripPluginVersion(spec);
@@ -1720,10 +1821,15 @@ var OpenClawApplier = class {
1720
1821
  }
1721
1822
  }
1722
1823
  /**
1723
- * Ensure the plugin is in plugins.allow in openclaw.json.
1824
+ * Ensure one or more plugins are in plugins.allow in openclaw.json.
1724
1825
  * Uses `openclaw config set` to avoid clobbering OpenClaw's own file format.
1826
+ *
1827
+ * Prefer passing the FULL set of plugins for an integration in a single call
1828
+ * (see `ensurePluginsAllowed`): each write triggers a runtime hot-reload, so
1829
+ * one write for N plugins is one reload instead of N.
1725
1830
  */
1726
- async ensurePluginsAllow(pkg) {
1831
+ async ensurePluginsAllow(pkgs) {
1832
+ const wanted = Array.isArray(pkgs) ? pkgs : [pkgs];
1727
1833
  let currentAllow = [];
1728
1834
  try {
1729
1835
  const { stdout } = await execFileAsync("openclaw", [
@@ -1734,24 +1840,30 @@ var OpenClawApplier = class {
1734
1840
  const parsed = JSON.parse(stdout.trim());
1735
1841
  if (Array.isArray(parsed)) currentAllow = parsed;
1736
1842
  } catch {}
1737
- const missing = [...new Set([pkg, ...BUNDLED_BASELINE_ALLOW])].filter((id) => !currentAllow.includes(id));
1843
+ const missing = [...new Set([...wanted, ...BUNDLED_BASELINE_ALLOW])].filter((id) => !currentAllow.includes(id));
1738
1844
  if (missing.length === 0) return;
1739
1845
  const updated = [...currentAllow, ...missing];
1740
1846
  try {
1741
- await execFileAsync("openclaw", [
1742
- "config",
1743
- "set",
1744
- "plugins.allow",
1745
- JSON.stringify(updated)
1746
- ], { timeout: 1e4 });
1847
+ await this.runConfigSet(["plugins.allow", JSON.stringify(updated)]);
1747
1848
  } catch (err) {
1748
1849
  log$1.warn({
1749
1850
  err: err instanceof Error ? err.message : String(err),
1750
- pkg
1851
+ pkgs: wanted
1751
1852
  }, "Failed to set plugins.allow via openclaw config set");
1752
1853
  }
1753
1854
  }
1754
1855
  /**
1856
+ * Pre-trust every plugin an integration ships in a SINGLE plugins.allow
1857
+ * write, before any are installed. Called once by the manager ahead of the
1858
+ * per-plugin install loop so activation triggers one hot-reload for the
1859
+ * allowlist instead of one per plugin. `applyPlugin`'s own per-plugin
1860
+ * `ensurePluginsAllow` then finds nothing missing and is a no-op.
1861
+ */
1862
+ async ensurePluginsAllowed(specs) {
1863
+ if (specs.length === 0) return;
1864
+ await this.ensurePluginsAllow(specs.map(stripPluginVersion));
1865
+ }
1866
+ /**
1755
1867
  * Check if a plugin is already installed.
1756
1868
  *
1757
1869
  * OpenClaw 2026.4 stored plugins under `~/.openclaw/extensions/{pkg-name-with-dashes}-{hash}`.
@@ -1882,12 +1994,7 @@ var OpenClawApplier = class {
1882
1994
  const merged = { ...await readParentObject(parentPath) };
1883
1995
  for (const [k, v] of dottedKvs) merged[k] = v;
1884
1996
  try {
1885
- await execFileAsync("openclaw", [
1886
- "config",
1887
- "set",
1888
- parentPath,
1889
- JSON.stringify(merged)
1890
- ], { timeout: 1e4 });
1997
+ await this.runConfigSet([parentPath, JSON.stringify(merged)]);
1891
1998
  } catch (err) {
1892
1999
  log$1.error({
1893
2000
  err: err instanceof Error ? err.message : String(err),
@@ -1897,12 +2004,7 @@ var OpenClawApplier = class {
1897
2004
  }
1898
2005
  }
1899
2006
  if (leaves.length > 0) try {
1900
- await execFileAsync("openclaw", [
1901
- "config",
1902
- "set",
1903
- "--batch-json",
1904
- JSON.stringify(leaves)
1905
- ], { timeout: 1e4 });
2007
+ await this.runConfigSet(["--batch-json", JSON.stringify(leaves)]);
1906
2008
  } catch (err) {
1907
2009
  log$1.error({
1908
2010
  err: err instanceof Error ? err.message : String(err),
@@ -1924,11 +2026,7 @@ var OpenClawApplier = class {
1924
2026
  const integrationConfig = integrations[integrationId];
1925
2027
  const { leaves, subtreesByParent } = partitionEntries(flattenConfig(integrationConfig));
1926
2028
  for (const { path } of leaves) try {
1927
- await execFileAsync("openclaw", [
1928
- "config",
1929
- "unset",
1930
- path
1931
- ], { timeout: 1e4 });
2029
+ await this.runConfigCommand(["unset", path]);
1932
2030
  } catch (err) {
1933
2031
  log$1.warn({
1934
2032
  err: err instanceof Error ? err.message : String(err),
@@ -1940,17 +2038,8 @@ var OpenClawApplier = class {
1940
2038
  const remaining = Object.fromEntries(Object.entries(existing).filter(([k]) => !dottedKvs.has(k)));
1941
2039
  if (Object.keys(existing).length === 0) continue;
1942
2040
  try {
1943
- if (Object.keys(remaining).length === 0) await execFileAsync("openclaw", [
1944
- "config",
1945
- "unset",
1946
- parentPath
1947
- ], { timeout: 1e4 });
1948
- else await execFileAsync("openclaw", [
1949
- "config",
1950
- "set",
1951
- parentPath,
1952
- JSON.stringify(remaining)
1953
- ], { timeout: 1e4 });
2041
+ if (Object.keys(remaining).length === 0) await this.runConfigCommand(["unset", parentPath]);
2042
+ else await this.runConfigSet([parentPath, JSON.stringify(remaining)]);
1954
2043
  } catch (err) {
1955
2044
  log$1.warn({
1956
2045
  err: err instanceof Error ? err.message : String(err),
@@ -2157,4 +2246,4 @@ var IntegrationManagerAdapter = class {
2157
2246
  }
2158
2247
  };
2159
2248
  //#endregion
2160
- export { Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, OpenClawApplier, Registry, RegistryResolveError, Resolver, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext };
2249
+ export { DEFAULT_REGISTRY_TTL_MS, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, OpenClawApplier, Registry, RegistryResolveError, Resolver, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",