@clawling/clawchat-plugin-openclaw 2026.9.26-3 → 2026.10.7-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,6 +82,7 @@ export const openclawClawlingAccountConfigSchema = {
82
82
  forwardThinking: { type: "boolean" },
83
83
  forwardToolCalls: { type: "boolean" },
84
84
  richInteractions: { type: "boolean" },
85
+ streamReplies: { type: "boolean" },
85
86
  awarenessNote: { type: "boolean" },
86
87
  friendGreeting: { type: "boolean" },
87
88
  livewareSample: { type: "boolean" },
@@ -448,6 +449,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
448
449
  const forwardThinking = typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
449
450
  const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
450
451
  const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
452
+ const streamReplies = typeof channel.streamReplies === "boolean" ? channel.streamReplies : false;
451
453
  const awarenessNote = typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
452
454
  const friendGreeting = typeof channel.friendGreeting === "boolean" ? channel.friendGreeting : true;
453
455
  const livewareSample = typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
@@ -476,6 +478,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
476
478
  forwardThinking,
477
479
  forwardToolCalls,
478
480
  richInteractions,
481
+ streamReplies,
479
482
  awarenessNote,
480
483
  friendGreeting,
481
484
  livewareSample,
@@ -0,0 +1,155 @@
1
+ /** The `@everyone` sentinel user id; never auto-linked from text. */
2
+ export const MENTION_ALL_SENTINEL = "all";
3
+ const AT = 0x40;
4
+ function isAsciiAlnum(c) {
5
+ return (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a);
6
+ }
7
+ function isEmailLocal(c) {
8
+ return isAsciiAlnum(c) || c === 0x2e || c === 0x5f || c === 0x25 || c === 0x2b || c === 0x2d;
9
+ }
10
+ function openBoundary(text, at) {
11
+ if (at === 0)
12
+ return true;
13
+ return !isEmailLocal(text.charCodeAt(at - 1));
14
+ }
15
+ function lowerAscii(c) {
16
+ return c >= 0x41 && c <= 0x5a ? c + 0x20 : c;
17
+ }
18
+ function matches(text, start, name, fold) {
19
+ const end = start + name.length;
20
+ if (end > text.length)
21
+ return false;
22
+ for (let k = 0; k < name.length; k += 1) {
23
+ let a = text.charCodeAt(start + k);
24
+ let b = name.charCodeAt(k);
25
+ if (fold) {
26
+ a = lowerAscii(a);
27
+ b = lowerAscii(b);
28
+ }
29
+ if (a !== b)
30
+ return false;
31
+ }
32
+ if (end === text.length)
33
+ return true;
34
+ return !(isAsciiAlnum(name.charCodeAt(name.length - 1)) && isAsciiAlnum(text.charCodeAt(end)));
35
+ }
36
+ function matchAt(text, start, targets) {
37
+ let best = null;
38
+ let bestExact = false;
39
+ let ambiguous = false;
40
+ for (const t of targets) {
41
+ const exact = matches(text, start, t.name, false);
42
+ const hit = exact || matches(text, start, t.name, true);
43
+ if (!hit)
44
+ continue;
45
+ if (!best ||
46
+ t.name.length > best.name.length ||
47
+ (t.name.length === best.name.length && exact && !bestExact)) {
48
+ best = t;
49
+ bestExact = exact;
50
+ ambiguous = false;
51
+ }
52
+ else if (t.name.length === best.name.length && exact === bestExact && t.userId !== best.userId) {
53
+ ambiguous = true;
54
+ }
55
+ }
56
+ return ambiguous ? null : best;
57
+ }
58
+ function targetsOf(roster, ownUserId) {
59
+ const out = [];
60
+ for (const m of roster) {
61
+ const name = typeof m.name === "string" ? m.name.trim() : "";
62
+ const userId = typeof m.userId === "string" ? m.userId.trim() : "";
63
+ if (!name || !userId)
64
+ continue;
65
+ if (userId === ownUserId)
66
+ continue;
67
+ if (userId === MENTION_ALL_SENTINEL)
68
+ continue;
69
+ out.push({ userId, name });
70
+ }
71
+ return out;
72
+ }
73
+ /** Whether `text` has an `@` worth fetching the group roster for. */
74
+ export function hasMentionCandidate(text) {
75
+ for (let i = 0; i < text.length - 1; i += 1) {
76
+ if (text.charCodeAt(i) === AT && openBoundary(text, i))
77
+ return true;
78
+ }
79
+ return false;
80
+ }
81
+ /**
82
+ * Split `text` into text / mention fragments using `roster`. The `@` is
83
+ * consumed by the mention fragment (`display` is the bare name; clients
84
+ * render `@display`). With no hit the result is a single text fragment
85
+ * (or `[]` for empty text).
86
+ */
87
+ export function autolinkMentions(text, roster, options) {
88
+ if (!text)
89
+ return [];
90
+ const targets = targetsOf(roster, options.ownUserId);
91
+ if (targets.length === 0)
92
+ return [{ kind: "text", text }];
93
+ const out = [];
94
+ let cursor = 0;
95
+ let i = 0;
96
+ while (i < text.length) {
97
+ if (text.charCodeAt(i) !== AT || !openBoundary(text, i)) {
98
+ i += 1;
99
+ continue;
100
+ }
101
+ const hit = matchAt(text, i + 1, targets);
102
+ if (!hit) {
103
+ i += 1;
104
+ continue;
105
+ }
106
+ if (i > cursor)
107
+ out.push({ kind: "text", text: text.slice(cursor, i) });
108
+ out.push({ kind: "mention", user_id: hit.userId, display: hit.name });
109
+ i += 1 + hit.name.length;
110
+ cursor = i;
111
+ }
112
+ if (cursor < text.length)
113
+ out.push({ kind: "text", text: text.slice(cursor) });
114
+ return out;
115
+ }
116
+ /** The `context.mentions` half: mention fragments de-duplicated by user id. */
117
+ export function mentionsIn(fragments) {
118
+ const seen = new Set();
119
+ const out = [];
120
+ for (const f of fragments) {
121
+ if (f.kind !== "mention")
122
+ continue;
123
+ const id = typeof f.user_id === "string" ? f.user_id : "";
124
+ if (!id || seen.has(id))
125
+ continue;
126
+ seen.add(id);
127
+ out.push({ kind: "mention", user_id: id, ...(f.display ? { display: f.display } : {}) });
128
+ }
129
+ return out;
130
+ }
131
+ const rosterResolvers = new Map();
132
+ /** Register the roster source for one account; returns an unregister function. */
133
+ export function registerMentionRosterResolver(accountId, resolver) {
134
+ rosterResolvers.set(accountId, resolver);
135
+ return () => {
136
+ if (rosterResolvers.get(accountId) === resolver)
137
+ rosterResolvers.delete(accountId);
138
+ };
139
+ }
140
+ /** Best-effort roster lookup: any failure or missing resolver yields `[]`. */
141
+ export async function resolveMentionRoster(accountId, groupId) {
142
+ const resolver = rosterResolvers.get(accountId);
143
+ if (!resolver)
144
+ return [];
145
+ try {
146
+ const roster = await resolver(groupId);
147
+ return Array.isArray(roster) ? roster : [];
148
+ }
149
+ catch {
150
+ return [];
151
+ }
152
+ }
153
+ export function clearMentionRosterResolversForTest() {
154
+ rosterResolvers.clear();
155
+ }
@@ -9,11 +9,12 @@
9
9
  * tolerated for free and must not be written into the pattern itself.
10
10
  *
11
11
  * RULE B — bare runtime silence markers (`NO_REPLY` / `[SILENT]` / `SILENT` /
12
- * `NO REPLY`), matched as a WHOLE STRING ONLY. OpenClaw itself has no such
13
- * convention; these are carried because other agent runtimes define them, so a
14
- * model may fall back to one instead of the `clawchat:` form. They are ordinary
15
- * English words — substring-matching them would swallow prose such as "there is
16
- * no reply from the server".
12
+ * `NO REPLY` / `HEARTBEAT_OK`), matched as a WHOLE STRING ONLY. These are
13
+ * carried because agent runtimes define them, so a model may fall back to one
14
+ * instead of the `clawchat:` form; `HEARTBEAT_OK` is the OpenClaw host's
15
+ * heartbeat acknowledgement, which some host versions let through as a reply.
16
+ * They are ordinary words — substring-matching them would swallow prose such as
17
+ * "there is no reply from the server".
17
18
  *
18
19
  * This module MUST stay a literal mirror of the Hermes plugin's equivalent
19
20
  * module. When one side changes, change the other in the same breath.
@@ -33,7 +34,7 @@ const CONTAINS_RE = new RegExp(CORE, "i");
33
34
  const DECORATION_OPEN = "[<\\[{`*_~]*";
34
35
  const DECORATION_CLOSE = "[/>\\]}`*_~]*";
35
36
  const STRIP_RE = new RegExp(DECORATION_OPEN + CORE + DECORATION_CLOSE, "gi");
36
- const HOST_MARKERS = new Set(["[SILENT]", "SILENT", "NO_REPLY", "NO REPLY"]);
37
+ const HOST_MARKERS = new Set(["[SILENT]", "SILENT", "NO_REPLY", "NO REPLY", "HEARTBEAT_OK"]);
37
38
  const HOST_MARKER_MAX_LEN = 64;
38
39
  // General_Category=Punctuation, matching the host's `unicodedata.category()`
39
40
  // check. Note `*` is Po (stripped) while `~` is Sm (kept) — same as the host.
@@ -7,6 +7,7 @@ import { createOpenclawClawlingApiClient } from "./api-client.js";
7
7
  import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.js";
8
8
  import { applyTextMentionLabels, buildMentionMessageFragments, normalizeMentionTargets, textToFragments, } from "./message-mapper.js";
9
9
  import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-runtime.js";
10
+ import { autolinkMentions, hasMentionCandidate, mentionsIn, resolveMentionRoster, } from "./mention-autolink.js";
10
11
  import { isClawChatNoopResponseText } from "./profile-prompt.js";
11
12
  import { stripNoReplyTokens } from "./no-reply.js";
12
13
  import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawClawlingClient, } from "./runtime.js";
@@ -423,8 +424,27 @@ export async function sendOpenclawClawlingText(params) {
423
424
  params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound suppressed: empty text and no media`);
424
425
  return null;
425
426
  }
426
- const mentions = params.mentions ?? [];
427
- const textFragments = text ? textToFragments(text) : [];
427
+ const messageMode = params.messageMode ?? "normal";
428
+ let mentions = params.mentions ?? [];
429
+ let textFragments = text ? textToFragments(text) : [];
430
+ // Text "@name" → structured mention (group, normal messages only). Process
431
+ // (thinking) output must not wake anyone. Best-effort: an unavailable roster
432
+ // leaves the text plain and never blocks the send.
433
+ if (textFragments.length > 0
434
+ && params.to.chatType === "group"
435
+ && messageMode === "normal"
436
+ && hasMentionCandidate(text)) {
437
+ const roster = params.mentionRoster
438
+ ?? await resolveMentionRoster(params.account.accountId, params.to.chatId);
439
+ const linked = autolinkMentions(text, roster, { ownUserId: params.account.userId ?? "" });
440
+ const linkedMentions = mentionsIn(linked);
441
+ if (linkedMentions.length > 0) {
442
+ textFragments = linked;
443
+ const seen = new Set(mentions.map((m) => m.user_id).filter(Boolean));
444
+ mentions = [...mentions, ...linkedMentions.filter((m) => !seen.has(m.user_id))];
445
+ params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound text mentions linked count=${linkedMentions.length} to=${params.to.chatId}`);
446
+ }
447
+ }
428
448
  // Each MediaItem object is structurally compatible
429
449
  // with one of the local narrow Fragment members (ImageFragment / FileFragment /
430
450
  // AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
@@ -445,7 +465,7 @@ export async function sendOpenclawClawlingText(params) {
445
465
  mode = "reply";
446
466
  const payload = {
447
467
  message_id: messageId,
448
- message_mode: "normal",
468
+ message_mode: messageMode,
449
469
  message: {
450
470
  body: { fragments },
451
471
  context: {
@@ -471,7 +491,8 @@ export async function sendOpenclawClawlingText(params) {
471
491
  });
472
492
  }
473
493
  else {
474
- mode = "send";
494
+ // A stream's final is a message.reply (§8.4) whether or not it quotes.
495
+ mode = params.finalizesStream ? "reply" : "send";
475
496
  const reply = params.replyCtx
476
497
  ? {
477
498
  reply_to_msg_id: params.replyCtx.replyToMessageId,
@@ -480,7 +501,7 @@ export async function sendOpenclawClawlingText(params) {
480
501
  : null;
481
502
  const payload = {
482
503
  message_id: messageId,
483
- message_mode: "normal",
504
+ message_mode: messageMode,
484
505
  message: {
485
506
  body: { fragments },
486
507
  context: { mentions, reply },
@@ -489,7 +510,7 @@ export async function sendOpenclawClawlingText(params) {
489
510
  ack = await sendAlignedAckableEnvelope({
490
511
  client: params.client,
491
512
  account: params.account,
492
- eventName: "message.send",
513
+ eventName: mode === "reply" ? "message.reply" : "message.send",
493
514
  chatId: params.to.chatId,
494
515
  payload,
495
516
  ...(params.log ? { log: params.log } : {}),
@@ -498,7 +519,7 @@ export async function sendOpenclawClawlingText(params) {
498
519
  if (ack.payload.message_id !== messageId) {
499
520
  throw new Error(`ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`);
500
521
  }
501
- params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
522
+ params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound mode=${mode} message_mode=${messageMode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
502
523
  return {
503
524
  messageId: ack.payload.message_id,
504
525
  acceptedAt: ack.payload.accepted_at,
@@ -6,7 +6,16 @@ import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-ru
6
6
  import { mintMessageId, sendOpenclawClawlingText, } from "./outbound.js";
7
7
  import { isClawChatNoopResponseText } from "./profile-prompt.js";
8
8
  import { consumeTerminalClawChatSend } from "./terminal-send.js";
9
+ import { createReplyStream } from "./reply-stream.js";
9
10
  import { openclawLlmContextDebug } from "./llm-context-debug.js";
11
+ /**
12
+ * Re-send cadence for `typing.update {is_typing:true}` while a reply is in
13
+ * flight: at most half of the client's typing display window (6 s), so a single
14
+ * lost frame never lets the indicator lapse.
15
+ */
16
+ export const TYPING_KEEPALIVE_MS = 3_000;
17
+ /** Safety cap for one reply's typing keepalive (15 min). */
18
+ export const TYPING_KEEPALIVE_MAX_MS = 15 * 60_000;
10
19
  const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
11
20
  const APPROVAL_FALLBACK_COMMAND_RE = /(?:^|\s)\/(?:approve|deny)\b/i;
12
21
  function ownerAttentionText(groupId, fallbackText) {
@@ -326,6 +335,40 @@ export function createOpenclawClawlingReplyDispatcher(options) {
326
335
  const outboundEventType = () => (replyCtx ? "message.reply" : "message.send");
327
336
  const outboundRaw = () => ({ target, replyCtx: replyCtx ?? null });
328
337
  const terminalSendScopeId = options.terminalSendScopeId ?? null;
338
+ // ----- Reply streaming (experimental, `streamReplies`) -----------------
339
+ //
340
+ // One open stream previews the assistant text being generated. The next
341
+ // normal materialized send claims it (`message.done` with the final text,
342
+ // then the final as `message.reply` reusing the stream's message_id, §8.4);
343
+ // a final that does not extend the preview, a no-reply, a terminal tool
344
+ // send, or an idle run with nothing delivered fails it. Host partials are snapshots of
345
+ // the current assistant message that may be rewritten, so a snapshot that no
346
+ // longer extends the preview fails the old stream and starts a new one.
347
+ const streamEnabled = account.streamReplies === true;
348
+ let replyStream = null;
349
+ const failStream = (reason) => {
350
+ replyStream?.fail(reason);
351
+ replyStream = null;
352
+ };
353
+ const claimStreamMessageId = (finalText) => {
354
+ const id = replyStream?.finish(finalText) ?? null;
355
+ replyStream = null;
356
+ return id;
357
+ };
358
+ const onStreamPartial = (text) => {
359
+ if (runDone || terminalReplySuppressed)
360
+ return;
361
+ if (replyStream?.diverges(text))
362
+ failStream("superseded");
363
+ replyStream ??= createReplyStream({
364
+ client,
365
+ chatId: target.chatId,
366
+ mintMessageId,
367
+ log,
368
+ logPrefix: `[${account.accountId}] clawchat-plugin-openclaw`,
369
+ });
370
+ replyStream.update(text);
371
+ };
329
372
  const consumeTerminalSend = (reason) => {
330
373
  if (terminalReplySuppressed)
331
374
  return true;
@@ -339,6 +382,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
339
382
  if (!terminal)
340
383
  return false;
341
384
  terminalReplySuppressed = true;
385
+ failStream("terminal-send");
342
386
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw suppressing ${reason} reply after terminal tool send msg=${terminal.messageId} to=${target.chatId}`);
343
387
  return true;
344
388
  };
@@ -424,7 +468,8 @@ export function createOpenclawClawlingReplyDispatcher(options) {
424
468
  bufferedOutputUrls.push(url);
425
469
  }
426
470
  };
427
- const fullSegmentKey = (text, urls) => JSON.stringify({
471
+ const fullSegmentKey = (text, urls, mode) => JSON.stringify({
472
+ mode,
428
473
  text: text.replace(/\s+/g, " ").trim(),
429
474
  urls: urls.filter(Boolean),
430
475
  });
@@ -458,6 +503,39 @@ export function createOpenclawClawlingReplyDispatcher(options) {
458
503
  log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw typing update failed: ${String(error)}`);
459
504
  }
460
505
  };
506
+ // Plugin-owned typing keepalive. The client lights "typing" for a fixed
507
+ // window per `is_typing:true` frame, so the sender must re-send at no more
508
+ // than half that window (TYPING_KEEPALIVE_MS) for as long as the reply is in
509
+ // flight, and send `false` exactly once at the end. The host's own keepalive
510
+ // (default 6 s, global config only) equals the client window, so one lost or
511
+ // late frame makes the indicator flash; we ask the host to turn it off
512
+ // (`typingKeepalive: false`, honoured by newer hosts) and ignore its repeated
513
+ // `onReplyStart` ticks on older hosts.
514
+ let typingTimer = null;
515
+ let typingCapTimer = null;
516
+ let typingFinished = false;
517
+ const stopTyping = () => {
518
+ if (typingTimer)
519
+ clearInterval(typingTimer);
520
+ if (typingCapTimer)
521
+ clearTimeout(typingCapTimer);
522
+ typingTimer = null;
523
+ typingCapTimer = null;
524
+ typingFinished = true;
525
+ emitTyping(false);
526
+ };
527
+ const startTyping = () => {
528
+ if (typingFinished || typingTimer)
529
+ return;
530
+ emitTyping(true);
531
+ typingTimer = setInterval(() => emitTyping(true), TYPING_KEEPALIVE_MS);
532
+ typingTimer.unref?.();
533
+ typingCapTimer = setTimeout(() => {
534
+ log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw typing keepalive cap reached; stopping to=${target.chatId}`);
535
+ stopTyping();
536
+ }, TYPING_KEEPALIVE_MAX_MS);
537
+ typingCapTimer.unref?.();
538
+ };
461
539
  const sendOwnerAttention = async (fallbackText, richFragment = null) => {
462
540
  const ownerTarget = ownerDirectTarget();
463
541
  if (!ownerTarget) {
@@ -479,10 +557,13 @@ export function createOpenclawClawlingReplyDispatcher(options) {
479
557
  };
480
558
  // ----- Static send ------------------------------------------------------
481
559
  const sendStatic = async (text, mediaFragments = [], richFragments = [], options = {}) => {
560
+ const messageMode = options.messageMode ?? "normal";
482
561
  if (isClawChatNoopResponseText(text) &&
483
562
  mediaFragments.length === 0 &&
484
563
  richFragments.length === 0) {
485
564
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw outbound suppressed: no-reply token`);
565
+ if (messageMode === "normal")
566
+ failStream("no-reply");
486
567
  openclawLlmContextDebug.writeSnapshot({
487
568
  visibility: "host_event",
488
569
  trace: {
@@ -506,11 +587,12 @@ export function createOpenclawClawlingReplyDispatcher(options) {
506
587
  return null;
507
588
  if (consumeTerminalSend("static"))
508
589
  return null;
509
- log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw sending static text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`);
510
- const messageId = mintStaticMessageId();
590
+ log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw sending static mode=${messageMode} text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`);
591
+ const streamMessageId = messageMode === "normal" ? claimStreamMessageId(text) : null;
592
+ const messageId = streamMessageId ?? mintStaticMessageId();
511
593
  const raw = { target, replyCtx: replyCtx ?? null, mode: "static" };
512
594
  const claimed = options.recordMessage
513
- ? claimOutbound(outboundEventType(), messageId, text, raw)
595
+ ? claimOutbound(streamMessageId ? "message.reply" : outboundEventType(), messageId, text, raw)
514
596
  : true;
515
597
  if (claimed === false) {
516
598
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw outbound duplicate skipped msg=${messageId}`);
@@ -529,10 +611,14 @@ export function createOpenclawClawlingReplyDispatcher(options) {
529
611
  ...(replyCtx ? { replyCtx } : {}),
530
612
  ...(richFragments.length > 0 ? { richFragments } : {}),
531
613
  ...(mediaFragments.length > 0 ? { mediaFragments } : {}),
614
+ ...(messageMode !== "normal" ? { messageMode } : {}),
615
+ ...(streamMessageId ? { finalizesStream: true } : {}),
532
616
  log,
533
617
  });
534
618
  recordOutboundAck("message", messageId, result);
535
- if (result)
619
+ // Process (thinking) messages are not replies: they must not make a
620
+ // bootstrap greeting look delivered.
621
+ if (result && messageMode === "normal")
536
622
  outboundDeliveredCount += 1;
537
623
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw send complete to=${target.chatId}`);
538
624
  openclawLlmContextDebug.writeSnapshot({
@@ -558,7 +644,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
558
644
  });
559
645
  return result;
560
646
  };
561
- const emitFullSegment = async (text, urls = []) => {
647
+ const emitFullSegment = async (text, urls = [], messageMode = "normal") => {
562
648
  if (outputVisibility !== "full" && !splitNormalBlockOutput) {
563
649
  appendBufferedText(text);
564
650
  appendBufferedUrls(urls);
@@ -572,24 +658,26 @@ export function createOpenclawClawlingReplyDispatcher(options) {
572
658
  const trimmed = text.trim();
573
659
  if (!trimmed && urls.length === 0)
574
660
  return;
575
- const segmentKey = fullSegmentKey(trimmed, urls);
661
+ const segmentKey = fullSegmentKey(trimmed, urls, messageMode);
576
662
  if (emittedFullSegmentSet.has(segmentKey))
577
663
  return;
578
664
  emittedFullSegmentSet.add(segmentKey);
579
665
  const mediaFragments = await uploadMediaUrls(urls);
580
- await sendStatic(trimmed, mediaFragments, [], { recordMessage: true });
666
+ await sendStatic(trimmed, mediaFragments, [], { recordMessage: true, messageMode });
581
667
  };
582
668
  const emitFullRuntimeText = async (label, text, urls = []) => {
583
669
  const summary = summarizeValue(text);
584
670
  if (!summary && urls.length === 0)
585
671
  return;
586
- await emitFullSegment(summary ? `[${label}] ${summary}` : "", urls);
672
+ await emitFullSegment(summary ? `[${label}] ${summary}` : "", urls, "thinking");
587
673
  };
674
+ /** Process output (tool / command / patch / compaction / reasoning): `message_mode: "thinking"`. */
675
+ const emitProcessSegment = (text, urls = []) => emitFullSegment(text, urls, "thinking");
588
676
  // ----- Dispatcher -------------------------------------------------------
589
677
  const base = runtime.channel.reply.createReplyDispatcherWithTyping({
590
678
  humanDelay,
591
679
  onReplyStart: async () => {
592
- emitTyping(true);
680
+ startTyping();
593
681
  },
594
682
  deliver: async (payload, info) => {
595
683
  if (consumeTerminalSend(info?.kind ?? "unknown"))
@@ -614,7 +702,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
614
702
  if (payload.isReasoning) {
615
703
  if (outputVisibility !== "full")
616
704
  return;
617
- await emitFullSegment(text, urls);
705
+ await emitProcessSegment(text, urls);
618
706
  const trimmed = text.trim();
619
707
  if (trimmed)
620
708
  reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
@@ -645,6 +733,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
645
733
  finalDeliverySeen = true;
646
734
  if (isClawChatNoopResponseText(text) && !richFragment && urls.length === 0) {
647
735
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
736
+ failStream("no-reply");
648
737
  openclawLlmContextDebug.writeSnapshot({
649
738
  visibility: "host_event",
650
739
  trace: {
@@ -670,6 +759,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
670
759
  !richFragment &&
671
760
  finalUrls.length === 0) {
672
761
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
762
+ failStream("no-reply");
673
763
  return;
674
764
  }
675
765
  const mediaFragments = await uploadMediaUrls(finalUrls);
@@ -687,23 +777,29 @@ export function createOpenclawClawlingReplyDispatcher(options) {
687
777
  void emitFullRuntimeText("error", errorText);
688
778
  },
689
779
  onIdle: async () => {
690
- emitTyping(false);
780
+ stopTyping();
691
781
  if (runDone)
692
782
  return;
693
783
  runDone = true;
694
- if (finalDeliverySeen)
695
- return;
696
- const fallbackText = bufferedOutputText.trim();
697
- const fallbackUrls = bufferedOutputUrls.slice();
698
- if (!fallbackText && fallbackUrls.length === 0)
699
- return;
700
- const mediaFragments = await uploadMediaUrls(fallbackUrls);
701
- const result = await sendStatic(fallbackText, mediaFragments, [], { recordMessage: true });
702
- if (result?.messageId)
703
- recordThinkingIfLinked(result.messageId);
784
+ try {
785
+ if (finalDeliverySeen)
786
+ return;
787
+ const fallbackText = bufferedOutputText.trim();
788
+ const fallbackUrls = bufferedOutputUrls.slice();
789
+ if (!fallbackText && fallbackUrls.length === 0)
790
+ return;
791
+ const mediaFragments = await uploadMediaUrls(fallbackUrls);
792
+ const result = await sendStatic(fallbackText, mediaFragments, [], { recordMessage: true });
793
+ if (result?.messageId)
794
+ recordThinkingIfLinked(result.messageId);
795
+ }
796
+ finally {
797
+ // A preview nothing materialized must not linger as an open stream.
798
+ failStream("undelivered");
799
+ }
704
800
  },
705
801
  onCleanup: () => {
706
- emitTyping(false);
802
+ stopTyping();
707
803
  },
708
804
  });
709
805
  return {
@@ -712,7 +808,15 @@ export function createOpenclawClawlingReplyDispatcher(options) {
712
808
  replyOptions: {
713
809
  ...base.replyOptions,
714
810
  sourceReplyDeliveryMode: "automatic",
715
- disableBlockStreaming: !splitNormalBlockOutput,
811
+ typingKeepalive: false,
812
+ // Streaming previews the assistant message and the final materializes it,
813
+ // so host block chunks (which split one message arbitrarily) stay off.
814
+ disableBlockStreaming: streamEnabled ? true : !splitNormalBlockOutput,
815
+ onPartialReply: streamEnabled
816
+ ? (payload) => {
817
+ onStreamPartial(typeof payload?.text === "string" ? payload.text : "");
818
+ }
819
+ : undefined,
716
820
  suppressDefaultToolProgressMessages: true,
717
821
  allowProgressCallbacksWhenSourceDeliverySuppressed: splitFullOutput ? true : undefined,
718
822
  onReasoningStream: splitFullOutput
@@ -730,7 +834,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
730
834
  ? async (payload) => {
731
835
  if (consumeTerminalSend("tool-start"))
732
836
  return;
733
- await emitFullSegment(formatToolStartSummary(payload));
837
+ await emitProcessSegment(formatToolStartSummary(payload));
734
838
  }
735
839
  : undefined,
736
840
  onToolResult: splitFullOutput
@@ -763,28 +867,28 @@ export function createOpenclawClawlingReplyDispatcher(options) {
763
867
  ? async (payload) => {
764
868
  if (consumeTerminalSend("command-output"))
765
869
  return;
766
- await emitFullSegment(formatCommandOutputSummary(payload));
870
+ await emitProcessSegment(formatCommandOutputSummary(payload));
767
871
  }
768
872
  : undefined,
769
873
  onPatchSummary: splitFullOutput
770
874
  ? async (payload) => {
771
875
  if (consumeTerminalSend("patch-summary"))
772
876
  return;
773
- await emitFullSegment(formatPatchSummary(payload));
877
+ await emitProcessSegment(formatPatchSummary(payload));
774
878
  }
775
879
  : undefined,
776
880
  onCompactionStart: splitFullOutput
777
881
  ? async () => {
778
882
  if (consumeTerminalSend("compaction-start"))
779
883
  return;
780
- await emitFullSegment("[compaction] started");
884
+ await emitProcessSegment("[compaction] started");
781
885
  }
782
886
  : undefined,
783
887
  onCompactionEnd: splitFullOutput
784
888
  ? async () => {
785
889
  if (consumeTerminalSend("compaction-end"))
786
890
  return;
787
- await emitFullSegment("[compaction] finished");
891
+ await emitProcessSegment("[compaction] finished");
788
892
  }
789
893
  : undefined,
790
894
  },