@clawling/clawchat-plugin-openclaw 2026.7.29-1 → 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.
@@ -79,12 +79,20 @@ function buildOutcomeNote(metadata: PermissionResultMetadata): string {
79
79
  * Mirrors the `buildAwarenessNoteEnvelope` pattern: a synthetic inbound
80
80
  * `message.send` targeting the owner's direct conversation, routed through
81
81
  * `handleInboundEnvelope` so the agent can reason about the outcome.
82
+ *
83
+ * `ownerConversationId` MUST be the owner's direct conversation id (`cnv_…`)
84
+ * recorded at activation, never `account.ownerUserId`. The agent's in-turn
85
+ * reply inherits this note's `chat_id`, and msghub resolves chat_id through
86
+ * member-backend — a `usr_…` there comes back "invalid conversation id", so the
87
+ * reply is dropped and the sender never learns. Hence the required non-null
88
+ * parameter: there is no safe value to fall back to.
82
89
  */
83
90
  export function buildPermissionResultEnvelope(params: {
84
91
  account: Pick<ResolvedOpenclawClawlingAccount, "userId" | "ownerUserId">;
85
92
  metadata: PermissionResultMetadata;
93
+ ownerConversationId: string;
86
94
  }): Envelope {
87
- const { account, metadata } = params;
95
+ const { account, metadata, ownerConversationId } = params;
88
96
  const text = buildOutcomeNote(metadata);
89
97
  const now = Date.now();
90
98
  return {
@@ -92,7 +100,7 @@ export function buildPermissionResultEnvelope(params: {
92
100
  event: EVENT.MESSAGE_SEND,
93
101
  trace_id: `clawchat-plugin-openclaw-permission-result-${metadata.request_id}-${now}`,
94
102
  emitted_at: now,
95
- chat_id: account.ownerUserId,
103
+ chat_id: ownerConversationId,
96
104
  chat_type: "direct",
97
105
  to: { id: account.userId, type: "direct" },
98
106
  sender: {
@@ -128,13 +136,21 @@ export function buildPermissionResultEnvelope(params: {
128
136
  *
129
137
  * Returns a synthetic context-note {@link Envelope} on a fresh receipt so
130
138
  * the caller can feed it to `handleInboundEnvelope`; returns `null` when
131
- * the `request_id` has already been processed.
139
+ * the `request_id` has already been processed, or when `ownerConversationId`
140
+ * is absent.
141
+ *
142
+ * A null `ownerConversationId` (activation has not recorded the owner's direct
143
+ * conversation yet) is checked BEFORE the dedup mark on purpose: the receipt
144
+ * cannot be routed anywhere, so burning its dedup slot would permanently
145
+ * swallow it — the replayed copy must still render once the id is known.
132
146
  */
133
147
  export function handlePermissionResult(
134
148
  metadata: PermissionResultMetadata,
135
149
  account: Pick<ResolvedOpenclawClawlingAccount, "userId" | "ownerUserId">,
150
+ ownerConversationId: string | null,
136
151
  ): Envelope | null {
152
+ if (!ownerConversationId) return null;
137
153
  if (metadata.request_id && _seenRequestIds.has(metadata.request_id)) return null;
138
154
  if (metadata.request_id) _seenRequestIds.add(metadata.request_id);
139
- return buildPermissionResultEnvelope({ account, metadata });
155
+ return buildPermissionResultEnvelope({ account, metadata, ownerConversationId });
140
156
  }
@@ -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
  }
@@ -59,6 +59,18 @@ export interface ReplyDispatcherOptions {
59
59
  ClawChatStore,
60
60
  "insertMessage" | "claimMessageOnce" | "markMessageAcknowledged" | "updateMessageByIdentity"
61
61
  > | null;
62
+ /**
63
+ * Resolves the owner's direct conversation id (`cnv_…`) recorded at
64
+ * activation, or null when none is on record. Used only by the group
65
+ * owner-attention escalation, which must address a conversation id — NOT
66
+ * `account.ownerUserId`. member-backend rejects a `usr_…` chat_id with
67
+ * "invalid conversation id", so such an escalation is dropped server-side
68
+ * and the owner never sees it.
69
+ *
70
+ * Called lazily at send time, so an activation that lands after this
71
+ * dispatcher was constructed is still picked up.
72
+ */
73
+ resolveOwnerConversationId?: () => string | null;
62
74
  log?: { info?: (m: string) => void; error?: (m: string) => void };
63
75
  }
64
76
 
@@ -387,15 +399,24 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
387
399
  replyCtx,
388
400
  inboundMessageId,
389
401
  store,
402
+ resolveOwnerConversationId,
390
403
  log,
391
404
  } = options;
392
405
  const isGroupTarget = target.chatType === "group";
393
406
  const outputVisibility = effectiveOutputVisibility(account, target.chatId, target.chatType);
394
407
  const splitFullOutput = outputVisibility === "full";
395
408
  const splitNormalBlockOutput = outputVisibility === "normal";
396
- const ownerDirectTarget = () => {
409
+ // The escalation target is the owner's ACTIVATION CONVERSATION id, never the
410
+ // owner's usr_… id: chat_id is resolved by msghub through member-backend, and
411
+ // a usr_… comes back "invalid conversation id" — the frame is dropped with no
412
+ // negative ack to this client, so the owner silently never gets the approval
413
+ // request. Both ids are required: ownerUserId gates whether an owner exists at
414
+ // all, the conversation id is where a frame can actually be delivered.
415
+ const ownerDirectTarget = (): { chatId: string; chatType: "direct" } | null => {
397
416
  const ownerUserId = account.ownerUserId?.trim();
398
- return ownerUserId ? { chatId: ownerUserId, chatType: "direct" as const } : null;
417
+ if (!ownerUserId) return null;
418
+ const conversationId = resolveOwnerConversationId?.()?.trim();
419
+ return conversationId ? { chatId: conversationId, chatType: "direct" as const } : null;
399
420
  };
400
421
  const humanDelay = runtime.channel.reply.resolveHumanDelayConfig(cfg, account.userId);
401
422
 
@@ -585,8 +606,11 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
585
606
  ): Promise<void> => {
586
607
  const ownerTarget = ownerDirectTarget();
587
608
  if (!ownerTarget) {
609
+ const reason = account.ownerUserId?.trim()
610
+ ? "missing_owner_conversation"
611
+ : "missing_owner_user_id";
588
612
  log?.error?.(
589
- `[${account.accountId}] clawchat-plugin-openclaw group owner attention suppressed reason=missing_owner_user_id group=${target.chatId}`,
613
+ `[${account.accountId}] clawchat-plugin-openclaw group owner attention suppressed reason=${reason} group=${target.chatId}`,
590
614
  );
591
615
  return;
592
616
  }
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" ||
@@ -2451,11 +2556,19 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2451
2556
  pendingAwarenessNote = true;
2452
2557
  queueMicrotask(() => {
2453
2558
  pendingAwarenessNote = false;
2559
+ // No activation conversation ⇒ no routable chat_id. Skip the note
2560
+ // rather than address it to the owner's usr_… id, which
2561
+ // member-backend rejects as "invalid conversation id" (the agent's
2562
+ // in-turn replies are then dropped with no negative ack).
2563
+ const ownerConversationId = resolveOwnerDirectConversationId();
2564
+ if (!ownerConversationId) {
2565
+ log?.info?.(
2566
+ `[${accountId}] clawchat-plugin-openclaw awareness note skipped: no activation conversation recorded`,
2567
+ );
2568
+ return;
2569
+ }
2454
2570
  void handleInboundEnvelope(
2455
- buildAwarenessNoteEnvelope({
2456
- account,
2457
- ownerConversationId: resolveOwnerDirectConversationId(),
2458
- }),
2571
+ buildAwarenessNoteEnvelope({ account, ownerConversationId }),
2459
2572
  );
2460
2573
  });
2461
2574
  }
@@ -2864,6 +2977,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2864
2977
  updateMessageByIdentity: (input) => store.updateMessageByIdentity?.(input),
2865
2978
  }
2866
2979
  : null,
2980
+ // Group owner-attention escalations address the owner's activation
2981
+ // conversation, not `ownerUserId` (a usr_… chat_id is rejected by
2982
+ // member-backend). Passed as a thunk so it is resolved at send time.
2983
+ resolveOwnerConversationId: resolveOwnerDirectConversationId,
2867
2984
  log,
2868
2985
  });
2869
2986
 
@@ -3164,7 +3281,23 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3164
3281
  // A permission_result receipt closes the loop on an earlier request.
3165
3282
  // Dedup by request_id; on a fresh receipt feed the synthetic context
3166
3283
  // note back to the agent so it can react to the outcome.
3167
- const note = handlePermissionResult(metadata, account);
3284
+ //
3285
+ // The note is addressed to the owner's ACTIVATION CONVERSATION id
3286
+ // (cnv_…), never `account.ownerUserId`: the agent's in-turn reply
3287
+ // inherits this chat_id, and member-backend rejects a usr_… with
3288
+ // "invalid conversation id" — the reply is then dropped server-side
3289
+ // with no negative ack, so the outcome silently never reaches the
3290
+ // owner. With no conversation on record the receipt is skipped
3291
+ // outright (see handlePermissionResult — the dedup slot is preserved
3292
+ // so a replay after activation still renders).
3293
+ const ownerConversationId = resolveOwnerDirectConversationId();
3294
+ if (!ownerConversationId) {
3295
+ log?.info?.(
3296
+ `[${accountId}] clawchat-plugin-openclaw permission_result receipt op=${metadata.operation} outcome=${metadata.outcome} request_id=${metadata.request_id} skipped: no activation conversation recorded`,
3297
+ );
3298
+ return;
3299
+ }
3300
+ const note = handlePermissionResult(metadata, account, ownerConversationId);
3168
3301
  if (note) {
3169
3302
  log?.info?.(
3170
3303
  `[${accountId}] clawchat-plugin-openclaw permission_result receipt op=${metadata.operation} outcome=${metadata.outcome} request_id=${metadata.request_id}; dispatching context note`,
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();