@clawling/clawchat-plugin-openclaw 2026.7.29-3 → 2026.8.4-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.
@@ -1,5 +1,6 @@
1
1
  import { randomInt } from "node:crypto";
2
- import { EVENT, MessageSendError } from "./protocol-types.js";
2
+ import { EVENT, MessageSendError, TERMINAL_CHAT_CODES } from "./protocol-types.js";
3
+ import { CHAT_ID_PREFIX, isValidChatId } from "./ws-client.js";
3
4
  import { createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result";
4
5
  import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
5
6
  import { createOpenclawClawlingApiClient } from "./api-client.js";
@@ -115,14 +116,71 @@ function getAlignedMessageErrorTracker(client, account, log) {
115
116
  export function setAlignedOutboundLogContext(client, context) {
116
117
  alignedOutboundContexts.set(client, context);
117
118
  }
119
+ /**
120
+ * The single outbound gate predicate. Narrow by design: only a SERVER-confirmed
121
+ * rejection blocks, never a `conversation.dissolved` inference, and never the
122
+ * owner's direct chat (the client applies that exemption inside isSendBlocked).
123
+ * Optional-call form because outbound tests hand-roll a client mock.
124
+ */
125
+ function isOutboundBlocked(client, chatId) {
126
+ if (!chatId)
127
+ return false;
128
+ return client.isSendBlocked?.(chatId) === true;
129
+ }
118
130
  export function flushAlignedOutboundQueue(client) {
119
131
  const queue = alignedOutboundQueues.get(client);
120
- queue?.flush((wire) => client.sendWire(wire));
132
+ // Re-check the gate at write time: this replay path (called on every
133
+ // `connected` edge) does not re-enter sendAlignedAckableEnvelope.
134
+ queue?.flush((wire) => client.sendWire(wire), { isBlocked: (item) => isOutboundBlocked(client, item.chatId) });
121
135
  }
122
136
  export function getAlignedOutboundQueueSize(client) {
123
137
  return alignedOutboundQueues.get(client)?.snapshot().length ?? 0;
124
138
  }
125
139
  async function sendAlignedAckableEnvelope(params) {
140
+ // Static addressing check, before any server state is consulted: this one is
141
+ // decidable locally and is never revocable, so it is not a "gate" that can
142
+ // later clear. msghub refuses such a frame with `invalid conversation id`.
143
+ if (!isValidChatId(params.chatId)) {
144
+ params.log?.info?.(formatWsLog({
145
+ event: "send_blocked",
146
+ accountId: params.account.accountId,
147
+ ...(alignedOutboundContexts.get(params.client)?.() ?? {
148
+ attempt: 1,
149
+ reconnectCount: 0,
150
+ state: params.client.transportState === "open" ? "ready" : "reconnecting",
151
+ }),
152
+ action: "drop",
153
+ fields: [
154
+ ["event_name", params.eventName],
155
+ ["chat_id", params.chatId],
156
+ ["reason", "invalid_chat_id"],
157
+ ],
158
+ }));
159
+ throw new MessageSendError("-", "invalid_chat_id", `"${params.chatId}" is not a ClawChat conversation id (expected ${CHAT_ID_PREFIX}…); refusing to send`, params.chatId);
160
+ }
161
+ // The server has already rejected this conversation with a terminal code and
162
+ // that rejection has not expired. Stop here: every further frame would be
163
+ // refused the same way. Gated on the narrow server-confirmed tier ONLY — a
164
+ // `conversation.dissolved` signal must never reach this branch, and the
165
+ // owner's direct chat is exempt (see ClawChatClient.isSendBlocked).
166
+ if (isOutboundBlocked(params.client, params.chatId)) {
167
+ params.log?.info?.(formatWsLog({
168
+ event: "send_blocked",
169
+ accountId: params.account.accountId,
170
+ ...(alignedOutboundContexts.get(params.client)?.() ?? {
171
+ attempt: 1,
172
+ reconnectCount: 0,
173
+ state: params.client.transportState === "open" ? "ready" : "reconnecting",
174
+ }),
175
+ action: "drop",
176
+ fields: [
177
+ ["event_name", params.eventName],
178
+ ["chat_id", params.chatId],
179
+ ["reason", "chat_rejected_by_server"],
180
+ ],
181
+ }));
182
+ throw new MessageSendError("-", "chat_rejected_local", "conversation was rejected by the server; refusing to send", params.chatId);
183
+ }
126
184
  const traceId = params.client.nextTraceId();
127
185
  const env = {
128
186
  version: "2",
@@ -206,6 +264,15 @@ async function sendAlignedAckableEnvelope(params) {
206
264
  : typeof payload.message === "string" && payload.message
207
265
  ? payload.message
208
266
  : "message send failed";
267
+ // A terminal refusal proves the conversation is unreachable for this
268
+ // sender right now. Record it on the SERVER tier so the gates below
269
+ // stop subsequent frames — this is fact, not the inference that
270
+ // `conversation.dissolved` gives us. It expires; see the client.
271
+ if (TERMINAL_CHAT_CODES.has(code)
272
+ && typeof ack.chat_id === "string"
273
+ && ack.chat_id) {
274
+ params.client.markChatDead?.(ack.chat_id, "server");
275
+ }
209
276
  fail(new MessageSendError(traceId, code, hint, ack.chat_id));
210
277
  return;
211
278
  }
@@ -242,6 +309,9 @@ async function sendAlignedAckableEnvelope(params) {
242
309
  onDrop: () => {
243
310
  fail(new Error(`send queue full; dropped ${params.eventName} before write for trace_id=${traceId}`));
244
311
  },
312
+ onBlock: () => {
313
+ fail(new MessageSendError("-", "chat_rejected_local", "conversation was rejected by the server while this frame was queued", params.chatId));
314
+ },
245
315
  };
246
316
  function isTerminalClose(close) {
247
317
  return close?.code === 1000 || close?.reason === "client close" || close?.reason === "auth failed";
@@ -280,7 +350,7 @@ async function sendAlignedAckableEnvelope(params) {
280
350
  }
281
351
  try {
282
352
  queue.enqueue(item);
283
- queue.flush((queuedWire) => params.client.sendWire(queuedWire));
353
+ queue.flush((queuedWire) => params.client.sendWire(queuedWire), { isBlocked: (queued) => isOutboundBlocked(params.client, queued.chatId) });
284
354
  }
285
355
  catch {
286
356
  // The queue keeps the failed frame at the head for reconnect retry, so
@@ -494,6 +564,14 @@ export async function sendOpenclawClawlingMedia(params) {
494
564
  * path used for message.send/reply does not apply). Matches the mobile client.
495
565
  */
496
566
  export function sendOpenclawClawlingReaction(params) {
567
+ if (!isValidChatId(params.chatId)) {
568
+ params.log?.info?.(`clawchat reaction blocked chat_id=${params.chatId} reason=invalid_chat_id`);
569
+ return;
570
+ }
571
+ if (isOutboundBlocked(params.client, params.chatId)) {
572
+ params.log?.info?.(`clawchat reaction blocked chat_id=${params.chatId} reason=chat_rejected_by_server`);
573
+ return;
574
+ }
497
575
  const payload = {
498
576
  target_message_id: params.targetMessageId,
499
577
  emoji: params.emoji,
@@ -58,6 +58,24 @@ export class MessageSendError extends Error {
58
58
  this.chatId = chatId;
59
59
  }
60
60
  }
61
+ /**
62
+ * `message.error` codes that PROVE the target conversation is unreachable for
63
+ * this sender right now (docs/client-integration.md §14.3). Both are documented
64
+ * as terminal for the affected uplink.
65
+ *
66
+ * Deliberately excludes `capability_missing` and `unsupported_version`: those
67
+ * are terminal too, but they indict THIS CLIENT's handshake/envelope, not the
68
+ * conversation — muting the chat on them would be a false positive.
69
+ *
70
+ * NOTE: "terminal for this uplink" is NOT "terminal forever". A deleted
71
+ * conversation can be revived in place under the same `cnv_` id (that is the
72
+ * path an agent re-pairing takes), so anything derived from these codes must
73
+ * be recoverable — see SERVER_REJECTION_TTL_MS in ws-client.ts.
74
+ */
75
+ export const TERMINAL_CHAT_CODES = new Set([
76
+ "chat_not_found",
77
+ "not_member",
78
+ ]);
61
79
  export class StateError extends Error {
62
80
  name = "StateError";
63
81
  }
@@ -8,7 +8,7 @@ import { buildActivationBootstrapText } from "./activation-greeting.js";
8
8
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
9
9
  import path from "node:path";
10
10
  import { ensureLivewareCli, resolveLivewarePath } from "./liveware-cli.js";
11
- import { LivewareSampleSupervisor } from "./liveware-sample.js";
11
+ import { LivewareSampleSupervisor, } from "./liveware-sample.js";
12
12
  import { ClawlingApiError } from "./api-types.js";
13
13
  import { RefreshManager } from "./refresh-manager.js";
14
14
  import { runOpenclawClawlingLogin, } from "./login.runtime.js";
@@ -32,11 +32,20 @@ import { createGroupMessageCoalescer } from "./group-message-coalescer.js";
32
32
  import { openclawLlmContextDebug } from "./llm-context-debug.js";
33
33
  import { GroupSettingsCache } from "./group-settings.js";
34
34
  import { PermissionCache } from "./permissions.js";
35
- import { buildAwarenessNoteEnvelope } from "./awareness-note-turn.js";
35
+ import { buildAwarenessNoteEnvelope, buildMomentCommentNoteEnvelope } from "./awareness-note-turn.js";
36
36
  import { handlePermissionResult } from "./permission-result.js";
37
37
  const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingRuntime } = createPluginRuntimeStore("clawchat-plugin-openclaw runtime not initialized");
38
38
  export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
39
39
  const activeClients = new Map();
40
+ // Live liveware-sample supervisors, keyed by the gateway's abortSignal and then by
41
+ // `platform:accountId`. startOpenclawClawlingGateway re-enters ITSELF (token-refresh
42
+ // reconnect, transport backoff, post-auth-failed re-activation) carrying the same
43
+ // `...params` — so the same abortSignal — and a per-call `new LivewareSampleSupervisor`
44
+ // left the previous one running: both then raced the bootstrap "account has zero apps"
45
+ // gate and each minted its own liveware app, leaving a duplicate app tile that no
46
+ // SQLite row tracks (the single-row PK means the loser is orphaned forever). Keyed by
47
+ // signal so an entry cannot outlive the channel it belongs to.
48
+ const livewareSampleSupervisors = new WeakMap();
40
49
  const CLAWCHAT_PLUGIN_SLASH_COMMANDS = new Set(["clawchat-activate", "clawchat-output"]);
41
50
  const ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS = 2_000;
42
51
  // Activation-bootstrap retry on the live connection. The bootstrap greeting is
@@ -791,7 +800,7 @@ export async function startOpenclawClawlingGateway(params) {
791
800
  let livewareSampleNotify = async () => false;
792
801
  if (store) {
793
802
  const stateDirForSample = runtime.state?.resolveStateDir?.();
794
- const livewareSampleSupervisor = new LivewareSampleSupervisor({
803
+ const livewareSampleDeps = {
795
804
  platform: "openclaw",
796
805
  accountId,
797
806
  enabled: account.livewareSample,
@@ -819,9 +828,29 @@ export async function startOpenclawClawlingGateway(params) {
819
828
  debug: (m) => log?.debug?.(m),
820
829
  error: (m) => log?.error?.(m),
821
830
  },
822
- });
823
- abortSignal.addEventListener("abort", () => livewareSampleSupervisor.stop());
824
- void livewareSampleSupervisor.start();
831
+ };
832
+ const supervisorsForSignal = livewareSampleSupervisors.get(abortSignal) ?? new Map();
833
+ livewareSampleSupervisors.set(abortSignal, supervisorsForSignal);
834
+ const supervisorKey = `openclaw:${accountId}`;
835
+ const runningSupervisor = supervisorsForSignal.get(supervisorKey);
836
+ if (runningSupervisor) {
837
+ // A gateway re-entry (refresh/backoff reconnect), not a fresh channel start:
838
+ // hand the live supervisor this closure's token + REST client + notify path
839
+ // instead of constructing a second one that would register a duplicate app.
840
+ log?.debug?.(`[${accountId}] clawchat-plugin-openclaw liveware-sample supervisor already live; re-pointing it at the re-entered gateway`);
841
+ runningSupervisor.adoptDeps(livewareSampleDeps);
842
+ }
843
+ else {
844
+ const livewareSampleSupervisor = new LivewareSampleSupervisor(livewareSampleDeps);
845
+ supervisorsForSignal.set(supervisorKey, livewareSampleSupervisor);
846
+ abortSignal.addEventListener("abort", () => {
847
+ livewareSampleSupervisor.stop();
848
+ if (supervisorsForSignal.get(supervisorKey) === livewareSampleSupervisor) {
849
+ supervisorsForSignal.delete(supervisorKey);
850
+ }
851
+ });
852
+ void livewareSampleSupervisor.start();
853
+ }
825
854
  }
826
855
  const resolveMutateConfigFile = () => {
827
856
  if (params.mutateConfigFile)
@@ -1840,6 +1869,30 @@ export async function startOpenclawClawlingGateway(params) {
1840
1869
  ],
1841
1870
  }));
1842
1871
  }
1872
+ else if (env.event === "message.error") {
1873
+ // Never fold this into `inbound_ignored`: `code` + `chat_id` are the
1874
+ // only in-plugin evidence that an uplink was refused, and the
1875
+ // 2026-07-19 incident needed a server-log dig purely because this line
1876
+ // did not exist. `action=message_error` marks it as semantically seen.
1877
+ const current = wsLogContext();
1878
+ const errorPayload = env.payload && typeof env.payload === "object"
1879
+ ? env.payload
1880
+ : undefined;
1881
+ log?.info?.(formatWsLog({
1882
+ event: "inbound_control",
1883
+ accountId,
1884
+ attempt: current.attempt,
1885
+ reconnectCount: current.reconnectCount,
1886
+ state: "ready",
1887
+ action: "message_error",
1888
+ fields: [
1889
+ ["event_name", env.event],
1890
+ ["trace_id", env.trace_id],
1891
+ ["chat_id", env.chat_id],
1892
+ ["code", typeof errorPayload?.code === "string" ? errorPayload.code : "-"],
1893
+ ],
1894
+ }));
1895
+ }
1843
1896
  else if (env.event !== "ping" && env.event !== "pong") {
1844
1897
  const current = wsLogContext();
1845
1898
  log?.info?.(formatWsLog({
@@ -1909,6 +1962,12 @@ export async function startOpenclawClawlingGateway(params) {
1909
1962
  ?.conversationId?.trim();
1910
1963
  return conversationId || null;
1911
1964
  };
1965
+ // R3: the owner's direct conversation is never gated by the dead-chat send
1966
+ // guard. If it were, the very message explaining why the agent went dark
1967
+ // (skill-update notices, auth/logout notices, group owner-attention
1968
+ // forwards) would be unreachable. Registered lazily — the activation
1969
+ // conversation may be recorded after the client is constructed.
1970
+ client.setOwnerDirectChatResolver(resolveOwnerDirectConversationId);
1912
1971
  const sendOwnerSkillMessage = async (text) => {
1913
1972
  const conversationId = resolveOwnerDirectConversationId();
1914
1973
  if (!conversationId) {
@@ -1996,7 +2055,11 @@ export async function startOpenclawClawlingGateway(params) {
1996
2055
  // The conversation is gone. Evict it so no further uplink is emitted
1997
2056
  // for it. Falls through to the awareness branch so the owner is told.
1998
2057
  const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
1999
- const evicted = client.markChatDead(entityId);
2058
+ // "signal" tier: this gates typing only. The protocol forbids gating
2059
+ // message.send on a dissolved signal — only a server rejection
2060
+ // (message.error, see src/outbound.ts) may do that, and unlike this
2061
+ // one that rejection expires.
2062
+ const evicted = client.markChatDead(entityId, "signal");
2000
2063
  log?.info?.(evicted
2001
2064
  ? `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received; evicting ${entityId}`
2002
2065
  : `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received with missing/invalid entity_id; no eviction performed`);
@@ -2018,6 +2081,32 @@ export async function startOpenclawClawlingGateway(params) {
2018
2081
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw skill-update check failed: ${err instanceof Error ? err.message : String(err)}`);
2019
2082
  });
2020
2083
  }
2084
+ else if (type === "moment.comment.created" || type === "moment.comment.replied") {
2085
+ // Content-free trigger: the note carries only the scenario + moment id
2086
+ // and points the agent at the get_moment tool — never the comment
2087
+ // text. Not gated behind account.awarenessNote and not debounced:
2088
+ // each moment-comment signal yields its own note (msghub already
2089
+ // coalesces multiple comments on one moment by entity_id).
2090
+ const momentId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
2091
+ // No activation conversation ⇒ no routable chat_id. Skip the note
2092
+ // rather than address it to the owner's usr_… id, which
2093
+ // member-backend rejects as "invalid conversation id" (the agent's
2094
+ // in-turn replies are then dropped with no negative ack). Resolve
2095
+ // this FIRST, mirroring the friend.*/conversation.* awareness
2096
+ // branch's guard below.
2097
+ const ownerConversationId = resolveOwnerDirectConversationId();
2098
+ if (!ownerConversationId) {
2099
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${type} signal received but no owner conversation resolved; skipping moment awareness note (moment ${momentId})`);
2100
+ return;
2101
+ }
2102
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${type} signal received; synthesizing moment awareness note (moment ${momentId})`);
2103
+ void handleInboundEnvelope(buildMomentCommentNoteEnvelope({
2104
+ account,
2105
+ ownerConversationId,
2106
+ momentId,
2107
+ replied: type === "moment.comment.replied",
2108
+ }));
2109
+ }
2021
2110
  else if (type === "friend.added" ||
2022
2111
  type === "friend.removed" ||
2023
2112
  type === "friend.profile_updated" ||
@@ -198,6 +198,9 @@ export const ClawchatDeleteMomentSchema = Type.Object({
198
198
  description: "Concrete ClawChat moment id to delete",
199
199
  }),
200
200
  });
201
+ export const ClawchatGetMomentSchema = Type.Object({
202
+ momentId: Type.Integer({ minimum: 1, description: "Concrete ClawChat moment id to fetch (with its visible comments)" }),
203
+ });
201
204
  export const ClawchatToggleMomentReactionSchema = Type.Object({
202
205
  momentId: Type.Integer({
203
206
  minimum: 1,
package/dist/src/tools.js CHANGED
@@ -12,7 +12,7 @@ import { getOpenclawClawlingClient, } from "./runtime.js";
12
12
  import { markTerminalClawChatSend, getCurrentTerminalSendScope } from "./terminal-send.js";
13
13
  import { editClawChatMemoryBody, readClawChatMemoryFile, resolveClawChatMemoryPath, searchClawChatMemory, writeClawChatMemoryBody, } from "./clawchat-memory.js";
14
14
  import { pullGroupMetadata, pullOwnerMetadata, pullUserMetadata, pushMetadata, updateMetadata, } from "./clawchat-metadata.js";
15
- import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAcceptFriendRequestSchema, ClawchatMemoryEditSchema, ClawchatMemoryReadSchema, ClawchatMemorySearchSchema, ClawchatMemoryWriteSchema, ClawchatMetadataSyncSchema, ClawchatMetadataUpdateSchema, ClawchatCreateMomentCommentSchema, ClawchatCreateMomentSchema, ClawchatDeleteMomentCommentSchema, ClawchatDeleteMomentSchema, ClawchatListAccountFriendsSchema, ClawchatListFriendRequestsSchema, ClawchatListMomentsSchema, ClawchatMentionMessageSchema, ClawchatReactMessageSchema, ClawchatReplyMomentCommentSchema, ClawchatRejectFriendRequestSchema, ClawchatRemoveFriendSchema, ClawchatSearchUsersSchema, ClawchatSendFriendRequestSchema, ClawchatToggleMomentReactionSchema, ClawchatUpdateAccountProfileSchema, ClawchatUploadAvatarImageSchema, ClawchatRegisterAppSchema, ClawchatListAppsSchema, ClawchatUnregisterAppSchema, ClawchatLivewareLoginSchema, } from "./tools-schema.js";
15
+ import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetMomentSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAcceptFriendRequestSchema, ClawchatMemoryEditSchema, ClawchatMemoryReadSchema, ClawchatMemorySearchSchema, ClawchatMemoryWriteSchema, ClawchatMetadataSyncSchema, ClawchatMetadataUpdateSchema, ClawchatCreateMomentCommentSchema, ClawchatCreateMomentSchema, ClawchatDeleteMomentCommentSchema, ClawchatDeleteMomentSchema, ClawchatListAccountFriendsSchema, ClawchatListFriendRequestsSchema, ClawchatListMomentsSchema, ClawchatMentionMessageSchema, ClawchatReactMessageSchema, ClawchatReplyMomentCommentSchema, ClawchatRejectFriendRequestSchema, ClawchatRemoveFriendSchema, ClawchatSearchUsersSchema, ClawchatSendFriendRequestSchema, ClawchatToggleMomentReactionSchema, ClawchatUpdateAccountProfileSchema, ClawchatUploadAvatarImageSchema, ClawchatRegisterAppSchema, ClawchatListAppsSchema, ClawchatUnregisterAppSchema, ClawchatLivewareLoginSchema, } from "./tools-schema.js";
16
16
  const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
17
17
  // Owner-approval gate business codes (must match the ClawChat backend's owner-approval codes).
18
18
  const CODE_PENDING_APPROVAL = 21001;
@@ -935,6 +935,18 @@ export function registerOpenclawClawlingTools(api, options = {}) {
935
935
  });
936
936
  },
937
937
  }, { name: "clawchat_list_moments" });
938
+ api.registerTool({
939
+ name: "clawchat_get_moment",
940
+ label: "Get ClawChat Moment",
941
+ description: toolDescription("Fetch a single ClawChat moment by id together with the comments visible to this agent. TRIGGER - after a moment.comment.created / moment.comment.replied awareness note, call this with the moment id to read the new comment before deciding whether to reply."),
942
+ parameters: ClawchatGetMomentSchema,
943
+ async execute(_callId, params) {
944
+ return await recordClawchatToolCall("clawchat_get_moment", params, async () => {
945
+ const p = (params ?? {});
946
+ return await withClient((c) => c.getMoment(p.momentId));
947
+ });
948
+ },
949
+ }, { name: "clawchat_get_moment" });
938
950
  api.registerTool({
939
951
  name: "clawchat_create_moment",
940
952
  label: "Create ClawChat Moment",
@@ -1196,5 +1208,5 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1196
1208
  });
1197
1209
  },
1198
1210
  }, { name: "clawchat_liveware_login" });
1199
- api.logger.debug?.("clawchat-plugin-openclaw: registered 30 clawchat_* tools (get_account_profile, get_user_profile, list_account_friends, send_friend_request, list_friend_requests, accept_friend_request, reject_friend_request, remove_friend, search_users, get_conversation, mention_message, list_moments, create_moment, delete_moment, toggle_moment_reaction, create_moment_comment, reply_moment_comment, delete_moment_comment, update_account_profile, upload_avatar_image, memory_search, memory_read, memory_write, memory_edit, metadata_sync, metadata_update, register_app, list_apps, unregister_app, liveware_login)");
1211
+ api.logger.debug?.("clawchat-plugin-openclaw: registered 31 clawchat_* tools (get_account_profile, get_user_profile, list_account_friends, send_friend_request, list_friend_requests, accept_friend_request, reject_friend_request, remove_friend, search_users, get_conversation, mention_message, list_moments, get_moment, create_moment, delete_moment, toggle_moment_reaction, create_moment_comment, reply_moment_comment, delete_moment_comment, update_account_profile, upload_avatar_image, memory_search, memory_read, memory_write, memory_edit, metadata_sync, metadata_update, register_app, list_apps, unregister_app, liveware_login)");
1200
1212
  }
@@ -40,9 +40,26 @@ export function createAlignedWsQueue(options) {
40
40
  queue.push(item);
41
41
  logFrame("send_queued", "queue", item, [["queue_size", queue.length]]);
42
42
  },
43
- flush(write) {
43
+ /**
44
+ * Drain the queue. `isBlocked` is consulted PER ITEM at write time, not at
45
+ * enqueue time: a conversation can be rejected by the server *after* a
46
+ * frame was queued, and this replay path never re-enters the send function
47
+ * that guards the live path. Without this hook the outbound gate is
48
+ * bypassable by any reconnect (and by any batch flush that happens to carry
49
+ * a live chat's frame).
50
+ */
51
+ flush(write, options = {}) {
44
52
  while (queue.length > 0) {
45
53
  const item = queue[0];
54
+ if (options.isBlocked?.(item)) {
55
+ queue.shift();
56
+ logFrame("send_blocked", "drop", item, [
57
+ ["reason", "chat_rejected_by_server"],
58
+ ["remaining", queue.length],
59
+ ]);
60
+ item.onBlock?.();
61
+ continue;
62
+ }
46
63
  try {
47
64
  write(item.wire);
48
65
  queue.shift();