@newbase-clawchat/openclaw-clawchat 2026.5.4 → 2026.5.12-13

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.
Files changed (85) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +121 -19
  3. package/dist/index.js +10 -19
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +78 -10
  6. package/dist/src/api-types.test-d.js +10 -0
  7. package/dist/src/channel.js +25 -156
  8. package/dist/src/channel.setup.js +120 -0
  9. package/dist/src/client.js +37 -41
  10. package/dist/src/config.js +75 -17
  11. package/dist/src/inbound.js +79 -61
  12. package/dist/src/login.runtime.js +84 -19
  13. package/dist/src/media-runtime.js +8 -8
  14. package/dist/src/message-mapper.js +1 -1
  15. package/dist/src/mock-transport.js +31 -0
  16. package/dist/src/outbound.js +410 -26
  17. package/dist/src/protocol-types.js +63 -0
  18. package/dist/src/protocol-types.typecheck.js +1 -0
  19. package/dist/src/protocol.js +2 -7
  20. package/dist/src/reply-dispatcher.js +157 -54
  21. package/dist/src/runtime.js +795 -119
  22. package/dist/src/storage.js +689 -0
  23. package/dist/src/tools-schema.js +98 -16
  24. package/dist/src/tools.js +422 -135
  25. package/dist/src/ws-alignment.js +178 -0
  26. package/dist/src/ws-client.js +588 -0
  27. package/dist/src/ws-log.js +19 -0
  28. package/index.ts +10 -22
  29. package/openclaw.plugin.json +37 -2
  30. package/package.json +17 -4
  31. package/setup-entry.ts +4 -0
  32. package/skills/clawchat/SKILL.md +88 -0
  33. package/src/api-client.test.ts +274 -14
  34. package/src/api-client.ts +138 -23
  35. package/src/api-types.test-d.ts +12 -0
  36. package/src/api-types.ts +90 -4
  37. package/src/buffered-stream.test.ts +14 -12
  38. package/src/buffered-stream.ts +1 -1
  39. package/src/channel.outbound.test.ts +269 -60
  40. package/src/channel.setup.ts +146 -0
  41. package/src/channel.test.ts +130 -24
  42. package/src/channel.ts +30 -186
  43. package/src/client.test.ts +197 -11
  44. package/src/client.ts +50 -57
  45. package/src/config.test.ts +108 -6
  46. package/src/config.ts +95 -24
  47. package/src/inbound.test.ts +288 -37
  48. package/src/inbound.ts +96 -84
  49. package/src/login.runtime.test.ts +347 -13
  50. package/src/login.runtime.ts +105 -23
  51. package/src/manifest.test.ts +146 -74
  52. package/src/media-runtime.test.ts +57 -2
  53. package/src/media-runtime.ts +26 -17
  54. package/src/message-mapper.test.ts +2 -2
  55. package/src/message-mapper.ts +2 -2
  56. package/src/mock-transport.test.ts +35 -0
  57. package/src/mock-transport.ts +38 -0
  58. package/src/outbound.test.ts +694 -73
  59. package/src/outbound.ts +484 -31
  60. package/src/plugin-entry.test.ts +1 -0
  61. package/src/protocol-types.test.ts +69 -0
  62. package/src/protocol-types.ts +296 -0
  63. package/src/protocol-types.typecheck.ts +89 -0
  64. package/src/protocol.test.ts +1 -6
  65. package/src/protocol.ts +2 -7
  66. package/src/reply-dispatcher.test.ts +819 -119
  67. package/src/reply-dispatcher.ts +202 -60
  68. package/src/runtime.test.ts +2120 -41
  69. package/src/runtime.ts +935 -142
  70. package/src/scripts.test.ts +85 -0
  71. package/src/storage.test.ts +793 -0
  72. package/src/storage.ts +1095 -0
  73. package/src/streaming.test.ts +9 -8
  74. package/src/streaming.ts +1 -1
  75. package/src/tools-schema.ts +148 -20
  76. package/src/tools.test.ts +377 -50
  77. package/src/tools.ts +574 -154
  78. package/src/ws-alignment.test.ts +103 -0
  79. package/src/ws-alignment.ts +275 -0
  80. package/src/ws-client.test.ts +1218 -0
  81. package/src/ws-client.ts +662 -0
  82. package/src/ws-log.test.ts +32 -0
  83. package/src/ws-log.ts +31 -0
  84. package/skills/clawchat-account-tools/SKILL.md +0 -26
  85. package/skills/clawchat-activate/SKILL.md +0 -47
@@ -1,4 +1,5 @@
1
- import type { ClawlingChatClient, Fragment } from "@newbase-clawchat/sdk";
1
+ import type { Fragment } from "./protocol-types.ts";
2
+ import type { ClawlingChatClient } from "./ws-client.ts";
2
3
  import {
3
4
  interactiveReplyToPresentation,
4
5
  renderMessagePresentationFallbackText,
@@ -8,6 +9,7 @@ import {
8
9
  } from "openclaw/plugin-sdk/interactive-runtime";
9
10
  import type { PluginRuntime } from "openclaw/plugin-sdk/core";
10
11
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
12
+ import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
11
13
  import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
12
14
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
13
15
  import {
@@ -15,12 +17,16 @@ import {
15
17
  mergeStreamingText,
16
18
  type BufferedStreamSession,
17
19
  } from "./buffered-stream.ts";
18
- import { emitFinalStreamReply, type ChatType, type EnvelopeRouting } from "./client.ts";
20
+ import type { ChatType, EnvelopeRouting } from "./client.ts";
19
21
  import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
20
- import { textToFragments } from "./message-mapper.ts";
21
22
  import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
22
- import { sendOpenclawClawlingText, type OutboundReplyCtx } from "./outbound.ts";
23
+ import {
24
+ sendOpenclawClawlingText,
25
+ type OutboundReplyCtx,
26
+ type SendResult,
27
+ } from "./outbound.ts";
23
28
  import { sendStreamingFailure } from "./streaming.ts";
29
+ import type { ClawChatStore } from "./storage.ts";
24
30
 
25
31
  export interface ReplyDispatcherOptions {
26
32
  cfg: OpenClawConfig;
@@ -51,6 +57,7 @@ export interface ReplyDispatcherOptions {
51
57
  senderNickName: string;
52
58
  bodyText: string;
53
59
  };
60
+ store?: Pick<ClawChatStore, "insertMessage" | "claimMessageOnce" | "updateMessageByIdentity"> | null;
54
61
  log?: { info?: (m: string) => void; error?: (m: string) => void };
55
62
  }
56
63
 
@@ -63,6 +70,11 @@ type StreamingReplyHooks = {
63
70
  onReasoningStream?: (payload: ReplyPayload) => Promise<void>;
64
71
  };
65
72
 
73
+ type ClawChatReplyOptions = TypedReplyDispatcherResult["replyOptions"] &
74
+ StreamingReplyHooks & {
75
+ sourceReplyDeliveryMode: "automatic";
76
+ };
77
+
66
78
  type RichAction = {
67
79
  id: string;
68
80
  label: string;
@@ -79,6 +91,8 @@ type RichInteractionFragment = {
79
91
  actions: RichAction[];
80
92
  };
81
93
 
94
+ const CLIENT_SAFE_REPLY_FAILURE_TEXT = "OpenClaw could not complete this reply.";
95
+
82
96
  function normalizeReplyErrorText(error: unknown): string {
83
97
  const raw = String(error);
84
98
  const retryWrapped = raw.match(/^Error: Retry failed for delivery [^:]+:\s*(.+)$/s);
@@ -192,7 +206,7 @@ function resolvePayloadText(payload: ReplyPayload): string {
192
206
  */
193
207
  export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOptions): {
194
208
  dispatcher: TypedReplyDispatcherResult["dispatcher"];
195
- replyOptions: TypedReplyDispatcherResult["replyOptions"] & StreamingReplyHooks;
209
+ replyOptions: ClawChatReplyOptions;
196
210
  markDispatchIdle: TypedReplyDispatcherResult["markDispatchIdle"];
197
211
  } {
198
212
  const {
@@ -204,6 +218,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
204
218
  replyCtx,
205
219
  inboundMessageId,
206
220
  inboundForFinalReply,
221
+ store,
207
222
  log,
208
223
  } = options;
209
224
  const routing: EnvelopeRouting = { chatId: target.chatId, chatType: target.chatType };
@@ -243,18 +258,94 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
243
258
  let streamingClosed = false;
244
259
  let runFailed = false;
245
260
  let runDone = false;
261
+ let streamClaimAttempted = false;
246
262
  // `streamCreatedEmitted` is the authoritative guard: once a `message.created`
247
263
  // has been emitted for this dispatcher instance, never emit another — even
248
264
  // if `onReplyStart` fires again or a pre-onReplyStart `onPartialReply`
249
265
  // raced the lazy open path.
250
266
  let streamCreatedEmitted = false;
251
267
 
268
+ const outboundEventType = () => (replyCtx ? "message.reply" : "message.send");
269
+ const outboundRaw = () => ({ target, replyCtx: replyCtx ?? null });
270
+ const claimOutbound = (
271
+ eventType: "message.created" | "message.send" | "message.reply",
272
+ messageId: string,
273
+ text: string,
274
+ raw: unknown,
275
+ ): true | false | null => {
276
+ if (!store || !messageId) return null;
277
+ try {
278
+ return store.claimMessageOnce({
279
+ platform: "openclaw",
280
+ accountId: account.accountId,
281
+ kind: "message",
282
+ direction: "outbound",
283
+ eventType,
284
+ chatId: target.chatId,
285
+ messageId,
286
+ text,
287
+ raw,
288
+ });
289
+ } catch {
290
+ log?.error?.(`[${account.accountId}] openclaw-clawchat sqlite outbound claim failed`);
291
+ return null;
292
+ }
293
+ };
294
+ const updateOutbound = (
295
+ eventType: "message.reply",
296
+ messageId: string,
297
+ text: string,
298
+ raw: unknown,
299
+ ) => {
300
+ if (!store || !messageId) return;
301
+ try {
302
+ store.updateMessageByIdentity({
303
+ accountId: account.accountId,
304
+ kind: "message",
305
+ direction: "outbound",
306
+ eventType,
307
+ chatId: target.chatId,
308
+ messageId,
309
+ text,
310
+ raw,
311
+ });
312
+ } catch {
313
+ log?.error?.(`[${account.accountId}] openclaw-clawchat sqlite outbound update failed; continuing`);
314
+ }
315
+ };
316
+ const recordOutbound = (kind: "message" | "thinking", messageId: string, text: string) => {
317
+ if (!store || !messageId) return;
318
+ try {
319
+ store.insertMessage({
320
+ platform: "openclaw",
321
+ accountId: account.accountId,
322
+ kind,
323
+ direction: "outbound",
324
+ eventType: outboundEventType(),
325
+ chatId: target.chatId,
326
+ messageId,
327
+ text,
328
+ raw: outboundRaw(),
329
+ });
330
+ } catch {
331
+ log?.error?.(`[${account.accountId}] openclaw-clawchat sqlite outbound insert failed; continuing`);
332
+ }
333
+ };
334
+ const recordThinkingIfLinked = (messageId: string) => {
335
+ const thinkingText = reasoningText.trim();
336
+ if (!thinkingText) return;
337
+ recordOutbound("thinking", messageId, thinkingText);
338
+ reasoningText = "";
339
+ };
340
+
252
341
  const mintStreamingMessageId = () =>
253
342
  `${account.userId}-stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
343
+ const mintStaticMessageId = () =>
344
+ `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
254
345
 
255
346
  const openSessionIfNeeded = () => {
256
- if (!streamingEnabled || streamingSession || streamCreatedEmitted) return;
257
- streamCreatedEmitted = true;
347
+ if (!streamingEnabled || streamingSession || streamCreatedEmitted || streamClaimAttempted) return;
348
+ streamClaimAttempted = true;
258
349
  // Mint a fresh agent-side message_id at `message.created` time. All
259
350
  // subsequent `message.add` / `message.done` / `message.reply` frames for
260
351
  // this stream reuse it. Once the stream finalizes (done or reply), this
@@ -263,12 +354,27 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
263
354
  // `replyTo.msgId`; keeping the two distinct avoids the agent's reply
264
355
  // frames shadowing the user turn they answer.
265
356
  streamingMessageId = mintStreamingMessageId();
357
+ const claimed = claimOutbound(
358
+ "message.created",
359
+ streamingMessageId,
360
+ "",
361
+ { target, replyCtx: replyCtx ?? null, mode: "stream" },
362
+ );
363
+ if (claimed !== true) {
364
+ streamCreatedEmitted = false;
365
+ streamingMessageId = "";
366
+ log?.[claimed === false ? "info" : "error"]?.(
367
+ `[${account.accountId}] openclaw-clawchat stream outbound skipped reason=${claimed === false ? "duplicate" : "claim_unavailable"}`,
368
+ );
369
+ return;
370
+ }
371
+ streamCreatedEmitted = true;
266
372
  streamingSession = openBufferedStreamingSession({
267
373
  client,
268
374
  routing,
269
375
  sender: {
270
376
  id: account.userId,
271
- type: target.chatType,
377
+ type: "direct",
272
378
  nick_name: account.userId,
273
379
  },
274
380
  messageId: streamingMessageId,
@@ -315,16 +421,31 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
315
421
  text: string,
316
422
  mediaFragments: ClawlingMediaFragment[] = [],
317
423
  richFragments: Fragment[] = [],
318
- ) => {
319
- if (!text.trim() && mediaFragments.length === 0 && richFragments.length === 0) return;
424
+ options: { recordMessage?: boolean } = {},
425
+ ): Promise<SendResult | null> => {
426
+ if (!text.trim() && mediaFragments.length === 0 && richFragments.length === 0) return null;
320
427
  log?.info?.(
321
428
  `[${account.accountId}] openclaw-clawchat sending static text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`,
322
429
  );
323
- await sendOpenclawClawlingText({
430
+ const messageId = mintStaticMessageId();
431
+ const raw = { target, replyCtx: replyCtx ?? null, mode: "static" };
432
+ const claimed = options.recordMessage
433
+ ? claimOutbound(outboundEventType(), messageId, text, raw)
434
+ : true;
435
+ if (claimed === false) {
436
+ log?.info?.(`[${account.accountId}] openclaw-clawchat outbound duplicate skipped msg=${messageId}`);
437
+ return null;
438
+ }
439
+ if (claimed === null) {
440
+ log?.error?.(`[${account.accountId}] openclaw-clawchat outbound skipped msg=${messageId} reason=claim_unavailable`);
441
+ return null;
442
+ }
443
+ const result = await sendOpenclawClawlingText({
324
444
  client,
325
445
  account,
326
446
  to: target,
327
447
  text,
448
+ messageId,
328
449
  ...(replyCtx ? { replyCtx } : {}),
329
450
  ...(richFragments.length > 0 ? { richFragments } : {}),
330
451
  ...(mediaFragments.length > 0 ? { mediaFragments } : {}),
@@ -333,11 +454,23 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
333
454
  log?.info?.(
334
455
  `[${account.accountId}] openclaw-clawchat send complete to=${target.chatId}`,
335
456
  );
457
+ return result;
458
+ };
459
+
460
+ const logDetachedFailure = (action: string, error: unknown) => {
461
+ log?.error?.(
462
+ `[${account.accountId}] openclaw-clawchat ${action} failed: ${String(error)}`,
463
+ );
464
+ };
465
+
466
+ const settleDetached = (action: string, promise: Promise<unknown>) => {
467
+ void promise.catch((error) => logDetachedFailure(action, error));
336
468
  };
337
469
 
338
470
  const emitFinalConsolidatedMessage = async () => {
339
471
  if (finalEmitted) return;
340
472
  finalEmitted = true;
473
+ if (!streamingMessageId) return;
341
474
  const mergedMedia = await uploadMediaUrls(accumulatedMediaUrls.slice());
342
475
  const mergedText = streamText.trim();
343
476
  if (!mergedText && finalRichFragments.length === 0 && mergedMedia.length === 0) {
@@ -349,29 +482,35 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
349
482
  log?.info?.(
350
483
  `[${account.accountId}] openclaw-clawchat emitting consolidated final (message.reply) msg=${streamingMessageId} text_len=${mergedText.length} media=${mergedMedia.length}`,
351
484
  );
352
- const bodyFragments: Fragment[] = [
353
- ...(mergedText ? textToFragments(mergedText) : []),
354
- ...finalRichFragments,
355
- // mediaFragments is the local wide shape; cast at SDK boundary as
356
- // we do in outbound.ts.
357
- ...(mergedMedia as Fragment[]),
358
- ];
485
+ const finalReplyCtx: OutboundReplyCtx = {
486
+ replyToMessageId: inboundMessageId ?? streamingMessageId,
487
+ replyPreviewChatId: inboundForFinalReply?.chatId ?? target.chatId,
488
+ replyPreviewSenderId: inboundForFinalReply?.senderId ?? target.chatId,
489
+ replyPreviewNickName:
490
+ inboundForFinalReply?.senderNickName ?? inboundForFinalReply?.senderId ?? target.chatId,
491
+ replyPreviewText: inboundForFinalReply?.bodyText ?? "",
492
+ };
359
493
  // Streaming message_id must match the created/add/done frames so the
360
- // backend can correlate the consolidated reply with the stream.
361
- emitFinalStreamReply(client, {
494
+ // backend can correlate the consolidated reply with the stream. Use the
495
+ // ackable send path so disconnects queue the final user-visible answer.
496
+ await sendOpenclawClawlingText({
497
+ client,
498
+ account,
499
+ to: target,
500
+ text: mergedText,
362
501
  messageId: streamingMessageId,
363
- routing,
364
- replyTo: {
365
- msgId: inboundMessageId ?? streamingMessageId,
366
- previewId: inboundForFinalReply?.chatId ?? target.chatId,
367
- nickName:
368
- inboundForFinalReply?.senderNickName ?? inboundForFinalReply?.senderId ?? target.chatId,
369
- fragments: inboundForFinalReply?.bodyText
370
- ? [{ kind: "text", text: inboundForFinalReply.bodyText }]
371
- : [],
372
- },
373
- body: { fragments: bodyFragments },
502
+ replyCtx: finalReplyCtx,
503
+ ...(finalRichFragments.length > 0 ? { richFragments: finalRichFragments } : {}),
504
+ ...(mergedMedia.length > 0 ? { mediaFragments: mergedMedia } : {}),
505
+ log,
374
506
  });
507
+ updateOutbound(
508
+ "message.reply",
509
+ streamingMessageId,
510
+ mergedText,
511
+ { target, replyCtx: replyCtx ?? null, mode: "stream-final" },
512
+ );
513
+ recordThinkingIfLinked(streamingMessageId);
375
514
  };
376
515
 
377
516
  const ingestFinalPayload = (payload: ReplyPayload, text: string, richFragment: Fragment | null) => {
@@ -379,10 +518,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
379
518
  finalRichFragments.push(richFragment);
380
519
  }
381
520
  if (text) streamText = mergeStreamingText(streamText, text);
382
- const urls = [
383
- ...(payload.mediaUrl ? [payload.mediaUrl] : []),
384
- ...(payload.mediaUrls ?? []),
385
- ].filter((u): u is string => Boolean(u));
521
+ const urls = resolveOutboundMediaUrls(payload).filter(Boolean);
386
522
  for (const url of urls) {
387
523
  if (!accumulatedMediaUrls.includes(url)) accumulatedMediaUrls.push(url);
388
524
  }
@@ -422,10 +558,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
422
558
  deliver: async (payload: ReplyPayload, info?: { kind: "tool" | "block" | "final" }) => {
423
559
  const richFragment = buildRichInteractionFragment(payload);
424
560
  const text = richFragment && account.richInteractions ? "" : resolvePayloadText(payload);
425
- const urls = [
426
- ...(payload.mediaUrl ? [payload.mediaUrl] : []),
427
- ...(payload.mediaUrls ?? []),
428
- ].filter((u): u is string => Boolean(u));
561
+ const urls = resolveOutboundMediaUrls(payload).filter(Boolean);
429
562
  log?.info?.(
430
563
  `[${account.accountId}] openclaw-clawchat deliver kind=${info?.kind ?? "unknown"} text_len=${text.length} media_urls=${urls.length} reasoning=${payload.isReasoning === true}`,
431
564
  );
@@ -436,7 +569,9 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
436
569
  reasoningText = mergeStreamingText(reasoningText, text);
437
570
  await queueStreamSnapshot();
438
571
  } else {
439
- await sendStatic(text);
572
+ reasoningText = mergeStreamingText(reasoningText, text);
573
+ const result = await sendStatic(text, [], [], { recordMessage: true });
574
+ if (result?.messageId) recordThinkingIfLinked(result.messageId);
440
575
  }
441
576
  return;
442
577
  }
@@ -451,13 +586,21 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
451
586
  );
452
587
  // For streaming: consolidated final is emitted in onIdle after done.
453
588
  // For static: emit immediately.
454
- if (!streamingEnabled) {
589
+ if (streamingEnabled) {
590
+ if (text.trim()) {
591
+ await queueStreamSnapshot();
592
+ } else if (richFragment || urls.length > 0) {
593
+ openSessionIfNeeded();
594
+ }
595
+ } else {
455
596
  const mediaFragments = await uploadMediaUrls(urls);
456
- await sendStatic(
597
+ const result = await sendStatic(
457
598
  text,
458
599
  mediaFragments,
459
600
  richFragment && account.richInteractions ? ([richFragment] as unknown as Fragment[]) : [],
601
+ { recordMessage: true },
460
602
  );
603
+ if (result?.messageId) recordThinkingIfLinked(result.messageId);
461
604
  }
462
605
  return;
463
606
  }
@@ -471,14 +614,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
471
614
  log?.info?.(
472
615
  `[${account.accountId}] openclaw-clawchat mid-stream media emitted as separate message (count=${mediaFragments.length})`,
473
616
  );
474
- await sendOpenclawClawlingText({
475
- client,
476
- account,
477
- to: target,
478
- text: "",
479
- mediaFragments,
480
- log,
481
- });
617
+ await sendStatic("", mediaFragments, [], { recordMessage: true });
482
618
  }
483
619
  }
484
620
  } else {
@@ -486,7 +622,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
486
622
  const richFragments =
487
623
  richFragment && account.richInteractions ? ([richFragment] as unknown as Fragment[]) : [];
488
624
  if (text.trim() || mediaFragments.length > 0 || richFragments.length > 0) {
489
- await sendStatic(text, mediaFragments, richFragments);
625
+ await sendStatic(text, mediaFragments, richFragments, { recordMessage: true });
490
626
  }
491
627
  }
492
628
  },
@@ -496,24 +632,29 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
496
632
  `[${account.accountId}] openclaw-clawchat ${info.kind} reply failed: ${errorText}`,
497
633
  );
498
634
  if (!streamingEnabled) {
499
- void sendStatic(errorText);
500
635
  return;
501
636
  }
502
637
  runFailed = true;
503
638
  if (streamingSession && !streamingClosed) {
504
- void closeStreamingSession("fail", errorText);
639
+ settleDetached(
640
+ "stream failure close",
641
+ closeStreamingSession("fail", CLIENT_SAFE_REPLY_FAILURE_TEXT),
642
+ );
505
643
  return;
506
644
  }
507
645
  if (streamingClosed) return;
508
646
  streamingClosed = true;
509
647
  if (!streamingMessageId) streamingMessageId = mintStreamingMessageId();
510
- void sendStreamingFailure({
511
- client,
512
- routing,
513
- messageId: streamingMessageId,
514
- currentSequence: 0,
515
- reason: errorText,
516
- });
648
+ settleDetached(
649
+ "stream failure send",
650
+ sendStreamingFailure({
651
+ client,
652
+ routing,
653
+ messageId: streamingMessageId,
654
+ currentSequence: 0,
655
+ reason: CLIENT_SAFE_REPLY_FAILURE_TEXT,
656
+ }),
657
+ );
517
658
  },
518
659
  onIdle: async () => {
519
660
  if (runDone) return;
@@ -551,6 +692,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
551
692
  dispatcher: base.dispatcher,
552
693
  replyOptions: {
553
694
  ...base.replyOptions,
695
+ sourceReplyDeliveryMode: "automatic",
554
696
  ...streamingHooks,
555
697
  },
556
698
  markDispatchIdle: base.markDispatchIdle,