@clawling/clawchat-plugin-openclaw 2026.7.1 → 2026.7.2-1

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.
@@ -11,7 +11,7 @@ import { RefreshManager } from "./refresh-manager.js";
11
11
  import { runOpenclawClawlingLogin, } from "./login.runtime.js";
12
12
  import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
13
13
  import { dispatchOpenclawClawlingInbound } from "./inbound.js";
14
- import { PendingConsentStore, handleOwnerConsentReply, readEffectiveSkillVersions, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
14
+ import { PendingConsentStore, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
15
15
  import { fetchInboundMedia } from "./media-runtime.js";
16
16
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
17
17
  import { runWithTerminalClawChatSendScope } from "./terminal-send.js";
@@ -1762,6 +1762,10 @@ export async function startOpenclawClawlingGateway(params) {
1762
1762
  // The bundled `./skills` dir is READ-ONLY here: a first-boot fallback for the
1763
1763
  // "current installed version" only — never written (it lives in node_modules
1764
1764
  // → may be read-only and is clobbered on `openclaw plugins update`).
1765
+ // Update detection converges on raw sha256 (readLocalSkillState), not the
1766
+ // frontmatter version, so a same-version content fix still applies; a
1767
+ // manifest `removed` id tombstones the LOCAL managed copy (never bundled)
1768
+ // via removeManagedSkill, deleting only ids the official manifest names.
1765
1769
  const skillUpdateConsent = new PendingConsentStore();
1766
1770
  const managedSkillsDir = resolveManagedSkillsDir();
1767
1771
  let bundledSkillsDir = null;
@@ -1771,14 +1775,29 @@ export async function startOpenclawClawlingGateway(params) {
1771
1775
  catch (err) {
1772
1776
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw skill-update: bundled skills dir not resolvable (using managed-only current): ${err instanceof Error ? err.message : String(err)}`);
1773
1777
  }
1774
- const sendOwnerSkillMessage = async (text) => {
1778
+ // A usr_... id is not a valid chat_id — member-backend rejects it with
1779
+ // "invalid conversation id" and msghub never acks the send. Proactive owner
1780
+ // messages must be addressed to the direct conversation recorded at
1781
+ // activation (parity with the hermes adapter's _owner_direct_chat_id).
1782
+ const resolveOwnerDirectConversationId = () => {
1775
1783
  const ownerUserId = account.ownerUserId?.trim();
1776
1784
  if (!ownerUserId)
1785
+ return null;
1786
+ const conversationId = store
1787
+ ?.getActivationConversation?.({ platform: "openclaw", accountId })
1788
+ ?.conversationId?.trim();
1789
+ return conversationId || null;
1790
+ };
1791
+ const sendOwnerSkillMessage = async (text) => {
1792
+ const conversationId = resolveOwnerDirectConversationId();
1793
+ if (!conversationId) {
1794
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw skill-update owner message skipped: no activation conversation recorded`);
1777
1795
  return;
1796
+ }
1778
1797
  await sendOpenclawClawlingText({
1779
1798
  client,
1780
1799
  account,
1781
- to: { chatId: ownerUserId, chatType: "direct" },
1800
+ to: { chatId: conversationId, chatType: "direct" },
1782
1801
  text,
1783
1802
  log,
1784
1803
  });
@@ -1790,10 +1809,10 @@ export async function startOpenclawClawlingGateway(params) {
1790
1809
  return;
1791
1810
  }
1792
1811
  // Effective current = managed copy if present, else bundled (host precedence).
1793
- const localVersions = await readEffectiveSkillVersions(managedSkillsDir, bundledSkillsDir);
1794
1812
  await runSkillUpdateCheck({
1795
1813
  ownerUserId,
1796
- localVersions,
1814
+ readLocalState: (id) => readLocalSkillState(managedSkillsDir, bundledSkillsDir, id),
1815
+ hasManagedSkill: (id) => managedSkillExists(managedSkillsDir, id),
1797
1816
  fetchFn: globalThis.fetch,
1798
1817
  store: skillUpdateConsent,
1799
1818
  sendOwnerMessage: sendOwnerSkillMessage,
@@ -2408,16 +2427,16 @@ export async function startOpenclawClawlingGateway(params) {
2408
2427
  },
2409
2428
  });
2410
2429
  const forwardGroupApprovalSlashToOwner = async (turn) => {
2411
- const ownerUserId = account.ownerUserId?.trim();
2412
- if (!ownerUserId) {
2413
- log?.error?.(`[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_user_id chat_id=${turn.peer.id} msg=${turn.messageId}`);
2430
+ const ownerConversationId = resolveOwnerDirectConversationId();
2431
+ if (!ownerConversationId) {
2432
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_conversation chat_id=${turn.peer.id} msg=${turn.messageId}`);
2414
2433
  return "skipped";
2415
2434
  }
2416
2435
  try {
2417
2436
  await sendOpenclawClawlingText({
2418
2437
  client,
2419
2438
  account,
2420
- to: { chatId: ownerUserId, chatType: "direct" },
2439
+ to: { chatId: ownerConversationId, chatType: "direct" },
2421
2440
  text: groupOwnerAttentionText(turn.peer.id, turn.rawBody),
2422
2441
  log,
2423
2442
  });
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.7.1",
3
+ "version": "2026.7.2-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
package/src/runtime.ts CHANGED
@@ -38,7 +38,8 @@ import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inboun
38
38
  import {
39
39
  PendingConsentStore,
40
40
  handleOwnerConsentReply,
41
- readEffectiveSkillVersions,
41
+ managedSkillExists,
42
+ readLocalSkillState,
42
43
  resolveBundledSkillsDir,
43
44
  resolveManagedSkillsDir,
44
45
  runSkillUpdateCheck,
@@ -2154,6 +2155,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2154
2155
  // The bundled `./skills` dir is READ-ONLY here: a first-boot fallback for the
2155
2156
  // "current installed version" only — never written (it lives in node_modules
2156
2157
  // → may be read-only and is clobbered on `openclaw plugins update`).
2158
+ // Update detection converges on raw sha256 (readLocalSkillState), not the
2159
+ // frontmatter version, so a same-version content fix still applies; a
2160
+ // manifest `removed` id tombstones the LOCAL managed copy (never bundled)
2161
+ // via removeManagedSkill, deleting only ids the official manifest names.
2157
2162
  const skillUpdateConsent = new PendingConsentStore();
2158
2163
  const managedSkillsDir = resolveManagedSkillsDir();
2159
2164
  let bundledSkillsDir: string | null = null;
@@ -2166,13 +2171,30 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2166
2171
  }`,
2167
2172
  );
2168
2173
  }
2169
- const sendOwnerSkillMessage = async (text: string): Promise<void> => {
2174
+ // A usr_... id is not a valid chat_id — member-backend rejects it with
2175
+ // "invalid conversation id" and msghub never acks the send. Proactive owner
2176
+ // messages must be addressed to the direct conversation recorded at
2177
+ // activation (parity with the hermes adapter's _owner_direct_chat_id).
2178
+ const resolveOwnerDirectConversationId = (): string | null => {
2170
2179
  const ownerUserId = account.ownerUserId?.trim();
2171
- if (!ownerUserId) return;
2180
+ if (!ownerUserId) return null;
2181
+ const conversationId = store
2182
+ ?.getActivationConversation?.({ platform: "openclaw", accountId })
2183
+ ?.conversationId?.trim();
2184
+ return conversationId || null;
2185
+ };
2186
+ const sendOwnerSkillMessage = async (text: string): Promise<void> => {
2187
+ const conversationId = resolveOwnerDirectConversationId();
2188
+ if (!conversationId) {
2189
+ log?.info?.(
2190
+ `[${accountId}] clawchat-plugin-openclaw skill-update owner message skipped: no activation conversation recorded`,
2191
+ );
2192
+ return;
2193
+ }
2172
2194
  await sendOpenclawClawlingText({
2173
2195
  client,
2174
2196
  account,
2175
- to: { chatId: ownerUserId, chatType: "direct" },
2197
+ to: { chatId: conversationId, chatType: "direct" },
2176
2198
  text,
2177
2199
  log,
2178
2200
  });
@@ -2186,10 +2208,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2186
2208
  return;
2187
2209
  }
2188
2210
  // Effective current = managed copy if present, else bundled (host precedence).
2189
- const localVersions = await readEffectiveSkillVersions(managedSkillsDir, bundledSkillsDir);
2190
2211
  await runSkillUpdateCheck({
2191
2212
  ownerUserId,
2192
- localVersions,
2213
+ readLocalState: (id) => readLocalSkillState(managedSkillsDir, bundledSkillsDir, id),
2214
+ hasManagedSkill: (id) => managedSkillExists(managedSkillsDir, id),
2193
2215
  fetchFn: globalThis.fetch,
2194
2216
  store: skillUpdateConsent,
2195
2217
  sendOwnerMessage: sendOwnerSkillMessage,
@@ -2886,10 +2908,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2886
2908
  });
2887
2909
 
2888
2910
  const forwardGroupApprovalSlashToOwner = async (turn: IngestTurnParams): Promise<IngestTurnResult> => {
2889
- const ownerUserId = account.ownerUserId?.trim();
2890
- if (!ownerUserId) {
2911
+ const ownerConversationId = resolveOwnerDirectConversationId();
2912
+ if (!ownerConversationId) {
2891
2913
  log?.error?.(
2892
- `[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_user_id chat_id=${turn.peer.id} msg=${turn.messageId}`,
2914
+ `[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_conversation chat_id=${turn.peer.id} msg=${turn.messageId}`,
2893
2915
  );
2894
2916
  return "skipped";
2895
2917
  }
@@ -2897,7 +2919,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2897
2919
  await sendOpenclawClawlingText({
2898
2920
  client,
2899
2921
  account,
2900
- to: { chatId: ownerUserId, chatType: "direct" },
2922
+ to: { chatId: ownerConversationId, chatType: "direct" },
2901
2923
  text: groupOwnerAttentionText(turn.peer.id, turn.rawBody),
2902
2924
  log,
2903
2925
  });
@@ -30,8 +30,12 @@ import { fileURLToPath } from "node:url";
30
30
  * implementation in `@clawling/clawchat-plugin-install-cli`
31
31
  * (`packages/core/src/skills/check-update.ts`). That package is
32
32
  * workspace-private, so the logic is duplicated here rather than imported.
33
- * - Strict semver compare mirrors the reference `parseComparableVersion`
34
- * (`X.Y[.Z[.W]][-buildnum]`).
33
+ * - Update detection converges on raw sha256 comparison: ANY byte difference
34
+ * from the official source (or a missing local file) is an update —
35
+ * upgrade, rollback, or same-version content fix alike. The strict semver
36
+ * compare (`parseComparableVersion` / `isVersionOlder`, still ported from
37
+ * the reference `parseComparableVersion`, `X.Y[.Z[.W]][-buildnum]`) is kept
38
+ * only for consent-prompt display wording, not for the `hasUpdate` decision.
35
39
  * - All network IO goes through an injected `FetchLike`; tests never touch the
36
40
  * real network.
37
41
  *
@@ -138,6 +142,9 @@ export interface SkillManifestEntry {
138
142
  export interface SkillsManifest {
139
143
  schema: number;
140
144
  skills: Record<string, Record<string, SkillManifestEntry>>;
145
+ /** Tombstoned skill ids per target. An adapter deletes its LOCAL copy of an
146
+ * id ONLY when it appears here — absence from `skills` alone never deletes. */
147
+ removed: Record<string, string[]>;
141
148
  }
142
149
 
143
150
  export interface SkillUpdate {
@@ -156,6 +163,8 @@ export interface CheckSkillUpdateOutcome {
156
163
  ref: string;
157
164
  results: SkillUpdate[];
158
165
  hasUpdate: boolean;
166
+ /** Tombstoned skill ids for this target, straight from `manifest.removed`. */
167
+ removedIds: string[];
159
168
  }
160
169
 
161
170
  function skillsBase(base: string | undefined, ref: string): string {
@@ -222,7 +231,25 @@ export function parseSkillsManifest(text: string): SkillsManifest {
222
231
  skills[target][skillId] = asEntry(entry, `${target}.${skillId}`);
223
232
  }
224
233
  }
225
- return { schema: 1, skills };
234
+ const removed: SkillsManifest["removed"] = {};
235
+ if (data.removed !== undefined) {
236
+ if (!data.removed || typeof data.removed !== "object" || Array.isArray(data.removed)) {
237
+ throw new Error("skills manifest `removed` must be an object");
238
+ }
239
+ for (const [target, ids] of Object.entries(data.removed as Record<string, unknown>)) {
240
+ if (!Array.isArray(ids) || !ids.every((i) => typeof i === "string" && (i as string).trim())) {
241
+ throw new Error(`skills manifest removed[${target}] must be a list of skill ids`);
242
+ }
243
+ const cleaned = ids.map((i) => (i as string).trim());
244
+ for (const id of cleaned) {
245
+ if (skills[target]?.[id]) {
246
+ throw new Error(`skill ${target}.${id} is in both skills and removed`);
247
+ }
248
+ }
249
+ removed[target] = cleaned;
250
+ }
251
+ }
252
+ return { schema: 1, skills, removed };
226
253
  }
227
254
 
228
255
  async function fetchText(url: string, fetchFn: FetchLike): Promise<string> {
@@ -239,17 +266,22 @@ async function fetchText(url: string, fetchFn: FetchLike): Promise<string> {
239
266
  }
240
267
 
241
268
  export interface CheckSkillUpdateOptions {
242
- /** Locally installed versions: `{ "<skillId>": "<version>" }`. */
243
- current: Record<string, string>;
269
+ /**
270
+ * Effective local state of one skill (managed-over-bundled), or null when
271
+ * not installed. Called once per remote manifest entry — remote-driven, so
272
+ * dynamically delivered extras are covered too.
273
+ */
274
+ readLocalState: (skillId: string) => Promise<LocalSkillState | null>;
244
275
  ref?: string;
245
276
  base?: string;
246
277
  fetchFn: FetchLike;
247
278
  }
248
279
 
249
280
  /**
250
- * Read the official manifest for the `openclaw` target and compare each skill's
251
- * offered version against the locally installed `current` map. A skill missing
252
- * from `current` is reported as `hasUpdate: true`.
281
+ * Read the official manifest for the `openclaw` target and compare each
282
+ * skill's offered content (sha256) against its effective local state, read
283
+ * on demand via `readLocalState`. Any sha256 mismatch — or no local state at
284
+ * all — is reported as `hasUpdate: true`.
253
285
  */
254
286
  export async function checkSkillUpdate(
255
287
  options: CheckSkillUpdateOptions,
@@ -264,11 +296,14 @@ export async function checkSkillUpdate(
264
296
 
265
297
  const results: SkillUpdate[] = [];
266
298
  for (const [skillId, entry] of Object.entries(targetSkills)) {
267
- const current = options.current[skillId] ?? null;
268
- const hasUpdate = current === null ? true : isVersionOlder(current, entry.version);
299
+ const state = await options.readLocalState(skillId);
300
+ // sha convergence: any byte difference from the official source (or a
301
+ // missing local file) is an update — upgrade, rollback, or same-version
302
+ // content fix alike.
303
+ const hasUpdate = !state || state.sha256 !== entry.sha256;
269
304
  results.push({
270
305
  skillId,
271
- current,
306
+ current: state?.version ?? null,
272
307
  latest: entry.version,
273
308
  hasUpdate,
274
309
  path: entry.path,
@@ -276,7 +311,12 @@ export async function checkSkillUpdate(
276
311
  bytes: entry.bytes,
277
312
  });
278
313
  }
279
- return { ref, results, hasUpdate: results.some((r) => r.hasUpdate) };
314
+ return {
315
+ ref,
316
+ results,
317
+ hasUpdate: results.some((r) => r.hasUpdate),
318
+ removedIds: manifest.removed[SKILL_TARGET] ?? [],
319
+ };
280
320
  }
281
321
 
282
322
  /**
@@ -318,58 +358,50 @@ export function parseSkillFrontmatterVersion(markdown: string): string | null {
318
358
  return raw || null;
319
359
  }
320
360
 
321
- /** Read one skill's frontmatter `version` from `<dir>/<skillId>/SKILL.md`. */
322
- async function readSingleSkillVersion(dir: string, skillId: string): Promise<string | null> {
323
- try {
324
- const md = await fs.readFile(path.join(dir, skillId, "SKILL.md"), "utf8");
325
- return parseSkillFrontmatterVersion(md);
326
- } catch {
327
- return null; // missing / unreadable
328
- }
361
+ export interface LocalSkillState {
362
+ /** Frontmatter `version` of the effective file, or null when unparsable. */
363
+ version: string | null;
364
+ /** Lowercase hex sha256 of the effective file's raw bytes. */
365
+ sha256: string;
329
366
  }
330
367
 
331
- /**
332
- * Read the installed version of each requested skill from a SINGLE directory
333
- * (`<skillsDir>/<skillId>/SKILL.md`). Missing files / missing frontmatter
334
- * version are simply omitted (treated as "not installed" → update available).
335
- */
336
- export async function readLocalSkillVersions(
337
- skillsDir: string,
338
- skillIds: readonly string[] = OPENCLAW_SKILL_IDS,
339
- ): Promise<Record<string, string>> {
340
- const versions: Record<string, string> = {};
341
- for (const skillId of skillIds) {
342
- const version = await readSingleSkillVersion(skillsDir, skillId);
343
- if (version) versions[skillId] = version;
368
+ async function readSkillStateFromDir(dir: string, skillId: string): Promise<LocalSkillState | null> {
369
+ try {
370
+ const buf = await fs.readFile(path.join(dir, skillId, "SKILL.md"));
371
+ return {
372
+ version: parseSkillFrontmatterVersion(buf.toString("utf8")),
373
+ sha256: crypto.createHash("sha256").update(buf).digest("hex"),
374
+ };
375
+ } catch {
376
+ return null;
344
377
  }
345
- return versions;
346
378
  }
347
379
 
348
380
  /**
349
- * Read the EFFECTIVE installed version of each skill, mirroring the host's
350
- * managed-over-bundled precedence (`src/skills/loading/workspace.ts:1220-1239`,
351
- * managed=3 > bundled=2): use the managed copy's frontmatter `version` when
352
- * `<managedDir>/<id>/SKILL.md` exists, otherwise fall back to the bundled
353
- * copy's. A skill present in neither is omitted (→ update available).
381
+ * Effective local state of ONE skill, mirroring the host's managed-over-bundled
382
+ * precedence: managed copy if present, else bundled, else null. Called per
383
+ * remote manifest entry so dynamically delivered extra skills are hashed too
384
+ * (a static bundled-id list would misreport extras as never-installed).
354
385
  */
355
- export async function readEffectiveSkillVersions(
386
+ export async function readLocalSkillState(
356
387
  managedDir: string,
357
388
  bundledDir: string | null,
358
- skillIds: readonly string[] = OPENCLAW_SKILL_IDS,
359
- ): Promise<Record<string, string>> {
360
- const versions: Record<string, string> = {};
361
- for (const skillId of skillIds) {
362
- const managed = await readSingleSkillVersion(managedDir, skillId);
363
- if (managed) {
364
- versions[skillId] = managed;
365
- continue;
366
- }
367
- if (bundledDir) {
368
- const bundled = await readSingleSkillVersion(bundledDir, skillId);
369
- if (bundled) versions[skillId] = bundled;
370
- }
389
+ skillId: string,
390
+ ): Promise<LocalSkillState | null> {
391
+ const managed = await readSkillStateFromDir(managedDir, skillId);
392
+ if (managed) return managed;
393
+ if (bundledDir) return readSkillStateFromDir(bundledDir, skillId);
394
+ return null;
395
+ }
396
+
397
+ /** True when `<managedDir>/<skillId>/SKILL.md` exists (removal precondition). */
398
+ export async function managedSkillExists(managedDir: string, skillId: string): Promise<boolean> {
399
+ try {
400
+ await fs.access(path.join(managedDir, skillId, "SKILL.md"));
401
+ return true;
402
+ } catch {
403
+ return false;
371
404
  }
372
- return versions;
373
405
  }
374
406
 
375
407
  /**
@@ -458,6 +490,19 @@ export async function atomicWriteSkill(
458
490
  }
459
491
  }
460
492
 
493
+ /**
494
+ * Delete a tombstoned managed skill: remove `<skillsDir>/<skillId>/SKILL.md`
495
+ * (the host chokidar-watches the dir; the unlink triggers a snapshot rebuild
496
+ * that drops the registration) and best-effort remove the now-empty dir.
497
+ * Deletion is the ONE legitimate delete of a skill path, and ONLY for ids the
498
+ * official manifest explicitly tombstones. Idempotent.
499
+ */
500
+ export async function removeManagedSkill(skillsDir: string, skillId: string): Promise<void> {
501
+ const dir = path.join(skillsDir, skillId);
502
+ await fs.rm(path.join(dir, "SKILL.md"), { force: true });
503
+ await fs.rmdir(dir).catch(() => {});
504
+ }
505
+
461
506
  // ---------------------------------------------------------------------------
462
507
  // Conservative consent parsing.
463
508
  // ---------------------------------------------------------------------------
@@ -530,11 +575,16 @@ export interface PendingSkillTarget {
530
575
  bytes: number;
531
576
  }
532
577
 
578
+ export interface PendingSkillRemoval {
579
+ skillId: string;
580
+ }
581
+
533
582
  export interface PendingSkillUpdate {
534
583
  ownerUserId: string;
535
584
  ref: string;
536
585
  base?: string;
537
586
  updates: PendingSkillTarget[];
587
+ removals: PendingSkillRemoval[];
538
588
  createdAt: number;
539
589
  expiresAt: number;
540
590
  }
@@ -576,8 +626,13 @@ export interface SkillUpdateLog {
576
626
 
577
627
  export interface RunSkillUpdateCheckOptions {
578
628
  ownerUserId: string;
579
- /** Locally installed versions, `{ "<skillId>": "<version>" }`. */
580
- localVersions: Record<string, string>;
629
+ /**
630
+ * Effective local state of one skill (managed-over-bundled), or null when
631
+ * not installed. See {@link CheckSkillUpdateOptions.readLocalState}.
632
+ */
633
+ readLocalState: (skillId: string) => Promise<LocalSkillState | null>;
634
+ /** True when `<managedDir>/<skillId>/SKILL.md` exists (removal precondition). */
635
+ hasManagedSkill: (skillId: string) => Promise<boolean>;
581
636
  fetchFn: FetchLike;
582
637
  ref?: string;
583
638
  base?: string;
@@ -589,7 +644,16 @@ export interface RunSkillUpdateCheckOptions {
589
644
  }
590
645
 
591
646
  function describeUpdate(u: PendingSkillTarget): string {
592
- return `「${u.skillId}」v${u.current ?? "无"} → v${u.target}`;
647
+ if (u.current === null) return `「${u.skillId}」v无 → v${u.target}`;
648
+ let cmp = -1;
649
+ try {
650
+ cmp = compareVersions(u.current, u.target);
651
+ } catch {
652
+ cmp = -1; // unparsable local version — show as a plain update
653
+ }
654
+ if (cmp === 0) return `「${u.skillId}」v${u.target}(内容修订)`;
655
+ if (cmp > 0) return `「${u.skillId}」v${u.current} → v${u.target}(回滚)`;
656
+ return `「${u.skillId}」v${u.current} → v${u.target}`;
593
657
  }
594
658
 
595
659
  /**
@@ -608,7 +672,7 @@ export async function runSkillUpdateCheck(
608
672
  }
609
673
 
610
674
  const outcome = await checkSkillUpdate({
611
- current: options.localVersions,
675
+ readLocalState: options.readLocalState,
612
676
  ...(options.ref ? { ref: options.ref } : {}),
613
677
  ...(options.base ? { base: options.base } : {}),
614
678
  fetchFn: options.fetchFn,
@@ -616,17 +680,19 @@ export async function runSkillUpdateCheck(
616
680
 
617
681
  const updates: PendingSkillTarget[] = outcome.results
618
682
  .filter((r) => r.hasUpdate)
619
- .map((r) => ({
620
- skillId: r.skillId,
621
- current: r.current,
622
- target: r.latest,
623
- path: r.path,
624
- sha256: r.sha256,
625
- bytes: r.bytes,
626
- }));
627
-
628
- if (updates.length === 0) {
629
- options.log?.info?.("clawchat skill-update check: no updates available");
683
+ .map((r) => ({ skillId: r.skillId, current: r.current, target: r.latest, path: r.path, sha256: r.sha256, bytes: r.bytes }));
684
+
685
+ const removals: PendingSkillRemoval[] = [];
686
+ for (const skillId of outcome.removedIds) {
687
+ if ((OPENCLAW_SKILL_IDS as readonly string[]).includes(skillId)) {
688
+ options.log?.info?.(`clawchat skill-update: ignoring tombstone for bundled skill ${skillId}`);
689
+ continue;
690
+ }
691
+ if (await options.hasManagedSkill(skillId)) removals.push({ skillId });
692
+ }
693
+
694
+ if (updates.length === 0 && removals.length === 0) {
695
+ options.log?.info?.("clawchat skill-update check: nothing to converge");
630
696
  return null;
631
697
  }
632
698
 
@@ -636,15 +702,19 @@ export async function runSkillUpdateCheck(
636
702
  ref: outcome.ref,
637
703
  ...(options.base ? { base: options.base } : {}),
638
704
  updates,
705
+ removals,
639
706
  createdAt: now,
640
707
  expiresAt: now + (options.timeoutMs ?? DEFAULT_CONSENT_TIMEOUT_MS),
641
708
  };
642
709
  options.store.set(record);
643
710
 
644
- const text = `我的技能有更新:${updates.map(describeUpdate).join(";")}。回复「更新」确认,「取消」忽略。`;
711
+ const segments: string[] = [];
712
+ if (updates.length > 0) segments.push(`我的技能有更新:${updates.map(describeUpdate).join(";")}`);
713
+ if (removals.length > 0) segments.push(`以下技能将下线移除:${removals.map((r) => `「${r.skillId}」`).join("、")}`);
714
+ const text = `${segments.join(";")}。回复「更新」确认,「取消」忽略。`;
645
715
  await options.sendOwnerMessage(text);
646
716
  options.log?.info?.(
647
- `clawchat skill-update check: asked owner for consent on ${updates.map((u) => u.skillId).join(",")}`,
717
+ `clawchat skill-update check: asked owner for consent on updates=[${updates.map((u) => u.skillId).join(",")}] removals=[${removals.map((r) => r.skillId).join(",")}]`,
648
718
  );
649
719
  return record;
650
720
  }
@@ -664,8 +734,8 @@ export interface HandleOwnerConsentReplyOptions {
664
734
  */
665
735
  skillsDir: string;
666
736
  sendOwnerMessage: (text: string) => Promise<void>;
667
- /** Re-read the on-disk version of a skill for idempotency checks. */
668
- readLocalVersion?: (skillId: string) => Promise<string | null>;
737
+ /** Re-read the on-disk sha of a managed skill for idempotency checks. */
738
+ readLocalSha?: (skillId: string) => Promise<string | null>;
669
739
  log?: SkillUpdateLog;
670
740
  }
671
741
 
@@ -703,23 +773,24 @@ export async function handleOwnerConsentReply(
703
773
  }
704
774
 
705
775
  // verdict === "affirm"
706
- const readLocalVersion =
707
- options.readLocalVersion ??
776
+ const readLocalSha =
777
+ options.readLocalSha ??
708
778
  (async (skillId: string) => {
709
779
  try {
710
- const md = await fs.readFile(path.join(options.skillsDir, skillId, "SKILL.md"), "utf8");
711
- return parseSkillFrontmatterVersion(md);
780
+ const buf = await fs.readFile(path.join(options.skillsDir, skillId, "SKILL.md"));
781
+ return crypto.createHash("sha256").update(buf).digest("hex");
712
782
  } catch {
713
783
  return null;
714
784
  }
715
785
  });
716
786
 
717
787
  const applied: PendingSkillTarget[] = [];
788
+ const removed: PendingSkillRemoval[] = [];
718
789
  try {
719
790
  for (const update of pending.updates) {
720
- // Idempotency: skip a skill already at the target version on disk.
721
- const onDisk = await readLocalVersion(update.skillId);
722
- if (onDisk === update.target) {
791
+ // Idempotency: skip a skill whose on-disk bytes already match the target.
792
+ const onDisk = await readLocalSha(update.skillId);
793
+ if (onDisk === update.sha256) {
723
794
  applied.push(update);
724
795
  continue;
725
796
  }
@@ -733,9 +804,21 @@ export async function handleOwnerConsentReply(
733
804
  );
734
805
  await atomicWriteSkill(options.skillsDir, update.skillId, content);
735
806
  applied.push(update);
736
- options.log?.info?.(
737
- `clawchat skill-update: applied ${update.skillId} -> v${update.target} (atomic overwrite)`,
738
- );
807
+ options.log?.info?.(`clawchat skill-update: applied ${update.skillId} -> v${update.target} (atomic overwrite)`);
808
+ }
809
+ for (const removal of pending.removals) {
810
+ // Defensive re-guard: bundled skills must never be deleted, even though
811
+ // runSkillUpdateCheck already filters them out of removal candidates.
812
+ // Unreachable in the normal flow; kept as a cheap safety net.
813
+ if ((OPENCLAW_SKILL_IDS as readonly string[]).includes(removal.skillId)) {
814
+ options.log?.info?.(
815
+ `clawchat skill-update: skipped removal of bundled skill ${removal.skillId} (defensive re-guard)`,
816
+ );
817
+ continue;
818
+ }
819
+ await removeManagedSkill(options.skillsDir, removal.skillId);
820
+ removed.push(removal);
821
+ options.log?.info?.(`clawchat skill-update: removed tombstoned skill ${removal.skillId}`);
739
822
  }
740
823
  } catch (err) {
741
824
  // Keep the pending record so the owner can retry with another "更新"; report
@@ -746,7 +829,9 @@ export async function handleOwnerConsentReply(
746
829
  }
747
830
 
748
831
  options.store.clear();
749
- const summary = applied.map((u) => `「${u.skillId}」v${u.target}`).join("、");
750
- await options.sendOwnerMessage(`✅ 已更新到 ${summary}`);
832
+ const segments: string[] = [];
833
+ if (applied.length > 0) segments.push(`已更新到 ${applied.map((u) => `「${u.skillId}」v${u.target}`).join("、")}`);
834
+ if (removed.length > 0) segments.push(`已移除 ${removed.map((r) => `「${r.skillId}」`).join("、")}`);
835
+ await options.sendOwnerMessage(`✅ ${segments.join(";")}`);
751
836
  return true;
752
837
  }