@alfe.ai/integrations 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -314,9 +314,17 @@ declare class Installer {
314
314
  interface RuntimeApplier {
315
315
  /** The runtime identifier (e.g. 'openclaw', 'nanoclaw') */
316
316
  readonly runtime: string;
317
- /** Install a plugin package into this runtime */
317
+ /**
318
+ * Install a plugin package into this runtime.
319
+ *
320
+ * `opts.integrationId` identifies the owning integration so the applier can
321
+ * distinguish a legitimate same-integration pin bump from a cross-integration
322
+ * pin conflict (two integrations pinning the same plugin at different
323
+ * versions). See `OpenClawApplier`'s first-writer-wins guard.
324
+ */
318
325
  applyPlugin(pkg: string, integrationInstallPath: string, opts?: {
319
326
  force?: boolean;
327
+ integrationId?: string;
320
328
  }): Promise<void>;
321
329
  /**
322
330
  * Optional: pre-trust an integration's full plugin set in one write before
@@ -689,6 +697,13 @@ declare class IntegrationManager {
689
697
  clear(): void;
690
698
  private checkHealth;
691
699
  private err;
700
+ /**
701
+ * Build a hook-failure message. A hook we SIGKILLed at its timeout is rendered
702
+ * as `(timed out after <ms>ms)` so it is visually distinguishable in Sentry
703
+ * from a genuine non-zero exit `(exit <code>)` — the two have very different
704
+ * root causes (Sentry AGENT-DAEMON-6).
705
+ */
706
+ private hookFailureMessage;
692
707
  /**
693
708
  * Compute the plugin (bare package) names and skill names still claimed by
694
709
  * SOME integration in the CURRENT lock state, keyed by runtime. Both
@@ -778,31 +793,16 @@ declare class StateManager {
778
793
  }
779
794
  //#endregion
780
795
  //#region src/hooks.d.ts
781
- /**
782
- * Hook Runner — executes integration lifecycle hook scripts.
783
- *
784
- * Hooks are scripts defined in the integration manifest. The runner
785
- * auto-detects the interpreter from the script's shebang line or file
786
- * extension (.js/.mjs → node, .py → python3, default → bash).
787
- *
788
- * Scripts run as child processes with a 30-second timeout.
789
- * stdout/stderr are captured and returned.
790
- *
791
- * The runner injects standard environment variables:
792
- * - ALFE_INTEGRATION_DIR — path to the integration install directory
793
- * - ALFE_STATE_DIR — path to the integration state directory (created if needed)
794
- * - ALFE_<NAME>_<KEY> — config values (non-secret AND secret, both injected as env vars)
795
- *
796
- * Secrets are acceptable as env vars because:
797
- * - Child process env is ephemeral (dies with the process)
798
- * - Same security model as Docker secrets / systemd credentials
799
- * - Never written to disk
800
- */
801
796
  interface HookResult {
802
797
  exitCode: number;
803
798
  stdout: string;
804
799
  stderr: string;
805
800
  timedOut: boolean;
801
+ /**
802
+ * When `timedOut` is true, the timeout ceiling (ms) that fired the SIGKILL.
803
+ * Lets callers render a timeout distinctly from a genuine non-zero exit.
804
+ */
805
+ timedOutAfterMs?: number;
806
806
  }
807
807
  interface HookEnvOptions {
808
808
  /** Integration name (e.g. "voice") */
@@ -849,9 +849,12 @@ declare function buildHookEnv(options: HookEnvOptions, additionalEnv?: Record<st
849
849
  * @param integrationPath - Base path of the integration (where alfe-integration.yaml lives)
850
850
  * @param hookScript - Relative path to the hook script (e.g. "scripts/activate.sh")
851
851
  * @param env - Additional environment variables to pass to the script
852
+ * @param timeoutMs - Timeout before the hook is SIGKILLed. Defaults to the fast
853
+ * lifecycle ceiling ({@link HOOK_TIMEOUT_MS}); install-phase callers pass
854
+ * {@link INSTALL_HOOK_TIMEOUT_MS}.
852
855
  * @returns Hook execution result
853
856
  */
854
- declare function runHook(integrationPath: string, hookScript: string, env?: Record<string, string>): Promise<HookResult>;
857
+ declare function runHook(integrationPath: string, hookScript: string, env?: Record<string, string>, timeoutMs?: number): Promise<HookResult>;
855
858
  /**
856
859
  * Run a hook script with full integration context (config + secrets as env vars).
857
860
  *
@@ -862,9 +865,12 @@ declare function runHook(integrationPath: string, hookScript: string, env?: Reco
862
865
  * @param integrationPath - Base path of the integration
863
866
  * @param hookScript - Relative path to the hook script
864
867
  * @param options - Integration name, config, and secrets
868
+ * @param timeoutMs - Timeout before the hook is SIGKILLed. Defaults to the fast
869
+ * lifecycle ceiling ({@link HOOK_TIMEOUT_MS}); install-phase callers pass
870
+ * {@link INSTALL_HOOK_TIMEOUT_MS}.
865
871
  * @returns Hook execution result
866
872
  */
867
- declare function runHookWithContext(integrationPath: string, hookScript: string, options: HookEnvOptions): Promise<HookResult>;
873
+ declare function runHookWithContext(integrationPath: string, hookScript: string, options: HookEnvOptions, timeoutMs?: number): Promise<HookResult>;
868
874
  //#endregion
869
875
  //#region src/openclaw-cli-lock.d.ts
870
876
  /**
@@ -995,6 +1001,24 @@ declare class OpenClawApplier implements RuntimeApplier {
995
1001
  * this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
996
1002
  */
997
1003
  private lastHealAt;
1004
+ /**
1005
+ * Cross-integration plugin-pin claims made during THIS process lifetime,
1006
+ * keyed by bare package name. `applyPlugin` is now version-aware for both
1007
+ * force and non-force callers — it actively reinstalls a plugin whose
1008
+ * installed version differs from the pinned spec (previously the non-force
1009
+ * path keyed only on bare-name presence and silently ignored a pin bump). If
1010
+ * two integrations pin the SAME plugin at DIFFERENT versions, that
1011
+ * version-aware reinstall would make them fight — each reconcile pass
1012
+ * uninstall+reinstalls the other's version forever. This map records the
1013
+ * first-applied pin per package so a later, divergent pin from a DIFFERENT
1014
+ * integration is refused (first-writer-wins) with a WARN instead of thrashing.
1015
+ * A later apply from the SAME integration (a legitimate pin bump across an
1016
+ * upgrade) is NOT a conflict and proceeds. Cleared per-package on
1017
+ * `removePlugin`, and wholesale on daemon restart. Convention is single-owner
1018
+ * per plugin (see the `alfe` manifest note) — this only turns a convention
1019
+ * violation into a stable warning.
1020
+ */
1021
+ private readonly appliedPluginPins;
998
1022
  constructor(options: OpenClawApplierOptions);
999
1023
  /**
1000
1024
  * Convenience: `openclaw config set <args>`, UNLOCKED + retried.
@@ -1080,8 +1104,16 @@ declare class OpenClawApplier implements RuntimeApplier {
1080
1104
  private healMalformedStateDb;
1081
1105
  applyPlugin(spec: string, _installPath?: string, opts?: {
1082
1106
  force?: boolean;
1107
+ integrationId?: string;
1083
1108
  }): Promise<void>;
1084
1109
  private applyPluginLocked;
1110
+ /**
1111
+ * Record the pin a caller just applied for a package, keyed by bare name, so
1112
+ * a later divergent pin from a DIFFERENT integration in this process can be
1113
+ * refused (first-writer-wins). Bare (versionless) pins are untrackable and
1114
+ * ignored — they can't conflict on version. See `appliedPluginPins`.
1115
+ */
1116
+ private recordPluginPin;
1085
1117
  /**
1086
1118
  * Ensure one or more plugins are in plugins.allow in openclaw.json, UNLOCKED.
1087
1119
  * Uses `openclaw config set` to avoid clobbering OpenClaw's own file format.
package/dist/index.js CHANGED
@@ -746,7 +746,19 @@ var LockManager = class {
746
746
  * - Same security model as Docker secrets / systemd credentials
747
747
  * - Never written to disk
748
748
  */
749
+ /**
750
+ * Default timeout for lifecycle hooks (activate/health_check/uninstall). These
751
+ * are expected to be fast; a long-running one is a bug and should be killed.
752
+ */
749
753
  const HOOK_TIMEOUT_MS = 3e4;
754
+ /**
755
+ * Timeout for install-phase hooks (pre_install/post_install). These legitimately
756
+ * take minutes — they clone repos, run `npm install`, download binaries — so the
757
+ * fast 30s ceiling would SIGKILL a healthy install mid-flight (Sentry
758
+ * AGENT-DAEMON-6). Callers opt into this longer ceiling explicitly per hook type;
759
+ * it is NOT the global default so lifecycle hooks stay fast.
760
+ */
761
+ const INSTALL_HOOK_TIMEOUT_MS = 6e5;
750
762
  const INTEGRATIONS_BASE_DIR = join(homedir(), ".alfe", "integrations");
751
763
  const STATE_BASE_DIR = join(homedir(), ".alfe", "state");
752
764
  /**
@@ -848,9 +860,12 @@ function resolveInterpreter(scriptPath) {
848
860
  * @param integrationPath - Base path of the integration (where alfe-integration.yaml lives)
849
861
  * @param hookScript - Relative path to the hook script (e.g. "scripts/activate.sh")
850
862
  * @param env - Additional environment variables to pass to the script
863
+ * @param timeoutMs - Timeout before the hook is SIGKILLed. Defaults to the fast
864
+ * lifecycle ceiling ({@link HOOK_TIMEOUT_MS}); install-phase callers pass
865
+ * {@link INSTALL_HOOK_TIMEOUT_MS}.
851
866
  * @returns Hook execution result
852
867
  */
853
- async function runHook(integrationPath, hookScript, env) {
868
+ async function runHook(integrationPath, hookScript, env, timeoutMs = HOOK_TIMEOUT_MS) {
854
869
  const scriptPath = join(integrationPath, hookScript);
855
870
  if (!existsSync(scriptPath)) return {
856
871
  exitCode: 0,
@@ -878,7 +893,7 @@ async function runHook(integrationPath, hookScript, env) {
878
893
  const timer = setTimeout(() => {
879
894
  timedOut = true;
880
895
  proc.kill("SIGKILL");
881
- }, HOOK_TIMEOUT_MS);
896
+ }, timeoutMs);
882
897
  proc.stdout.on("data", (data) => {
883
898
  stdout += data.toString();
884
899
  if (stdout.length > 1e5) stdout = stdout.slice(0, 1e5) + "\n[truncated]";
@@ -893,7 +908,8 @@ async function runHook(integrationPath, hookScript, env) {
893
908
  exitCode: code ?? 1,
894
909
  stdout: stdout.trim(),
895
910
  stderr: stderr.trim(),
896
- timedOut
911
+ timedOut,
912
+ timedOutAfterMs: timedOut ? timeoutMs : void 0
897
913
  });
898
914
  });
899
915
  proc.on("error", (err) => {
@@ -917,10 +933,13 @@ async function runHook(integrationPath, hookScript, env) {
917
933
  * @param integrationPath - Base path of the integration
918
934
  * @param hookScript - Relative path to the hook script
919
935
  * @param options - Integration name, config, and secrets
936
+ * @param timeoutMs - Timeout before the hook is SIGKILLed. Defaults to the fast
937
+ * lifecycle ceiling ({@link HOOK_TIMEOUT_MS}); install-phase callers pass
938
+ * {@link INSTALL_HOOK_TIMEOUT_MS}.
920
939
  * @returns Hook execution result
921
940
  */
922
- async function runHookWithContext(integrationPath, hookScript, options) {
923
- return runHook(integrationPath, hookScript, buildHookEnv(options));
941
+ async function runHookWithContext(integrationPath, hookScript, options, timeoutMs = HOOK_TIMEOUT_MS) {
942
+ return runHook(integrationPath, hookScript, buildHookEnv(options), timeoutMs);
924
943
  }
925
944
  //#endregion
926
945
  //#region src/plugin-spec.ts
@@ -1127,8 +1146,8 @@ var IntegrationManager = class {
1127
1146
  if (!installHooksSupported && (manifest.hooks.pre_install || manifest.hooks.post_install)) this.log.warn(`Integration "${name}" install hooks skipped — no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${(manifest.supported_agents ?? []).join(", ")})`);
1128
1147
  if (installHooksSupported && manifest.hooks.pre_install) {
1129
1148
  this.log.info(`Running pre_install hook: ${manifest.hooks.pre_install}`);
1130
- const hookResult = await runHook(installPath, manifest.hooks.pre_install);
1131
- if (hookResult.exitCode !== 0) throw new Error(`pre_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1149
+ const hookResult = await runHook(installPath, manifest.hooks.pre_install, void 0, INSTALL_HOOK_TIMEOUT_MS);
1150
+ if (hookResult.exitCode !== 0) throw new Error(this.hookFailureMessage("pre_install hook failed", hookResult));
1132
1151
  }
1133
1152
  if (installHooksSupported && manifest.hooks.post_install) {
1134
1153
  this.log.info(`Running post_install hook: ${manifest.hooks.post_install}`);
@@ -1137,8 +1156,8 @@ var IntegrationManager = class {
1137
1156
  config: config ?? {},
1138
1157
  secrets: this.secrets.get(name),
1139
1158
  runtimes: [...this.runtimeAppliers.keys()]
1140
- });
1141
- if (hookResult.exitCode !== 0) throw new Error(`post_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1159
+ }, INSTALL_HOOK_TIMEOUT_MS);
1160
+ if (hookResult.exitCode !== 0) throw new Error(this.hookFailureMessage("post_install hook failed", hookResult));
1142
1161
  }
1143
1162
  this.state.set(name, {
1144
1163
  status: "installed",
@@ -1271,7 +1290,10 @@ var IntegrationManager = class {
1271
1290
  for (const plugin of plugins) {
1272
1291
  this.log.info(`Applying plugin ${plugin.package} to ${runtimeName}`);
1273
1292
  try {
1274
- await applier.applyPlugin(plugin.package, installPath, { force: opts?.forcePlugins });
1293
+ await applier.applyPlugin(plugin.package, installPath, {
1294
+ force: opts?.forcePlugins,
1295
+ integrationId
1296
+ });
1275
1297
  } catch (err) {
1276
1298
  const msg = err instanceof Error ? err.message : String(err);
1277
1299
  this.log.error(`Failed to apply plugin ${plugin.package}: ${msg}`);
@@ -1325,8 +1347,9 @@ var IntegrationManager = class {
1325
1347
  runtimes: [...this.runtimeAppliers.keys()]
1326
1348
  });
1327
1349
  if (hookResult.exitCode !== 0) {
1328
- this.state.setStatus(integrationId, "error", `post_activate hook failed: ${hookResult.stderr || hookResult.stdout}`);
1329
- return this.err("POST_ACTIVATE_FAILED", `post_activate hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1350
+ const message = this.hookFailureMessage("post_activate hook failed", hookResult);
1351
+ this.state.setStatus(integrationId, "error", message);
1352
+ return this.err("POST_ACTIVATE_FAILED", message);
1330
1353
  }
1331
1354
  }
1332
1355
  if (manifest.hooks.health_check && !runtimeSupported) this.log.warn(`Integration "${integrationId}" health_check hook skipped — no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${(supportedAgents ?? []).join(", ")})`);
@@ -1339,8 +1362,9 @@ var IntegrationManager = class {
1339
1362
  runtimes: [...this.runtimeAppliers.keys()]
1340
1363
  });
1341
1364
  if (hookResult.exitCode !== 0) {
1342
- this.state.setStatus(integrationId, "error", `Health check failed: ${hookResult.stderr || hookResult.stdout}`);
1343
- return this.err("HEALTH_CHECK_FAILED", `Health check failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1365
+ const message = this.hookFailureMessage("Health check failed", hookResult);
1366
+ this.state.setStatus(integrationId, "error", message);
1367
+ return this.err("HEALTH_CHECK_FAILED", message);
1344
1368
  }
1345
1369
  }
1346
1370
  this.log.info(`Integration "${integrationId}" activated`);
@@ -1482,8 +1506,8 @@ var IntegrationManager = class {
1482
1506
  config: entry.config,
1483
1507
  secrets: this.secrets.get(name),
1484
1508
  runtimes: [...this.runtimeAppliers.keys()]
1485
- });
1486
- if (hookResult.exitCode !== 0) this.log.warn(`pre_uninstall hook failed (continuing): ${hookResult.stderr}`);
1509
+ }, INSTALL_HOOK_TIMEOUT_MS);
1510
+ if (hookResult.exitCode !== 0) this.log.warn(this.hookFailureMessage("pre_uninstall hook failed (continuing)", hookResult));
1487
1511
  }
1488
1512
  if (uninstallHooksSupported && manifest?.hooks.post_uninstall) {
1489
1513
  this.log.info(`Running post_uninstall hook: ${manifest.hooks.post_uninstall}`);
@@ -1492,8 +1516,8 @@ var IntegrationManager = class {
1492
1516
  config: entry.config,
1493
1517
  secrets: this.secrets.get(name),
1494
1518
  runtimes: [...this.runtimeAppliers.keys()]
1495
- });
1496
- if (hookResult.exitCode !== 0) this.log.warn(`post_uninstall hook failed (non-fatal): ${hookResult.stderr}`);
1519
+ }, INSTALL_HOOK_TIMEOUT_MS);
1520
+ if (hookResult.exitCode !== 0) this.log.warn(this.hookFailureMessage("post_uninstall hook failed (non-fatal)", hookResult));
1497
1521
  }
1498
1522
  if (this.installer.isInstalled(name)) {
1499
1523
  await this.installer.remove(name);
@@ -1644,8 +1668,8 @@ var IntegrationManager = class {
1644
1668
  if (!installHooksSupported && (newManifest.hooks.pre_install || newManifest.hooks.post_install)) this.log.warn(`Integration "${name}" upgrade install hooks skipped — no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${(newManifest.supported_agents ?? []).join(", ")})`);
1645
1669
  if (installHooksSupported && newManifest.hooks.pre_install) {
1646
1670
  this.log.info(`Running pre_install hook: ${newManifest.hooks.pre_install}`);
1647
- const hookResult = await runHook(this.installer.getInstallPath(name), newManifest.hooks.pre_install);
1648
- if (hookResult.exitCode !== 0) throw new Error(`pre_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1671
+ const hookResult = await runHook(this.installer.getInstallPath(name), newManifest.hooks.pre_install, void 0, INSTALL_HOOK_TIMEOUT_MS);
1672
+ if (hookResult.exitCode !== 0) throw new Error(this.hookFailureMessage("pre_install hook failed", hookResult));
1649
1673
  }
1650
1674
  if (installHooksSupported && newManifest.hooks.post_install) {
1651
1675
  this.log.info(`Running post_install hook: ${newManifest.hooks.post_install}`);
@@ -1654,8 +1678,8 @@ var IntegrationManager = class {
1654
1678
  config: config ?? existing.config,
1655
1679
  secrets: this.secrets.get(name),
1656
1680
  runtimes: [...this.runtimeAppliers.keys()]
1657
- });
1658
- if (hookResult.exitCode !== 0) throw new Error(`post_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1681
+ }, INSTALL_HOOK_TIMEOUT_MS);
1682
+ if (hookResult.exitCode !== 0) throw new Error(this.hookFailureMessage("post_install hook failed", hookResult));
1659
1683
  }
1660
1684
  await this.applyUpgradeDiffRemovals(name, oldManifest, newManifest);
1661
1685
  return await this.activate(name, { forcePlugins: true });
@@ -1834,7 +1858,7 @@ var IntegrationManager = class {
1834
1858
  healthy: hookResult.exitCode === 0,
1835
1859
  status: entry.status,
1836
1860
  version: manifest.version,
1837
- message: hookResult.exitCode === 0 ? "Healthy" : `Health check failed (exit ${String(hookResult.exitCode)})`,
1861
+ message: hookResult.exitCode === 0 ? "Healthy" : hookResult.timedOut ? `Health check failed (timed out after ${String(hookResult.timedOutAfterMs ?? "?")}ms)` : `Health check failed (exit ${String(hookResult.exitCode)})`,
1838
1862
  stdout: hookResult.stdout || void 0,
1839
1863
  stderr: hookResult.stderr || void 0
1840
1864
  };
@@ -1858,6 +1882,17 @@ var IntegrationManager = class {
1858
1882
  };
1859
1883
  }
1860
1884
  /**
1885
+ * Build a hook-failure message. A hook we SIGKILLed at its timeout is rendered
1886
+ * as `(timed out after <ms>ms)` so it is visually distinguishable in Sentry
1887
+ * from a genuine non-zero exit `(exit <code>)` — the two have very different
1888
+ * root causes (Sentry AGENT-DAEMON-6).
1889
+ */
1890
+ hookFailureMessage(label, hookResult) {
1891
+ const reason = hookResult.timedOut ? `timed out after ${String(hookResult.timedOutAfterMs ?? "?")}ms` : `exit ${String(hookResult.exitCode)}`;
1892
+ const output = hookResult.stderr || hookResult.stdout;
1893
+ return output ? `${label} (${reason}): ${output}` : `${label} (${reason})`;
1894
+ }
1895
+ /**
1861
1896
  * Compute the plugin (bare package) names and skill names still claimed by
1862
1897
  * SOME integration in the CURRENT lock state, keyed by runtime. Both
1863
1898
  * `deactivate` and `upgrade`'s diff-removal call this AFTER
@@ -2301,6 +2336,24 @@ var OpenClawApplier = class {
2301
2336
  * this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
2302
2337
  */
2303
2338
  lastHealAt = 0;
2339
+ /**
2340
+ * Cross-integration plugin-pin claims made during THIS process lifetime,
2341
+ * keyed by bare package name. `applyPlugin` is now version-aware for both
2342
+ * force and non-force callers — it actively reinstalls a plugin whose
2343
+ * installed version differs from the pinned spec (previously the non-force
2344
+ * path keyed only on bare-name presence and silently ignored a pin bump). If
2345
+ * two integrations pin the SAME plugin at DIFFERENT versions, that
2346
+ * version-aware reinstall would make them fight — each reconcile pass
2347
+ * uninstall+reinstalls the other's version forever. This map records the
2348
+ * first-applied pin per package so a later, divergent pin from a DIFFERENT
2349
+ * integration is refused (first-writer-wins) with a WARN instead of thrashing.
2350
+ * A later apply from the SAME integration (a legitimate pin bump across an
2351
+ * upgrade) is NOT a conflict and proceeds. Cleared per-package on
2352
+ * `removePlugin`, and wholesale on daemon restart. Convention is single-owner
2353
+ * per plugin (see the `alfe` manifest note) — this only turns a convention
2354
+ * violation into a stable warning.
2355
+ */
2356
+ appliedPluginPins = /* @__PURE__ */ new Map();
2304
2357
  constructor(options) {
2305
2358
  const home = options.home ?? options.workspace;
2306
2359
  if (!home) throw new Error("OpenClawApplier requires `home` (or legacy `workspace`) option");
@@ -2484,25 +2537,43 @@ var OpenClawApplier = class {
2484
2537
  }
2485
2538
  async applyPluginLocked(spec, opts) {
2486
2539
  const pkg = stripPluginVersion(spec);
2540
+ const pinnedVersion = pluginSpecVersion(spec);
2541
+ const claim = this.appliedPluginPins.get(pkg);
2542
+ if (pinnedVersion !== void 0 && claim !== void 0 && claim.version !== pinnedVersion && claim.integrationId !== opts?.integrationId) {
2543
+ log$3.warn({
2544
+ pkg,
2545
+ requestedVersion: pinnedVersion,
2546
+ keptVersion: claim.version,
2547
+ requestedBy: opts?.integrationId,
2548
+ ownedBy: claim.integrationId
2549
+ }, "Conflicting plugin pin across integrations — keeping the first-applied version (first-writer-wins)");
2550
+ return;
2551
+ }
2487
2552
  await this.ensurePluginsAllowUnlocked(pkg);
2488
2553
  this.cleanupUntrackedExtensionInstall(pkg);
2489
- if (opts?.force && this.isPluginInstalled(pkg)) {
2490
- const pinnedVersion = pluginSpecVersion(spec);
2554
+ if (this.isPluginInstalled(pkg)) {
2491
2555
  const installedVersion = this.installedPluginVersion(pkg);
2492
- if (pinnedVersion && installedVersion && installedVersion === pinnedVersion) {
2493
- log$3.info({
2556
+ const versionsComparable = pinnedVersion !== void 0 && installedVersion !== void 0;
2557
+ if (versionsComparable && installedVersion === pinnedVersion) {
2558
+ if (opts?.force) log$3.info({
2494
2559
  pkg,
2495
2560
  spec,
2496
2561
  version: installedVersion
2497
2562
  }, "Force mode — installed version already matches pinned spec, skipping uninstall+reinstall");
2563
+ this.recordPluginPin(pkg, pinnedVersion, opts?.integrationId);
2564
+ return;
2565
+ }
2566
+ if (!(versionsComparable && installedVersion !== pinnedVersion) && !opts?.force) {
2567
+ this.recordPluginPin(pkg, pinnedVersion, opts?.integrationId);
2498
2568
  return;
2499
2569
  }
2500
2570
  log$3.info({
2501
2571
  pkg,
2502
2572
  spec,
2503
2573
  installedVersion,
2504
- pinnedVersion
2505
- }, "Force mode — uninstalling plugin before reinstall");
2574
+ pinnedVersion,
2575
+ force: opts?.force ?? false
2576
+ }, "Reinstalling plugin to converge on the pinned version");
2506
2577
  try {
2507
2578
  await this.removePluginUnlocked(pkg);
2508
2579
  } catch (err) {
@@ -2510,7 +2581,7 @@ var OpenClawApplier = class {
2510
2581
  pkg,
2511
2582
  spec,
2512
2583
  err: err instanceof Error ? err.message : String(err)
2513
- }, "Failed to uninstall plugin during force reinstall — proceeding");
2584
+ }, "Failed to uninstall plugin before reinstall — proceeding");
2514
2585
  }
2515
2586
  }
2516
2587
  if (!this.isPluginInstalled(pkg)) {
@@ -2547,6 +2618,20 @@ var OpenClawApplier = class {
2547
2618
  setTimeout(r, 500);
2548
2619
  });
2549
2620
  }
2621
+ this.recordPluginPin(pkg, pinnedVersion, opts?.integrationId);
2622
+ }
2623
+ /**
2624
+ * Record the pin a caller just applied for a package, keyed by bare name, so
2625
+ * a later divergent pin from a DIFFERENT integration in this process can be
2626
+ * refused (first-writer-wins). Bare (versionless) pins are untrackable and
2627
+ * ignored — they can't conflict on version. See `appliedPluginPins`.
2628
+ */
2629
+ recordPluginPin(pkg, version, integrationId) {
2630
+ if (version === void 0) return;
2631
+ this.appliedPluginPins.set(pkg, {
2632
+ version,
2633
+ integrationId
2634
+ });
2550
2635
  }
2551
2636
  /**
2552
2637
  * Ensure one or more plugins are in plugins.allow in openclaw.json, UNLOCKED.
@@ -2692,6 +2777,7 @@ var OpenClawApplier = class {
2692
2777
  */
2693
2778
  async removePluginUnlocked(spec) {
2694
2779
  const pkg = stripPluginVersion(spec);
2780
+ this.appliedPluginPins.delete(pkg);
2695
2781
  await this.execOpenClawHealing([
2696
2782
  "plugins",
2697
2783
  "uninstall",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",