@clawling/clawchat-plugin-openclaw 2026.7.29-3 → 2026.8.2-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/outbound.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomInt } from "node:crypto";
2
- import { EVENT, MessageSendError, type Envelope, type Fragment, type MentionFragment, type MessageAckPayload, type MessageErrorPayload, type MessageReactionPayload } from "./protocol-types.ts";
3
- import type { ClawlingChatClient } from "./ws-client.ts";
2
+ import { EVENT, MessageSendError, TERMINAL_CHAT_CODES, type Envelope, type Fragment, type MentionFragment, type MessageAckPayload, type MessageErrorPayload, type MessageReactionPayload } from "./protocol-types.ts";
3
+ import { CHAT_ID_PREFIX, isValidChatId, type ClawlingChatClient } from "./ws-client.ts";
4
4
  import {
5
5
  createAttachedChannelResultAdapter,
6
6
  type ChannelOutboundAdapter,
@@ -219,9 +219,25 @@ export function setAlignedOutboundLogContext(
219
219
  alignedOutboundContexts.set(client as object, context);
220
220
  }
221
221
 
222
+ /**
223
+ * The single outbound gate predicate. Narrow by design: only a SERVER-confirmed
224
+ * rejection blocks, never a `conversation.dissolved` inference, and never the
225
+ * owner's direct chat (the client applies that exemption inside isSendBlocked).
226
+ * Optional-call form because outbound tests hand-roll a client mock.
227
+ */
228
+ function isOutboundBlocked(client: ClawlingChatClient, chatId: string | undefined): boolean {
229
+ if (!chatId) return false;
230
+ return (client as { isSendBlocked?: (chatId: string) => boolean }).isSendBlocked?.(chatId) === true;
231
+ }
232
+
222
233
  export function flushAlignedOutboundQueue(client: ClawlingChatClient): void {
223
234
  const queue = alignedOutboundQueues.get(client as object);
224
- queue?.flush((wire) => client.sendWire(wire));
235
+ // Re-check the gate at write time: this replay path (called on every
236
+ // `connected` edge) does not re-enter sendAlignedAckableEnvelope.
237
+ queue?.flush(
238
+ (wire) => client.sendWire(wire),
239
+ { isBlocked: (item) => isOutboundBlocked(client, item.chatId) },
240
+ );
225
241
  }
226
242
 
227
243
  export function getAlignedOutboundQueueSize(client: ClawlingChatClient): number {
@@ -236,6 +252,68 @@ async function sendAlignedAckableEnvelope(params: {
236
252
  payload: object;
237
253
  log?: LogSink;
238
254
  }): Promise<Envelope<MessageAckPayload>> {
255
+ // Static addressing check, before any server state is consulted: this one is
256
+ // decidable locally and is never revocable, so it is not a "gate" that can
257
+ // later clear. msghub refuses such a frame with `invalid conversation id`.
258
+ if (!isValidChatId(params.chatId)) {
259
+ params.log?.info?.(
260
+ formatWsLog({
261
+ event: "send_blocked",
262
+ accountId: params.account.accountId,
263
+ ...(
264
+ alignedOutboundContexts.get(params.client as object)?.() ?? {
265
+ attempt: 1,
266
+ reconnectCount: 0,
267
+ state: params.client.transportState === "open" ? "ready" : "reconnecting",
268
+ }
269
+ ),
270
+ action: "drop",
271
+ fields: [
272
+ ["event_name", params.eventName],
273
+ ["chat_id", params.chatId],
274
+ ["reason", "invalid_chat_id"],
275
+ ],
276
+ }),
277
+ );
278
+ throw new MessageSendError(
279
+ "-",
280
+ "invalid_chat_id",
281
+ `"${params.chatId}" is not a ClawChat conversation id (expected ${CHAT_ID_PREFIX}…); refusing to send`,
282
+ params.chatId,
283
+ );
284
+ }
285
+ // The server has already rejected this conversation with a terminal code and
286
+ // that rejection has not expired. Stop here: every further frame would be
287
+ // refused the same way. Gated on the narrow server-confirmed tier ONLY — a
288
+ // `conversation.dissolved` signal must never reach this branch, and the
289
+ // owner's direct chat is exempt (see ClawChatClient.isSendBlocked).
290
+ if (isOutboundBlocked(params.client, params.chatId)) {
291
+ params.log?.info?.(
292
+ formatWsLog({
293
+ event: "send_blocked",
294
+ accountId: params.account.accountId,
295
+ ...(
296
+ alignedOutboundContexts.get(params.client as object)?.() ?? {
297
+ attempt: 1,
298
+ reconnectCount: 0,
299
+ state: params.client.transportState === "open" ? "ready" : "reconnecting",
300
+ }
301
+ ),
302
+ action: "drop",
303
+ fields: [
304
+ ["event_name", params.eventName],
305
+ ["chat_id", params.chatId],
306
+ ["reason", "chat_rejected_by_server"],
307
+ ],
308
+ }),
309
+ );
310
+ throw new MessageSendError(
311
+ "-",
312
+ "chat_rejected_local",
313
+ "conversation was rejected by the server; refusing to send",
314
+ params.chatId,
315
+ );
316
+ }
239
317
  const traceId = params.client.nextTraceId();
240
318
  const env = {
241
319
  version: "2" as const,
@@ -331,6 +409,19 @@ async function sendAlignedAckableEnvelope(params: {
331
409
  : typeof payload.message === "string" && payload.message
332
410
  ? payload.message
333
411
  : "message send failed";
412
+ // A terminal refusal proves the conversation is unreachable for this
413
+ // sender right now. Record it on the SERVER tier so the gates below
414
+ // stop subsequent frames — this is fact, not the inference that
415
+ // `conversation.dissolved` gives us. It expires; see the client.
416
+ if (
417
+ TERMINAL_CHAT_CODES.has(code)
418
+ && typeof ack.chat_id === "string"
419
+ && ack.chat_id
420
+ ) {
421
+ (params.client as {
422
+ markChatDead?: (chatId: string, source: "signal" | "server") => boolean;
423
+ }).markChatDead?.(ack.chat_id, "server");
424
+ }
334
425
  fail(new MessageSendError(traceId, code, hint, ack.chat_id));
335
426
  return;
336
427
  }
@@ -366,6 +457,14 @@ async function sendAlignedAckableEnvelope(params: {
366
457
  onDrop: () => {
367
458
  fail(new Error(`send queue full; dropped ${params.eventName} before write for trace_id=${traceId}`));
368
459
  },
460
+ onBlock: () => {
461
+ fail(new MessageSendError(
462
+ "-",
463
+ "chat_rejected_local",
464
+ "conversation was rejected by the server while this frame was queued",
465
+ params.chatId,
466
+ ));
467
+ },
369
468
  };
370
469
 
371
470
  function isTerminalClose(close: AlignedOutboundClose): boolean {
@@ -407,7 +506,10 @@ async function sendAlignedAckableEnvelope(params: {
407
506
 
408
507
  try {
409
508
  queue.enqueue(item);
410
- queue.flush((queuedWire) => params.client.sendWire(queuedWire));
509
+ queue.flush(
510
+ (queuedWire) => params.client.sendWire(queuedWire),
511
+ { isBlocked: (queued) => isOutboundBlocked(params.client, queued.chatId) },
512
+ );
411
513
  } catch {
412
514
  // The queue keeps the failed frame at the head for reconnect retry, so
413
515
  // keep this promise pending until the frame is written+acked, dropped, or timed out.
@@ -663,6 +765,18 @@ export interface SendReactionParams {
663
765
  * path used for message.send/reply does not apply). Matches the mobile client.
664
766
  */
665
767
  export function sendOpenclawClawlingReaction(params: SendReactionParams): void {
768
+ if (!isValidChatId(params.chatId)) {
769
+ params.log?.info?.(
770
+ `clawchat reaction blocked chat_id=${params.chatId} reason=invalid_chat_id`,
771
+ );
772
+ return;
773
+ }
774
+ if (isOutboundBlocked(params.client, params.chatId)) {
775
+ params.log?.info?.(
776
+ `clawchat reaction blocked chat_id=${params.chatId} reason=chat_rejected_by_server`,
777
+ );
778
+ return;
779
+ }
666
780
  const payload: MessageReactionPayload = {
667
781
  target_message_id: params.targetMessageId,
668
782
  emoji: params.emoji,
@@ -326,6 +326,25 @@ export class MessageSendError extends Error {
326
326
  }
327
327
  }
328
328
 
329
+ /**
330
+ * `message.error` codes that PROVE the target conversation is unreachable for
331
+ * this sender right now (docs/client-integration.md §14.3). Both are documented
332
+ * as terminal for the affected uplink.
333
+ *
334
+ * Deliberately excludes `capability_missing` and `unsupported_version`: those
335
+ * are terminal too, but they indict THIS CLIENT's handshake/envelope, not the
336
+ * conversation — muting the chat on them would be a false positive.
337
+ *
338
+ * NOTE: "terminal for this uplink" is NOT "terminal forever". A deleted
339
+ * conversation can be revived in place under the same `cnv_` id (that is the
340
+ * path an agent re-pairing takes), so anything derived from these codes must
341
+ * be recoverable — see SERVER_REJECTION_TTL_MS in ws-client.ts.
342
+ */
343
+ export const TERMINAL_CHAT_CODES: ReadonlySet<string> = new Set([
344
+ "chat_not_found",
345
+ "not_member",
346
+ ]);
347
+
329
348
  export class StateError extends Error {
330
349
  override name = "StateError";
331
350
  }
package/src/runtime.ts CHANGED
@@ -19,7 +19,11 @@ import { buildActivationBootstrapText } from "./activation-greeting.ts";
19
19
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
20
20
  import path from "node:path";
21
21
  import { ensureLivewareCli, resolveLivewarePath } from "./liveware-cli.ts";
22
- import { LivewareSampleSupervisor, type LivewareSampleStoreLike } from "./liveware-sample.ts";
22
+ import {
23
+ LivewareSampleSupervisor,
24
+ type LivewareSampleStoreLike,
25
+ type LivewareSampleSupervisorDeps,
26
+ } from "./liveware-sample.ts";
23
27
  import { ClawlingApiError } from "./api-types.ts";
24
28
  import { RefreshManager } from "./refresh-manager.ts";
25
29
  import {
@@ -85,7 +89,7 @@ import { createGroupMessageCoalescer } from "./group-message-coalescer.ts";
85
89
  import { openclawLlmContextDebug } from "./llm-context-debug.ts";
86
90
  import { GroupSettingsCache } from "./group-settings.ts";
87
91
  import { PermissionCache } from "./permissions.ts";
88
- import { buildAwarenessNoteEnvelope } from "./awareness-note-turn.ts";
92
+ import { buildAwarenessNoteEnvelope, buildMomentCommentNoteEnvelope } from "./awareness-note-turn.ts";
89
93
  import { handlePermissionResult } from "./permission-result.ts";
90
94
 
91
95
  type Log = { debug?: (m: string) => void; info?: (m: string) => void; error?: (m: string) => void };
@@ -129,6 +133,18 @@ const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingR
129
133
  export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
130
134
 
131
135
  const activeClients = new Map<string, ClawlingChatClient>();
136
+ // Live liveware-sample supervisors, keyed by the gateway's abortSignal and then by
137
+ // `platform:accountId`. startOpenclawClawlingGateway re-enters ITSELF (token-refresh
138
+ // reconnect, transport backoff, post-auth-failed re-activation) carrying the same
139
+ // `...params` — so the same abortSignal — and a per-call `new LivewareSampleSupervisor`
140
+ // left the previous one running: both then raced the bootstrap "account has zero apps"
141
+ // gate and each minted its own liveware app, leaving a duplicate app tile that no
142
+ // SQLite row tracks (the single-row PK means the loser is orphaned forever). Keyed by
143
+ // signal so an entry cannot outlive the channel it belongs to.
144
+ const livewareSampleSupervisors = new WeakMap<
145
+ AbortSignal,
146
+ Map<string, LivewareSampleSupervisor>
147
+ >();
132
148
  const CLAWCHAT_PLUGIN_SLASH_COMMANDS = new Set(["clawchat-activate", "clawchat-output"]);
133
149
  const ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS = 2_000;
134
150
  // Activation-bootstrap retry on the live connection. The bootstrap greeting is
@@ -1111,7 +1127,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1111
1127
  let livewareSampleNotify: (text: string) => Promise<boolean> = async () => false;
1112
1128
  if (store) {
1113
1129
  const stateDirForSample = runtime.state?.resolveStateDir?.();
1114
- const livewareSampleSupervisor = new LivewareSampleSupervisor({
1130
+ const livewareSampleDeps: LivewareSampleSupervisorDeps = {
1115
1131
  platform: "openclaw",
1116
1132
  accountId,
1117
1133
  enabled: account.livewareSample,
@@ -1139,9 +1155,31 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1139
1155
  debug: (m) => log?.debug?.(m),
1140
1156
  error: (m) => log?.error?.(m),
1141
1157
  },
1142
- });
1143
- abortSignal.addEventListener("abort", () => livewareSampleSupervisor.stop());
1144
- void livewareSampleSupervisor.start();
1158
+ };
1159
+ const supervisorsForSignal =
1160
+ livewareSampleSupervisors.get(abortSignal) ?? new Map<string, LivewareSampleSupervisor>();
1161
+ livewareSampleSupervisors.set(abortSignal, supervisorsForSignal);
1162
+ const supervisorKey = `openclaw:${accountId}`;
1163
+ const runningSupervisor = supervisorsForSignal.get(supervisorKey);
1164
+ if (runningSupervisor) {
1165
+ // A gateway re-entry (refresh/backoff reconnect), not a fresh channel start:
1166
+ // hand the live supervisor this closure's token + REST client + notify path
1167
+ // instead of constructing a second one that would register a duplicate app.
1168
+ log?.debug?.(
1169
+ `[${accountId}] clawchat-plugin-openclaw liveware-sample supervisor already live; re-pointing it at the re-entered gateway`,
1170
+ );
1171
+ runningSupervisor.adoptDeps(livewareSampleDeps);
1172
+ } else {
1173
+ const livewareSampleSupervisor = new LivewareSampleSupervisor(livewareSampleDeps);
1174
+ supervisorsForSignal.set(supervisorKey, livewareSampleSupervisor);
1175
+ abortSignal.addEventListener("abort", () => {
1176
+ livewareSampleSupervisor.stop();
1177
+ if (supervisorsForSignal.get(supervisorKey) === livewareSampleSupervisor) {
1178
+ supervisorsForSignal.delete(supervisorKey);
1179
+ }
1180
+ });
1181
+ void livewareSampleSupervisor.start();
1182
+ }
1145
1183
  }
1146
1184
 
1147
1185
  const resolveMutateConfigFile = (): OpenclawClawchatMutateConfigFile | undefined => {
@@ -2236,6 +2274,31 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2236
2274
  ],
2237
2275
  }),
2238
2276
  );
2277
+ } else if (env.event === "message.error") {
2278
+ // Never fold this into `inbound_ignored`: `code` + `chat_id` are the
2279
+ // only in-plugin evidence that an uplink was refused, and the
2280
+ // 2026-07-19 incident needed a server-log dig purely because this line
2281
+ // did not exist. `action=message_error` marks it as semantically seen.
2282
+ const current = wsLogContext();
2283
+ const errorPayload = env.payload && typeof env.payload === "object"
2284
+ ? (env.payload as { code?: unknown })
2285
+ : undefined;
2286
+ log?.info?.(
2287
+ formatWsLog({
2288
+ event: "inbound_control",
2289
+ accountId,
2290
+ attempt: current.attempt,
2291
+ reconnectCount: current.reconnectCount,
2292
+ state: "ready",
2293
+ action: "message_error",
2294
+ fields: [
2295
+ ["event_name", env.event],
2296
+ ["trace_id", env.trace_id],
2297
+ ["chat_id", env.chat_id],
2298
+ ["code", typeof errorPayload?.code === "string" ? errorPayload.code : "-"],
2299
+ ],
2300
+ }),
2301
+ );
2239
2302
  } else if (env.event !== "ping" && env.event !== "pong") {
2240
2303
  const current = wsLogContext();
2241
2304
  log?.info?.(
@@ -2310,6 +2373,12 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2310
2373
  ?.conversationId?.trim();
2311
2374
  return conversationId || null;
2312
2375
  };
2376
+ // R3: the owner's direct conversation is never gated by the dead-chat send
2377
+ // guard. If it were, the very message explaining why the agent went dark
2378
+ // (skill-update notices, auth/logout notices, group owner-attention
2379
+ // forwards) would be unreachable. Registered lazily — the activation
2380
+ // conversation may be recorded after the client is constructed.
2381
+ client.setOwnerDirectChatResolver(resolveOwnerDirectConversationId);
2313
2382
  const sendOwnerSkillMessage = async (text: string): Promise<void> => {
2314
2383
  const conversationId = resolveOwnerDirectConversationId();
2315
2384
  if (!conversationId) {
@@ -2406,7 +2475,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2406
2475
  // for it. Falls through to the awareness branch so the owner is told.
2407
2476
  const entityId =
2408
2477
  typeof payload?.entity_id === "string" ? payload.entity_id : "";
2409
- const evicted = client.markChatDead(entityId);
2478
+ // "signal" tier: this gates typing only. The protocol forbids gating
2479
+ // message.send on a dissolved signal — only a server rejection
2480
+ // (message.error, see src/outbound.ts) may do that, and unlike this
2481
+ // one that rejection expires.
2482
+ const evicted = client.markChatDead(entityId, "signal");
2410
2483
  log?.info?.(
2411
2484
  evicted
2412
2485
  ? `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received; evicting ${entityId}`
@@ -2437,6 +2510,38 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2437
2510
  }`,
2438
2511
  );
2439
2512
  });
2513
+ } else if (type === "moment.comment.created" || type === "moment.comment.replied") {
2514
+ // Content-free trigger: the note carries only the scenario + moment id
2515
+ // and points the agent at the get_moment tool — never the comment
2516
+ // text. Not gated behind account.awarenessNote and not debounced:
2517
+ // each moment-comment signal yields its own note (msghub already
2518
+ // coalesces multiple comments on one moment by entity_id).
2519
+ const momentId =
2520
+ typeof payload?.entity_id === "string" ? payload.entity_id : "";
2521
+ // No activation conversation ⇒ no routable chat_id. Skip the note
2522
+ // rather than address it to the owner's usr_… id, which
2523
+ // member-backend rejects as "invalid conversation id" (the agent's
2524
+ // in-turn replies are then dropped with no negative ack). Resolve
2525
+ // this FIRST, mirroring the friend.*/conversation.* awareness
2526
+ // branch's guard below.
2527
+ const ownerConversationId = resolveOwnerDirectConversationId();
2528
+ if (!ownerConversationId) {
2529
+ log?.info?.(
2530
+ `[${accountId}] clawchat-plugin-openclaw ${type} signal received but no owner conversation resolved; skipping moment awareness note (moment ${momentId})`,
2531
+ );
2532
+ return;
2533
+ }
2534
+ log?.info?.(
2535
+ `[${accountId}] clawchat-plugin-openclaw ${type} signal received; synthesizing moment awareness note (moment ${momentId})`,
2536
+ );
2537
+ void handleInboundEnvelope(
2538
+ buildMomentCommentNoteEnvelope({
2539
+ account,
2540
+ ownerConversationId,
2541
+ momentId,
2542
+ replied: type === "moment.comment.replied",
2543
+ }),
2544
+ );
2440
2545
  } else if (
2441
2546
  type === "friend.added" ||
2442
2547
  type === "friend.removed" ||
package/src/storage.ts CHANGED
@@ -180,14 +180,20 @@ export type LivewareSampleUpsert = {
180
180
  port: number;
181
181
  publicUrl?: string | null;
182
182
  sampleVersion: string;
183
- status: "active" | "failed" | "disabled";
183
+ // "pending": `liveware app create` succeeded but the app is not registered with
184
+ // ClawChat yet. Persisted mid-bootstrap so a failure after create resumes with
185
+ // the same app id instead of minting (and leaking) another one.
186
+ status: "pending" | "active" | "failed" | "disabled";
184
187
  lastError?: string | null;
185
188
  };
186
189
 
187
190
  export type UpdateLivewareSampleStatusInput = {
188
191
  platform: string;
189
192
  accountId: string;
190
- status: "active" | "failed" | "disabled";
193
+ // "pending": `liveware app create` succeeded but the app is not registered with
194
+ // ClawChat yet. Persisted mid-bootstrap so a failure after create resumes with
195
+ // the same app id instead of minting (and leaking) another one.
196
+ status: "pending" | "active" | "failed" | "disabled";
191
197
  lastError?: string | null;
192
198
  };
193
199
 
@@ -323,6 +323,11 @@ export const ClawchatDeleteMomentSchema = Type.Object({
323
323
  });
324
324
  export type ClawchatDeleteMomentParams = Static<typeof ClawchatDeleteMomentSchema>;
325
325
 
326
+ export const ClawchatGetMomentSchema = Type.Object({
327
+ momentId: Type.Integer({ minimum: 1, description: "Concrete ClawChat moment id to fetch (with its visible comments)" }),
328
+ });
329
+ export type ClawchatGetMomentParams = Static<typeof ClawchatGetMomentSchema>;
330
+
326
331
  export const ClawchatToggleMomentReactionSchema = Type.Object({
327
332
  momentId: Type.Integer({
328
333
  minimum: 1,
package/src/tools.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  import {
41
41
  ClawchatGetAccountProfileSchema,
42
42
  ClawchatGetConversationSchema,
43
+ ClawchatGetMomentSchema,
43
44
  ClawchatGetUserProfileSchema,
44
45
  ClawchatLeaveGroupSchema,
45
46
  ClawchatAcceptFriendRequestSchema,
@@ -82,6 +83,7 @@ import {
82
83
  type ClawchatDeleteMomentParams,
83
84
  type ClawchatAcceptFriendRequestParams,
84
85
  type ClawchatGetConversationParams,
86
+ type ClawchatGetMomentParams,
85
87
  type ClawchatGetUserProfileParams,
86
88
  type ClawchatLeaveGroupParams,
87
89
  type ClawchatListFriendRequestsParams,
@@ -1194,6 +1196,24 @@ export function registerOpenclawClawlingTools(
1194
1196
  { name: "clawchat_list_moments" },
1195
1197
  );
1196
1198
 
1199
+ api.registerTool(
1200
+ {
1201
+ name: "clawchat_get_moment",
1202
+ label: "Get ClawChat Moment",
1203
+ description: toolDescription(
1204
+ "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.",
1205
+ ),
1206
+ parameters: ClawchatGetMomentSchema,
1207
+ async execute(_callId, params) {
1208
+ return await recordClawchatToolCall("clawchat_get_moment", params, async () => {
1209
+ const p = (params ?? {}) as ClawchatGetMomentParams;
1210
+ return await withClient((c) => c.getMoment(p.momentId));
1211
+ });
1212
+ },
1213
+ },
1214
+ { name: "clawchat_get_moment" },
1215
+ );
1216
+
1197
1217
  api.registerTool(
1198
1218
  {
1199
1219
  name: "clawchat_create_moment",
@@ -1544,6 +1564,6 @@ export function registerOpenclawClawlingTools(
1544
1564
  );
1545
1565
 
1546
1566
  api.logger.debug?.(
1547
- "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)",
1567
+ "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)",
1548
1568
  );
1549
1569
  }
@@ -9,6 +9,8 @@ export interface AlignedWsQueueItem {
9
9
  wire: string;
10
10
  onWrite?: () => void;
11
11
  onDrop?: () => void;
12
+ /** Called instead of `write` when the flush-time gate blocks this item. */
13
+ onBlock?: () => void;
12
14
  }
13
15
 
14
16
  export interface CreateAlignedWsQueueOptions {
@@ -85,9 +87,34 @@ export function createAlignedWsQueue(options: CreateAlignedWsQueueOptions) {
85
87
  logFrame("send_queued", "queue", item, [["queue_size", queue.length]]);
86
88
  },
87
89
 
88
- flush(write: (wire: string) => void) {
90
+ /**
91
+ * Drain the queue. `isBlocked` is consulted PER ITEM at write time, not at
92
+ * enqueue time: a conversation can be rejected by the server *after* a
93
+ * frame was queued, and this replay path never re-enters the send function
94
+ * that guards the live path. Without this hook the outbound gate is
95
+ * bypassable by any reconnect (and by any batch flush that happens to carry
96
+ * a live chat's frame).
97
+ */
98
+ flush(
99
+ write: (wire: string) => void,
100
+ options: { isBlocked?: (item: AlignedWsQueueItem) => boolean } = {},
101
+ ) {
89
102
  while (queue.length > 0) {
90
103
  const item = queue[0]!;
104
+ if (options.isBlocked?.(item)) {
105
+ queue.shift();
106
+ logFrame(
107
+ "send_blocked",
108
+ "drop",
109
+ item,
110
+ [
111
+ ["reason", "chat_rejected_by_server"],
112
+ ["remaining", queue.length],
113
+ ],
114
+ );
115
+ item.onBlock?.();
116
+ continue;
117
+ }
91
118
  try {
92
119
  write(item.wire);
93
120
  queue.shift();