@clawling/clawchat-plugin-openclaw 2026.6.30-4 → 2026.7.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.
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),
@@ -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
@@ -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).
@@ -2193,6 +2221,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2193
2221
  });
2194
2222
  };
2195
2223
 
2224
+ // Debounce flag for lighter-awareness consolidated notes.
2225
+ // A single queueMicrotask coalesces all awareness signals that arrive in
2226
+ // the same synchronous turn into exactly one handleInboundEnvelope call.
2227
+ let pendingAwarenessNote = false;
2228
+
2196
2229
  client.on("notify:signal", (env: Envelope) => {
2197
2230
  // §9.4 reliable system notification. The plugin holds no friend/roster
2198
2231
  // cache (friends are fetched on demand via REST tools), so there is nothing
@@ -2204,11 +2237,17 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2204
2237
  ? (env.payload as Record<string, unknown>)
2205
2238
  : undefined;
2206
2239
  const type = typeof payload?.type === "string" ? payload.type : "";
2240
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
2207
2241
  if (type === "agent.config.changed") {
2208
2242
  log?.info?.(
2209
2243
  `[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`,
2210
2244
  );
2211
2245
  pullGroupSettings();
2246
+ } else if (type === "agent.permission.changed") {
2247
+ log?.info?.(
2248
+ `[${accountId}] clawchat-plugin-openclaw agent.permission.changed signal received; re-pulling permissions`,
2249
+ );
2250
+ pullPermissions();
2212
2251
  } else if (type === "clawchat.skill.update.check") {
2213
2252
  // Content-free trigger: check the official skill source in code and, if
2214
2253
  // there is an update, ask the owner in chat for consent. The atomic
@@ -2223,6 +2262,66 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2223
2262
  }`,
2224
2263
  );
2225
2264
  });
2265
+ } else if (type === "friend.request") {
2266
+ // Dedup is already applied above by notifySignalObserver.observe (the live
2267
+ // frame and its reliable-inbox replay share the same event_id and collapse
2268
+ // to one observation). Build one synthetic reasoning turn so the agent can
2269
+ // act on the pending request; the prompt text varies by policy state.
2270
+ const state = permissionCache.stateOf("friend.add");
2271
+ log?.info?.(
2272
+ `[${accountId}] clawchat-plugin-openclaw friend.request signal received; dispatching reasoning turn entity_id=${entityId} policy=${state}`,
2273
+ );
2274
+ void handleInboundEnvelope(buildFriendRequestEnvelope({ account, state, entityId }));
2275
+ } else if (
2276
+ type === "friend.added" ||
2277
+ type === "friend.removed" ||
2278
+ type === "friend.profile_updated" ||
2279
+ type.startsWith("conversation.")
2280
+ ) {
2281
+ // Lighter-awareness: log + optional single consolidated note. Never
2282
+ // injects a full reasoning turn per event (unlike friend.request).
2283
+ log?.info?.(
2284
+ `[${accountId}] clawchat-plugin-openclaw ${type} signal received; lighter-awareness handling`,
2285
+ );
2286
+ if (account.awarenessNote && !pendingAwarenessNote) {
2287
+ pendingAwarenessNote = true;
2288
+ queueMicrotask(() => {
2289
+ pendingAwarenessNote = false;
2290
+ const now = Date.now();
2291
+ void handleInboundEnvelope({
2292
+ version: "2",
2293
+ event: EVENT.MESSAGE_SEND,
2294
+ trace_id: `clawchat-plugin-openclaw-awareness-${now}`,
2295
+ emitted_at: now,
2296
+ chat_id: account.ownerUserId,
2297
+ chat_type: "direct",
2298
+ to: { id: account.userId, type: "direct" },
2299
+ sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
2300
+ payload: {
2301
+ message_id: `clawchat-plugin-openclaw-awareness-${now}`,
2302
+ message_mode: "normal",
2303
+ message: {
2304
+ body: {
2305
+ fragments: [
2306
+ {
2307
+ kind: "text",
2308
+ text: "ClawChat awareness: your contacts or conversations have been updated.",
2309
+ },
2310
+ ],
2311
+ },
2312
+ context: { mentions: [], reply: null },
2313
+ streaming: {
2314
+ status: "static",
2315
+ sequence: 0,
2316
+ mutation_policy: "sealed",
2317
+ started_at: null,
2318
+ completed_at: null,
2319
+ },
2320
+ },
2321
+ },
2322
+ } as unknown as Envelope);
2323
+ });
2324
+ }
2226
2325
  }
2227
2326
  }
2228
2327
  });
@@ -2924,6 +3023,18 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2924
3023
  account,
2925
3024
  log,
2926
3025
  consumeOwnerConsent: consumeOwnerSkillConsent,
3026
+ consumePermissionResult: async (metadata) => {
3027
+ // A permission_result receipt closes the loop on an earlier request.
3028
+ // Dedup by request_id; on a fresh receipt feed the synthetic context
3029
+ // note back to the agent so it can react to the outcome.
3030
+ const note = handlePermissionResult(metadata, account);
3031
+ if (note) {
3032
+ log?.info?.(
3033
+ `[${accountId}] clawchat-plugin-openclaw permission_result receipt op=${metadata.operation} outcome=${metadata.outcome} request_id=${metadata.request_id}; dispatching context note`,
3034
+ );
3035
+ void handleInboundEnvelope(note);
3036
+ }
3037
+ },
2927
3038
  ingest: async (turn) => {
2928
3039
  ingestResult = await ingestTurn(turn);
2929
3040
  },
@@ -3020,6 +3131,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3020
3131
  // rotating `account.token` out from under the already-constructed WS client.
3021
3132
  // Settling this releases the Finding-2 group-dispatch gate.
3022
3133
  pullGroupSettings({ bypassRefresh: true });
3134
+ // Initial pull of permission policy at startup (best-effort; errors logged,
3135
+ // stale cache kept). Uses bypassRefresh for the same reason as pullGroupSettings.
3136
+ pullPermissions({ bypassRefresh: true });
3023
3137
  // Safety: never strand a held group message if the runtime is torn down while a
3024
3138
  // pull is in flight. `waitWithTimeout` already releases group waiters on abort,
3025
3139
  // but force the gate open too so any later `wait()` resolves immediately.