@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.
@@ -196,6 +196,7 @@ export function createOpenclawClawlingApiClient(opts) {
196
196
  code,
197
197
  status: res.status,
198
198
  path,
199
+ ...(env.data !== undefined ? { data: env.data } : {}),
199
200
  });
200
201
  }
201
202
  return env.data;
@@ -516,5 +517,17 @@ export function createOpenclawClawlingApiClient(opts) {
516
517
  }
517
518
  return { authoritative: true, rows };
518
519
  },
520
+ async getMyPermissions() {
521
+ const data = await call("GET", "/v1/agents/me/permissions");
522
+ const byOperation = {};
523
+ let momentVisibility = [];
524
+ for (const [k, v] of Object.entries(data ?? {})) {
525
+ if (k === "moment.visibility")
526
+ momentVisibility = v ?? [];
527
+ else
528
+ byOperation[k] = v;
529
+ }
530
+ return { byOperation, momentVisibility };
531
+ },
519
532
  };
520
533
  }
@@ -81,6 +81,7 @@ export const openclawClawlingConfigSchema = {
81
81
  forwardThinking: { type: "boolean" },
82
82
  forwardToolCalls: { type: "boolean" },
83
83
  richInteractions: { type: "boolean" },
84
+ awarenessNote: { type: "boolean" },
84
85
  reconnect: {
85
86
  type: "object",
86
87
  additionalProperties: false,
@@ -206,6 +207,28 @@ function decodeJwtStringClaim(token, claim) {
206
207
  return "";
207
208
  }
208
209
  }
210
+ /**
211
+ * Resolve the bot-account userId, validated against the token's `sub` claim.
212
+ *
213
+ * The WS session authenticates with the access token, so the backend binds the
214
+ * session to the token's `sub` — that IS the bot's user id for this session.
215
+ * Unlike ownerUserId (explicit config wins over the `oid` claim), a configured
216
+ * userId that disagrees with `sub` is always wrong for the session this token
217
+ * creates, and it silently breaks self-echo detection: with the OWNER's id
218
+ * here, every owner message is dropped as the bot's own echo (2026-07-02
219
+ * clawnest incident — a provisioner wrote the owner id into the config). Token
220
+ * wins; the mismatch is logged. Also derives userId from `sub` when nothing is
221
+ * configured, mirroring the `oid` fallback above.
222
+ */
223
+ function resolveUserId(token, configured) {
224
+ const tokenSub = decodeJwtStringClaim(token, "sub");
225
+ if (!tokenSub)
226
+ return configured;
227
+ if (configured && configured !== tokenSub) {
228
+ console.warn(`clawchat userId mismatch: configured ${configured} does not equal the access token's sub claim ${tokenSub}; using the token value. A misconfigured userId (e.g. the owner's id) breaks self-echo detection and mention gating — fix the channel userId / CLAWCHAT_USER_ID.`);
229
+ }
230
+ return tokenSub;
231
+ }
209
232
  function readGroupMode(value) {
210
233
  return value === "mention" ? "mention" : "all";
211
234
  }
@@ -311,7 +334,7 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
311
334
  const mediaBaseUrl = readOptionalString(channel.mediaBaseUrl) || readEnvString(env, CLAWCHAT_MEDIA_BASE_URL_ENV);
312
335
  const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
313
336
  const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
314
- const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
337
+ const userId = resolveUserId(token, readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV));
315
338
  const ownerUserId = readOptionalString(channel.ownerUserId) ||
316
339
  readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV) ||
317
340
  // Fall back to the access token's `oid` (owner) claim. A provisioner may
@@ -328,6 +351,7 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
328
351
  const forwardThinking = typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
329
352
  const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
330
353
  const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
354
+ const awarenessNote = typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
331
355
  return {
332
356
  accountId: DEFAULT_ACCOUNT_ID,
333
357
  name: CHANNEL_ID,
@@ -353,6 +377,7 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
353
377
  forwardThinking,
354
378
  forwardToolCalls,
355
379
  richInteractions,
380
+ awarenessNote,
356
381
  allowFrom: [],
357
382
  reconnect: readReconnect(channel.reconnect),
358
383
  heartbeat: readHeartbeat(channel.heartbeat),
@@ -0,0 +1,73 @@
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 } from "./protocol-types.js";
12
+ /**
13
+ * Returns canned prompt text for a friend-request reasoning turn based on the
14
+ * current "friend.add" permission policy state.
15
+ *
16
+ * - `deny` → inform the agent it should decline; no accept instruction.
17
+ * - `ask` / `allow` → instruct the agent to review the pending request.
18
+ */
19
+ export function friendRequestPromptFor(state) {
20
+ if (state === "deny") {
21
+ return [
22
+ "A new friend request has arrived.",
23
+ "Your current friend-add policy is set to deny.",
24
+ "Do not add this contact.",
25
+ "You may inform the requester that you cannot add them at this time.",
26
+ ].join(" ");
27
+ }
28
+ return [
29
+ "A new friend request has arrived.",
30
+ "Please review the pending request by calling `clawchat_list_friend_requests`",
31
+ "and decide whether to accept it.",
32
+ ].join(" ");
33
+ }
34
+ /**
35
+ * Builds a synthetic `message.send` envelope that triggers one agent reasoning
36
+ * turn for a pending friend request. The envelope shape mirrors the
37
+ * activation-bootstrap envelope; it targets the owner's direct conversation so
38
+ * the agent has context about who it is reasoning for.
39
+ */
40
+ export function buildFriendRequestEnvelope(params) {
41
+ const { account, state, entityId } = params;
42
+ const text = friendRequestPromptFor(state);
43
+ const now = Date.now();
44
+ return {
45
+ version: "2",
46
+ event: EVENT.MESSAGE_SEND,
47
+ trace_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
48
+ emitted_at: now,
49
+ chat_id: account.ownerUserId,
50
+ chat_type: "direct",
51
+ to: { id: account.userId, type: "direct" },
52
+ sender: {
53
+ id: "clawchat-friend-request",
54
+ type: "direct",
55
+ nick_name: "ClawChat",
56
+ },
57
+ payload: {
58
+ message_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
59
+ message_mode: "normal",
60
+ message: {
61
+ body: { fragments: [{ kind: "text", text }] },
62
+ context: { mentions: [], reply: null },
63
+ streaming: {
64
+ status: "static",
65
+ sequence: 0,
66
+ mutation_policy: "sealed",
67
+ started_at: null,
68
+ completed_at: null,
69
+ },
70
+ },
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,26 @@
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
+ export function mapGateOutcome(code, data) {
11
+ if (code === 21001) {
12
+ return {
13
+ status: "pending_owner_approval",
14
+ request_id: typeof data.request_id === "string" ? data.request_id : "",
15
+ operation: typeof data.operation === "string" ? data.operation : "",
16
+ expires_at: typeof data.expires_at === "number" ? data.expires_at : 0,
17
+ };
18
+ }
19
+ if (code === 21003) {
20
+ return {
21
+ status: "forbidden_by_owner",
22
+ operation: typeof data.operation === "string" ? data.operation : "",
23
+ };
24
+ }
25
+ return null;
26
+ }
@@ -115,6 +115,17 @@ export async function dispatchOpenclawClawlingInbound(params) {
115
115
  return;
116
116
  }
117
117
  if (sender.id === "system") {
118
+ // Intercept `permission_result` receipts before the generic system-message
119
+ // drop. The discriminator is `payload.metadata.kind === "permission_result"`;
120
+ // all other system messages (group-join notifications, etc.) still drop.
121
+ const rawPayload = envelope.payload;
122
+ const meta = rawPayload.metadata;
123
+ if (params.consumePermissionResult &&
124
+ meta != null &&
125
+ meta.kind === "permission_result") {
126
+ await params.consumePermissionResult(meta);
127
+ return;
128
+ }
118
129
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip system message msg=${payload.message_id}`);
119
130
  return;
120
131
  }
@@ -0,0 +1,107 @@
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 } from "./protocol-types.js";
23
+ // Module-level dedup set — `request_id` values are stable backend identifiers.
24
+ // A duplicate receipt (e.g. from WS replay on reconnect) must not re-trigger
25
+ // an agent turn.
26
+ const _seenRequestIds = new Set();
27
+ /**
28
+ * @internal Test helper — reset the dedup set between isolated test runs.
29
+ * Never call this in production code.
30
+ */
31
+ export function _resetPermissionResultDedup() {
32
+ _seenRequestIds.clear();
33
+ }
34
+ function buildOutcomeNote(metadata) {
35
+ const { operation, outcome, reason } = metadata;
36
+ const outcomeLabel = outcome === "approved"
37
+ ? "approved"
38
+ : outcome === "denied"
39
+ ? "denied"
40
+ : outcome === "expired"
41
+ ? "expired"
42
+ : "failed";
43
+ const resolution = outcome === "approved"
44
+ ? "The requested action has been completed."
45
+ : "No further action was taken.";
46
+ return [
47
+ `Permission request for operation "${operation}" has been ${outcomeLabel}.`,
48
+ `Reason: ${reason}.`,
49
+ resolution,
50
+ ].join(" ");
51
+ }
52
+ /**
53
+ * Builds a synthetic `message.send` envelope that delivers a
54
+ * `permission_result` context note to the agent.
55
+ *
56
+ * Mirrors the `buildFriendRequestEnvelope` pattern: a synthetic inbound
57
+ * `message.send` targeting the owner's direct conversation, routed through
58
+ * `handleInboundEnvelope` so the agent can reason about the outcome.
59
+ */
60
+ export function buildPermissionResultEnvelope(params) {
61
+ const { account, metadata } = params;
62
+ const text = buildOutcomeNote(metadata);
63
+ const now = Date.now();
64
+ return {
65
+ version: "2",
66
+ event: EVENT.MESSAGE_SEND,
67
+ trace_id: `clawchat-plugin-openclaw-permission-result-${metadata.request_id}-${now}`,
68
+ emitted_at: now,
69
+ chat_id: account.ownerUserId,
70
+ chat_type: "direct",
71
+ to: { id: account.userId, type: "direct" },
72
+ sender: {
73
+ id: "clawchat-permission-result",
74
+ type: "direct",
75
+ nick_name: "ClawChat",
76
+ },
77
+ payload: {
78
+ message_id: `clawchat-plugin-openclaw-permission-result-${metadata.request_id}-${now}`,
79
+ message_mode: "normal",
80
+ message: {
81
+ body: { fragments: [{ kind: "text", text }] },
82
+ context: { mentions: [], reply: null },
83
+ streaming: {
84
+ status: "static",
85
+ sequence: 0,
86
+ mutation_policy: "sealed",
87
+ started_at: null,
88
+ completed_at: null,
89
+ },
90
+ },
91
+ },
92
+ };
93
+ }
94
+ /**
95
+ * Handles a `permission_result` receipt. Deduplicates by `request_id`
96
+ * (idempotent — WS replay on reconnect may deliver the same receipt again).
97
+ *
98
+ * Returns a synthetic context-note {@link Envelope} on a fresh receipt so
99
+ * the caller can feed it to `handleInboundEnvelope`; returns `null` when
100
+ * the `request_id` has already been processed.
101
+ */
102
+ export function handlePermissionResult(metadata, account) {
103
+ if (_seenRequestIds.has(metadata.request_id))
104
+ return null;
105
+ _seenRequestIds.add(metadata.request_id);
106
+ return buildPermissionResultEnvelope({ account, metadata });
107
+ }
@@ -0,0 +1,6 @@
1
+ export class PermissionCache {
2
+ p = { byOperation: {}, momentVisibility: [] };
3
+ get() { return this.p; }
4
+ set(p) { this.p = p; }
5
+ stateOf(op) { return this.p.byOperation[op] ?? "ask"; }
6
+ }
@@ -28,6 +28,9 @@ import { clearClawChatPromptInjectionForSession, stageClawChatPromptInjection, }
28
28
  import { createGroupMessageCoalescer } from "./group-message-coalescer.js";
29
29
  import { openclawLlmContextDebug } from "./llm-context-debug.js";
30
30
  import { GroupSettingsCache } from "./group-settings.js";
31
+ import { PermissionCache } from "./permissions.js";
32
+ import { buildFriendRequestEnvelope } from "./friend-request-turn.js";
33
+ import { handlePermissionResult } from "./permission-result.js";
31
34
  const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingRuntime } = createPluginRuntimeStore("clawchat-plugin-openclaw runtime not initialized");
32
35
  export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
33
36
  const activeClients = new Map();
@@ -1381,6 +1384,7 @@ export async function startOpenclawClawlingGateway(params) {
1381
1384
  context: wsLogContext,
1382
1385
  });
1383
1386
  const groupSettingsCache = new GroupSettingsCache();
1387
+ const permissionCache = new PermissionCache();
1384
1388
  // Findings 2 & 3 — gate group dispatch on the IN-FLIGHT settings pull, not just
1385
1389
  // the first one. The pull is fire-and-forget, so replayed/live group messages
1386
1390
  // could otherwise enter the governance gate against a stale/empty cache
@@ -1454,6 +1458,25 @@ export async function startOpenclawClawlingGateway(params) {
1454
1458
  }
1455
1459
  })();
1456
1460
  };
1461
+ // Best-effort pull of the agent's permission policy from the backend.
1462
+ // Called at startup (bypassRefresh: true, mirrors pullGroupSettings startup)
1463
+ // and on every successful (re)connect after hello-ok. On failure, logs and
1464
+ // keeps the stale cache — never throws, never blocks message handling.
1465
+ const pullPermissions = (opts) => {
1466
+ void (async () => {
1467
+ try {
1468
+ const policy = opts?.bypassRefresh
1469
+ ? await (conversationApiClient ??= buildConversationApiClient()).getMyPermissions()
1470
+ : await getConversationApiClient().getMyPermissions();
1471
+ permissionCache.set(policy);
1472
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw permissions pull ok ops=${Object.keys(policy.byOperation).length}`);
1473
+ }
1474
+ catch (err) {
1475
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw permissions pull failed: ${err instanceof Error ? err.message : String(err)}`);
1476
+ // Keep stale cache; never throw.
1477
+ }
1478
+ })();
1479
+ };
1457
1480
  const logAuthFailure = (reason) => {
1458
1481
  if (authFailureLogged)
1459
1482
  return;
@@ -1585,6 +1608,8 @@ export async function startOpenclawClawlingGateway(params) {
1585
1608
  // Re-pull per-group agent settings on every successful (re)connect so the
1586
1609
  // cache stays current after a network interruption or token refresh.
1587
1610
  pullGroupSettings();
1611
+ // Re-pull permission policy alongside group settings on every (re)connect.
1612
+ pullPermissions();
1588
1613
  // §A.1 — arm the proactive refresh timer from the live token's `exp`
1589
1614
  // every time a connection becomes ready (re-armed after every refresh via
1590
1615
  // the gateway re-enter).
@@ -1746,14 +1771,29 @@ export async function startOpenclawClawlingGateway(params) {
1746
1771
  catch (err) {
1747
1772
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw skill-update: bundled skills dir not resolvable (using managed-only current): ${err instanceof Error ? err.message : String(err)}`);
1748
1773
  }
1749
- const sendOwnerSkillMessage = async (text) => {
1774
+ // A usr_... id is not a valid chat_id — member-backend rejects it with
1775
+ // "invalid conversation id" and msghub never acks the send. Proactive owner
1776
+ // messages must be addressed to the direct conversation recorded at
1777
+ // activation (parity with the hermes adapter's _owner_direct_chat_id).
1778
+ const resolveOwnerDirectConversationId = () => {
1750
1779
  const ownerUserId = account.ownerUserId?.trim();
1751
1780
  if (!ownerUserId)
1781
+ return null;
1782
+ const conversationId = store
1783
+ ?.getActivationConversation?.({ platform: "openclaw", accountId })
1784
+ ?.conversationId?.trim();
1785
+ return conversationId || null;
1786
+ };
1787
+ const sendOwnerSkillMessage = async (text) => {
1788
+ const conversationId = resolveOwnerDirectConversationId();
1789
+ if (!conversationId) {
1790
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw skill-update owner message skipped: no activation conversation recorded`);
1752
1791
  return;
1792
+ }
1753
1793
  await sendOpenclawClawlingText({
1754
1794
  client,
1755
1795
  account,
1756
- to: { chatId: ownerUserId, chatType: "direct" },
1796
+ to: { chatId: conversationId, chatType: "direct" },
1757
1797
  text,
1758
1798
  log,
1759
1799
  });
@@ -1796,6 +1836,10 @@ export async function startOpenclawClawlingGateway(params) {
1796
1836
  },
1797
1837
  });
1798
1838
  };
1839
+ // Debounce flag for lighter-awareness consolidated notes.
1840
+ // A single queueMicrotask coalesces all awareness signals that arrive in
1841
+ // the same synchronous turn into exactly one handleInboundEnvelope call.
1842
+ let pendingAwarenessNote = false;
1799
1843
  client.on("notify:signal", (env) => {
1800
1844
  // §9.4 reliable system notification. The plugin holds no friend/roster
1801
1845
  // cache (friends are fetched on demand via REST tools), so there is nothing
@@ -1807,10 +1851,15 @@ export async function startOpenclawClawlingGateway(params) {
1807
1851
  ? env.payload
1808
1852
  : undefined;
1809
1853
  const type = typeof payload?.type === "string" ? payload.type : "";
1854
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
1810
1855
  if (type === "agent.config.changed") {
1811
1856
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`);
1812
1857
  pullGroupSettings();
1813
1858
  }
1859
+ else if (type === "agent.permission.changed") {
1860
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw agent.permission.changed signal received; re-pulling permissions`);
1861
+ pullPermissions();
1862
+ }
1814
1863
  else if (type === "clawchat.skill.update.check") {
1815
1864
  // Content-free trigger: check the official skill source in code and, if
1816
1865
  // there is an update, ask the owner in chat for consent. The atomic
@@ -1820,6 +1869,62 @@ export async function startOpenclawClawlingGateway(params) {
1820
1869
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw skill-update check failed: ${err instanceof Error ? err.message : String(err)}`);
1821
1870
  });
1822
1871
  }
1872
+ else if (type === "friend.request") {
1873
+ // Dedup is already applied above by notifySignalObserver.observe (the live
1874
+ // frame and its reliable-inbox replay share the same event_id and collapse
1875
+ // to one observation). Build one synthetic reasoning turn so the agent can
1876
+ // act on the pending request; the prompt text varies by policy state.
1877
+ const state = permissionCache.stateOf("friend.add");
1878
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw friend.request signal received; dispatching reasoning turn entity_id=${entityId} policy=${state}`);
1879
+ void handleInboundEnvelope(buildFriendRequestEnvelope({ account, state, entityId }));
1880
+ }
1881
+ else if (type === "friend.added" ||
1882
+ type === "friend.removed" ||
1883
+ type === "friend.profile_updated" ||
1884
+ type.startsWith("conversation.")) {
1885
+ // Lighter-awareness: log + optional single consolidated note. Never
1886
+ // injects a full reasoning turn per event (unlike friend.request).
1887
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${type} signal received; lighter-awareness handling`);
1888
+ if (account.awarenessNote && !pendingAwarenessNote) {
1889
+ pendingAwarenessNote = true;
1890
+ queueMicrotask(() => {
1891
+ pendingAwarenessNote = false;
1892
+ const now = Date.now();
1893
+ void handleInboundEnvelope({
1894
+ version: "2",
1895
+ event: EVENT.MESSAGE_SEND,
1896
+ trace_id: `clawchat-plugin-openclaw-awareness-${now}`,
1897
+ emitted_at: now,
1898
+ chat_id: account.ownerUserId,
1899
+ chat_type: "direct",
1900
+ to: { id: account.userId, type: "direct" },
1901
+ sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
1902
+ payload: {
1903
+ message_id: `clawchat-plugin-openclaw-awareness-${now}`,
1904
+ message_mode: "normal",
1905
+ message: {
1906
+ body: {
1907
+ fragments: [
1908
+ {
1909
+ kind: "text",
1910
+ text: "ClawChat awareness: your contacts or conversations have been updated.",
1911
+ },
1912
+ ],
1913
+ },
1914
+ context: { mentions: [], reply: null },
1915
+ streaming: {
1916
+ status: "static",
1917
+ sequence: 0,
1918
+ mutation_policy: "sealed",
1919
+ started_at: null,
1920
+ completed_at: null,
1921
+ },
1922
+ },
1923
+ },
1924
+ });
1925
+ });
1926
+ }
1927
+ }
1823
1928
  }
1824
1929
  });
1825
1930
  client.on("replay:done", (env) => {
@@ -2318,16 +2423,16 @@ export async function startOpenclawClawlingGateway(params) {
2318
2423
  },
2319
2424
  });
2320
2425
  const forwardGroupApprovalSlashToOwner = async (turn) => {
2321
- const ownerUserId = account.ownerUserId?.trim();
2322
- if (!ownerUserId) {
2323
- log?.error?.(`[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_user_id chat_id=${turn.peer.id} msg=${turn.messageId}`);
2426
+ const ownerConversationId = resolveOwnerDirectConversationId();
2427
+ if (!ownerConversationId) {
2428
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_conversation chat_id=${turn.peer.id} msg=${turn.messageId}`);
2324
2429
  return "skipped";
2325
2430
  }
2326
2431
  try {
2327
2432
  await sendOpenclawClawlingText({
2328
2433
  client,
2329
2434
  account,
2330
- to: { chatId: ownerUserId, chatType: "direct" },
2435
+ to: { chatId: ownerConversationId, chatType: "direct" },
2331
2436
  text: groupOwnerAttentionText(turn.peer.id, turn.rawBody),
2332
2437
  log,
2333
2438
  });
@@ -2439,6 +2544,16 @@ export async function startOpenclawClawlingGateway(params) {
2439
2544
  account,
2440
2545
  log,
2441
2546
  consumeOwnerConsent: consumeOwnerSkillConsent,
2547
+ consumePermissionResult: async (metadata) => {
2548
+ // A permission_result receipt closes the loop on an earlier request.
2549
+ // Dedup by request_id; on a fresh receipt feed the synthetic context
2550
+ // note back to the agent so it can react to the outcome.
2551
+ const note = handlePermissionResult(metadata, account);
2552
+ if (note) {
2553
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw permission_result receipt op=${metadata.operation} outcome=${metadata.outcome} request_id=${metadata.request_id}; dispatching context note`);
2554
+ void handleInboundEnvelope(note);
2555
+ }
2556
+ },
2442
2557
  ingest: async (turn) => {
2443
2558
  ingestResult = await ingestTurn(turn);
2444
2559
  },
@@ -2525,6 +2640,9 @@ export async function startOpenclawClawlingGateway(params) {
2525
2640
  // rotating `account.token` out from under the already-constructed WS client.
2526
2641
  // Settling this releases the Finding-2 group-dispatch gate.
2527
2642
  pullGroupSettings({ bypassRefresh: true });
2643
+ // Initial pull of permission policy at startup (best-effort; errors logged,
2644
+ // stale cache kept). Uses bypassRefresh for the same reason as pullGroupSettings.
2645
+ pullPermissions({ bypassRefresh: true });
2528
2646
  // Safety: never strand a held group message if the runtime is torn down while a
2529
2647
  // pull is in flight. `waitWithTimeout` already releases group waiters on abort,
2530
2648
  // but force the gate open too so any later `wait()` resolves immediately.