@clawling/clawchat-plugin-openclaw 2026.6.30-4 → 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.
@@ -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/dist/src/tools.js CHANGED
@@ -4,6 +4,7 @@ import { execFile } from "node:child_process";
4
4
  import { createOpenclawClawlingApiClient } from "./api-client.js";
5
5
  import { resolveLivewarePath } from "./liveware-cli.js";
6
6
  import { ClawlingApiError, } from "./api-types.js";
7
+ import { mapGateOutcome } from "./gate-outcome.js";
7
8
  import { resolveOpenclawClawlingAccount } from "./config.js";
8
9
  import { clawChatDbPathForStateDir, getClawChatStore, } from "./storage.js";
9
10
  import { sendOpenclawClawlingMentionMessage } from "./outbound.js";
@@ -272,6 +273,38 @@ export function registerOpenclawClawlingTools(api, options = {}) {
272
273
  }
273
274
  })();
274
275
  }
276
+ /**
277
+ * Like withClient, but maps gate codes (21001/21003) to structured outcomes
278
+ * via mapGateOutcome so the agent sees a clear pending/forbidden status
279
+ * instead of a generic error. Used for friend and moment mutation tools.
280
+ */
281
+ function withGatedClient(fn) {
282
+ return (async () => {
283
+ const built = buildClient();
284
+ if (!built.ok)
285
+ return built.error;
286
+ try {
287
+ const data = await fn(built.client);
288
+ return jsonResponse(data);
289
+ }
290
+ catch (err) {
291
+ if (err instanceof ClawlingApiError) {
292
+ const code = typeof err.meta?.code === "number" ? err.meta.code : null;
293
+ if (code !== null) {
294
+ const rawData = err.meta?.data;
295
+ const gateData = rawData !== null && rawData !== undefined && typeof rawData === "object"
296
+ ? rawData
297
+ : {};
298
+ const outcome = mapGateOutcome(code, gateData);
299
+ if (outcome !== null)
300
+ return jsonResponse(outcome);
301
+ }
302
+ return apiError(err);
303
+ }
304
+ return genericError(err);
305
+ }
306
+ })();
307
+ }
275
308
  function workspaceRoot(ctx) {
276
309
  const root = typeof ctx.workspaceDir === "string" ? ctx.workspaceDir.trim() : "";
277
310
  return root || null;
@@ -640,7 +673,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
640
673
  async execute(_callId, params) {
641
674
  return await recordClawchatToolCall("clawchat_send_friend_request", params, async () => {
642
675
  const p = params;
643
- return await withClient((c) => c.sendFriendRequest(p));
676
+ return await withGatedClient((c) => c.sendFriendRequest(p));
644
677
  });
645
678
  },
646
679
  }, { name: "clawchat_send_friend_request" });
@@ -668,7 +701,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
668
701
  async execute(_callId, params) {
669
702
  return await recordClawchatToolCall("clawchat_accept_friend_request", params, async () => {
670
703
  const p = params;
671
- return await withClient((c) => c.acceptFriendRequest(p.requestId));
704
+ return await withGatedClient((c) => c.acceptFriendRequest(p.requestId));
672
705
  });
673
706
  },
674
707
  }, { name: "clawchat_accept_friend_request" });
@@ -682,7 +715,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
682
715
  async execute(_callId, params) {
683
716
  return await recordClawchatToolCall("clawchat_reject_friend_request", params, async () => {
684
717
  const p = params;
685
- return await withClient((c) => c.rejectFriendRequest(p.requestId));
718
+ return await withGatedClient((c) => c.rejectFriendRequest(p.requestId));
686
719
  });
687
720
  },
688
721
  }, { name: "clawchat_reject_friend_request" });
@@ -696,7 +729,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
696
729
  async execute(_callId, params) {
697
730
  return await recordClawchatToolCall("clawchat_remove_friend", params, async () => {
698
731
  const p = params;
699
- return await withClient((c) => c.removeFriend(p.friendUserId));
732
+ return await withGatedClient((c) => c.removeFriend(p.friendUserId));
700
733
  });
701
734
  },
702
735
  }, { name: "clawchat_remove_friend" });
@@ -841,7 +874,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
841
874
  if (!text && (!images || images.length === 0)) {
842
875
  return validationError("clawchat-plugin-openclaw: at least one of text or images is required");
843
876
  }
844
- return await withClient((c) => c.createMoment({
877
+ return await withGatedClient((c) => c.createMoment({
845
878
  ...(text !== undefined ? { text } : {}),
846
879
  ...(images !== undefined ? { images } : {}),
847
880
  }));
@@ -858,7 +891,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
858
891
  async execute(_callId, params) {
859
892
  return await recordClawchatToolCall("clawchat_delete_moment", params, async () => {
860
893
  const p = params;
861
- return await withClient((c) => c.deleteMoment(p.momentId));
894
+ return await withGatedClient((c) => c.deleteMoment(p.momentId));
862
895
  });
863
896
  },
864
897
  }, { name: "clawchat_delete_moment" });
@@ -875,7 +908,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
875
908
  if (!p.emoji?.trim()) {
876
909
  return validationError("clawchat-plugin-openclaw: emoji is required");
877
910
  }
878
- return await withClient((c) => c.toggleMomentReaction({ momentId: p.momentId, emoji: p.emoji }));
911
+ return await withGatedClient((c) => c.toggleMomentReaction({ momentId: p.momentId, emoji: p.emoji }));
879
912
  });
880
913
  },
881
914
  }, { name: "clawchat_toggle_moment_reaction" });
@@ -892,7 +925,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
892
925
  if (!p.text?.trim()) {
893
926
  return validationError("clawchat-plugin-openclaw: text is required");
894
927
  }
895
- return await withClient((c) => c.createMomentComment({ momentId: p.momentId, text: p.text }));
928
+ return await withGatedClient((c) => c.createMomentComment({ momentId: p.momentId, text: p.text }));
896
929
  });
897
930
  },
898
931
  }, { name: "clawchat_create_moment_comment" });
@@ -909,7 +942,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
909
942
  if (!p.text?.trim()) {
910
943
  return validationError("clawchat-plugin-openclaw: text is required");
911
944
  }
912
- return await withClient((c) => c.replyMomentComment({
945
+ return await withGatedClient((c) => c.replyMomentComment({
913
946
  momentId: p.momentId,
914
947
  replyToCommentId: p.replyToCommentId,
915
948
  text: p.text,
@@ -927,7 +960,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
927
960
  async execute(_callId, params) {
928
961
  return await recordClawchatToolCall("clawchat_delete_moment_comment", params, async () => {
929
962
  const p = params;
930
- return await withClient((c) => c.deleteMomentComment({ momentId: p.momentId, commentId: p.commentId }));
963
+ return await withGatedClient((c) => c.deleteMomentComment({ momentId: p.momentId, commentId: p.commentId }));
931
964
  });
932
965
  },
933
966
  }, { name: "clawchat_delete_moment_comment" });
@@ -84,6 +84,23 @@ export function createWebSocketTransport(WebSocketCtor = globalThis.WebSocket) {
84
84
  },
85
85
  };
86
86
  }
87
+ /**
88
+ * Returns the capability set advertised in the connect handshake.
89
+ * Exported so tests can assert on the exact fields without a full WS setup.
90
+ *
91
+ * permission_events: forward-compat: advertises readiness for a future native
92
+ * `permission.receipt` wire frame; today the permission-decision receipt is
93
+ * delivered as a system message until msghub emits the native frame.
94
+ */
95
+ export function buildConnectCapabilities() {
96
+ return {
97
+ multi_device: false,
98
+ device_replay: true,
99
+ chat_meta_events: true,
100
+ notify_signals: true,
101
+ permission_events: true,
102
+ };
103
+ }
87
104
  export class ClawChatClient extends EventEmitter {
88
105
  opts;
89
106
  currentState = "idle";
@@ -333,12 +350,8 @@ export class ClawChatClient extends EventEmitter {
333
350
  // *send* history.transit, which a single-device agent never does, and we
334
351
  // do not handle inbound history.transit — advertising it would invite
335
352
  // frames we cannot process. This is a deliberate, spec-legal omission.
336
- capabilities: {
337
- multi_device: false,
338
- device_replay: true,
339
- chat_meta_events: true,
340
- notify_signals: true,
341
- },
353
+ // permission_events: forward-compat; see buildConnectCapabilities().
354
+ capabilities: buildConnectCapabilities(),
342
355
  };
343
356
  const traceId = this.nextTraceId();
344
357
  this.expectedConnectTraceId = traceId;
@@ -107,6 +107,7 @@
107
107
  "forwardThinking": { "type": "boolean" },
108
108
  "forwardToolCalls": { "type": "boolean" },
109
109
  "richInteractions": { "type": "boolean" },
110
+ "awarenessNote": { "type": "boolean" },
110
111
  "reconnect": {
111
112
  "type": "object",
112
113
  "additionalProperties": false,
@@ -180,6 +181,7 @@
180
181
  "forwardThinking": { "type": "boolean" },
181
182
  "forwardToolCalls": { "type": "boolean" },
182
183
  "richInteractions": { "type": "boolean" },
184
+ "awarenessNote": { "type": "boolean" },
183
185
  "reconnect": {
184
186
  "type": "object",
185
187
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.6.30-4",
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/api-client.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type UserSearchHit,
18
18
  } from "./api-types.ts";
19
19
  import type { GroupSettings, GroupSettingsFetchResult } from "./group-settings.ts";
20
+ import type { PermissionPolicy, PermState } from "./permissions.ts";
20
21
  import { CHANNEL_ID } from "./config.ts";
21
22
 
22
23
  export interface PluginReportInput {
@@ -179,6 +180,17 @@ export interface OpenclawClawlingApiClient {
179
180
  * error still throws.
180
181
  */
181
182
  getMyGroupSettings(): Promise<GroupSettingsFetchResult>;
183
+ /**
184
+ * Fetch the agent's permission policy from `GET /v1/agents/me/permissions`.
185
+ *
186
+ * The response `data` is a flat object mapping operation name → state
187
+ * (`"ask"|"allow"|"deny"`), with one special key `"moment.visibility"` whose
188
+ * value is a string array. Returns a `PermissionPolicy` with `byOperation`
189
+ * (all non-visibility keys) and `momentVisibility` (the array). Throws on
190
+ * network errors or non-2xx responses; callers must catch and keep the stale
191
+ * cache (never throw / never block message handling).
192
+ */
193
+ getMyPermissions(): Promise<PermissionPolicy>;
182
194
  registerApp(params: { name: string; appId: string; url: string }): Promise<{ app: AgentAppView }>;
183
195
  listApps(): Promise<{ apps: AgentAppView[] }>;
184
196
  unregisterApp(appId: string): Promise<{ deleted: boolean }>;
@@ -362,6 +374,7 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
362
374
  code,
363
375
  status: res.status,
364
376
  path,
377
+ ...(env.data !== undefined ? { data: env.data } : {}),
365
378
  });
366
379
  }
367
380
  return env.data as T;
@@ -759,5 +772,15 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
759
772
  }
760
773
  return { authoritative: true, rows };
761
774
  },
775
+ async getMyPermissions(): Promise<PermissionPolicy> {
776
+ const data = await call<Record<string, unknown>>("GET", "/v1/agents/me/permissions");
777
+ const byOperation: Record<string, PermState> = {};
778
+ let momentVisibility: string[] = [];
779
+ for (const [k, v] of Object.entries(data ?? {})) {
780
+ if (k === "moment.visibility") momentVisibility = (v as string[]) ?? [];
781
+ else byOperation[k] = v as PermState;
782
+ }
783
+ return { byOperation, momentVisibility };
784
+ },
762
785
  };
763
786
  }
package/src/api-types.ts CHANGED
@@ -157,7 +157,7 @@ export class ClawlingApiError extends Error {
157
157
  constructor(
158
158
  public readonly kind: ClawlingApiErrorKind,
159
159
  message: string,
160
- public readonly meta?: { code?: number; status?: number; path?: string },
160
+ public readonly meta?: { code?: number; status?: number; path?: string; data?: unknown },
161
161
  ) {
162
162
  super(message);
163
163
  this.name = "ClawlingApiError";
package/src/config.ts CHANGED
@@ -106,6 +106,8 @@ export type OpenclawClawlingConfig = {
106
106
  forwardToolCalls?: boolean;
107
107
  /** Emit approval/action rich fragments instead of plain fallback text. */
108
108
  richInteractions?: boolean;
109
+ /** Emit ONE consolidated awareness note to the agent when friend/conversation signals arrive. */
110
+ awarenessNote?: boolean;
109
111
  reconnect?: OpenclawClawlingReconnectConfig;
110
112
  heartbeat?: OpenclawClawlingHeartbeatConfig;
111
113
  ack?: OpenclawClawlingAckConfig;
@@ -152,6 +154,7 @@ export const openclawClawlingConfigSchema = {
152
154
  forwardThinking: { type: "boolean" },
153
155
  forwardToolCalls: { type: "boolean" },
154
156
  richInteractions: { type: "boolean" },
157
+ awarenessNote: { type: "boolean" },
155
158
  reconnect: {
156
159
  type: "object",
157
160
  additionalProperties: false,
@@ -274,6 +277,7 @@ export type ResolvedOpenclawClawlingAccount = {
274
277
  forwardThinking: boolean;
275
278
  forwardToolCalls: boolean;
276
279
  richInteractions: boolean;
280
+ awarenessNote: boolean;
277
281
  allowFrom: string[];
278
282
  reconnect: Required<OpenclawClawlingReconnectConfig>;
279
283
  heartbeat: Required<OpenclawClawlingHeartbeatConfig>;
@@ -482,6 +486,8 @@ export function resolveOpenclawClawlingAccount(
482
486
  typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
483
487
  const richInteractions =
484
488
  typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
489
+ const awarenessNote =
490
+ typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
485
491
 
486
492
  return {
487
493
  accountId: DEFAULT_ACCOUNT_ID,
@@ -508,6 +514,7 @@ export function resolveOpenclawClawlingAccount(
508
514
  forwardThinking,
509
515
  forwardToolCalls,
510
516
  richInteractions,
517
+ awarenessNote,
511
518
  allowFrom: [],
512
519
  reconnect: readReconnect(channel.reconnect),
513
520
  heartbeat: readHeartbeat(channel.heartbeat),