@clawling/clawchat-plugin-openclaw 2026.7.2 → 2026.7.6-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.
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ // Worded to make the greeting the single required action. Earlier wording
5
+ // ("ClawChat activation bootstrap: ... do both ...") led some agents to treat
6
+ // it as a setup task and write a `BOOTSTRAP.md` file instead of replying, so
7
+ // no greeting reached the user. Lead with the chat reply, forbid file/tool
8
+ // detours, and keep the profile update strictly optional and secondary.
9
+ const ACTIVATION_BOOTSTRAP_FALLBACK = [
10
+ "You are now connected to a ClawChat direct conversation with your user.",
11
+ "Reply now with one short, friendly greeting message in this conversation: introduce yourself and say you are connected and ready.",
12
+ "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
13
+ "Only if you already have your own profile details (display name, bio, or avatar) may you also call `clawchat_update_account_profile` (use `clawchat_upload_avatar_image` first for a local avatar image); otherwise skip that and just greet.",
14
+ "Do not ask the user for profile information.",
15
+ ].join("\n");
16
+ // Cross-plugin, user-editable override read lazily so edits apply on the next
17
+ // first-load without a restart. Any read failure falls back to the built-in text.
18
+ export function buildActivationBootstrapText(homeDir = os.homedir()) {
19
+ const greetingPath = path.join(homeDir, "clawchat", "greeting.md");
20
+ try {
21
+ const raw = fs.readFileSync(greetingPath);
22
+ const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
23
+ if (override.length > 0) {
24
+ return override;
25
+ }
26
+ }
27
+ catch (error) {
28
+ const code = error.code;
29
+ if (code !== "ENOENT") {
30
+ console.warn(`clawchat.greeting failed to read override ${greetingPath}:`, error);
31
+ }
32
+ }
33
+ return ACTIVATION_BOOTSTRAP_FALLBACK;
34
+ }
@@ -5,6 +5,7 @@ export function buildPluginReportBody(input) {
5
5
  device_id: input.deviceId,
6
6
  platform: input.platform,
7
7
  plugin_version: input.pluginVersion,
8
+ agent_version: input.agentVersion,
8
9
  runtime_name: input.runtimeName,
9
10
  runtime_version: input.runtimeVersion,
10
11
  };
@@ -216,7 +216,7 @@ function decodeJwtStringClaim(token, claim) {
216
216
  * userId that disagrees with `sub` is always wrong for the session this token
217
217
  * creates, and it silently breaks self-echo detection: with the OWNER's id
218
218
  * here, every owner message is dropped as the bot's own echo (2026-07-02
219
- * clawnest incident — a provisioner wrote the owner id into the config). Token
219
+ * production incident — a provisioner wrote the owner id into the config). Token
220
220
  * wins; the mismatch is logged. Also derives userId from `sub` when nothing is
221
221
  * configured, mirroring the `oid` fallback above.
222
222
  */
@@ -53,6 +53,7 @@ export async function reportPluginVersionSafe(p) {
53
53
  deviceId: p.deviceId,
54
54
  platform: "openclaw",
55
55
  pluginVersion: p.pluginVersion,
56
+ agentVersion: p.agentVersion,
56
57
  runtimeName: "node",
57
58
  runtimeVersion: process.version,
58
59
  }, { authenticated: p.authenticated });
@@ -24,6 +24,13 @@ export const MIN_REFRESH_INTERVAL_MS = 30_000;
24
24
  export const PROACTIVE_JITTER_MS = 5 * MINUTE_MS;
25
25
  /** §A.0 — fallback access-token TTL when `exp` is unparseable. */
26
26
  export const ACCESS_TOKEN_TTL_MS = 24 * HOUR_MS;
27
+ /**
28
+ * §A.1 — Node stores a `setTimeout` delay in a signed 32-bit int; a delay above
29
+ * this silently clamps to 1ms and fires *immediately*. Long-lived (~30-day)
30
+ * access tokens push `refresh_at - now` well past this, so the proactive wait is
31
+ * chunked into at-most-this-long sleeps that re-arm until the real time.
32
+ */
33
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647; // 2^31 - 1
27
34
  /**
28
35
  * §A.0/§A.1 — compute the absolute epoch-ms at which to proactively refresh.
29
36
  * `refresh_at = exp - max(30min, min(2h, 0.25 * (exp - iat)))` plus jitter.
@@ -204,6 +211,18 @@ export class RefreshManager {
204
211
  jitterMs,
205
212
  });
206
213
  const delayMs = Math.max(0, refreshAtMs - this.now());
214
+ if (delayMs > MAX_TIMER_DELAY_MS) {
215
+ // Too far out for a single setTimeout without 32-bit overflow (which would
216
+ // fire at 1ms and trigger an immediate refresh + WS reconnect, cancelling
217
+ // any in-flight send). Sleep one max chunk, then re-arm from the still-live
218
+ // token for the remaining wait.
219
+ this.ports.log?.debug?.(`clawchat-plugin-openclaw proactive timer chunked delayMs=${delayMs} chunk=${MAX_TIMER_DELAY_MS}`);
220
+ this.proactiveTimer = this.setTimer(() => {
221
+ this.proactiveTimer = null;
222
+ this.armProactiveTimer(activatedAtMs);
223
+ }, MAX_TIMER_DELAY_MS);
224
+ return;
225
+ }
207
226
  this.ports.log?.debug?.(`clawchat-plugin-openclaw proactive timer armed delayMs=${delayMs}`);
208
227
  this.proactiveTimer = this.setTimer(() => {
209
228
  this.proactiveTimer = null;
@@ -4,6 +4,7 @@ import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
4
4
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
5
5
  import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.js";
6
6
  import { createOpenclawClawlingApiClient } from "./api-client.js";
7
+ import { buildActivationBootstrapText } from "./activation-greeting.js";
7
8
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
8
9
  import { ensureLivewareCli } from "./liveware-cli.js";
9
10
  import { ClawlingApiError } from "./api-types.js";
@@ -11,7 +12,7 @@ import { RefreshManager } from "./refresh-manager.js";
11
12
  import { runOpenclawClawlingLogin, } from "./login.runtime.js";
12
13
  import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
13
14
  import { dispatchOpenclawClawlingInbound } from "./inbound.js";
14
- import { PendingConsentStore, handleOwnerConsentReply, readEffectiveSkillVersions, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
15
+ import { PendingConsentStore, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
15
16
  import { fetchInboundMedia } from "./media-runtime.js";
16
17
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
17
18
  import { runWithTerminalClawChatSendScope } from "./terminal-send.js";
@@ -309,20 +310,6 @@ function withClawChatSessionScope(cfg) {
309
310
  },
310
311
  };
311
312
  }
312
- function buildActivationBootstrapText() {
313
- // Worded to make the greeting the single required action. Earlier wording
314
- // ("ClawChat activation bootstrap: ... do both ...") led some agents to treat
315
- // it as a setup task and write a `BOOTSTRAP.md` file instead of replying, so
316
- // no greeting reached the user. Lead with the chat reply, forbid file/tool
317
- // detours, and keep the profile update strictly optional and secondary.
318
- return [
319
- "You are now connected to a ClawChat direct conversation with your user.",
320
- "Reply now with one short, friendly greeting message in this conversation: introduce yourself and say you are connected and ready.",
321
- "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
322
- "Only if you already have your own profile details (display name, bio, or avatar) may you also call `clawchat_update_account_profile` (use `clawchat_upload_avatar_image` first for a local avatar image); otherwise skip that and just greet.",
323
- "Do not ask the user for profile information.",
324
- ].join("\n");
325
- }
326
313
  function buildActivationBootstrapEnvelope(params) {
327
314
  const text = buildActivationBootstrapText();
328
315
  const now = Date.now();
@@ -574,6 +561,26 @@ function waitForActivationPoll(abortSignal, ms) {
574
561
  async function waitForActivationCredentials(params) {
575
562
  const { abortSignal, getStatus, setStatus, store, log } = params;
576
563
  const accountId = params.account.accountId;
564
+ // SQLite-first: activation and token rotation persist to the activations
565
+ // row, so a complete row is the durable credential source of truth even
566
+ // when channel config/env also carries credentials. Config/env credentials
567
+ // only bootstrap the no-row case (the caller seeds a row from them).
568
+ if (store?.getActivationCredentials) {
569
+ const stored = store.getActivationCredentials({ platform: "openclaw", accountId });
570
+ if (stored && stored.accessToken !== params.rejectedActivationToken) {
571
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`);
572
+ return {
573
+ source: "sqlite",
574
+ account: {
575
+ ...params.account,
576
+ configured: true,
577
+ token: stored.accessToken,
578
+ userId: stored.userId,
579
+ ownerUserId: stored.ownerUserId,
580
+ },
581
+ };
582
+ }
583
+ }
577
584
  if (accountHasConnectCredentials(params.account)) {
578
585
  return { account: params.account, source: "configured" };
579
586
  }
@@ -647,12 +654,14 @@ export async function startOpenclawClawlingGateway(params) {
647
654
  // so the backend links both to the same row. Imported from ./client.ts.
648
655
  const reportDeviceId = resolveOpenclawClawlingDeviceId(account);
649
656
  const pluginVersion = resolvePluginVersion();
657
+ const agentVersion = runtime.version ?? "";
650
658
  void reportPluginVersionSafe({
651
659
  baseUrl: account.baseUrl,
652
660
  mediaBaseUrl: account.mediaBaseUrl,
653
661
  token: "",
654
662
  deviceId: reportDeviceId,
655
663
  pluginVersion,
664
+ agentVersion,
656
665
  authenticated: false,
657
666
  log,
658
667
  });
@@ -693,6 +702,26 @@ export async function startOpenclawClawlingGateway(params) {
693
702
  if (!activationAccount)
694
703
  return;
695
704
  account = activationAccount.account;
705
+ // Config/env bootstrap seeding: converge on SQLite as the single durable
706
+ // credential store, so token rotation always has a row to update and the
707
+ // next startup resolves SQLite-first to the same credentials.
708
+ if (activationAccount.source === "configured" && store?.upsertActivation) {
709
+ try {
710
+ store.upsertActivation({
711
+ platform: "openclaw",
712
+ accountId,
713
+ userId: account.userId,
714
+ ownerUserId: account.ownerUserId,
715
+ accessToken: account.token,
716
+ refreshToken: readConfigRefreshToken(cfg),
717
+ loginMethod: "config-seed",
718
+ deviceId: CHANNEL_ID,
719
+ });
720
+ }
721
+ catch (error) {
722
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw sqlite credential seed failed; continuing with config credentials: ${error instanceof Error ? error.message : String(error)}`);
723
+ }
724
+ }
696
725
  // Paired: link the report row via the authenticated endpoint, reusing the
697
726
  // SAME frozen device_id so the backend upserts the existing unpaired row.
698
727
  void reportPluginVersionSafe({
@@ -701,12 +730,15 @@ export async function startOpenclawClawlingGateway(params) {
701
730
  token: account.token,
702
731
  deviceId: reportDeviceId,
703
732
  pluginVersion,
733
+ agentVersion,
704
734
  authenticated: true,
705
735
  log,
706
736
  });
707
- // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null when
708
- // the credentials came from config (no activation row yet) — in that case the
709
- // refresh manager relies on the JWT `exp` alone.
737
+ // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null for a
738
+ // configured start even though the seed block above may have just created a
739
+ // row — this cell only ever queries `store.getActivationCredentials` when
740
+ // `source === "sqlite"`, so a config-sourced account relies on the JWT `exp`
741
+ // alone until its next SQLite-first resolution.
710
742
  let activatedAtMs = activationAccount.source === "sqlite" && store?.getActivationCredentials
711
743
  ? store.getActivationCredentials({ platform: "openclaw", accountId })?.activatedAt ?? null
712
744
  : null;
@@ -872,16 +904,16 @@ export async function startOpenclawClawlingGateway(params) {
872
904
  getAccessToken: () => account.token,
873
905
  getRefreshToken: () => latestRefreshToken,
874
906
  persistRotatedTokens: async (tokens) => {
875
- // §0 — persist to BOTH stores BEFORE the in-memory swap. A failure in
876
- // EITHER store must REJECT so the manager skips the in-memory swap and
877
- // treats the refresh as transient (keep the current tokens, back off). Do
878
- // NOT swallow the SQLite write error: `rotateActivationTokens` returns
879
- // `null` when its internal `write()` caught an exception (a real write
880
- // failure), `false` only when no activation row exists yet (config-sourced
881
- // agent — legitimately nothing to update). A swallowed write failure must
882
- // not leave the SQLite row holding the now-dead refresh token while the
883
- // in-memory token is rotated, which would brick a sqlite-sourced agent on
884
- // restart.
907
+ // §0 — persist durably BEFORE the in-memory swap. SQLite is the
908
+ // startup credential source of truth: when a store is available its
909
+ // write must succeed or the refresh stays transient (no swap, keep
910
+ // current tokens, back off). A 0-row UPDATE (`false`) means the
911
+ // activations row is missing (e.g. cleared out-of-band) rather than a
912
+ // write failure — self-heal by re-seeding it from the in-memory
913
+ // identity with the ROTATED pair (mirrors Hermes's seed-on-rotate
914
+ // §C.2 semantics), then continue exactly as on success. `null` is a
915
+ // caught write exception and stays transient — that's a real failure,
916
+ // not a missing row.
885
917
  if (store?.rotateActivationTokens) {
886
918
  const rotateResult = store.rotateActivationTokens({
887
919
  platform: "openclaw",
@@ -889,12 +921,42 @@ export async function startOpenclawClawlingGateway(params) {
889
921
  accessToken: tokens.accessToken,
890
922
  refreshToken: tokens.refreshToken,
891
923
  });
892
- if (rotateResult === null) {
924
+ if (rotateResult === false) {
925
+ if (!store.upsertActivation) {
926
+ throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
927
+ }
928
+ try {
929
+ store.upsertActivation({
930
+ platform: "openclaw",
931
+ accountId,
932
+ userId: account.userId,
933
+ ownerUserId: account.ownerUserId,
934
+ accessToken: tokens.accessToken,
935
+ refreshToken: tokens.refreshToken,
936
+ loginMethod: "config-seed",
937
+ deviceId: refreshDeviceId,
938
+ });
939
+ }
940
+ catch {
941
+ throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
942
+ }
943
+ }
944
+ else if (rotateResult === null) {
893
945
  throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
894
946
  }
947
+ // Channel config is a human-readable mirror once SQLite holds the
948
+ // pair — sync best-effort, never fail the refresh over it.
949
+ try {
950
+ await persistConfigTokens(tokens);
951
+ }
952
+ catch (error) {
953
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw config token mirror failed (sqlite already rotated): ${error instanceof Error ? error.message : String(error)}`);
954
+ }
955
+ return;
895
956
  }
896
- // A config write failure rejects out of `mutateConfigFile` and propagates
897
- // here, which is what we want — persistence incomplete ⇒ no swap.
957
+ // No SQLite store available — channel config is the only durable
958
+ // store, so its write failure must reject and keep the refresh
959
+ // transient (pre-existing behavior for store-less test transports).
898
960
  await persistConfigTokens(tokens);
899
961
  },
900
962
  swapInMemoryTokens: (tokens) => {
@@ -1762,6 +1824,10 @@ export async function startOpenclawClawlingGateway(params) {
1762
1824
  // The bundled `./skills` dir is READ-ONLY here: a first-boot fallback for the
1763
1825
  // "current installed version" only — never written (it lives in node_modules
1764
1826
  // → may be read-only and is clobbered on `openclaw plugins update`).
1827
+ // Update detection converges on raw sha256 (readLocalSkillState), not the
1828
+ // frontmatter version, so a same-version content fix still applies; a
1829
+ // manifest `removed` id tombstones the LOCAL managed copy (never bundled)
1830
+ // via removeManagedSkill, deleting only ids the official manifest names.
1765
1831
  const skillUpdateConsent = new PendingConsentStore();
1766
1832
  const managedSkillsDir = resolveManagedSkillsDir();
1767
1833
  let bundledSkillsDir = null;
@@ -1805,10 +1871,10 @@ export async function startOpenclawClawlingGateway(params) {
1805
1871
  return;
1806
1872
  }
1807
1873
  // Effective current = managed copy if present, else bundled (host precedence).
1808
- const localVersions = await readEffectiveSkillVersions(managedSkillsDir, bundledSkillsDir);
1809
1874
  await runSkillUpdateCheck({
1810
1875
  ownerUserId,
1811
- localVersions,
1876
+ readLocalState: (id) => readLocalSkillState(managedSkillsDir, bundledSkillsDir, id),
1877
+ hasManagedSkill: (id) => managedSkillExists(managedSkillsDir, id),
1812
1878
  fetchFn: globalThis.fetch,
1813
1879
  store: skillUpdateConsent,
1814
1880
  sendOwnerMessage: sendOwnerSkillMessage,
@@ -29,8 +29,12 @@ import { fileURLToPath } from "node:url";
29
29
  * implementation in `@clawling/clawchat-plugin-install-cli`
30
30
  * (`packages/core/src/skills/check-update.ts`). That package is
31
31
  * workspace-private, so the logic is duplicated here rather than imported.
32
- * - Strict semver compare mirrors the reference `parseComparableVersion`
33
- * (`X.Y[.Z[.W]][-buildnum]`).
32
+ * - Update detection converges on raw sha256 comparison: ANY byte difference
33
+ * from the official source (or a missing local file) is an update —
34
+ * upgrade, rollback, or same-version content fix alike. The strict semver
35
+ * compare (`parseComparableVersion` / `isVersionOlder`, still ported from
36
+ * the reference `parseComparableVersion`, `X.Y[.Z[.W]][-buildnum]`) is kept
37
+ * only for consent-prompt display wording, not for the `hasUpdate` decision.
34
38
  * - All network IO goes through an injected `FetchLike`; tests never touch the
35
39
  * real network.
36
40
  *
@@ -151,7 +155,25 @@ export function parseSkillsManifest(text) {
151
155
  skills[target][skillId] = asEntry(entry, `${target}.${skillId}`);
152
156
  }
153
157
  }
154
- return { schema: 1, skills };
158
+ const removed = {};
159
+ if (data.removed !== undefined) {
160
+ if (!data.removed || typeof data.removed !== "object" || Array.isArray(data.removed)) {
161
+ throw new Error("skills manifest `removed` must be an object");
162
+ }
163
+ for (const [target, ids] of Object.entries(data.removed)) {
164
+ if (!Array.isArray(ids) || !ids.every((i) => typeof i === "string" && i.trim())) {
165
+ throw new Error(`skills manifest removed[${target}] must be a list of skill ids`);
166
+ }
167
+ const cleaned = ids.map((i) => i.trim());
168
+ for (const id of cleaned) {
169
+ if (skills[target]?.[id]) {
170
+ throw new Error(`skill ${target}.${id} is in both skills and removed`);
171
+ }
172
+ }
173
+ removed[target] = cleaned;
174
+ }
175
+ }
176
+ return { schema: 1, skills, removed };
155
177
  }
156
178
  async function fetchText(url, fetchFn) {
157
179
  let response;
@@ -167,9 +189,10 @@ async function fetchText(url, fetchFn) {
167
189
  return response.text();
168
190
  }
169
191
  /**
170
- * Read the official manifest for the `openclaw` target and compare each skill's
171
- * offered version against the locally installed `current` map. A skill missing
172
- * from `current` is reported as `hasUpdate: true`.
192
+ * Read the official manifest for the `openclaw` target and compare each
193
+ * skill's offered content (sha256) against its effective local state, read
194
+ * on demand via `readLocalState`. Any sha256 mismatch — or no local state at
195
+ * all — is reported as `hasUpdate: true`.
173
196
  */
174
197
  export async function checkSkillUpdate(options) {
175
198
  const ref = options.ref ?? DEFAULT_SKILLS_REF;
@@ -181,11 +204,14 @@ export async function checkSkillUpdate(options) {
181
204
  }
182
205
  const results = [];
183
206
  for (const [skillId, entry] of Object.entries(targetSkills)) {
184
- const current = options.current[skillId] ?? null;
185
- const hasUpdate = current === null ? true : isVersionOlder(current, entry.version);
207
+ const state = await options.readLocalState(skillId);
208
+ // sha convergence: any byte difference from the official source (or a
209
+ // missing local file) is an update — upgrade, rollback, or same-version
210
+ // content fix alike.
211
+ const hasUpdate = !state || state.sha256 !== entry.sha256;
186
212
  results.push({
187
213
  skillId,
188
- current,
214
+ current: state?.version ?? null,
189
215
  latest: entry.version,
190
216
  hasUpdate,
191
217
  path: entry.path,
@@ -193,7 +219,12 @@ export async function checkSkillUpdate(options) {
193
219
  bytes: entry.bytes,
194
220
  });
195
221
  }
196
- return { ref, results, hasUpdate: results.some((r) => r.hasUpdate) };
222
+ return {
223
+ ref,
224
+ results,
225
+ hasUpdate: results.some((r) => r.hasUpdate),
226
+ removedIds: manifest.removed[SKILL_TARGET] ?? [],
227
+ };
197
228
  }
198
229
  /**
199
230
  * Download one skill markdown file and integrity-check it against the manifest
@@ -230,52 +261,41 @@ export function parseSkillFrontmatterVersion(markdown) {
230
261
  const raw = (versionLine[1] ?? "").trim().replace(/^["']|["']$/g, "");
231
262
  return raw || null;
232
263
  }
233
- /** Read one skill's frontmatter `version` from `<dir>/<skillId>/SKILL.md`. */
234
- async function readSingleSkillVersion(dir, skillId) {
264
+ async function readSkillStateFromDir(dir, skillId) {
235
265
  try {
236
- const md = await fs.readFile(path.join(dir, skillId, "SKILL.md"), "utf8");
237
- return parseSkillFrontmatterVersion(md);
266
+ const buf = await fs.readFile(path.join(dir, skillId, "SKILL.md"));
267
+ return {
268
+ version: parseSkillFrontmatterVersion(buf.toString("utf8")),
269
+ sha256: crypto.createHash("sha256").update(buf).digest("hex"),
270
+ };
238
271
  }
239
272
  catch {
240
- return null; // missing / unreadable
273
+ return null;
241
274
  }
242
275
  }
243
276
  /**
244
- * Read the installed version of each requested skill from a SINGLE directory
245
- * (`<skillsDir>/<skillId>/SKILL.md`). Missing files / missing frontmatter
246
- * version are simply omitted (treated as "not installed" → update available).
277
+ * Effective local state of ONE skill, mirroring the host's managed-over-bundled
278
+ * precedence: managed copy if present, else bundled, else null. Called per
279
+ * remote manifest entry so dynamically delivered extra skills are hashed too
280
+ * (a static bundled-id list would misreport extras as never-installed).
247
281
  */
248
- export async function readLocalSkillVersions(skillsDir, skillIds = OPENCLAW_SKILL_IDS) {
249
- const versions = {};
250
- for (const skillId of skillIds) {
251
- const version = await readSingleSkillVersion(skillsDir, skillId);
252
- if (version)
253
- versions[skillId] = version;
254
- }
255
- return versions;
282
+ export async function readLocalSkillState(managedDir, bundledDir, skillId) {
283
+ const managed = await readSkillStateFromDir(managedDir, skillId);
284
+ if (managed)
285
+ return managed;
286
+ if (bundledDir)
287
+ return readSkillStateFromDir(bundledDir, skillId);
288
+ return null;
256
289
  }
257
- /**
258
- * Read the EFFECTIVE installed version of each skill, mirroring the host's
259
- * managed-over-bundled precedence (`src/skills/loading/workspace.ts:1220-1239`,
260
- * managed=3 > bundled=2): use the managed copy's frontmatter `version` when
261
- * `<managedDir>/<id>/SKILL.md` exists, otherwise fall back to the bundled
262
- * copy's. A skill present in neither is omitted (→ update available).
263
- */
264
- export async function readEffectiveSkillVersions(managedDir, bundledDir, skillIds = OPENCLAW_SKILL_IDS) {
265
- const versions = {};
266
- for (const skillId of skillIds) {
267
- const managed = await readSingleSkillVersion(managedDir, skillId);
268
- if (managed) {
269
- versions[skillId] = managed;
270
- continue;
271
- }
272
- if (bundledDir) {
273
- const bundled = await readSingleSkillVersion(bundledDir, skillId);
274
- if (bundled)
275
- versions[skillId] = bundled;
276
- }
290
+ /** True when `<managedDir>/<skillId>/SKILL.md` exists (removal precondition). */
291
+ export async function managedSkillExists(managedDir, skillId) {
292
+ try {
293
+ await fs.access(path.join(managedDir, skillId, "SKILL.md"));
294
+ return true;
295
+ }
296
+ catch {
297
+ return false;
277
298
  }
278
- return versions;
279
299
  }
280
300
  /**
281
301
  * Resolve OpenClaw's state directory, mirroring the host `src/utils.ts:132-154`:
@@ -359,6 +379,18 @@ export async function atomicWriteSkill(skillsDir, skillId, content) {
359
379
  throw err;
360
380
  }
361
381
  }
382
+ /**
383
+ * Delete a tombstoned managed skill: remove `<skillsDir>/<skillId>/SKILL.md`
384
+ * (the host chokidar-watches the dir; the unlink triggers a snapshot rebuild
385
+ * that drops the registration) and best-effort remove the now-empty dir.
386
+ * Deletion is the ONE legitimate delete of a skill path, and ONLY for ids the
387
+ * official manifest explicitly tombstones. Idempotent.
388
+ */
389
+ export async function removeManagedSkill(skillsDir, skillId) {
390
+ const dir = path.join(skillsDir, skillId);
391
+ await fs.rm(path.join(dir, "SKILL.md"), { force: true });
392
+ await fs.rmdir(dir).catch(() => { });
393
+ }
362
394
  const AFFIRM_TOKENS = new Set([
363
395
  "更新",
364
396
  "更新吧",
@@ -438,7 +470,20 @@ export class PendingConsentStore {
438
470
  }
439
471
  }
440
472
  function describeUpdate(u) {
441
- return `「${u.skillId}」v${u.current ?? "无"} → v${u.target}`;
473
+ if (u.current === null)
474
+ return `「${u.skillId}」v无 → v${u.target}`;
475
+ let cmp = -1;
476
+ try {
477
+ cmp = compareVersions(u.current, u.target);
478
+ }
479
+ catch {
480
+ cmp = -1; // unparsable local version — show as a plain update
481
+ }
482
+ if (cmp === 0)
483
+ return `「${u.skillId}」v${u.target}(内容修订)`;
484
+ if (cmp > 0)
485
+ return `「${u.skillId}」v${u.current} → v${u.target}(回滚)`;
486
+ return `「${u.skillId}」v${u.current} → v${u.target}`;
442
487
  }
443
488
  /**
444
489
  * Step ③–⑤ (plan §2): on `clawchat.skill.update.check`, check the official
@@ -453,23 +498,25 @@ export async function runSkillUpdateCheck(options) {
453
498
  return null;
454
499
  }
455
500
  const outcome = await checkSkillUpdate({
456
- current: options.localVersions,
501
+ readLocalState: options.readLocalState,
457
502
  ...(options.ref ? { ref: options.ref } : {}),
458
503
  ...(options.base ? { base: options.base } : {}),
459
504
  fetchFn: options.fetchFn,
460
505
  });
461
506
  const updates = outcome.results
462
507
  .filter((r) => r.hasUpdate)
463
- .map((r) => ({
464
- skillId: r.skillId,
465
- current: r.current,
466
- target: r.latest,
467
- path: r.path,
468
- sha256: r.sha256,
469
- bytes: r.bytes,
470
- }));
471
- if (updates.length === 0) {
472
- options.log?.info?.("clawchat skill-update check: no updates available");
508
+ .map((r) => ({ skillId: r.skillId, current: r.current, target: r.latest, path: r.path, sha256: r.sha256, bytes: r.bytes }));
509
+ const removals = [];
510
+ for (const skillId of outcome.removedIds) {
511
+ if (OPENCLAW_SKILL_IDS.includes(skillId)) {
512
+ options.log?.info?.(`clawchat skill-update: ignoring tombstone for bundled skill ${skillId}`);
513
+ continue;
514
+ }
515
+ if (await options.hasManagedSkill(skillId))
516
+ removals.push({ skillId });
517
+ }
518
+ if (updates.length === 0 && removals.length === 0) {
519
+ options.log?.info?.("clawchat skill-update check: nothing to converge");
473
520
  return null;
474
521
  }
475
522
  const now = (options.now ?? Date.now)();
@@ -478,13 +525,19 @@ export async function runSkillUpdateCheck(options) {
478
525
  ref: outcome.ref,
479
526
  ...(options.base ? { base: options.base } : {}),
480
527
  updates,
528
+ removals,
481
529
  createdAt: now,
482
530
  expiresAt: now + (options.timeoutMs ?? DEFAULT_CONSENT_TIMEOUT_MS),
483
531
  };
484
532
  options.store.set(record);
485
- const text = `我的技能有更新:${updates.map(describeUpdate).join(";")}。回复「更新」确认,「取消」忽略。`;
533
+ const segments = [];
534
+ if (updates.length > 0)
535
+ segments.push(`我的技能有更新:${updates.map(describeUpdate).join(";")}`);
536
+ if (removals.length > 0)
537
+ segments.push(`以下技能将下线移除:${removals.map((r) => `「${r.skillId}」`).join("、")}`);
538
+ const text = `${segments.join(";")}。回复「更新」确认,「取消」忽略。`;
486
539
  await options.sendOwnerMessage(text);
487
- options.log?.info?.(`clawchat skill-update check: asked owner for consent on ${updates.map((u) => u.skillId).join(",")}`);
540
+ options.log?.info?.(`clawchat skill-update check: asked owner for consent on updates=[${updates.map((u) => u.skillId).join(",")}] removals=[${removals.map((r) => r.skillId).join(",")}]`);
488
541
  return record;
489
542
  }
490
543
  /**
@@ -516,22 +569,23 @@ export async function handleOwnerConsentReply(options) {
516
569
  return true;
517
570
  }
518
571
  // verdict === "affirm"
519
- const readLocalVersion = options.readLocalVersion ??
572
+ const readLocalSha = options.readLocalSha ??
520
573
  (async (skillId) => {
521
574
  try {
522
- const md = await fs.readFile(path.join(options.skillsDir, skillId, "SKILL.md"), "utf8");
523
- return parseSkillFrontmatterVersion(md);
575
+ const buf = await fs.readFile(path.join(options.skillsDir, skillId, "SKILL.md"));
576
+ return crypto.createHash("sha256").update(buf).digest("hex");
524
577
  }
525
578
  catch {
526
579
  return null;
527
580
  }
528
581
  });
529
582
  const applied = [];
583
+ const removed = [];
530
584
  try {
531
585
  for (const update of pending.updates) {
532
- // Idempotency: skip a skill already at the target version on disk.
533
- const onDisk = await readLocalVersion(update.skillId);
534
- if (onDisk === update.target) {
586
+ // Idempotency: skip a skill whose on-disk bytes already match the target.
587
+ const onDisk = await readLocalSha(update.skillId);
588
+ if (onDisk === update.sha256) {
535
589
  applied.push(update);
536
590
  continue;
537
591
  }
@@ -544,6 +598,18 @@ export async function handleOwnerConsentReply(options) {
544
598
  applied.push(update);
545
599
  options.log?.info?.(`clawchat skill-update: applied ${update.skillId} -> v${update.target} (atomic overwrite)`);
546
600
  }
601
+ for (const removal of pending.removals) {
602
+ // Defensive re-guard: bundled skills must never be deleted, even though
603
+ // runSkillUpdateCheck already filters them out of removal candidates.
604
+ // Unreachable in the normal flow; kept as a cheap safety net.
605
+ if (OPENCLAW_SKILL_IDS.includes(removal.skillId)) {
606
+ options.log?.info?.(`clawchat skill-update: skipped removal of bundled skill ${removal.skillId} (defensive re-guard)`);
607
+ continue;
608
+ }
609
+ await removeManagedSkill(options.skillsDir, removal.skillId);
610
+ removed.push(removal);
611
+ options.log?.info?.(`clawchat skill-update: removed tombstoned skill ${removal.skillId}`);
612
+ }
547
613
  }
548
614
  catch (err) {
549
615
  // Keep the pending record so the owner can retry with another "更新"; report
@@ -553,7 +619,11 @@ export async function handleOwnerConsentReply(options) {
553
619
  return true;
554
620
  }
555
621
  options.store.clear();
556
- const summary = applied.map((u) => `「${u.skillId}」v${u.target}`).join("、");
557
- await options.sendOwnerMessage(`✅ 已更新到 ${summary}`);
622
+ const segments = [];
623
+ if (applied.length > 0)
624
+ segments.push(`已更新到 ${applied.map((u) => `「${u.skillId}」v${u.target}`).join("、")}`);
625
+ if (removed.length > 0)
626
+ segments.push(`已移除 ${removed.map((r) => `「${r.skillId}」`).join("、")}`);
627
+ await options.sendOwnerMessage(`✅ ${segments.join(";")}`);
558
628
  return true;
559
629
  }