@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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Synthetic reasoning-turn builder for `friend.request` notify.signal events.
3
+ *
4
+ * When the server fires a `friend.request` signal the runtime dispatches one
5
+ * deduped synthetic `message.send` envelope so the agent can reason about the
6
+ * pending request. The prompt text varies by the current "friend.add" permission
7
+ * policy state: a deny policy yields a decline/inform prompt; ask or allow
8
+ * yields a prompt that instructs the agent to review the request via the
9
+ * `clawchat_list_friend_requests` tool.
10
+ */
11
+ import { EVENT, type Envelope } from "./protocol-types.ts";
12
+ import type { PermState } from "./permissions.ts";
13
+ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
14
+
15
+ /**
16
+ * Returns canned prompt text for a friend-request reasoning turn based on the
17
+ * current "friend.add" permission policy state.
18
+ *
19
+ * - `deny` → inform the agent it should decline; no accept instruction.
20
+ * - `ask` / `allow` → instruct the agent to review the pending request.
21
+ */
22
+ export function friendRequestPromptFor(state: PermState): string {
23
+ if (state === "deny") {
24
+ return [
25
+ "A new friend request has arrived.",
26
+ "Your current friend-add policy is set to deny.",
27
+ "Do not add this contact.",
28
+ "You may inform the requester that you cannot add them at this time.",
29
+ ].join(" ");
30
+ }
31
+ return [
32
+ "A new friend request has arrived.",
33
+ "Please review the pending request by calling `clawchat_list_friend_requests`",
34
+ "and decide whether to accept it.",
35
+ ].join(" ");
36
+ }
37
+
38
+ export interface BuildFriendRequestEnvelopeParams {
39
+ account: ResolvedOpenclawClawlingAccount;
40
+ /** Current "friend.add" permission policy state. */
41
+ state: PermState;
42
+ /** entity_id from the notify.signal payload (the requesting user). */
43
+ entityId: string;
44
+ }
45
+
46
+ /**
47
+ * Builds a synthetic `message.send` envelope that triggers one agent reasoning
48
+ * turn for a pending friend request. The envelope shape mirrors the
49
+ * activation-bootstrap envelope; it targets the owner's direct conversation so
50
+ * the agent has context about who it is reasoning for.
51
+ */
52
+ export function buildFriendRequestEnvelope(
53
+ params: BuildFriendRequestEnvelopeParams,
54
+ ): Envelope {
55
+ const { account, state, entityId } = params;
56
+ const text = friendRequestPromptFor(state);
57
+ const now = Date.now();
58
+ return {
59
+ version: "2",
60
+ event: EVENT.MESSAGE_SEND,
61
+ trace_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
62
+ emitted_at: now,
63
+ chat_id: account.ownerUserId,
64
+ chat_type: "direct",
65
+ to: { id: account.userId, type: "direct" },
66
+ sender: {
67
+ id: "clawchat-friend-request",
68
+ type: "direct",
69
+ nick_name: "ClawChat",
70
+ },
71
+ payload: {
72
+ message_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
73
+ message_mode: "normal",
74
+ message: {
75
+ body: { fragments: [{ kind: "text", text }] },
76
+ context: { mentions: [], reply: null },
77
+ streaming: {
78
+ status: "static",
79
+ sequence: 0,
80
+ mutation_policy: "sealed",
81
+ started_at: null,
82
+ completed_at: null,
83
+ },
84
+ },
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Maps backend owner-approval gate codes to structured tool outcomes.
3
+ *
4
+ * Backend gate codes (confirmed live contract):
5
+ * 21001 — pending owner approval (payload: { request_id, operation, expires_at })
6
+ * 21003 — policy forbidden (payload: { operation })
7
+ *
8
+ * Returns null for any other code so callers can fall through to normal handling.
9
+ */
10
+
11
+ export type PendingApprovalOutcome = {
12
+ status: "pending_owner_approval";
13
+ request_id: string;
14
+ operation: string;
15
+ expires_at: number;
16
+ };
17
+
18
+ export type ForbiddenOutcome = {
19
+ status: "forbidden_by_owner";
20
+ operation: string;
21
+ };
22
+
23
+ export type GateOutcome = PendingApprovalOutcome | ForbiddenOutcome;
24
+
25
+ export function mapGateOutcome(
26
+ code: number,
27
+ data: Record<string, unknown>,
28
+ ): GateOutcome | null {
29
+ if (code === 21001) {
30
+ return {
31
+ status: "pending_owner_approval",
32
+ request_id: typeof data.request_id === "string" ? data.request_id : "",
33
+ operation: typeof data.operation === "string" ? data.operation : "",
34
+ expires_at: typeof data.expires_at === "number" ? data.expires_at : 0,
35
+ };
36
+ }
37
+ if (code === 21003) {
38
+ return {
39
+ status: "forbidden_by_owner",
40
+ operation: typeof data.operation === "string" ? data.operation : "",
41
+ };
42
+ }
43
+ return null;
44
+ }
package/src/inbound.ts CHANGED
@@ -8,6 +8,7 @@ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
8
8
  import type { MediaItem } from "./media-runtime.ts";
9
9
  import { extractMediaFragments, fragmentsToText, type MentionedUser } from "./message-mapper.ts";
10
10
  import { hasRenderableText, isInboundMessagePayload } from "./protocol.ts";
11
+ import type { PermissionResultMetadata } from "./permission-result.ts";
11
12
 
12
13
  export interface IngestTurnParams {
13
14
  channel: "clawchat-plugin-openclaw";
@@ -81,6 +82,20 @@ export interface DispatchInboundParams {
81
82
  text: string;
82
83
  chatId: string;
83
84
  }) => Promise<boolean>;
85
+ /**
86
+ * Optional consumer for `permission_result` system-message receipts.
87
+ *
88
+ * When `sender.id === "system"` and `payload.metadata.kind ===
89
+ * "permission_result"`, the guard calls this callback with the metadata
90
+ * object instead of performing the generic system-message drop. The
91
+ * callback is responsible for deduplication and synthetic-turn dispatch
92
+ * (see `src/permission-result.ts`).
93
+ *
94
+ * When absent, `permission_result` frames fall through to the existing
95
+ * system-message drop (safe default — no regression for callers that have
96
+ * not yet wired the consumer).
97
+ */
98
+ consumePermissionResult?: (metadata: PermissionResultMetadata) => Promise<void>;
84
99
  log?: { info?: (m: string) => void; error?: (m: string) => void };
85
100
  }
86
101
 
@@ -241,6 +256,19 @@ export async function dispatchOpenclawClawlingInbound(
241
256
  return;
242
257
  }
243
258
  if (sender.id === "system") {
259
+ // Intercept `permission_result` receipts before the generic system-message
260
+ // drop. The discriminator is `payload.metadata.kind === "permission_result"`;
261
+ // all other system messages (group-join notifications, etc.) still drop.
262
+ const rawPayload = envelope.payload as Record<string, unknown>;
263
+ const meta = rawPayload.metadata as Record<string, unknown> | undefined;
264
+ if (
265
+ params.consumePermissionResult &&
266
+ meta != null &&
267
+ meta.kind === "permission_result"
268
+ ) {
269
+ await params.consumePermissionResult(meta as unknown as PermissionResultMetadata);
270
+ return;
271
+ }
244
272
  log?.info?.(
245
273
  `[${account.accountId}] clawchat-plugin-openclaw skip system message msg=${payload.message_id}`,
246
274
  );
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Consumer for `permission_result` system-message receipts.
3
+ *
4
+ * When the backend delivers a `permission_result` outcome as a
5
+ * `message.send` system frame (`sender.id="system"`, `payload.metadata.kind
6
+ * ="permission_result"`), this module deduplicates by `request_id` and
7
+ * produces a synthetic context-note turn so the agent can close the loop
8
+ * on the original permission request.
9
+ *
10
+ * Wire shape (authoritative):
11
+ * ```
12
+ * payload.metadata = {
13
+ * kind: "permission_result",
14
+ * operation: "friend.add",
15
+ * outcome: "approved" | "denied" | "expired" | "failed",
16
+ * reason: "owner_allowed" | …,
17
+ * request_id: "prq_…",
18
+ * }
19
+ * ```
20
+ * The discriminator is `payload.metadata.kind === "permission_result"`.
21
+ */
22
+ import { EVENT, type Envelope } from "./protocol-types.ts";
23
+ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
24
+
25
+ export interface PermissionResultMetadata {
26
+ kind: "permission_result";
27
+ operation: string;
28
+ outcome: "approved" | "denied" | "expired" | "failed";
29
+ reason: string;
30
+ request_id: string;
31
+ }
32
+
33
+ // Module-level dedup set — `request_id` values are stable backend identifiers.
34
+ // A duplicate receipt (e.g. from WS replay on reconnect) must not re-trigger
35
+ // an agent turn.
36
+ const _seenRequestIds = new Set<string>();
37
+
38
+ /**
39
+ * @internal Test helper — reset the dedup set between isolated test runs.
40
+ * Never call this in production code.
41
+ */
42
+ export function _resetPermissionResultDedup(): void {
43
+ _seenRequestIds.clear();
44
+ }
45
+
46
+ function buildOutcomeNote(metadata: PermissionResultMetadata): string {
47
+ const { operation, outcome, reason } = metadata;
48
+ const outcomeLabel =
49
+ outcome === "approved"
50
+ ? "approved"
51
+ : outcome === "denied"
52
+ ? "denied"
53
+ : outcome === "expired"
54
+ ? "expired"
55
+ : "failed";
56
+ const resolution =
57
+ outcome === "approved"
58
+ ? "The requested action has been completed."
59
+ : "No further action was taken.";
60
+ return [
61
+ `Permission request for operation "${operation}" has been ${outcomeLabel}.`,
62
+ `Reason: ${reason}.`,
63
+ resolution,
64
+ ].join(" ");
65
+ }
66
+
67
+ /**
68
+ * Builds a synthetic `message.send` envelope that delivers a
69
+ * `permission_result` context note to the agent.
70
+ *
71
+ * Mirrors the `buildFriendRequestEnvelope` pattern: a synthetic inbound
72
+ * `message.send` targeting the owner's direct conversation, routed through
73
+ * `handleInboundEnvelope` so the agent can reason about the outcome.
74
+ */
75
+ export function buildPermissionResultEnvelope(params: {
76
+ account: Pick<ResolvedOpenclawClawlingAccount, "userId" | "ownerUserId">;
77
+ metadata: PermissionResultMetadata;
78
+ }): Envelope {
79
+ const { account, metadata } = params;
80
+ const text = buildOutcomeNote(metadata);
81
+ const now = Date.now();
82
+ return {
83
+ version: "2",
84
+ event: EVENT.MESSAGE_SEND,
85
+ trace_id: `clawchat-plugin-openclaw-permission-result-${metadata.request_id}-${now}`,
86
+ emitted_at: now,
87
+ chat_id: account.ownerUserId,
88
+ chat_type: "direct",
89
+ to: { id: account.userId, type: "direct" },
90
+ sender: {
91
+ id: "clawchat-permission-result",
92
+ type: "direct",
93
+ nick_name: "ClawChat",
94
+ },
95
+ payload: {
96
+ message_id: `clawchat-plugin-openclaw-permission-result-${metadata.request_id}-${now}`,
97
+ message_mode: "normal",
98
+ message: {
99
+ body: { fragments: [{ kind: "text", text }] },
100
+ context: { mentions: [], reply: null },
101
+ streaming: {
102
+ status: "static",
103
+ sequence: 0,
104
+ mutation_policy: "sealed",
105
+ started_at: null,
106
+ completed_at: null,
107
+ },
108
+ },
109
+ },
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Handles a `permission_result` receipt. Deduplicates by `request_id`
115
+ * (idempotent — WS replay on reconnect may deliver the same receipt again).
116
+ *
117
+ * Returns a synthetic context-note {@link Envelope} on a fresh receipt so
118
+ * the caller can feed it to `handleInboundEnvelope`; returns `null` when
119
+ * the `request_id` has already been processed.
120
+ */
121
+ export function handlePermissionResult(
122
+ metadata: PermissionResultMetadata,
123
+ account: Pick<ResolvedOpenclawClawlingAccount, "userId" | "ownerUserId">,
124
+ ): Envelope | null {
125
+ if (_seenRequestIds.has(metadata.request_id)) return null;
126
+ _seenRequestIds.add(metadata.request_id);
127
+ return buildPermissionResultEnvelope({ account, metadata });
128
+ }
@@ -0,0 +1,13 @@
1
+ export type PermState = "ask" | "allow" | "deny";
2
+
3
+ export interface PermissionPolicy {
4
+ byOperation: Record<string, PermState>;
5
+ momentVisibility: string[];
6
+ }
7
+
8
+ export class PermissionCache {
9
+ private p: PermissionPolicy = { byOperation: {}, momentVisibility: [] };
10
+ get(): PermissionPolicy { return this.p; }
11
+ set(p: PermissionPolicy): void { this.p = p; }
12
+ stateOf(op: string): PermState { return this.p.byOperation[op] ?? "ask"; }
13
+ }
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,
@@ -78,6 +79,9 @@ import {
78
79
  import { createGroupMessageCoalescer } from "./group-message-coalescer.ts";
79
80
  import { openclawLlmContextDebug } from "./llm-context-debug.ts";
80
81
  import { GroupSettingsCache } from "./group-settings.ts";
82
+ import { PermissionCache } from "./permissions.ts";
83
+ import { buildFriendRequestEnvelope } from "./friend-request-turn.ts";
84
+ import { handlePermissionResult } from "./permission-result.ts";
81
85
 
82
86
  type Log = { debug?: (m: string) => void; info?: (m: string) => void; error?: (m: string) => void };
83
87
  type ChannelContextBuilder = (params: Record<string, unknown>) => unknown;
@@ -1752,6 +1756,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1752
1756
  context: wsLogContext,
1753
1757
  });
1754
1758
  const groupSettingsCache = new GroupSettingsCache();
1759
+ const permissionCache = new PermissionCache();
1755
1760
  // Findings 2 & 3 — gate group dispatch on the IN-FLIGHT settings pull, not just
1756
1761
  // the first one. The pull is fire-and-forget, so replayed/live group messages
1757
1762
  // could otherwise enter the governance gate against a stale/empty cache
@@ -1829,6 +1834,28 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1829
1834
  }
1830
1835
  })();
1831
1836
  };
1837
+ // Best-effort pull of the agent's permission policy from the backend.
1838
+ // Called at startup (bypassRefresh: true, mirrors pullGroupSettings startup)
1839
+ // and on every successful (re)connect after hello-ok. On failure, logs and
1840
+ // keeps the stale cache — never throws, never blocks message handling.
1841
+ const pullPermissions = (opts?: { bypassRefresh?: boolean }): void => {
1842
+ void (async () => {
1843
+ try {
1844
+ const policy = opts?.bypassRefresh
1845
+ ? await (conversationApiClient ??= buildConversationApiClient()).getMyPermissions()
1846
+ : await getConversationApiClient().getMyPermissions();
1847
+ permissionCache.set(policy);
1848
+ log?.info?.(
1849
+ `[${accountId}] clawchat-plugin-openclaw permissions pull ok ops=${Object.keys(policy.byOperation).length}`,
1850
+ );
1851
+ } catch (err) {
1852
+ log?.error?.(
1853
+ `[${accountId}] clawchat-plugin-openclaw permissions pull failed: ${err instanceof Error ? err.message : String(err)}`,
1854
+ );
1855
+ // Keep stale cache; never throw.
1856
+ }
1857
+ })();
1858
+ };
1832
1859
  const logAuthFailure = (reason: string) => {
1833
1860
  if (authFailureLogged) return;
1834
1861
  authFailureLogged = true;
@@ -1962,6 +1989,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1962
1989
  // Re-pull per-group agent settings on every successful (re)connect so the
1963
1990
  // cache stays current after a network interruption or token refresh.
1964
1991
  pullGroupSettings();
1992
+ // Re-pull permission policy alongside group settings on every (re)connect.
1993
+ pullPermissions();
1965
1994
  // §A.1 — arm the proactive refresh timer from the live token's `exp`
1966
1995
  // every time a connection becomes ready (re-armed after every refresh via
1967
1996
  // the gateway re-enter).
@@ -2126,6 +2155,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2126
2155
  // The bundled `./skills` dir is READ-ONLY here: a first-boot fallback for the
2127
2156
  // "current installed version" only — never written (it lives in node_modules
2128
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.
2129
2162
  const skillUpdateConsent = new PendingConsentStore();
2130
2163
  const managedSkillsDir = resolveManagedSkillsDir();
2131
2164
  let bundledSkillsDir: string | null = null;
@@ -2138,13 +2171,30 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2138
2171
  }`,
2139
2172
  );
2140
2173
  }
2141
- 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 => {
2142
2179
  const ownerUserId = account.ownerUserId?.trim();
2143
- 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
+ }
2144
2194
  await sendOpenclawClawlingText({
2145
2195
  client,
2146
2196
  account,
2147
- to: { chatId: ownerUserId, chatType: "direct" },
2197
+ to: { chatId: conversationId, chatType: "direct" },
2148
2198
  text,
2149
2199
  log,
2150
2200
  });
@@ -2158,10 +2208,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2158
2208
  return;
2159
2209
  }
2160
2210
  // Effective current = managed copy if present, else bundled (host precedence).
2161
- const localVersions = await readEffectiveSkillVersions(managedSkillsDir, bundledSkillsDir);
2162
2211
  await runSkillUpdateCheck({
2163
2212
  ownerUserId,
2164
- localVersions,
2213
+ readLocalState: (id) => readLocalSkillState(managedSkillsDir, bundledSkillsDir, id),
2214
+ hasManagedSkill: (id) => managedSkillExists(managedSkillsDir, id),
2165
2215
  fetchFn: globalThis.fetch,
2166
2216
  store: skillUpdateConsent,
2167
2217
  sendOwnerMessage: sendOwnerSkillMessage,
@@ -2193,6 +2243,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2193
2243
  });
2194
2244
  };
2195
2245
 
2246
+ // Debounce flag for lighter-awareness consolidated notes.
2247
+ // A single queueMicrotask coalesces all awareness signals that arrive in
2248
+ // the same synchronous turn into exactly one handleInboundEnvelope call.
2249
+ let pendingAwarenessNote = false;
2250
+
2196
2251
  client.on("notify:signal", (env: Envelope) => {
2197
2252
  // §9.4 reliable system notification. The plugin holds no friend/roster
2198
2253
  // cache (friends are fetched on demand via REST tools), so there is nothing
@@ -2204,11 +2259,17 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2204
2259
  ? (env.payload as Record<string, unknown>)
2205
2260
  : undefined;
2206
2261
  const type = typeof payload?.type === "string" ? payload.type : "";
2262
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
2207
2263
  if (type === "agent.config.changed") {
2208
2264
  log?.info?.(
2209
2265
  `[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`,
2210
2266
  );
2211
2267
  pullGroupSettings();
2268
+ } else if (type === "agent.permission.changed") {
2269
+ log?.info?.(
2270
+ `[${accountId}] clawchat-plugin-openclaw agent.permission.changed signal received; re-pulling permissions`,
2271
+ );
2272
+ pullPermissions();
2212
2273
  } else if (type === "clawchat.skill.update.check") {
2213
2274
  // Content-free trigger: check the official skill source in code and, if
2214
2275
  // there is an update, ask the owner in chat for consent. The atomic
@@ -2223,6 +2284,66 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2223
2284
  }`,
2224
2285
  );
2225
2286
  });
2287
+ } else if (type === "friend.request") {
2288
+ // Dedup is already applied above by notifySignalObserver.observe (the live
2289
+ // frame and its reliable-inbox replay share the same event_id and collapse
2290
+ // to one observation). Build one synthetic reasoning turn so the agent can
2291
+ // act on the pending request; the prompt text varies by policy state.
2292
+ const state = permissionCache.stateOf("friend.add");
2293
+ log?.info?.(
2294
+ `[${accountId}] clawchat-plugin-openclaw friend.request signal received; dispatching reasoning turn entity_id=${entityId} policy=${state}`,
2295
+ );
2296
+ void handleInboundEnvelope(buildFriendRequestEnvelope({ account, state, entityId }));
2297
+ } else if (
2298
+ type === "friend.added" ||
2299
+ type === "friend.removed" ||
2300
+ type === "friend.profile_updated" ||
2301
+ type.startsWith("conversation.")
2302
+ ) {
2303
+ // Lighter-awareness: log + optional single consolidated note. Never
2304
+ // injects a full reasoning turn per event (unlike friend.request).
2305
+ log?.info?.(
2306
+ `[${accountId}] clawchat-plugin-openclaw ${type} signal received; lighter-awareness handling`,
2307
+ );
2308
+ if (account.awarenessNote && !pendingAwarenessNote) {
2309
+ pendingAwarenessNote = true;
2310
+ queueMicrotask(() => {
2311
+ pendingAwarenessNote = false;
2312
+ const now = Date.now();
2313
+ void handleInboundEnvelope({
2314
+ version: "2",
2315
+ event: EVENT.MESSAGE_SEND,
2316
+ trace_id: `clawchat-plugin-openclaw-awareness-${now}`,
2317
+ emitted_at: now,
2318
+ chat_id: account.ownerUserId,
2319
+ chat_type: "direct",
2320
+ to: { id: account.userId, type: "direct" },
2321
+ sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
2322
+ payload: {
2323
+ message_id: `clawchat-plugin-openclaw-awareness-${now}`,
2324
+ message_mode: "normal",
2325
+ message: {
2326
+ body: {
2327
+ fragments: [
2328
+ {
2329
+ kind: "text",
2330
+ text: "ClawChat awareness: your contacts or conversations have been updated.",
2331
+ },
2332
+ ],
2333
+ },
2334
+ context: { mentions: [], reply: null },
2335
+ streaming: {
2336
+ status: "static",
2337
+ sequence: 0,
2338
+ mutation_policy: "sealed",
2339
+ started_at: null,
2340
+ completed_at: null,
2341
+ },
2342
+ },
2343
+ },
2344
+ } as unknown as Envelope);
2345
+ });
2346
+ }
2226
2347
  }
2227
2348
  }
2228
2349
  });
@@ -2787,10 +2908,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2787
2908
  });
2788
2909
 
2789
2910
  const forwardGroupApprovalSlashToOwner = async (turn: IngestTurnParams): Promise<IngestTurnResult> => {
2790
- const ownerUserId = account.ownerUserId?.trim();
2791
- if (!ownerUserId) {
2911
+ const ownerConversationId = resolveOwnerDirectConversationId();
2912
+ if (!ownerConversationId) {
2792
2913
  log?.error?.(
2793
- `[${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}`,
2794
2915
  );
2795
2916
  return "skipped";
2796
2917
  }
@@ -2798,7 +2919,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2798
2919
  await sendOpenclawClawlingText({
2799
2920
  client,
2800
2921
  account,
2801
- to: { chatId: ownerUserId, chatType: "direct" },
2922
+ to: { chatId: ownerConversationId, chatType: "direct" },
2802
2923
  text: groupOwnerAttentionText(turn.peer.id, turn.rawBody),
2803
2924
  log,
2804
2925
  });
@@ -2924,6 +3045,18 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2924
3045
  account,
2925
3046
  log,
2926
3047
  consumeOwnerConsent: consumeOwnerSkillConsent,
3048
+ consumePermissionResult: async (metadata) => {
3049
+ // A permission_result receipt closes the loop on an earlier request.
3050
+ // Dedup by request_id; on a fresh receipt feed the synthetic context
3051
+ // note back to the agent so it can react to the outcome.
3052
+ const note = handlePermissionResult(metadata, account);
3053
+ if (note) {
3054
+ log?.info?.(
3055
+ `[${accountId}] clawchat-plugin-openclaw permission_result receipt op=${metadata.operation} outcome=${metadata.outcome} request_id=${metadata.request_id}; dispatching context note`,
3056
+ );
3057
+ void handleInboundEnvelope(note);
3058
+ }
3059
+ },
2927
3060
  ingest: async (turn) => {
2928
3061
  ingestResult = await ingestTurn(turn);
2929
3062
  },
@@ -3020,6 +3153,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3020
3153
  // rotating `account.token` out from under the already-constructed WS client.
3021
3154
  // Settling this releases the Finding-2 group-dispatch gate.
3022
3155
  pullGroupSettings({ bypassRefresh: true });
3156
+ // Initial pull of permission policy at startup (best-effort; errors logged,
3157
+ // stale cache kept). Uses bypassRefresh for the same reason as pullGroupSettings.
3158
+ pullPermissions({ bypassRefresh: true });
3023
3159
  // Safety: never strand a held group message if the runtime is torn down while a
3024
3160
  // pull is in flight. `waitWithTimeout` already releases group waiters on abort,
3025
3161
  // but force the gate open too so any later `wait()` resolves immediately.