@clawling/clawchat-plugin-openclaw 2026.5.12-31 → 2026.5.12-38

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.
@@ -249,7 +249,7 @@ export function createOpenclawClawlingApiClient(opts) {
249
249
  headers: { "content-type": "application/json" },
250
250
  });
251
251
  },
252
- async agentsConnect({ code: inviteCode, platform, type }) {
252
+ async agentsConnect({ code: inviteCode, platform, type, user_id: userId }) {
253
253
  if (!inviteCode?.trim()) {
254
254
  throw new ClawlingApiError("validation", "agentsConnect: inviteCode is required");
255
255
  }
@@ -259,14 +259,18 @@ export function createOpenclawClawlingApiClient(opts) {
259
259
  if (!type?.trim()) {
260
260
  throw new ClawlingApiError("validation", "agentsConnect: type is required");
261
261
  }
262
+ const body = {
263
+ code: inviteCode.trim(),
264
+ platform: platform.trim(),
265
+ type: type.trim(),
266
+ };
267
+ if (userId?.trim()) {
268
+ body.user_id = userId.trim();
269
+ }
262
270
  return await call("POST", "/v1/agents/connect", {
263
271
  // `X-Device-Id` is added globally via `authHeaders` on every request.
264
272
  headers: { "content-type": "application/json" },
265
- body: JSON.stringify({
266
- code: inviteCode.trim(),
267
- platform: platform.trim(),
268
- type: type.trim(),
269
- }),
273
+ body: JSON.stringify(body),
270
274
  });
271
275
  },
272
276
  async uploadMedia(params) {
@@ -1,9 +1,17 @@
1
+ import { createHash } from "node:crypto";
2
+ import os from "node:os";
1
3
  import { createClawChatClient } from "./ws-client.js";
4
+ import { CHANNEL_ID } from "./config.js";
5
+ export function resolveOpenclawClawlingDeviceId(account) {
6
+ const material = [CHANNEL_ID, account.accountId, account.userId, os.hostname()].join("\0");
7
+ const digest = createHash("sha256").update(material).digest("hex").slice(0, 24);
8
+ return `${CHANNEL_ID}-${digest}`;
9
+ }
2
10
  export function createOpenclawClawlingClient(account, overrides = {}) {
3
11
  const client = createClawChatClient({
4
12
  url: account.websocketUrl,
5
13
  token: account.token,
6
- deviceId: account.userId,
14
+ deviceId: resolveOpenclawClawlingDeviceId(account),
7
15
  ...(overrides.transport ? { transport: overrides.transport } : {}),
8
16
  reconnect: {
9
17
  enabled: true,
@@ -64,18 +64,16 @@ function persistOutputVisibility(draft, chatId, outputVisibility) {
64
64
  });
65
65
  }
66
66
  function formatOutputVisibilityResult(outputVisibility) {
67
- const runtimeStatus = outputVisibility === "full" ? "on" : "off";
68
67
  const detailLevel = {
69
- minimal: "quiet",
70
- normal: "normal",
71
- full: "verbose",
68
+ minimal: "final only",
69
+ normal: "final plus block output",
70
+ full: "final plus buffered reasoning, tool/progress, and block output",
72
71
  };
73
72
  return [
74
73
  "**ClawChat output updated**",
75
74
  "",
76
75
  `- visibility: \`${outputVisibility}\``,
77
- `- runtime status: \`${runtimeStatus}\``,
78
- `- detail level: \`${detailLevel[outputVisibility]}\``,
76
+ `- output: \`${detailLevel[outputVisibility]}\``,
79
77
  "",
80
78
  "Applies to new ClawChat messages.",
81
79
  ].join("\n");
@@ -191,6 +191,29 @@ function readGroupMode(value) {
191
191
  function readGroupCommandMode(value) {
192
192
  return value === "all" || value === "off" ? value : "owner";
193
193
  }
194
+ function readOutputVisibility(value) {
195
+ return value === "minimal" || value === "full" ? value : "normal";
196
+ }
197
+ function readOptionalOutputVisibility(value) {
198
+ return value === "minimal" || value === "normal" || value === "full" ? value : undefined;
199
+ }
200
+ function readChats(value) {
201
+ const rawChats = value && typeof value === "object" && !Array.isArray(value)
202
+ ? value
203
+ : {};
204
+ const chats = {};
205
+ for (const [chatId, rawChat] of Object.entries(rawChats)) {
206
+ if (!chatId)
207
+ continue;
208
+ const chat = rawChat && typeof rawChat === "object" && !Array.isArray(rawChat)
209
+ ? rawChat
210
+ : {};
211
+ const outputVisibility = readOptionalOutputVisibility(chat.outputVisibility);
212
+ if (outputVisibility)
213
+ chats[chatId] = { outputVisibility };
214
+ }
215
+ return chats;
216
+ }
194
217
  function readGroups(value) {
195
218
  const rawGroups = value && typeof value === "object" && !Array.isArray(value)
196
219
  ? value
@@ -202,9 +225,11 @@ function readGroups(value) {
202
225
  const group = rawGroup && typeof rawGroup === "object" && !Array.isArray(rawGroup)
203
226
  ? rawGroup
204
227
  : {};
228
+ const outputVisibility = readOptionalOutputVisibility(group.outputVisibility);
205
229
  groups[chatId] = {
206
230
  groupMode: readGroupMode(group.groupMode),
207
231
  groupCommandMode: readGroupCommandMode(group.groupCommandMode),
232
+ ...(outputVisibility ? { outputVisibility } : {}),
208
233
  };
209
234
  }
210
235
  return groups;
@@ -219,6 +244,13 @@ export function effectiveGroupCommandMode(account, chatId) {
219
244
  ?? account.groups["*"]?.groupCommandMode
220
245
  ?? account.groupCommandMode;
221
246
  }
247
+ export function effectiveOutputVisibility(account, chatId, chatType) {
248
+ return account.chats?.[chatId]?.outputVisibility
249
+ ?? (chatType === "group" ? account.groups?.[chatId]?.outputVisibility : undefined)
250
+ ?? (chatType === "group" ? account.groups?.["*"]?.outputVisibility : undefined)
251
+ ?? account.outputVisibility
252
+ ?? "normal";
253
+ }
222
254
  function readReconnect(raw) {
223
255
  const s = raw && typeof raw === "object" ? raw : {};
224
256
  return {
@@ -261,6 +293,8 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
261
293
  const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
262
294
  const ownerUserId = readOptionalString(channel.ownerUserId) || readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV);
263
295
  const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
296
+ const outputVisibility = readOutputVisibility(channel.outputVisibility);
297
+ const chats = readChats(channel.chats);
264
298
  const groupMode = readGroupMode(channel.groupMode);
265
299
  const groupCommandMode = readGroupCommandMode(channel.groupCommandMode);
266
300
  const groups = readGroups(channel.groups);
@@ -284,6 +318,8 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
284
318
  agentId,
285
319
  userId,
286
320
  ownerUserId,
321
+ outputVisibility,
322
+ chats,
287
323
  groupMode,
288
324
  groupCommandMode,
289
325
  groups,
@@ -139,10 +139,12 @@ export async function runOpenclawClawlingLogin(params) {
139
139
  runtime.log("Verifying invite code …");
140
140
  let result;
141
141
  try {
142
+ const existingUserId = account.userId.trim();
142
143
  result = await apiClient.agentsConnect({
143
144
  code: inviteCode,
144
145
  platform: AGENTS_CONNECT_PLATFORM,
145
146
  type: AGENTS_CONNECT_TYPE,
147
+ ...(existingUserId ? { user_id: existingUserId } : {}),
146
148
  });
147
149
  }
148
150
  catch (err) {
@@ -1,6 +1,7 @@
1
1
  import { interactiveReplyToPresentation, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime";
2
2
  import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
3
3
  import { createOpenclawClawlingApiClient } from "./api-client.js";
4
+ import { effectiveOutputVisibility, } from "./config.js";
4
5
  import { uploadOutboundMedia } from "./media-runtime.js";
5
6
  import { sendOpenclawClawlingText, } from "./outbound.js";
6
7
  import { isClawChatNoopResponseText } from "./profile-prompt.js";
@@ -103,18 +104,162 @@ function resolvePayloadText(payload) {
103
104
  return payload.text ?? "";
104
105
  return renderMessagePresentationFallbackText({ presentation, text: payload.text ?? null });
105
106
  }
107
+ const FULL_OUTPUT_SUMMARY_MAX = 600;
108
+ function truncateSummary(text) {
109
+ const compact = text.replace(/\s+/g, " ").trim();
110
+ if (compact.length <= FULL_OUTPUT_SUMMARY_MAX)
111
+ return compact;
112
+ return `${compact.slice(0, FULL_OUTPUT_SUMMARY_MAX - 1).trimEnd()}…`;
113
+ }
114
+ function summarizeValue(value) {
115
+ if (value == null)
116
+ return "";
117
+ if (typeof value === "string") {
118
+ const trimmed = value.trim();
119
+ if (!trimmed)
120
+ return "";
121
+ try {
122
+ const parsed = JSON.parse(trimmed);
123
+ if (parsed && typeof parsed === "object")
124
+ return summarizeValue(parsed);
125
+ }
126
+ catch {
127
+ // Plain text is already the best summary.
128
+ }
129
+ return truncateSummary(trimmed);
130
+ }
131
+ if (typeof value === "number" || typeof value === "boolean")
132
+ return String(value);
133
+ if (Array.isArray(value))
134
+ return `${value.length} item${value.length === 1 ? "" : "s"}`;
135
+ if (typeof value === "object") {
136
+ const keys = Object.keys(value).slice(0, 6);
137
+ return keys.length ? `object with ${keys.join(", ")}` : "object";
138
+ }
139
+ return truncateSummary(String(value));
140
+ }
141
+ function readStringField(payload, field) {
142
+ const value = payload[field];
143
+ return typeof value === "string" ? value.trim() : "";
144
+ }
145
+ function readPayloadCommand(payload) {
146
+ const args = payload.args;
147
+ if (!args || typeof args !== "object" || Array.isArray(args))
148
+ return "";
149
+ const command = args.command;
150
+ return typeof command === "string" ? command.trim() : "";
151
+ }
152
+ function normalizeCommandLabel(value) {
153
+ const trimmed = value.trim();
154
+ return trimmed.replace(/^command\s+/i, "").trim() || trimmed;
155
+ }
156
+ function isTerminalCommandOutput(payload) {
157
+ const phase = readStringField(payload, "phase").toLowerCase();
158
+ const status = readStringField(payload, "status").toLowerCase();
159
+ return (phase === "end" ||
160
+ phase === "error" ||
161
+ typeof payload.exitCode === "number" ||
162
+ status === "completed" ||
163
+ status === "ok" ||
164
+ status === "success" ||
165
+ status === "failed" ||
166
+ status === "error");
167
+ }
168
+ function isToolProgressItem(payload) {
169
+ const kind = readStringField(payload, "kind").toLowerCase();
170
+ const title = readStringField(payload, "title").toLowerCase();
171
+ const name = readStringField(payload, "name").toLowerCase();
172
+ const progressText = readStringField(payload, "progressText").toLowerCase();
173
+ return (kind === "tool" ||
174
+ kind === "command" ||
175
+ title.startsWith("exec ") ||
176
+ title.startsWith("command ") ||
177
+ name.startsWith("exec ") ||
178
+ name.startsWith("command ") ||
179
+ progressText.startsWith("exec ") ||
180
+ progressText.startsWith("command "));
181
+ }
182
+ function isDefaultToolResultText(text) {
183
+ return /^[🛠🔧]/u.test(text.trim());
184
+ }
185
+ function summarizeProgressPayload(payload) {
186
+ return (readStringField(payload, "progressText") ||
187
+ readStringField(payload, "summary") ||
188
+ readStringField(payload, "message") ||
189
+ readStringField(payload, "title") ||
190
+ readStringField(payload, "name") ||
191
+ readStringField(payload, "status") ||
192
+ summarizeValue(payload));
193
+ }
194
+ function formatToolStartSummary(payload) {
195
+ const name = payload.name?.trim() || "tool";
196
+ const phase = payload.phase?.trim();
197
+ if (phase && phase !== "start")
198
+ return "";
199
+ const command = readPayloadCommand(payload);
200
+ if ((name === "exec" || name === "command") && !command)
201
+ return "";
202
+ return `[tool] ${[name, command].filter(Boolean).join(" ")} started`;
203
+ }
204
+ function formatCommandOutputSummary(payload) {
205
+ if (!isTerminalCommandOutput(payload))
206
+ return "";
207
+ const name = normalizeCommandLabel(readStringField(payload, "title") || readStringField(payload, "name") || "command");
208
+ const status = readStringField(payload, "status").toLowerCase();
209
+ const visibleStatus = status && status !== "ok" && status !== "completed" && status !== "success" ? status : "";
210
+ const exitCode = typeof payload.exitCode === "number" ? ` exit ${payload.exitCode}` : "";
211
+ const output = summarizeValue(payload.output);
212
+ const prefix = `[command] ${[name, visibleStatus].filter(Boolean).join(" ")}${exitCode}`;
213
+ return truncateSummary(output ? `${prefix}: ${output}` : prefix);
214
+ }
215
+ function formatPatchSummary(payload) {
216
+ const summary = readStringField(payload, "summary");
217
+ if (summary)
218
+ return `[patch] ${truncateSummary(summary)}`;
219
+ const parts = [];
220
+ for (const key of ["added", "modified", "deleted"]) {
221
+ const value = payload[key];
222
+ if (Array.isArray(value) && value.length > 0)
223
+ parts.push(`${key}: ${value.map(String).join(", ")}`);
224
+ else if (typeof value === "number" && value > 0)
225
+ parts.push(`${key}: ${value}`);
226
+ }
227
+ return `[patch] ${truncateSummary(parts.join("; ") || "updated")}`;
228
+ }
229
+ function formatItemEventSummary(payload) {
230
+ const kind = readStringField(payload, "kind") || "progress";
231
+ const title = readStringField(payload, "title") || readStringField(payload, "name");
232
+ const status = readStringField(payload, "status");
233
+ const phase = readStringField(payload, "phase");
234
+ const summary = summarizeProgressPayload(payload);
235
+ const label = [title, status || phase].filter(Boolean).join(" ");
236
+ return `[${kind}] ${truncateSummary(label || summary || "activity")}`;
237
+ }
238
+ function formatPlanSummary(payload) {
239
+ const title = readStringField(payload, "title") || "plan";
240
+ const explanation = readStringField(payload, "explanation");
241
+ const steps = Array.isArray(payload.steps) ? payload.steps.map(String).filter(Boolean) : [];
242
+ return `[plan] ${truncateSummary([title, explanation, ...steps].filter(Boolean).join(": "))}`;
243
+ }
244
+ function formatApprovalSummary(payload) {
245
+ const title = readStringField(payload, "title") || readStringField(payload, "kind") || "approval";
246
+ const status = readStringField(payload, "status") || readStringField(payload, "phase");
247
+ const message = readStringField(payload, "message") || readStringField(payload, "reason");
248
+ return `[approval] ${truncateSummary([title, status, message].filter(Boolean).join(" "))}`;
249
+ }
106
250
  /**
107
251
  * Reply dispatcher for clawchat-plugin-openclaw.
108
252
  *
109
- * The plugin intentionally forces complete-message delivery. It sets
110
- * `disableBlockStreaming: true` in reply options so OpenClaw does not split
111
- * deliver blocks for this channel. If the host still delivers non-final
112
- * blocks, the dispatcher buffers or ignores them and only emits materialized
113
- * `message.send` / `message.reply` frames for the final reply.
253
+ * ClawChat emits only materialized `message.send` / `message.reply` frames for
254
+ * complete OpenClaw output units. `disableBlockStreaming` prevents token/block
255
+ * streaming; full visibility still forwards complete tool/progress/output units
256
+ * as separate ClawChat messages.
114
257
  */
115
258
  export function createOpenclawClawlingReplyDispatcher(options) {
116
259
  const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, store, log, } = options;
117
260
  const isGroupTarget = target.chatType === "group";
261
+ const outputVisibility = effectiveOutputVisibility(account, target.chatId, target.chatType);
262
+ const splitFullOutput = outputVisibility === "full" && !isGroupTarget;
118
263
  const ownerDirectTarget = () => {
119
264
  const ownerUserId = account.ownerUserId?.trim();
120
265
  return ownerUserId ? { chatId: ownerUserId, chatType: "direct" } : null;
@@ -142,9 +287,14 @@ export function createOpenclawClawlingReplyDispatcher(options) {
142
287
  }
143
288
  // ----- Reply state ------------------------------------------------------
144
289
  let reasoningText = "";
290
+ let bufferedOutputText = "";
291
+ const bufferedOutputLineSet = new Set();
292
+ const emittedFullSegmentSet = new Set();
293
+ const bufferedOutputUrls = [];
145
294
  let runDone = false;
146
295
  let typingActive = false;
147
296
  let terminalReplySuppressed = false;
297
+ let finalDeliverySeen = false;
148
298
  const outboundEventType = () => (replyCtx ? "message.reply" : "message.send");
149
299
  const outboundRaw = () => ({ target, replyCtx: replyCtx ?? null });
150
300
  const terminalSendScopeId = options.terminalSendScopeId ?? null;
@@ -229,6 +379,42 @@ export function createOpenclawClawlingReplyDispatcher(options) {
229
379
  recordOutbound("thinking", messageId, thinkingText);
230
380
  reasoningText = "";
231
381
  };
382
+ const appendBufferedText = (value) => {
383
+ const trimmed = value.trim();
384
+ if (!trimmed)
385
+ return;
386
+ if (!trimmed.includes("\n")) {
387
+ if (bufferedOutputLineSet.has(trimmed))
388
+ return;
389
+ bufferedOutputLineSet.add(trimmed);
390
+ }
391
+ bufferedOutputText = bufferedOutputText ? `${bufferedOutputText}\n${trimmed}` : trimmed;
392
+ };
393
+ const appendBufferedUrls = (urls) => {
394
+ for (const url of urls) {
395
+ if (url && !bufferedOutputUrls.includes(url))
396
+ bufferedOutputUrls.push(url);
397
+ }
398
+ };
399
+ const fullSegmentKey = (text, urls) => JSON.stringify({
400
+ text: text.replace(/\s+/g, " ").trim(),
401
+ urls: urls.filter(Boolean),
402
+ });
403
+ const mergeFinalText = (text) => {
404
+ if (outputVisibility === "minimal" || outputVisibility === "full")
405
+ return text;
406
+ return [bufferedOutputText.trim(), text.trim()].filter(Boolean).join("\n");
407
+ };
408
+ const mergeFinalUrls = (urls) => {
409
+ if (outputVisibility === "minimal" || outputVisibility === "full")
410
+ return urls;
411
+ const merged = bufferedOutputUrls.slice();
412
+ for (const url of urls) {
413
+ if (url && !merged.includes(url))
414
+ merged.push(url);
415
+ }
416
+ return merged;
417
+ };
232
418
  const mintStaticMessageId = () => `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
233
419
  const emitTyping = (isTyping) => {
234
420
  if (!isTyping && !typingActive)
@@ -336,13 +522,38 @@ export function createOpenclawClawlingReplyDispatcher(options) {
336
522
  });
337
523
  return result;
338
524
  };
525
+ const emitFullSegment = async (text, urls = []) => {
526
+ if (outputVisibility !== "full") {
527
+ appendBufferedText(text);
528
+ appendBufferedUrls(urls);
529
+ return;
530
+ }
531
+ if (!splitFullOutput) {
532
+ appendBufferedText(text);
533
+ appendBufferedUrls(urls);
534
+ return;
535
+ }
536
+ const trimmed = text.trim();
537
+ if (!trimmed && urls.length === 0)
538
+ return;
539
+ const segmentKey = fullSegmentKey(trimmed, urls);
540
+ if (emittedFullSegmentSet.has(segmentKey))
541
+ return;
542
+ emittedFullSegmentSet.add(segmentKey);
543
+ const mediaFragments = await uploadMediaUrls(urls);
544
+ await sendStatic(trimmed, mediaFragments, [], { recordMessage: true });
545
+ };
546
+ const emitFullRuntimeText = async (label, text, urls = []) => {
547
+ const summary = summarizeValue(text);
548
+ if (!summary && urls.length === 0)
549
+ return;
550
+ await emitFullSegment(summary ? `[${label}] ${summary}` : "", urls);
551
+ };
339
552
  // ----- Dispatcher -------------------------------------------------------
340
553
  const base = runtime.channel.reply.createReplyDispatcherWithTyping({
341
554
  humanDelay,
342
555
  onReplyStart: async () => {
343
556
  emitTyping(true);
344
- reasoningText = "";
345
- runDone = false;
346
557
  },
347
558
  deliver: async (payload, info) => {
348
559
  if (consumeTerminalSend(info?.kind ?? "unknown"))
@@ -352,24 +563,47 @@ export function createOpenclawClawlingReplyDispatcher(options) {
352
563
  const urls = resolveOutboundMediaUrls(payload).filter(Boolean);
353
564
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw deliver kind=${info?.kind ?? "unknown"} text_len=${text.length} media_urls=${urls.length} reasoning=${payload.isReasoning === true}`);
354
565
  if (isGroupTarget && richFragment) {
566
+ if (info?.kind === "final")
567
+ finalDeliverySeen = true;
355
568
  if (info?.kind !== "final")
356
569
  return;
357
570
  await sendOwnerAttention(resolvePayloadText(payload), richFragment);
358
571
  return;
359
572
  }
360
573
  if (isGroupTarget && info?.kind === "final" && looksLikeApprovalFallbackText(text)) {
574
+ finalDeliverySeen = true;
361
575
  await sendOwnerAttention(text);
362
576
  return;
363
577
  }
364
578
  if (payload.isReasoning) {
365
- if (isGroupTarget || !account.forwardThinking)
579
+ if (outputVisibility !== "full")
366
580
  return;
367
- reasoningText = text;
581
+ await emitFullSegment(text, urls);
582
+ const trimmed = text.trim();
583
+ if (trimmed)
584
+ reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
368
585
  return;
369
586
  }
370
- if (info?.kind === "tool")
587
+ if (info?.kind === "tool") {
588
+ if (isDefaultToolResultText(text))
589
+ return;
590
+ if (outputVisibility === "full") {
591
+ await emitFullRuntimeText("tool result", text, urls);
592
+ }
371
593
  return;
594
+ }
595
+ if (info?.kind === "block") {
596
+ if (outputVisibility === "full") {
597
+ await emitFullSegment(text, urls);
598
+ }
599
+ else if (outputVisibility === "minimal" || outputVisibility === "normal") {
600
+ appendBufferedText(text);
601
+ appendBufferedUrls(urls);
602
+ }
603
+ return;
604
+ }
372
605
  if (info?.kind === "final") {
606
+ finalDeliverySeen = true;
373
607
  if (isClawChatNoopResponseText(text) && !richFragment && urls.length === 0) {
374
608
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
375
609
  openclawLlmContextDebug.writeSnapshot({
@@ -391,19 +625,21 @@ export function createOpenclawClawlingReplyDispatcher(options) {
391
625
  });
392
626
  return;
393
627
  }
394
- const mediaFragments = await uploadMediaUrls(urls);
395
- const result = await sendStatic(text, mediaFragments, richFragment && account.richInteractions ? [richFragment] : [], { recordMessage: true });
628
+ const finalText = richFragment && account.richInteractions ? mergeFinalText("") : mergeFinalText(text);
629
+ const finalUrls = mergeFinalUrls(urls);
630
+ const mediaFragments = await uploadMediaUrls(finalUrls);
631
+ const result = await sendStatic(finalText, mediaFragments, richFragment && account.richInteractions ? [richFragment] : [], { recordMessage: true });
396
632
  if (result?.messageId)
397
633
  recordThinkingIfLinked(result.messageId);
398
634
  return;
399
635
  }
400
- // kind === "block" or unknown: OpenClaw may still call this path while
401
- // the model is producing output. ClawChat gets only the final materialized
402
- // reply.
636
+ // Unknown delivery kind: keep ClawChat output tied to OpenClaw final.
403
637
  },
404
638
  onError: (error, info) => {
405
639
  const errorText = normalizeReplyErrorText(error);
406
640
  log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw ${info.kind} reply failed: ${errorText}`);
641
+ if (!isGroupTarget && outputVisibility === "full")
642
+ void emitFullRuntimeText("error", errorText);
407
643
  if (isGroupTarget) {
408
644
  log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw group runtime failure suppressed from ClawChat clients group=${target.chatId}`);
409
645
  return;
@@ -414,6 +650,16 @@ export function createOpenclawClawlingReplyDispatcher(options) {
414
650
  if (runDone)
415
651
  return;
416
652
  runDone = true;
653
+ if (finalDeliverySeen)
654
+ return;
655
+ const fallbackText = bufferedOutputText.trim();
656
+ const fallbackUrls = bufferedOutputUrls.slice();
657
+ if (!fallbackText && fallbackUrls.length === 0)
658
+ return;
659
+ const mediaFragments = await uploadMediaUrls(fallbackUrls);
660
+ const result = await sendStatic(fallbackText, mediaFragments, [], { recordMessage: true });
661
+ if (result?.messageId)
662
+ recordThinkingIfLinked(result.messageId);
417
663
  },
418
664
  onCleanup: () => {
419
665
  emitTyping(false);
@@ -425,6 +671,80 @@ export function createOpenclawClawlingReplyDispatcher(options) {
425
671
  ...base.replyOptions,
426
672
  sourceReplyDeliveryMode: "automatic",
427
673
  disableBlockStreaming: true,
674
+ suppressDefaultToolProgressMessages: true,
675
+ allowProgressCallbacksWhenSourceDeliverySuppressed: outputVisibility === "full" ? true : undefined,
676
+ onReasoningStream: outputVisibility === "full"
677
+ ? async (payload) => {
678
+ if (consumeTerminalSend("reasoning"))
679
+ return;
680
+ const text = resolvePayloadText(payload);
681
+ await emitFullRuntimeText("reasoning", text, resolveOutboundMediaUrls(payload).filter(Boolean));
682
+ const trimmed = text.trim();
683
+ if (trimmed)
684
+ reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
685
+ }
686
+ : undefined,
687
+ onToolStart: outputVisibility === "full"
688
+ ? async (payload) => {
689
+ if (consumeTerminalSend("tool-start"))
690
+ return;
691
+ await emitFullSegment(formatToolStartSummary(payload));
692
+ }
693
+ : undefined,
694
+ onToolResult: outputVisibility === "full"
695
+ ? async (payload) => {
696
+ if (consumeTerminalSend("tool-result"))
697
+ return;
698
+ const text = resolvePayloadText(payload);
699
+ if (isDefaultToolResultText(text))
700
+ return;
701
+ await emitFullRuntimeText("tool result", text, resolveOutboundMediaUrls(payload).filter(Boolean));
702
+ }
703
+ : undefined,
704
+ onItemEvent: outputVisibility === "full"
705
+ ? async (payload) => {
706
+ if (consumeTerminalSend("item-event"))
707
+ return;
708
+ if (isToolProgressItem(payload))
709
+ return;
710
+ await emitFullRuntimeText("progress", summarizeProgressPayload(payload));
711
+ }
712
+ : undefined,
713
+ onPlanUpdate: outputVisibility === "full"
714
+ ? async (payload) => {
715
+ if (consumeTerminalSend("plan-update"))
716
+ return;
717
+ await emitFullRuntimeText("plan", summarizeProgressPayload(payload));
718
+ }
719
+ : undefined,
720
+ onCommandOutput: outputVisibility === "full"
721
+ ? async (payload) => {
722
+ if (consumeTerminalSend("command-output"))
723
+ return;
724
+ await emitFullSegment(formatCommandOutputSummary(payload));
725
+ }
726
+ : undefined,
727
+ onPatchSummary: outputVisibility === "full"
728
+ ? async (payload) => {
729
+ if (consumeTerminalSend("patch-summary"))
730
+ return;
731
+ await emitFullSegment(formatPatchSummary(payload));
732
+ }
733
+ : undefined,
734
+ onCompactionStart: outputVisibility === "full"
735
+ ? async () => {
736
+ if (consumeTerminalSend("compaction-start"))
737
+ return;
738
+ await emitFullSegment("[compaction] started");
739
+ }
740
+ : undefined,
741
+ onCompactionEnd: outputVisibility === "full"
742
+ ? async () => {
743
+ if (consumeTerminalSend("compaction-end"))
744
+ return;
745
+ await emitFullSegment("[compaction] finished");
746
+ }
747
+ : undefined,
428
748
  },
429
749
  markDispatchIdle: base.markDispatchIdle,
430
750
  };