@alfe.ai/integrations 0.2.6 → 0.2.8

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
@@ -870,6 +870,11 @@ declare class OpenClawApplier implements RuntimeApplier {
870
870
  * acquires it ONCE at the top and calls only `*Unlocked` helpers inside.
871
871
  */
872
872
  private cliLock;
873
+ /**
874
+ * Monotonic-ish guard for the state-DB self-heal — timestamp of the last heal
875
+ * this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
876
+ */
877
+ private lastHealAt;
873
878
  constructor(options: OpenClawApplierOptions);
874
879
  /**
875
880
  * Convenience: `openclaw config set <args>`, UNLOCKED + retried.
@@ -913,6 +918,46 @@ declare class OpenClawApplier implements RuntimeApplier {
913
918
  * on the first success, false once all attempts are exhausted.
914
919
  */
915
920
  private verifyApplied;
921
+ /**
922
+ * `openclaw <args>` with automatic malformed-state-DB self-heal. On a
923
+ * malformed-DB failure (see isMalformedStateDbError): quarantine + recreate the
924
+ * state DB (healMalformedStateDb), then retry the ORIGINAL command exactly
925
+ * once. If the heal is rate-limited/failed, or the single retry also fails, the
926
+ * error propagates unchanged — callers keep their existing failure semantics
927
+ * (retry loops, install-tolerance checks, log.warn) as if healing never ran.
928
+ *
929
+ * Assumes the shared CLI lock is already held by the calling public method: the
930
+ * heal runs INSIDE the same locked section as the failed command. NEVER
931
+ * re-acquires the non-re-entrant `cliLock`.
932
+ *
933
+ * Runtime-alive safety: reconcile-path callers run under the gateway's
934
+ * RuntimeGate (runtime stopped), but the `setConfigRaw` hook (daemon
935
+ * `alfe.config_set`) can heal while `openclaw gateway run` still holds the DB.
936
+ * That is safe by fd/inode decoupling, not by exclusivity: renameSync is
937
+ * inode-level, the live runtime keeps writing its orphaned inode (discarded on
938
+ * its next restart — the state DB is disposable), and the recreated DB gets
939
+ * fresh -wal/-shm files the old process never attaches to.
940
+ */
941
+ private execOpenClawHealing;
942
+ /**
943
+ * Quarantine the corrupt OpenClaw state DB and recreate it. Never throws
944
+ * (logs + returns whether the heal succeeded). Rate-limited to one attempt per
945
+ * HEAL_MIN_INTERVAL_MS so a heal→re-corrupt→heal loop can't thrash the box.
946
+ *
947
+ * Steps (human-verified against a live managed agent): rename
948
+ * `state/openclaw.sqlite{,-wal,-shm}` aside with a `.quarantined-<ISO>` suffix
949
+ * (keep for forensics; -wal/-shm may be absent), then run `openclaw plugins
950
+ * list` to rebuild the DB via OpenClaw's migration framework (slow on a 2-vCPU
951
+ * box — 90s budget). Assumes the caller holds `cliLock` (see
952
+ * execOpenClawHealing for the runtime-alive rename semantics).
953
+ *
954
+ * A SUCCESSFUL heal consumes the full rate-limit window (a fresh DB exists for
955
+ * the runtime to re-corrupt — that loop is what the window prevents). A FAILED
956
+ * heal only gets the short cooldown: no fresh DB was produced, and a full
957
+ * window would strand the box with the main DB quarantined and no recreate for
958
+ * 10 minutes.
959
+ */
960
+ private healMalformedStateDb;
916
961
  applyPlugin(spec: string, _installPath?: string, opts?: {
917
962
  force?: boolean;
918
963
  }): Promise<void>;
@@ -951,6 +996,16 @@ declare class OpenClawApplier implements RuntimeApplier {
951
996
  * plugins under 2026.5+, which made force-mode skip its uninstall step.
952
997
  */
953
998
  private isPluginInstalled;
999
+ /**
1000
+ * Best-effort read of the installed version of a plugin, or `undefined` when
1001
+ * it can't be determined. Reads the version from the installed package's
1002
+ * package.json under the npm registry path OpenClaw 2026.5+ installs to
1003
+ * (`~/.openclaw/npm/node_modules/<pkg>/package.json`) — the same root
1004
+ * `isPluginInstalled` checks. 2026.4 extensions-dir installs have no
1005
+ * stable package.json version here and return `undefined` (callers fail
1006
+ * open to the safe reinstall path).
1007
+ */
1008
+ private installedPluginVersion;
954
1009
  /**
955
1010
  * Remove an extensions/ install that has no matching record in
956
1011
  * plugins/installs.json. OpenClaw 2026.5+ tracks installs via the records
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { execFile, spawn } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { homedir, platform, tmpdir } from "node:os";
5
- import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { buildConfigValidationSchema, parseManifestFile } from "@alfe.ai/integration-manifest";
7
7
  import { createLogger } from "@auriclabs/logger";
8
8
  import { parseDocument } from "yaml";
@@ -856,6 +856,22 @@ function stripPluginVersion(spec) {
856
856
  if (at <= 0) return spec;
857
857
  return spec.slice(0, at);
858
858
  }
859
+ /**
860
+ * Return the pinned version from a plugin spec, or `undefined` when the spec
861
+ * carries no version (bare package name). Mirror of `stripPluginVersion`: the
862
+ * version is everything after the LAST '@' when that '@' sits at index > 0
863
+ * (i.e. it's a real version delimiter, not the scope's leading '@').
864
+ *
865
+ * Used by the force-mode reinstall path to compare the pinned version against
866
+ * the installed one so an already-current plugin can skip a ~45s
867
+ * uninstall+reinstall cycle.
868
+ */
869
+ function pluginSpecVersion(spec) {
870
+ const at = spec.lastIndexOf("@");
871
+ if (at <= 0) return void 0;
872
+ const version = spec.slice(at + 1);
873
+ return version.length > 0 ? version : void 0;
874
+ }
859
875
  //#endregion
860
876
  //#region src/integration-manager.ts
861
877
  /**
@@ -1729,16 +1745,86 @@ const BUNDLED_BASELINE_ALLOW = ["browser"];
1729
1745
  const CONFIG_SET_RETRIES$1 = 3;
1730
1746
  const CONFIG_SET_RETRY_DELAY_MS$1 = 750;
1731
1747
  /**
1748
+ * Max `openclaw config get` spawns in flight at once during the batch
1749
+ * verify-after-write. A naive `Promise.all(leaves.map(...))` fires one CLI
1750
+ * process per leaf simultaneously — a 36-leaf model-provider batch on a 2-vCPU
1751
+ * box means 36 concurrent `config get`s, each needing ~1.9s of CPU, so every
1752
+ * process blows its own 10s timeout and the verify reports false even when the
1753
+ * write actually landed. A small pool keeps the read-backs honest under load.
1754
+ */
1755
+ const VERIFY_GET_CONCURRENCY = 4;
1756
+ /**
1732
1757
  * Sentinel OpenClaw returns from `config get` in place of a sensitive value —
1733
1758
  * the key IS set, OpenClaw is just hiding it. Verify-after-write must treat a
1734
1759
  * read-back of this as "present/matches" rather than a mismatch (see
1735
1760
  * `configValueMatches`).
1736
1761
  */
1737
1762
  const OPENCLAW_REDACTED = "__OPENCLAW_REDACTED__";
1763
+ /**
1764
+ * OpenClaw's gateway runtime deterministically truncates its own WAL-mode state
1765
+ * DB (`<home>/state/openclaw.sqlite`) ~50s after boot: the page_count header
1766
+ * shrinks while btree/freelist entries still reference pages past the new end.
1767
+ * Once corrupt, EVERY `openclaw` CLI invocation that walks the freelist dies at
1768
+ * startup with this exact phrase (printed to STDOUT, exit 1), which kills every
1769
+ * `plugins install` during activation. Upstream (no fix, suggests exactly this
1770
+ * quarantine-and-recreate recovery): https://github.com/openclaw/openclaw/issues/71689.
1771
+ * `doctor --fix` does NOT detect it. The state DB is disposable — plugin installs
1772
+ * live in `<home>/npm/node_modules` + the on-disk registry, so quarantine + any
1773
+ * CLI command rebuilds it via migrations with no meaningful loss.
1774
+ */
1775
+ const MALFORMED_STATE_DB_PHRASE = "database disk image is malformed";
1776
+ /**
1777
+ * Rate-limit the state-DB self-heal to ONE attempt per applier instance per
1778
+ * window so a heal→corrupt→heal loop (the runtime re-corrupts the fresh DB ~50s
1779
+ * later) can't thrash the box. Wall-clock `Date.now()` is fine here — this is
1780
+ * runtime daemon code, not a workflow script.
1781
+ */
1782
+ const HEAL_MIN_INTERVAL_MS = 600 * 1e3;
1783
+ /**
1784
+ * Cooldown after a FAILED heal attempt. A failed heal produced no fresh DB, so
1785
+ * the full HEAL_MIN_INTERVAL_MS lockout is unjustified (it would strand the box
1786
+ * with the main DB quarantined and no recreate); a short cooldown just stops a
1787
+ * tight retry loop from hammering rename/recreate.
1788
+ */
1789
+ const FAILED_HEAL_COOLDOWN_MS = 60 * 1e3;
1790
+ /**
1791
+ * True when an execFile rejection carries the malformed-state-DB signature.
1792
+ * OpenClaw prints the reason to STDOUT (not stderr), and Node's execFile error
1793
+ * embeds the argv in `message`, so all three streams are checked. The phrase is
1794
+ * specific enough to match on directly (case-insensitive).
1795
+ */
1796
+ function isMalformedStateDbError(err) {
1797
+ if (!(err instanceof Error)) return false;
1798
+ const e = err;
1799
+ return `${e.message}\n${e.stderr ?? ""}\n${e.stdout ?? ""}`.toLowerCase().includes(MALFORMED_STATE_DB_PHRASE);
1800
+ }
1738
1801
  const delay$1 = (ms) => new Promise((resolve) => {
1739
1802
  setTimeout(resolve, ms);
1740
1803
  });
1741
1804
  /**
1805
+ * Run `fn` over `items` with at most `limit` invocations in flight at once,
1806
+ * preserving result order. A tiny local pool (no new dependency) so the batch
1807
+ * verify-after-write doesn't stampede one `openclaw config get` per leaf — see
1808
+ * VERIFY_GET_CONCURRENCY.
1809
+ *
1810
+ * NOTE: relies on `fn` never rejecting (configValueMatches catches internally).
1811
+ * A throwing `fn` would reject the pool while sibling workers keep draining
1812
+ * items in the background — don't wire a throwing `fn` in here.
1813
+ */
1814
+ async function mapWithConcurrency(items, limit, fn) {
1815
+ const results = new Array(items.length);
1816
+ let cursor = 0;
1817
+ const worker = async () => {
1818
+ while (cursor < items.length) {
1819
+ const index = cursor++;
1820
+ results[index] = await fn(items[index]);
1821
+ }
1822
+ };
1823
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
1824
+ await Promise.all(workers);
1825
+ return results;
1826
+ }
1827
+ /**
1742
1828
  * Asymmetric structural containment for verify-after-write: is every field we
1743
1829
  * INTENDED present in the ACTUAL read-back with our value? `actual` may carry
1744
1830
  * EXTRA keys that `intended` does not.
@@ -1781,19 +1867,70 @@ function deepSubset(intended, actual) {
1781
1867
  function redactConfigSetTarget(args) {
1782
1868
  return `openclaw config ${args.slice(0, 2).join(" ")}`.trim();
1783
1869
  }
1870
+ /** Cap a captured stream so a runaway OpenClaw dump can't bloat errorMessage. */
1871
+ const ERROR_STREAM_MAX = 400;
1872
+ function truncateForError(s) {
1873
+ return s.length > ERROR_STREAM_MAX ? `${s.slice(0, ERROR_STREAM_MAX)}… (truncated)` : s;
1874
+ }
1875
+ /**
1876
+ * Collect the value strings a `config set` argv carries so they can be scrubbed
1877
+ * out of OpenClaw's own output. OpenClaw validation errors can echo the
1878
+ * offending VALUE back in their diagnostic text, so stdout/stderr must be
1879
+ * treated as value-bearing, not just the argv. For `{path, value}` batch
1880
+ * entries only the `value` side is collected — redacting the path out of
1881
+ * "invalid path models.providers…" would gut the diagnostic.
1882
+ */
1883
+ function collectArgvValues(args) {
1884
+ const out = [];
1885
+ const collect = (v) => {
1886
+ if (typeof v === "string") {
1887
+ if (v.length >= 4) out.push(v);
1888
+ } else if (Array.isArray(v)) v.forEach(collect);
1889
+ else if (v !== null && typeof v === "object") {
1890
+ const o = v;
1891
+ if (typeof o.path === "string" && "value" in o) collect(o.value);
1892
+ else Object.values(o).forEach(collect);
1893
+ }
1894
+ };
1895
+ for (const token of args.slice(2)) {
1896
+ if (token.startsWith("--")) continue;
1897
+ try {
1898
+ collect(JSON.parse(token));
1899
+ } catch {
1900
+ collect(token);
1901
+ }
1902
+ }
1903
+ return out.sort((a, b) => b.length - a.length);
1904
+ }
1905
+ /** Replace every occurrence of the argv-derived values in a captured stream. */
1906
+ function scrubStream(s, values) {
1907
+ let out = s;
1908
+ for (const v of values) if (out.includes(v)) out = out.split(v).join("[REDACTED]");
1909
+ return out;
1910
+ }
1784
1911
  /**
1785
1912
  * Build a diagnosable BUT scrubbed message from an execFile rejection. Node's
1786
1913
  * execFile error `message` is "Command failed: <full argv>" — which for
1787
1914
  * `config set` embeds the value payload — so we never surface it. We keep the
1788
- * redacted target, the exit code, and `stderr` (openclaw's own error text).
1915
+ * redacted target, the exit code, `stderr`, and `stdout`.
1916
+ *
1917
+ * OpenClaw prints its own error text to STDOUT (not stderr), so on a genuine
1918
+ * failure `stderr` is often empty and the real cause is in stdout — surfacing
1919
+ * only stderr left days of bare "(exit 1)" in the logs. We include a truncated
1920
+ * stdout too — but scrubbed first: OpenClaw's error text can echo the offending
1921
+ * value, so both streams are run through `scrubStream` with the argv values
1922
+ * before anything reaches the dashboard-visible errorMessage.
1789
1923
  */
1790
1924
  function configSetErrorMessage(err, args) {
1791
1925
  const target = redactConfigSetTarget(args);
1792
1926
  if (err instanceof Error) {
1793
1927
  const e = err;
1794
- const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
1928
+ const values = collectArgvValues(args);
1929
+ const stderr = typeof e.stderr === "string" ? scrubStream(e.stderr.trim(), values) : "";
1930
+ const stdout = typeof e.stdout === "string" ? scrubStream(e.stdout.trim(), values) : "";
1795
1931
  const code = e.code !== void 0 ? ` (exit ${e.code})` : "";
1796
- return stderr ? `${target} failed${code}: ${stderr}` : `${target} failed${code}`;
1932
+ const details = [stderr, stdout ? `stdout: ${truncateForError(stdout)}` : ""].filter(Boolean).join("; ");
1933
+ return details ? `${target} failed${code}: ${details}` : `${target} failed${code}`;
1797
1934
  }
1798
1935
  return `${target} failed: ${String(err)}`;
1799
1936
  }
@@ -1826,6 +1963,11 @@ var OpenClawApplier = class {
1826
1963
  * acquires it ONCE at the top and calls only `*Unlocked` helpers inside.
1827
1964
  */
1828
1965
  cliLock;
1966
+ /**
1967
+ * Monotonic-ish guard for the state-DB self-heal — timestamp of the last heal
1968
+ * this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
1969
+ */
1970
+ lastHealAt = 0;
1829
1971
  constructor(options) {
1830
1972
  const home = options.home ?? options.workspace;
1831
1973
  if (!home) throw new Error("OpenClawApplier requires `home` (or legacy `workspace`) option");
@@ -1864,7 +2006,7 @@ var OpenClawApplier = class {
1864
2006
  const timeout = opts.timeout ?? 1e4;
1865
2007
  let lastErr;
1866
2008
  for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
1867
- await execFileAsync$1("openclaw", ["config", ...args], { timeout });
2009
+ await this.execOpenClawHealing(["config", ...args], { timeout });
1868
2010
  return;
1869
2011
  } catch (err) {
1870
2012
  lastErr = err;
@@ -1911,6 +2053,99 @@ var OpenClawApplier = class {
1911
2053
  }
1912
2054
  return false;
1913
2055
  }
2056
+ /**
2057
+ * `openclaw <args>` with automatic malformed-state-DB self-heal. On a
2058
+ * malformed-DB failure (see isMalformedStateDbError): quarantine + recreate the
2059
+ * state DB (healMalformedStateDb), then retry the ORIGINAL command exactly
2060
+ * once. If the heal is rate-limited/failed, or the single retry also fails, the
2061
+ * error propagates unchanged — callers keep their existing failure semantics
2062
+ * (retry loops, install-tolerance checks, log.warn) as if healing never ran.
2063
+ *
2064
+ * Assumes the shared CLI lock is already held by the calling public method: the
2065
+ * heal runs INSIDE the same locked section as the failed command. NEVER
2066
+ * re-acquires the non-re-entrant `cliLock`.
2067
+ *
2068
+ * Runtime-alive safety: reconcile-path callers run under the gateway's
2069
+ * RuntimeGate (runtime stopped), but the `setConfigRaw` hook (daemon
2070
+ * `alfe.config_set`) can heal while `openclaw gateway run` still holds the DB.
2071
+ * That is safe by fd/inode decoupling, not by exclusivity: renameSync is
2072
+ * inode-level, the live runtime keeps writing its orphaned inode (discarded on
2073
+ * its next restart — the state DB is disposable), and the recreated DB gets
2074
+ * fresh -wal/-shm files the old process never attaches to.
2075
+ */
2076
+ async execOpenClawHealing(args, opts = {}) {
2077
+ try {
2078
+ await execFileAsync$1("openclaw", args, opts);
2079
+ return;
2080
+ } catch (err) {
2081
+ if (!isMalformedStateDbError(err)) throw err;
2082
+ log$3.warn({ args: args.slice(0, 2) }, "openclaw CLI failed with a malformed state DB — attempting self-heal then one retry");
2083
+ if (!await this.healMalformedStateDb()) throw err;
2084
+ await execFileAsync$1("openclaw", args, opts);
2085
+ }
2086
+ }
2087
+ /**
2088
+ * Quarantine the corrupt OpenClaw state DB and recreate it. Never throws
2089
+ * (logs + returns whether the heal succeeded). Rate-limited to one attempt per
2090
+ * HEAL_MIN_INTERVAL_MS so a heal→re-corrupt→heal loop can't thrash the box.
2091
+ *
2092
+ * Steps (human-verified against a live managed agent): rename
2093
+ * `state/openclaw.sqlite{,-wal,-shm}` aside with a `.quarantined-<ISO>` suffix
2094
+ * (keep for forensics; -wal/-shm may be absent), then run `openclaw plugins
2095
+ * list` to rebuild the DB via OpenClaw's migration framework (slow on a 2-vCPU
2096
+ * box — 90s budget). Assumes the caller holds `cliLock` (see
2097
+ * execOpenClawHealing for the runtime-alive rename semantics).
2098
+ *
2099
+ * A SUCCESSFUL heal consumes the full rate-limit window (a fresh DB exists for
2100
+ * the runtime to re-corrupt — that loop is what the window prevents). A FAILED
2101
+ * heal only gets the short cooldown: no fresh DB was produced, and a full
2102
+ * window would strand the box with the main DB quarantined and no recreate for
2103
+ * 10 minutes.
2104
+ */
2105
+ async healMalformedStateDb() {
2106
+ const now = Date.now();
2107
+ if (now - this.lastHealAt < HEAL_MIN_INTERVAL_MS) {
2108
+ log$3.warn({ sinceLastHealMs: now - this.lastHealAt }, "openclaw state DB malformed but a heal ran within the rate-limit window — skipping to avoid a heal→corrupt→heal loop");
2109
+ return false;
2110
+ }
2111
+ this.lastHealAt = now;
2112
+ const stateDir = join(this.home, "state");
2113
+ const stamp = new Date(now).toISOString().replaceAll(":", "-");
2114
+ const quarantined = [];
2115
+ try {
2116
+ for (const name of [
2117
+ "openclaw.sqlite",
2118
+ "openclaw.sqlite-wal",
2119
+ "openclaw.sqlite-shm"
2120
+ ]) {
2121
+ const src = join(stateDir, name);
2122
+ if (!existsSync(src)) continue;
2123
+ const dest = `${src}.quarantined-${stamp}`;
2124
+ renameSync(src, dest);
2125
+ quarantined.push(dest);
2126
+ }
2127
+ if (quarantined.length === 0) {
2128
+ log$3.warn({ stateDir }, "malformed openclaw state DB reported but no state/openclaw.sqlite* files found to quarantine");
2129
+ this.lastHealAt = now - HEAL_MIN_INTERVAL_MS + FAILED_HEAL_COOLDOWN_MS;
2130
+ return false;
2131
+ }
2132
+ log$3.warn({
2133
+ stateDir,
2134
+ quarantined
2135
+ }, "quarantined malformed openclaw state DB — recreating via `openclaw plugins list` (upstream openclaw/openclaw#71689)");
2136
+ await execFileAsync$1("openclaw", ["plugins", "list"], { timeout: 9e4 });
2137
+ log$3.warn({ stateDir }, "recreated openclaw state DB after quarantine");
2138
+ return true;
2139
+ } catch (err) {
2140
+ log$3.warn({
2141
+ stateDir,
2142
+ quarantined,
2143
+ err: err instanceof Error ? err.message : String(err)
2144
+ }, "failed to heal malformed openclaw state DB — leaving quarantined files for forensics");
2145
+ this.lastHealAt = now - HEAL_MIN_INTERVAL_MS + FAILED_HEAL_COOLDOWN_MS;
2146
+ return false;
2147
+ }
2148
+ }
1914
2149
  applyPlugin(spec, _installPath, opts) {
1915
2150
  return this.cliLock.run(() => this.applyPluginLocked(spec, opts));
1916
2151
  }
@@ -1919,9 +2154,21 @@ var OpenClawApplier = class {
1919
2154
  await this.ensurePluginsAllowUnlocked(pkg);
1920
2155
  this.cleanupUntrackedExtensionInstall(pkg);
1921
2156
  if (opts?.force && this.isPluginInstalled(pkg)) {
2157
+ const pinnedVersion = pluginSpecVersion(spec);
2158
+ const installedVersion = this.installedPluginVersion(pkg);
2159
+ if (pinnedVersion && installedVersion && installedVersion === pinnedVersion) {
2160
+ log$3.info({
2161
+ pkg,
2162
+ spec,
2163
+ version: installedVersion
2164
+ }, "Force mode — installed version already matches pinned spec, skipping uninstall+reinstall");
2165
+ return;
2166
+ }
1922
2167
  log$3.info({
1923
2168
  pkg,
1924
- spec
2169
+ spec,
2170
+ installedVersion,
2171
+ pinnedVersion
1925
2172
  }, "Force mode — uninstalling plugin before reinstall");
1926
2173
  try {
1927
2174
  await this.removePluginUnlocked(pkg);
@@ -1944,12 +2191,12 @@ var OpenClawApplier = class {
1944
2191
  const args = useUnsafeFlag ? [...baseArgs, "--dangerously-force-unsafe-install"] : baseArgs;
1945
2192
  try {
1946
2193
  try {
1947
- await execFileAsync$1("openclaw", args, { timeout: 6e4 });
2194
+ await this.execOpenClawHealing(args, { timeout: 6e4 });
1948
2195
  } catch (err) {
1949
2196
  const errText = err instanceof Error ? `${err.message}\n${err.stderr ?? ""}` : String(err);
1950
2197
  if (useUnsafeFlag && errText.includes("unknown option")) {
1951
2198
  log$3.info({ pkg }, "OpenClaw does not support --dangerously-force-unsafe-install, retrying without");
1952
- await execFileAsync$1("openclaw", baseArgs, { timeout: 6e4 });
2199
+ await this.execOpenClawHealing(baseArgs, { timeout: 6e4 });
1953
2200
  } else throw err;
1954
2201
  }
1955
2202
  } catch (err) {
@@ -2039,6 +2286,25 @@ var OpenClawApplier = class {
2039
2286
  return readdirSync(extensionsDir).some((dir) => dir.startsWith(prefix) && /^[0-9a-f]+$/i.test(dir.slice(prefix.length)));
2040
2287
  }
2041
2288
  /**
2289
+ * Best-effort read of the installed version of a plugin, or `undefined` when
2290
+ * it can't be determined. Reads the version from the installed package's
2291
+ * package.json under the npm registry path OpenClaw 2026.5+ installs to
2292
+ * (`~/.openclaw/npm/node_modules/<pkg>/package.json`) — the same root
2293
+ * `isPluginInstalled` checks. 2026.4 extensions-dir installs have no
2294
+ * stable package.json version here and return `undefined` (callers fail
2295
+ * open to the safe reinstall path).
2296
+ */
2297
+ installedPluginVersion(pkg) {
2298
+ const pkgJsonPath = join(this.home, "npm", "node_modules", ...pkg.split("/"), "package.json");
2299
+ if (!existsSync(pkgJsonPath)) return void 0;
2300
+ try {
2301
+ const data = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
2302
+ return typeof data.version === "string" ? data.version : void 0;
2303
+ } catch {
2304
+ return;
2305
+ }
2306
+ }
2307
+ /**
2042
2308
  * Remove an extensions/ install that has no matching record in
2043
2309
  * plugins/installs.json. OpenClaw 2026.5+ tracks installs via the records
2044
2310
  * file — a leftover dir from 2026.4 is invisible to `openclaw plugins
@@ -2092,11 +2358,12 @@ var OpenClawApplier = class {
2092
2358
  * path). Never call outside a locked section.
2093
2359
  */
2094
2360
  async removePluginUnlocked(spec) {
2095
- await execFileAsync$1("openclaw", [
2361
+ const pkg = stripPluginVersion(spec);
2362
+ await this.execOpenClawHealing([
2096
2363
  "plugins",
2097
2364
  "uninstall",
2098
2365
  "--force",
2099
- stripPluginVersion(spec)
2366
+ pkg
2100
2367
  ], { timeout: 3e4 });
2101
2368
  }
2102
2369
  applySkill(name, srcPath) {
@@ -2192,10 +2459,10 @@ var OpenClawApplier = class {
2192
2459
  "--batch-json",
2193
2460
  JSON.stringify(leaves),
2194
2461
  "--replace"
2195
- ]);
2462
+ ], { timeout: Math.min(Math.max(3e4, leaves.length * 2e3), 12e4) });
2196
2463
  } catch (err) {
2197
2464
  if (await this.verifyApplied(async () => {
2198
- return (await Promise.all(leaves.map((l) => this.configValueMatches(l.path, l.value)))).every(Boolean);
2465
+ return (await mapWithConcurrency(leaves, VERIFY_GET_CONCURRENCY, (l) => this.configValueMatches(l.path, l.value))).every(Boolean);
2199
2466
  })) log$3.warn({ count: leaves.length }, "openclaw config set --batch-json exited non-zero but config landed — continuing");
2200
2467
  else {
2201
2468
  log$3.error({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",