@clawling/clawchat-plugin-openclaw 2026.6.30-3 → 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.
@@ -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,
@@ -328,6 +329,7 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
328
329
  const forwardThinking = typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
329
330
  const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
330
331
  const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
332
+ const awarenessNote = typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
331
333
  return {
332
334
  accountId: DEFAULT_ACCOUNT_ID,
333
335
  name: CHANNEL_ID,
@@ -353,6 +355,7 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
353
355
  forwardThinking,
354
356
  forwardToolCalls,
355
357
  richInteractions,
358
+ awarenessNote,
356
359
  allowFrom: [],
357
360
  reconnect: readReconnect(channel.reconnect),
358
361
  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
+ }
@@ -296,6 +296,12 @@ export function createOpenclawClawlingReplyDispatcher(options) {
296
296
  let typingActive = false;
297
297
  let terminalReplySuppressed = false;
298
298
  let finalDeliverySeen = false;
299
+ // Count of outbound messages that were actually delivered to the target
300
+ // (a real, successful send — not a suppressed/duplicate/failed one). This is
301
+ // the reliable "did anything actually reach the user?" signal; host-side
302
+ // `counts.block`/`counts.final` only reflect what the agent emitted, not
303
+ // whether the send succeeded (a cancelled send still counts as a block).
304
+ let outboundDeliveredCount = 0;
299
305
  const outboundEventType = () => (replyCtx ? "message.reply" : "message.send");
300
306
  const outboundRaw = () => ({ target, replyCtx: replyCtx ?? null });
301
307
  const terminalSendScopeId = options.terminalSendScopeId ?? null;
@@ -502,6 +508,8 @@ export function createOpenclawClawlingReplyDispatcher(options) {
502
508
  log,
503
509
  });
504
510
  recordOutboundAck("message", messageId, result);
511
+ if (result)
512
+ outboundDeliveredCount += 1;
505
513
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw send complete to=${target.chatId}`);
506
514
  openclawLlmContextDebug.writeSnapshot({
507
515
  visibility: "host_event",
@@ -676,6 +684,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
676
684
  });
677
685
  return {
678
686
  dispatcher: base.dispatcher,
687
+ getOutboundDeliveredCount: () => outboundDeliveredCount,
679
688
  replyOptions: {
680
689
  ...base.replyOptions,
681
690
  sourceReplyDeliveryMode: "automatic",
@@ -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;
@@ -1474,10 +1497,11 @@ export async function startOpenclawClawlingGateway(params) {
1474
1497
  };
1475
1498
  let dispatchActivationBootstrap = async () => { };
1476
1499
  // Set by dispatchTurnToAgent when it dispatches the synthetic
1477
- // `clawchat-bootstrap` turn: true only if the agent actually produced a final
1478
- // outbound message (the greeting). The bootstrap dispatcher reads it to decide
1479
- // whether to mark the bootstrap sent or leave it pending for a later retry.
1480
- let bootstrapProducedFinal = false;
1500
+ // `clawchat-bootstrap` turn: true only if the greeting was actually delivered
1501
+ // (a real successful outbound send — block or final). The bootstrap dispatcher
1502
+ // reads it to decide whether to mark the bootstrap sent or leave it pending for
1503
+ // a later retry.
1504
+ let bootstrapGreetingDelivered = false;
1481
1505
  // Per-connection retry for a pending activation bootstrap (see the module-level
1482
1506
  // constants). Re-attempts on the live connection while `wsReady`, bounded by
1483
1507
  // ACTIVATION_BOOTSTRAP_MAX_RETRIES; the budget resets on each fresh connect.
@@ -1584,6 +1608,8 @@ export async function startOpenclawClawlingGateway(params) {
1584
1608
  // Re-pull per-group agent settings on every successful (re)connect so the
1585
1609
  // cache stays current after a network interruption or token refresh.
1586
1610
  pullGroupSettings();
1611
+ // Re-pull permission policy alongside group settings on every (re)connect.
1612
+ pullPermissions();
1587
1613
  // §A.1 — arm the proactive refresh timer from the live token's `exp`
1588
1614
  // every time a connection becomes ready (re-armed after every refresh via
1589
1615
  // the gateway re-enter).
@@ -1795,6 +1821,10 @@ export async function startOpenclawClawlingGateway(params) {
1795
1821
  },
1796
1822
  });
1797
1823
  };
1824
+ // Debounce flag for lighter-awareness consolidated notes.
1825
+ // A single queueMicrotask coalesces all awareness signals that arrive in
1826
+ // the same synchronous turn into exactly one handleInboundEnvelope call.
1827
+ let pendingAwarenessNote = false;
1798
1828
  client.on("notify:signal", (env) => {
1799
1829
  // §9.4 reliable system notification. The plugin holds no friend/roster
1800
1830
  // cache (friends are fetched on demand via REST tools), so there is nothing
@@ -1806,10 +1836,15 @@ export async function startOpenclawClawlingGateway(params) {
1806
1836
  ? env.payload
1807
1837
  : undefined;
1808
1838
  const type = typeof payload?.type === "string" ? payload.type : "";
1839
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
1809
1840
  if (type === "agent.config.changed") {
1810
1841
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`);
1811
1842
  pullGroupSettings();
1812
1843
  }
1844
+ else if (type === "agent.permission.changed") {
1845
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw agent.permission.changed signal received; re-pulling permissions`);
1846
+ pullPermissions();
1847
+ }
1813
1848
  else if (type === "clawchat.skill.update.check") {
1814
1849
  // Content-free trigger: check the official skill source in code and, if
1815
1850
  // there is an update, ask the owner in chat for consent. The atomic
@@ -1819,6 +1854,62 @@ export async function startOpenclawClawlingGateway(params) {
1819
1854
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw skill-update check failed: ${err instanceof Error ? err.message : String(err)}`);
1820
1855
  });
1821
1856
  }
1857
+ else if (type === "friend.request") {
1858
+ // Dedup is already applied above by notifySignalObserver.observe (the live
1859
+ // frame and its reliable-inbox replay share the same event_id and collapse
1860
+ // to one observation). Build one synthetic reasoning turn so the agent can
1861
+ // act on the pending request; the prompt text varies by policy state.
1862
+ const state = permissionCache.stateOf("friend.add");
1863
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw friend.request signal received; dispatching reasoning turn entity_id=${entityId} policy=${state}`);
1864
+ void handleInboundEnvelope(buildFriendRequestEnvelope({ account, state, entityId }));
1865
+ }
1866
+ else if (type === "friend.added" ||
1867
+ type === "friend.removed" ||
1868
+ type === "friend.profile_updated" ||
1869
+ type.startsWith("conversation.")) {
1870
+ // Lighter-awareness: log + optional single consolidated note. Never
1871
+ // injects a full reasoning turn per event (unlike friend.request).
1872
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${type} signal received; lighter-awareness handling`);
1873
+ if (account.awarenessNote && !pendingAwarenessNote) {
1874
+ pendingAwarenessNote = true;
1875
+ queueMicrotask(() => {
1876
+ pendingAwarenessNote = false;
1877
+ const now = Date.now();
1878
+ void handleInboundEnvelope({
1879
+ version: "2",
1880
+ event: EVENT.MESSAGE_SEND,
1881
+ trace_id: `clawchat-plugin-openclaw-awareness-${now}`,
1882
+ emitted_at: now,
1883
+ chat_id: account.ownerUserId,
1884
+ chat_type: "direct",
1885
+ to: { id: account.userId, type: "direct" },
1886
+ sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
1887
+ payload: {
1888
+ message_id: `clawchat-plugin-openclaw-awareness-${now}`,
1889
+ message_mode: "normal",
1890
+ message: {
1891
+ body: {
1892
+ fragments: [
1893
+ {
1894
+ kind: "text",
1895
+ text: "ClawChat awareness: your contacts or conversations have been updated.",
1896
+ },
1897
+ ],
1898
+ },
1899
+ context: { mentions: [], reply: null },
1900
+ streaming: {
1901
+ status: "static",
1902
+ sequence: 0,
1903
+ mutation_policy: "sealed",
1904
+ started_at: null,
1905
+ completed_at: null,
1906
+ },
1907
+ },
1908
+ },
1909
+ });
1910
+ });
1911
+ }
1912
+ }
1822
1913
  }
1823
1914
  });
1824
1915
  client.on("replay:done", (env) => {
@@ -2166,7 +2257,7 @@ export async function startOpenclawClawlingGateway(params) {
2166
2257
  chatType: turn.peer.kind,
2167
2258
  agentId: route.agentId,
2168
2259
  });
2169
- const { dispatcher, replyOptions, markDispatchIdle } = createOpenclawClawlingReplyDispatcher({
2260
+ const { dispatcher, replyOptions, markDispatchIdle, getOutboundDeliveredCount } = createOpenclawClawlingReplyDispatcher({
2170
2261
  cfg,
2171
2262
  runtime,
2172
2263
  account,
@@ -2202,10 +2293,14 @@ export async function startOpenclawClawlingGateway(params) {
2202
2293
  const counts = dispatchResult?.counts ?? {};
2203
2294
  const queuedFinal = Boolean(dispatchResult?.queuedFinal);
2204
2295
  if (turn.senderId === "clawchat-bootstrap") {
2205
- // Did the agent actually emit a greeting? A final outbound message
2206
- // means yes; block/tool-only (or nothing) means the greeting never
2207
- // reached the user, so the bootstrap dispatcher will keep it pending.
2208
- bootstrapProducedFinal = queuedFinal || (counts.final ?? 0) > 0;
2296
+ // Did the greeting actually reach the user? The reliable signal is a
2297
+ // real, successful outbound send (the agent may deliver the greeting
2298
+ // as a `block`, not a `final`, and a cancelled send still shows up in
2299
+ // host `counts.block`). Treat a queued/counted final as delivered too,
2300
+ // to cover deferred/human-delay finals not yet flushed at this point.
2301
+ const deliveredOutbound = getOutboundDeliveredCount?.() ?? 0;
2302
+ bootstrapGreetingDelivered =
2303
+ deliveredOutbound > 0 || queuedFinal || (counts.final ?? 0) > 0;
2209
2304
  }
2210
2305
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw dispatch complete msg=${turn.messageId} queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)}`);
2211
2306
  if (!queuedFinal && Object.values(counts).every((n) => !n)) {
@@ -2434,6 +2529,16 @@ export async function startOpenclawClawlingGateway(params) {
2434
2529
  account,
2435
2530
  log,
2436
2531
  consumeOwnerConsent: consumeOwnerSkillConsent,
2532
+ consumePermissionResult: async (metadata) => {
2533
+ // A permission_result receipt closes the loop on an earlier request.
2534
+ // Dedup by request_id; on a fresh receipt feed the synthetic context
2535
+ // note back to the agent so it can react to the outcome.
2536
+ const note = handlePermissionResult(metadata, account);
2537
+ if (note) {
2538
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw permission_result receipt op=${metadata.operation} outcome=${metadata.outcome} request_id=${metadata.request_id}; dispatching context note`);
2539
+ void handleInboundEnvelope(note);
2540
+ }
2541
+ },
2437
2542
  ingest: async (turn) => {
2438
2543
  ingestResult = await ingestTurn(turn);
2439
2544
  },
@@ -2473,21 +2578,21 @@ export async function startOpenclawClawlingGateway(params) {
2473
2578
  const claimedBootstrap = bootstrap;
2474
2579
  claimedInFlight = true;
2475
2580
  incrementActivationBootstrapInFlight(accountId);
2476
- bootstrapProducedFinal = false;
2581
+ bootstrapGreetingDelivered = false;
2477
2582
  const result = await handleInboundEnvelope(buildActivationBootstrapEnvelope({ account, conversationId: claimedBootstrap.conversationId }));
2478
2583
  if (result !== "submitted") {
2479
2584
  releaseBootstrap();
2480
2585
  armBootstrapRetry();
2481
2586
  return;
2482
2587
  }
2483
- // Only mark the bootstrap done once the agent has actually sent a greeting
2484
- // (a final outbound message). If the turn was handed off but produced no
2485
- // final reply — e.g. the greeting send was cancelled by a reconnect, or the
2486
- // agent went off to write a file/note instead of replying — leave the
2588
+ // Only mark the bootstrap done once the greeting was actually delivered
2589
+ // (a real successful outbound send). If the turn was handed off but nothing
2590
+ // reached the user — e.g. the greeting send was cancelled by a reconnect, or
2591
+ // the agent went off to write a file/note instead of replying — leave the
2487
2592
  // bootstrap pending (release the claim) and retry on the live connection,
2488
2593
  // rather than silently consuming it forever or waiting for the next reconnect.
2489
- if (!bootstrapProducedFinal) {
2490
- log?.info?.(`[${accountId}] clawchat-plugin-openclaw activation bootstrap dispatched but produced no final reply; leaving pending for retry`);
2594
+ if (!bootstrapGreetingDelivered) {
2595
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw activation bootstrap dispatched but greeting was not delivered; leaving pending for retry`);
2491
2596
  releaseBootstrap();
2492
2597
  armBootstrapRetry();
2493
2598
  return;
@@ -2520,6 +2625,9 @@ export async function startOpenclawClawlingGateway(params) {
2520
2625
  // rotating `account.token` out from under the already-constructed WS client.
2521
2626
  // Settling this releases the Finding-2 group-dispatch gate.
2522
2627
  pullGroupSettings({ bypassRefresh: true });
2628
+ // Initial pull of permission policy at startup (best-effort; errors logged,
2629
+ // stale cache kept). Uses bypassRefresh for the same reason as pullGroupSettings.
2630
+ pullPermissions({ bypassRefresh: true });
2523
2631
  // Safety: never strand a held group message if the runtime is torn down while a
2524
2632
  // pull is in flight. `waitWithTimeout` already releases group waiters on abort,
2525
2633
  // but force the gate open too so any later `wait()` resolves immediately.