@clawling/clawchat-plugin-openclaw 2026.6.30-4 → 2026.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts CHANGED
@@ -78,6 +78,9 @@ import {
78
78
  import { createGroupMessageCoalescer } from "./group-message-coalescer.ts";
79
79
  import { openclawLlmContextDebug } from "./llm-context-debug.ts";
80
80
  import { GroupSettingsCache } from "./group-settings.ts";
81
+ import { PermissionCache } from "./permissions.ts";
82
+ import { buildFriendRequestEnvelope } from "./friend-request-turn.ts";
83
+ import { handlePermissionResult } from "./permission-result.ts";
81
84
 
82
85
  type Log = { debug?: (m: string) => void; info?: (m: string) => void; error?: (m: string) => void };
83
86
  type ChannelContextBuilder = (params: Record<string, unknown>) => unknown;
@@ -1752,6 +1755,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1752
1755
  context: wsLogContext,
1753
1756
  });
1754
1757
  const groupSettingsCache = new GroupSettingsCache();
1758
+ const permissionCache = new PermissionCache();
1755
1759
  // Findings 2 & 3 — gate group dispatch on the IN-FLIGHT settings pull, not just
1756
1760
  // the first one. The pull is fire-and-forget, so replayed/live group messages
1757
1761
  // could otherwise enter the governance gate against a stale/empty cache
@@ -1829,6 +1833,28 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1829
1833
  }
1830
1834
  })();
1831
1835
  };
1836
+ // Best-effort pull of the agent's permission policy from the backend.
1837
+ // Called at startup (bypassRefresh: true, mirrors pullGroupSettings startup)
1838
+ // and on every successful (re)connect after hello-ok. On failure, logs and
1839
+ // keeps the stale cache — never throws, never blocks message handling.
1840
+ const pullPermissions = (opts?: { bypassRefresh?: boolean }): void => {
1841
+ void (async () => {
1842
+ try {
1843
+ const policy = opts?.bypassRefresh
1844
+ ? await (conversationApiClient ??= buildConversationApiClient()).getMyPermissions()
1845
+ : await getConversationApiClient().getMyPermissions();
1846
+ permissionCache.set(policy);
1847
+ log?.info?.(
1848
+ `[${accountId}] clawchat-plugin-openclaw permissions pull ok ops=${Object.keys(policy.byOperation).length}`,
1849
+ );
1850
+ } catch (err) {
1851
+ log?.error?.(
1852
+ `[${accountId}] clawchat-plugin-openclaw permissions pull failed: ${err instanceof Error ? err.message : String(err)}`,
1853
+ );
1854
+ // Keep stale cache; never throw.
1855
+ }
1856
+ })();
1857
+ };
1832
1858
  const logAuthFailure = (reason: string) => {
1833
1859
  if (authFailureLogged) return;
1834
1860
  authFailureLogged = true;
@@ -1962,6 +1988,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1962
1988
  // Re-pull per-group agent settings on every successful (re)connect so the
1963
1989
  // cache stays current after a network interruption or token refresh.
1964
1990
  pullGroupSettings();
1991
+ // Re-pull permission policy alongside group settings on every (re)connect.
1992
+ pullPermissions();
1965
1993
  // §A.1 — arm the proactive refresh timer from the live token's `exp`
1966
1994
  // every time a connection becomes ready (re-armed after every refresh via
1967
1995
  // the gateway re-enter).
@@ -2138,13 +2166,30 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2138
2166
  }`,
2139
2167
  );
2140
2168
  }
2141
- const sendOwnerSkillMessage = async (text: string): Promise<void> => {
2169
+ // A usr_... id is not a valid chat_id — member-backend rejects it with
2170
+ // "invalid conversation id" and msghub never acks the send. Proactive owner
2171
+ // messages must be addressed to the direct conversation recorded at
2172
+ // activation (parity with the hermes adapter's _owner_direct_chat_id).
2173
+ const resolveOwnerDirectConversationId = (): string | null => {
2142
2174
  const ownerUserId = account.ownerUserId?.trim();
2143
- if (!ownerUserId) return;
2175
+ if (!ownerUserId) return null;
2176
+ const conversationId = store
2177
+ ?.getActivationConversation?.({ platform: "openclaw", accountId })
2178
+ ?.conversationId?.trim();
2179
+ return conversationId || null;
2180
+ };
2181
+ const sendOwnerSkillMessage = async (text: string): Promise<void> => {
2182
+ const conversationId = resolveOwnerDirectConversationId();
2183
+ if (!conversationId) {
2184
+ log?.info?.(
2185
+ `[${accountId}] clawchat-plugin-openclaw skill-update owner message skipped: no activation conversation recorded`,
2186
+ );
2187
+ return;
2188
+ }
2144
2189
  await sendOpenclawClawlingText({
2145
2190
  client,
2146
2191
  account,
2147
- to: { chatId: ownerUserId, chatType: "direct" },
2192
+ to: { chatId: conversationId, chatType: "direct" },
2148
2193
  text,
2149
2194
  log,
2150
2195
  });
@@ -2193,6 +2238,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2193
2238
  });
2194
2239
  };
2195
2240
 
2241
+ // Debounce flag for lighter-awareness consolidated notes.
2242
+ // A single queueMicrotask coalesces all awareness signals that arrive in
2243
+ // the same synchronous turn into exactly one handleInboundEnvelope call.
2244
+ let pendingAwarenessNote = false;
2245
+
2196
2246
  client.on("notify:signal", (env: Envelope) => {
2197
2247
  // §9.4 reliable system notification. The plugin holds no friend/roster
2198
2248
  // cache (friends are fetched on demand via REST tools), so there is nothing
@@ -2204,11 +2254,17 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2204
2254
  ? (env.payload as Record<string, unknown>)
2205
2255
  : undefined;
2206
2256
  const type = typeof payload?.type === "string" ? payload.type : "";
2257
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
2207
2258
  if (type === "agent.config.changed") {
2208
2259
  log?.info?.(
2209
2260
  `[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`,
2210
2261
  );
2211
2262
  pullGroupSettings();
2263
+ } else if (type === "agent.permission.changed") {
2264
+ log?.info?.(
2265
+ `[${accountId}] clawchat-plugin-openclaw agent.permission.changed signal received; re-pulling permissions`,
2266
+ );
2267
+ pullPermissions();
2212
2268
  } else if (type === "clawchat.skill.update.check") {
2213
2269
  // Content-free trigger: check the official skill source in code and, if
2214
2270
  // there is an update, ask the owner in chat for consent. The atomic
@@ -2223,6 +2279,66 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2223
2279
  }`,
2224
2280
  );
2225
2281
  });
2282
+ } else if (type === "friend.request") {
2283
+ // Dedup is already applied above by notifySignalObserver.observe (the live
2284
+ // frame and its reliable-inbox replay share the same event_id and collapse
2285
+ // to one observation). Build one synthetic reasoning turn so the agent can
2286
+ // act on the pending request; the prompt text varies by policy state.
2287
+ const state = permissionCache.stateOf("friend.add");
2288
+ log?.info?.(
2289
+ `[${accountId}] clawchat-plugin-openclaw friend.request signal received; dispatching reasoning turn entity_id=${entityId} policy=${state}`,
2290
+ );
2291
+ void handleInboundEnvelope(buildFriendRequestEnvelope({ account, state, entityId }));
2292
+ } else if (
2293
+ type === "friend.added" ||
2294
+ type === "friend.removed" ||
2295
+ type === "friend.profile_updated" ||
2296
+ type.startsWith("conversation.")
2297
+ ) {
2298
+ // Lighter-awareness: log + optional single consolidated note. Never
2299
+ // injects a full reasoning turn per event (unlike friend.request).
2300
+ log?.info?.(
2301
+ `[${accountId}] clawchat-plugin-openclaw ${type} signal received; lighter-awareness handling`,
2302
+ );
2303
+ if (account.awarenessNote && !pendingAwarenessNote) {
2304
+ pendingAwarenessNote = true;
2305
+ queueMicrotask(() => {
2306
+ pendingAwarenessNote = false;
2307
+ const now = Date.now();
2308
+ void handleInboundEnvelope({
2309
+ version: "2",
2310
+ event: EVENT.MESSAGE_SEND,
2311
+ trace_id: `clawchat-plugin-openclaw-awareness-${now}`,
2312
+ emitted_at: now,
2313
+ chat_id: account.ownerUserId,
2314
+ chat_type: "direct",
2315
+ to: { id: account.userId, type: "direct" },
2316
+ sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
2317
+ payload: {
2318
+ message_id: `clawchat-plugin-openclaw-awareness-${now}`,
2319
+ message_mode: "normal",
2320
+ message: {
2321
+ body: {
2322
+ fragments: [
2323
+ {
2324
+ kind: "text",
2325
+ text: "ClawChat awareness: your contacts or conversations have been updated.",
2326
+ },
2327
+ ],
2328
+ },
2329
+ context: { mentions: [], reply: null },
2330
+ streaming: {
2331
+ status: "static",
2332
+ sequence: 0,
2333
+ mutation_policy: "sealed",
2334
+ started_at: null,
2335
+ completed_at: null,
2336
+ },
2337
+ },
2338
+ },
2339
+ } as unknown as Envelope);
2340
+ });
2341
+ }
2226
2342
  }
2227
2343
  }
2228
2344
  });
@@ -2787,10 +2903,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2787
2903
  });
2788
2904
 
2789
2905
  const forwardGroupApprovalSlashToOwner = async (turn: IngestTurnParams): Promise<IngestTurnResult> => {
2790
- const ownerUserId = account.ownerUserId?.trim();
2791
- if (!ownerUserId) {
2906
+ const ownerConversationId = resolveOwnerDirectConversationId();
2907
+ if (!ownerConversationId) {
2792
2908
  log?.error?.(
2793
- `[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_user_id chat_id=${turn.peer.id} msg=${turn.messageId}`,
2909
+ `[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_conversation chat_id=${turn.peer.id} msg=${turn.messageId}`,
2794
2910
  );
2795
2911
  return "skipped";
2796
2912
  }
@@ -2798,7 +2914,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2798
2914
  await sendOpenclawClawlingText({
2799
2915
  client,
2800
2916
  account,
2801
- to: { chatId: ownerUserId, chatType: "direct" },
2917
+ to: { chatId: ownerConversationId, chatType: "direct" },
2802
2918
  text: groupOwnerAttentionText(turn.peer.id, turn.rawBody),
2803
2919
  log,
2804
2920
  });
@@ -2924,6 +3040,18 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2924
3040
  account,
2925
3041
  log,
2926
3042
  consumeOwnerConsent: consumeOwnerSkillConsent,
3043
+ consumePermissionResult: async (metadata) => {
3044
+ // A permission_result receipt closes the loop on an earlier request.
3045
+ // Dedup by request_id; on a fresh receipt feed the synthetic context
3046
+ // note back to the agent so it can react to the outcome.
3047
+ const note = handlePermissionResult(metadata, account);
3048
+ if (note) {
3049
+ log?.info?.(
3050
+ `[${accountId}] clawchat-plugin-openclaw permission_result receipt op=${metadata.operation} outcome=${metadata.outcome} request_id=${metadata.request_id}; dispatching context note`,
3051
+ );
3052
+ void handleInboundEnvelope(note);
3053
+ }
3054
+ },
2927
3055
  ingest: async (turn) => {
2928
3056
  ingestResult = await ingestTurn(turn);
2929
3057
  },
@@ -3020,6 +3148,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3020
3148
  // rotating `account.token` out from under the already-constructed WS client.
3021
3149
  // Settling this releases the Finding-2 group-dispatch gate.
3022
3150
  pullGroupSettings({ bypassRefresh: true });
3151
+ // Initial pull of permission policy at startup (best-effort; errors logged,
3152
+ // stale cache kept). Uses bypassRefresh for the same reason as pullGroupSettings.
3153
+ pullPermissions({ bypassRefresh: true });
3023
3154
  // Safety: never strand a held group message if the runtime is torn down while a
3024
3155
  // pull is in flight. `waitWithTimeout` already releases group waiters on abort,
3025
3156
  // but force the gate open too so any later `wait()` resolves immediately.
package/src/tools.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  ClawlingApiError,
10
10
  type Profile,
11
11
  } from "./api-types.ts";
12
+ import { mapGateOutcome } from "./gate-outcome.ts";
12
13
  import { resolveOpenclawClawlingAccount } from "./config.ts";
13
14
  import {
14
15
  clawChatDbPathForStateDir,
@@ -389,6 +390,39 @@ export function registerOpenclawClawlingTools(
389
390
  })();
390
391
  }
391
392
 
393
+ /**
394
+ * Like withClient, but maps gate codes (21001/21003) to structured outcomes
395
+ * via mapGateOutcome so the agent sees a clear pending/forbidden status
396
+ * instead of a generic error. Used for friend and moment mutation tools.
397
+ */
398
+ function withGatedClient<T>(
399
+ fn: (client: ReturnType<typeof createOpenclawClawlingApiClient>) => Promise<T>,
400
+ ): Promise<OpenClawAgentToolResult<unknown>> {
401
+ return (async (): Promise<OpenClawAgentToolResult<unknown>> => {
402
+ const built = buildClient();
403
+ if (!built.ok) return built.error;
404
+ try {
405
+ const data = await fn(built.client);
406
+ return jsonResponse(data);
407
+ } catch (err) {
408
+ if (err instanceof ClawlingApiError) {
409
+ const code = typeof err.meta?.code === "number" ? err.meta.code : null;
410
+ if (code !== null) {
411
+ const rawData = err.meta?.data;
412
+ const gateData =
413
+ rawData !== null && rawData !== undefined && typeof rawData === "object"
414
+ ? (rawData as Record<string, unknown>)
415
+ : {};
416
+ const outcome = mapGateOutcome(code, gateData);
417
+ if (outcome !== null) return jsonResponse(outcome);
418
+ }
419
+ return apiError(err);
420
+ }
421
+ return genericError(err);
422
+ }
423
+ })();
424
+ }
425
+
392
426
  function workspaceRoot(ctx: OpenClawPluginToolContext): string | null {
393
427
  const root = typeof ctx.workspaceDir === "string" ? ctx.workspaceDir.trim() : "";
394
428
  return root || null;
@@ -829,7 +863,7 @@ export function registerOpenclawClawlingTools(
829
863
  async execute(_callId, params) {
830
864
  return await recordClawchatToolCall("clawchat_send_friend_request", params, async () => {
831
865
  const p = params as ClawchatSendFriendRequestParams;
832
- return await withClient((c) => c.sendFriendRequest(p));
866
+ return await withGatedClient((c) => c.sendFriendRequest(p));
833
867
  });
834
868
  },
835
869
  },
@@ -869,7 +903,7 @@ export function registerOpenclawClawlingTools(
869
903
  async execute(_callId, params) {
870
904
  return await recordClawchatToolCall("clawchat_accept_friend_request", params, async () => {
871
905
  const p = params as ClawchatAcceptFriendRequestParams;
872
- return await withClient((c) => c.acceptFriendRequest(p.requestId));
906
+ return await withGatedClient((c) => c.acceptFriendRequest(p.requestId));
873
907
  });
874
908
  },
875
909
  },
@@ -889,7 +923,7 @@ export function registerOpenclawClawlingTools(
889
923
  async execute(_callId, params) {
890
924
  return await recordClawchatToolCall("clawchat_reject_friend_request", params, async () => {
891
925
  const p = params as ClawchatRejectFriendRequestParams;
892
- return await withClient((c) => c.rejectFriendRequest(p.requestId));
926
+ return await withGatedClient((c) => c.rejectFriendRequest(p.requestId));
893
927
  });
894
928
  },
895
929
  },
@@ -909,7 +943,7 @@ export function registerOpenclawClawlingTools(
909
943
  async execute(_callId, params) {
910
944
  return await recordClawchatToolCall("clawchat_remove_friend", params, async () => {
911
945
  const p = params as ClawchatRemoveFriendParams;
912
- return await withClient((c) => c.removeFriend(p.friendUserId));
946
+ return await withGatedClient((c) => c.removeFriend(p.friendUserId));
913
947
  });
914
948
  },
915
949
  },
@@ -1084,7 +1118,7 @@ export function registerOpenclawClawlingTools(
1084
1118
  if (!text && (!images || images.length === 0)) {
1085
1119
  return validationError("clawchat-plugin-openclaw: at least one of text or images is required");
1086
1120
  }
1087
- return await withClient((c) =>
1121
+ return await withGatedClient((c) =>
1088
1122
  c.createMoment({
1089
1123
  ...(text !== undefined ? { text } : {}),
1090
1124
  ...(images !== undefined ? { images } : {}),
@@ -1109,7 +1143,7 @@ export function registerOpenclawClawlingTools(
1109
1143
  async execute(_callId, params) {
1110
1144
  return await recordClawchatToolCall("clawchat_delete_moment", params, async () => {
1111
1145
  const p = params as ClawchatDeleteMomentParams;
1112
- return await withClient((c) => c.deleteMoment(p.momentId));
1146
+ return await withGatedClient((c) => c.deleteMoment(p.momentId));
1113
1147
  });
1114
1148
  },
1115
1149
  },
@@ -1132,7 +1166,7 @@ export function registerOpenclawClawlingTools(
1132
1166
  if (!p.emoji?.trim()) {
1133
1167
  return validationError("clawchat-plugin-openclaw: emoji is required");
1134
1168
  }
1135
- return await withClient((c) =>
1169
+ return await withGatedClient((c) =>
1136
1170
  c.toggleMomentReaction({ momentId: p.momentId, emoji: p.emoji }),
1137
1171
  );
1138
1172
  });
@@ -1157,7 +1191,7 @@ export function registerOpenclawClawlingTools(
1157
1191
  if (!p.text?.trim()) {
1158
1192
  return validationError("clawchat-plugin-openclaw: text is required");
1159
1193
  }
1160
- return await withClient((c) =>
1194
+ return await withGatedClient((c) =>
1161
1195
  c.createMomentComment({ momentId: p.momentId, text: p.text }),
1162
1196
  );
1163
1197
  });
@@ -1182,7 +1216,7 @@ export function registerOpenclawClawlingTools(
1182
1216
  if (!p.text?.trim()) {
1183
1217
  return validationError("clawchat-plugin-openclaw: text is required");
1184
1218
  }
1185
- return await withClient((c) =>
1219
+ return await withGatedClient((c) =>
1186
1220
  c.replyMomentComment({
1187
1221
  momentId: p.momentId,
1188
1222
  replyToCommentId: p.replyToCommentId,
@@ -1208,7 +1242,7 @@ export function registerOpenclawClawlingTools(
1208
1242
  async execute(_callId, params) {
1209
1243
  return await recordClawchatToolCall("clawchat_delete_moment_comment", params, async () => {
1210
1244
  const p = params as ClawchatDeleteMomentCommentParams;
1211
- return await withClient((c) =>
1245
+ return await withGatedClient((c) =>
1212
1246
  c.deleteMomentComment({ momentId: p.momentId, commentId: p.commentId }),
1213
1247
  );
1214
1248
  });
package/src/ws-client.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  TransportError,
10
10
  isBusinessDispatchEvent,
11
11
  type ConnState,
12
+ type ConnectCapabilities,
12
13
  type ConnectPayload,
13
14
  type EmptyPayload,
14
15
  type Envelope,
@@ -146,6 +147,24 @@ type PendingAck = {
146
147
  reject: (err: Error) => void;
147
148
  };
148
149
 
150
+ /**
151
+ * Returns the capability set advertised in the connect handshake.
152
+ * Exported so tests can assert on the exact fields without a full WS setup.
153
+ *
154
+ * permission_events: forward-compat: advertises readiness for a future native
155
+ * `permission.receipt` wire frame; today the permission-decision receipt is
156
+ * delivered as a system message until msghub emits the native frame.
157
+ */
158
+ export function buildConnectCapabilities(): ConnectCapabilities {
159
+ return {
160
+ multi_device: false,
161
+ device_replay: true,
162
+ chat_meta_events: true,
163
+ notify_signals: true,
164
+ permission_events: true,
165
+ };
166
+ }
167
+
149
168
  export class ClawChatClient extends EventEmitter {
150
169
  private currentState: ConnState = "idle";
151
170
  private connectResolve?: () => void;
@@ -398,12 +417,8 @@ export class ClawChatClient extends EventEmitter {
398
417
  // *send* history.transit, which a single-device agent never does, and we
399
418
  // do not handle inbound history.transit — advertising it would invite
400
419
  // frames we cannot process. This is a deliberate, spec-legal omission.
401
- capabilities: {
402
- multi_device: false,
403
- device_replay: true,
404
- chat_meta_events: true,
405
- notify_signals: true,
406
- },
420
+ // permission_events: forward-compat; see buildConnectCapabilities().
421
+ capabilities: buildConnectCapabilities(),
407
422
  };
408
423
  const traceId = this.nextTraceId();
409
424
  this.expectedConnectTraceId = traceId;