@clawling/clawchat-plugin-openclaw 2026.5.12-39 → 2026.5.13-dev.0

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.
@@ -13,6 +13,8 @@ export const EVENT = {
13
13
  MESSAGE_FAILED: "message.failed",
14
14
  TYPING_UPDATE: "typing.update",
15
15
  CHAT_METADATA_INVALIDATED: "chat.metadata.invalidated",
16
+ NOTIFY_SIGNAL: "notify.signal",
17
+ REPLAY_DONE: "replay.done",
16
18
  OFFLINE_BATCH: "offline.batch",
17
19
  OFFLINE_ACK: "offline.ack",
18
20
  OFFLINE_DONE: "offline.done",
@@ -259,8 +259,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
259
259
  const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, store, log, } = options;
260
260
  const isGroupTarget = target.chatType === "group";
261
261
  const outputVisibility = effectiveOutputVisibility(account, target.chatId, target.chatType);
262
- const splitFullOutput = outputVisibility === "full";
263
- const splitNormalBlockOutput = outputVisibility === "normal";
262
+ const splitFullOutput = outputVisibility === "full" && !isGroupTarget;
264
263
  const ownerDirectTarget = () => {
265
264
  const ownerUserId = account.ownerUserId?.trim();
266
265
  return ownerUserId ? { chatId: ownerUserId, chatType: "direct" } : null;
@@ -524,12 +523,12 @@ export function createOpenclawClawlingReplyDispatcher(options) {
524
523
  return result;
525
524
  };
526
525
  const emitFullSegment = async (text, urls = []) => {
527
- if (outputVisibility !== "full" && !splitNormalBlockOutput) {
526
+ if (outputVisibility !== "full") {
528
527
  appendBufferedText(text);
529
528
  appendBufferedUrls(urls);
530
529
  return;
531
530
  }
532
- if (!splitFullOutput && !splitNormalBlockOutput) {
531
+ if (!splitFullOutput) {
533
532
  appendBufferedText(text);
534
533
  appendBufferedUrls(urls);
535
534
  return;
@@ -597,9 +596,6 @@ export function createOpenclawClawlingReplyDispatcher(options) {
597
596
  if (outputVisibility === "full") {
598
597
  await emitFullSegment(text, urls);
599
598
  }
600
- else if (splitNormalBlockOutput) {
601
- await emitFullSegment(text, urls);
602
- }
603
599
  else if (outputVisibility === "minimal" || outputVisibility === "normal") {
604
600
  appendBufferedText(text);
605
601
  appendBufferedUrls(urls);
@@ -631,12 +627,6 @@ export function createOpenclawClawlingReplyDispatcher(options) {
631
627
  }
632
628
  const finalText = richFragment && account.richInteractions ? mergeFinalText("") : mergeFinalText(text);
633
629
  const finalUrls = mergeFinalUrls(urls);
634
- if (isClawChatNoopResponseText(finalText) &&
635
- !richFragment &&
636
- finalUrls.length === 0) {
637
- log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
638
- return;
639
- }
640
630
  const mediaFragments = await uploadMediaUrls(finalUrls);
641
631
  const result = await sendStatic(finalText, mediaFragments, richFragment && account.richInteractions ? [richFragment] : [], { recordMessage: true });
642
632
  if (result?.messageId)
@@ -648,8 +638,12 @@ export function createOpenclawClawlingReplyDispatcher(options) {
648
638
  onError: (error, info) => {
649
639
  const errorText = normalizeReplyErrorText(error);
650
640
  log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw ${info.kind} reply failed: ${errorText}`);
651
- if (outputVisibility === "full")
641
+ if (!isGroupTarget && outputVisibility === "full")
652
642
  void emitFullRuntimeText("error", errorText);
643
+ if (isGroupTarget) {
644
+ log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw group runtime failure suppressed from ClawChat clients group=${target.chatId}`);
645
+ return;
646
+ }
653
647
  },
654
648
  onIdle: async () => {
655
649
  emitTyping(false);
@@ -676,10 +670,10 @@ export function createOpenclawClawlingReplyDispatcher(options) {
676
670
  replyOptions: {
677
671
  ...base.replyOptions,
678
672
  sourceReplyDeliveryMode: "automatic",
679
- disableBlockStreaming: !splitNormalBlockOutput,
673
+ disableBlockStreaming: true,
680
674
  suppressDefaultToolProgressMessages: true,
681
- allowProgressCallbacksWhenSourceDeliverySuppressed: splitFullOutput ? true : undefined,
682
- onReasoningStream: splitFullOutput
675
+ allowProgressCallbacksWhenSourceDeliverySuppressed: outputVisibility === "full" ? true : undefined,
676
+ onReasoningStream: outputVisibility === "full"
683
677
  ? async (payload) => {
684
678
  if (consumeTerminalSend("reasoning"))
685
679
  return;
@@ -690,14 +684,14 @@ export function createOpenclawClawlingReplyDispatcher(options) {
690
684
  reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
691
685
  }
692
686
  : undefined,
693
- onToolStart: splitFullOutput
687
+ onToolStart: outputVisibility === "full"
694
688
  ? async (payload) => {
695
689
  if (consumeTerminalSend("tool-start"))
696
690
  return;
697
691
  await emitFullSegment(formatToolStartSummary(payload));
698
692
  }
699
693
  : undefined,
700
- onToolResult: splitFullOutput
694
+ onToolResult: outputVisibility === "full"
701
695
  ? async (payload) => {
702
696
  if (consumeTerminalSend("tool-result"))
703
697
  return;
@@ -707,7 +701,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
707
701
  await emitFullRuntimeText("tool result", text, resolveOutboundMediaUrls(payload).filter(Boolean));
708
702
  }
709
703
  : undefined,
710
- onItemEvent: splitFullOutput
704
+ onItemEvent: outputVisibility === "full"
711
705
  ? async (payload) => {
712
706
  if (consumeTerminalSend("item-event"))
713
707
  return;
@@ -716,35 +710,35 @@ export function createOpenclawClawlingReplyDispatcher(options) {
716
710
  await emitFullRuntimeText("progress", summarizeProgressPayload(payload));
717
711
  }
718
712
  : undefined,
719
- onPlanUpdate: splitFullOutput
713
+ onPlanUpdate: outputVisibility === "full"
720
714
  ? async (payload) => {
721
715
  if (consumeTerminalSend("plan-update"))
722
716
  return;
723
717
  await emitFullRuntimeText("plan", summarizeProgressPayload(payload));
724
718
  }
725
719
  : undefined,
726
- onCommandOutput: splitFullOutput
720
+ onCommandOutput: outputVisibility === "full"
727
721
  ? async (payload) => {
728
722
  if (consumeTerminalSend("command-output"))
729
723
  return;
730
724
  await emitFullSegment(formatCommandOutputSummary(payload));
731
725
  }
732
726
  : undefined,
733
- onPatchSummary: splitFullOutput
727
+ onPatchSummary: outputVisibility === "full"
734
728
  ? async (payload) => {
735
729
  if (consumeTerminalSend("patch-summary"))
736
730
  return;
737
731
  await emitFullSegment(formatPatchSummary(payload));
738
732
  }
739
733
  : undefined,
740
- onCompactionStart: splitFullOutput
734
+ onCompactionStart: outputVisibility === "full"
741
735
  ? async () => {
742
736
  if (consumeTerminalSend("compaction-start"))
743
737
  return;
744
738
  await emitFullSegment("[compaction] started");
745
739
  }
746
740
  : undefined,
747
- onCompactionEnd: splitFullOutput
741
+ onCompactionEnd: outputVisibility === "full"
748
742
  ? async () => {
749
743
  if (consumeTerminalSend("compaction-end"))
750
744
  return;
@@ -12,7 +12,7 @@ import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
12
12
  import { runWithTerminalClawChatSendScope } from "./terminal-send.js";
13
13
  import { flushAlignedOutboundQueue, getAlignedOutboundQueueSize, sendOpenclawClawlingText, setAlignedOutboundLogContext, } from "./outbound.js";
14
14
  import { formatWsLog } from "./ws-log.js";
15
- import { createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.js";
15
+ import { createNotifySignalObserver, createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.js";
16
16
  import { clawChatDbPathForStateDir, getClawChatStore, } from "./storage.js";
17
17
  import { getClawChatGroupPrompt, getClawChatUserPrompt } from "./plugin-prompts.js";
18
18
  import { loadClawChatPromptMetadata, renderClawChatProfilePrompt, resolveSenderRelation, } from "./profile-prompt.js";
@@ -641,6 +641,11 @@ export async function startOpenclawClawlingGateway(params) {
641
641
  send: () => { },
642
642
  context: wsLogContext,
643
643
  });
644
+ const notifySignalObserver = createNotifySignalObserver({
645
+ accountId,
646
+ log: (msg) => log?.info?.(msg),
647
+ context: wsLogContext,
648
+ });
644
649
  const logAuthFailure = (reason) => {
645
650
  if (authFailureLogged)
646
651
  return;
@@ -863,6 +868,19 @@ export async function startOpenclawClawlingGateway(params) {
863
868
  client.on("metadata:invalidated", (env) => {
864
869
  void handleMetadataInvalidation(env);
865
870
  });
871
+ client.on("notify:signal", (env) => {
872
+ // §9.4 reliable system notification. The plugin holds no friend/roster
873
+ // cache (friends are fetched on demand via REST tools), so there is nothing
874
+ // to invalidate — observe + dedup only. The live frame and its reliable
875
+ // inbox replay carry the same event_id and collapse to one observation.
876
+ notifySignalObserver.observe(env);
877
+ });
878
+ client.on("replay:done", (env) => {
879
+ // §11.5 terminal control frame: device replay drained, live delivery begins.
880
+ // Fires on every reconnect (even zero-backlog). Replayed messages are
881
+ // processed inline, so this is a logged boundary marker, not a gate.
882
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw replay.done trace=${env.trace_id}`);
883
+ });
866
884
  client.on("error", (err) => {
867
885
  const classified = classifyClawlingClientError(err);
868
886
  if (classified.kind === "auth") {
@@ -176,3 +176,70 @@ export function createProtocolControlHandler(options) {
176
176
  },
177
177
  };
178
178
  }
179
+ /**
180
+ * Observes reliable `notify.signal` frames (§9.4). The agent plugin keeps no
181
+ * friend/roster cache (friends are fetched on demand via REST tools), so there
182
+ * is nothing to invalidate — this is a pure observability hook: it dedups by
183
+ * `event_id` (the live frame and its reliable-inbox replay collapse to one),
184
+ * structured-logs the signal, and returns the outcome. It deliberately takes no
185
+ * action on the agent; wire a real reaction here if the product later needs one.
186
+ */
187
+ export function createNotifySignalObserver(options) {
188
+ const maxSeen = options.maxSeen ?? 512;
189
+ const seen = new Set();
190
+ const order = [];
191
+ const context = () => options.context?.() ?? { attempt: 1, reconnectCount: 0, state: "ready" };
192
+ const logSignal = (event, action, fields) => {
193
+ const current = context();
194
+ options.log(formatWsLog({
195
+ event,
196
+ accountId: options.accountId,
197
+ attempt: current.attempt,
198
+ reconnectCount: current.reconnectCount,
199
+ state: current.state,
200
+ action,
201
+ fields,
202
+ }));
203
+ };
204
+ return {
205
+ /** Returns whether this signal was newly observed, a duplicate, or malformed. */
206
+ observe(env) {
207
+ const payload = env.payload && typeof env.payload === "object"
208
+ ? env.payload
209
+ : undefined;
210
+ const eventId = typeof payload?.event_id === "string" ? payload.event_id : "";
211
+ const type = typeof payload?.type === "string" ? payload.type : "";
212
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
213
+ const version = typeof payload?.version === "number" ? payload.version : undefined;
214
+ if (!eventId || !type) {
215
+ logSignal("notify_signal_invalid", "ignore", [
216
+ ["trace_id", env.trace_id],
217
+ ["type", type || null],
218
+ ["event_id", eventId || null],
219
+ ]);
220
+ return "invalid";
221
+ }
222
+ if (seen.has(eventId)) {
223
+ logSignal("notify_signal_duplicate", "ignore", [
224
+ ["type", type],
225
+ ["event_id", eventId],
226
+ ]);
227
+ return "duplicate";
228
+ }
229
+ seen.add(eventId);
230
+ order.push(eventId);
231
+ while (order.length > maxSeen) {
232
+ const evicted = order.shift();
233
+ if (evicted !== undefined)
234
+ seen.delete(evicted);
235
+ }
236
+ logSignal("notify_signal_observed", "observe", [
237
+ ["type", type],
238
+ ["entity_id", entityId || null],
239
+ ["version", version ?? null],
240
+ ["event_id", eventId],
241
+ ]);
242
+ return "observed";
243
+ },
244
+ };
245
+ }
@@ -301,6 +301,10 @@ export class ClawChatClient extends EventEmitter {
301
301
  this.emit("typing", env);
302
302
  if (env.event === EVENT.CHAT_METADATA_INVALIDATED)
303
303
  this.emit("metadata:invalidated", env);
304
+ if (env.event === EVENT.NOTIFY_SIGNAL)
305
+ this.emit("notify:signal", env);
306
+ if (env.event === EVENT.REPLAY_DONE)
307
+ this.emit("replay:done", env);
304
308
  if (env.event === EVENT.OFFLINE_DONE)
305
309
  this.emit("offline:done");
306
310
  }
@@ -322,7 +326,15 @@ export class ClawChatClient extends EventEmitter {
322
326
  token: this.opts.token,
323
327
  nonce,
324
328
  ...(this.opts.deviceId ? { device_id: this.opts.deviceId } : {}),
325
- capabilities: { multi_device: true, device_replay: true, chat_meta_events: true },
329
+ // Agent runtime is single-device: multi_device stays off so the server
330
+ // never self-fans-out this connection's own messages. notify_signals is
331
+ // advertised because we now handle the notify.signal frame (§9.4).
332
+ capabilities: {
333
+ multi_device: false,
334
+ device_replay: true,
335
+ chat_meta_events: true,
336
+ notify_signals: true,
337
+ },
326
338
  };
327
339
  const traceId = this.nextTraceId();
328
340
  this.expectedConnectTraceId = traceId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.5.12-39",
3
+ "version": "2026.5.13-dev.0",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -13,7 +13,6 @@ This skill guides agent behavior for ClawChat-aware tasks. Use the registered Cl
13
13
 
14
14
  - Use registered ClawChat plugin tools for account/profile, friends, users, moments, comments, reactions, avatar, media, and read-only conversation lookup.
15
15
  - If a requested ClawChat tool is unavailable or returns a config error, report that result and stop instead of bypassing the plugin.
16
- - Use the `/clawchat-output` slash command when the user asks to change how much ClawChat runtime output is shown in the current conversation.
17
16
 
18
17
  ## OpenClaw CLI
19
18
 
@@ -31,18 +30,6 @@ Use `update --force` only when local ClawChat plugin or skill files look corrupt
31
30
 
32
31
  If `channels add` reports `Unknown channel: clawchat-plugin-openclaw`, use the runtime slash command `/clawchat-activate CODE` after the operator ensures the plugin is loaded.
33
32
 
34
- ## Output Visibility
35
-
36
- When the user asks to change ClawChat output verbosity, use the runtime slash command for the current conversation. Treat natural-language wording as aliases for the three supported modes:
37
-
38
- | User wording | Command |
39
- | --- | --- |
40
- | quiet mode, silent mode, minimal output, final-only output, `minimal` | `/clawchat-output minimal` |
41
- | conversation mode, normal mode, regular mode, default output, `normal` | `/clawchat-output normal` |
42
- | dev mode, developer mode, verbose mode, full output, `full` | `/clawchat-output full` |
43
-
44
- Do not edit config files directly for this request. If the slash command returns an error, report that error instead of claiming the mode changed.
45
-
46
33
  ## Plugin Tool Routing
47
34
 
48
35
  Tool descriptions are authoritative. These routing hints resolve common ambiguity:
@@ -13,6 +13,8 @@ export const EVENT = {
13
13
  MESSAGE_FAILED: "message.failed",
14
14
  TYPING_UPDATE: "typing.update",
15
15
  CHAT_METADATA_INVALIDATED: "chat.metadata.invalidated",
16
+ NOTIFY_SIGNAL: "notify.signal",
17
+ REPLAY_DONE: "replay.done",
16
18
  OFFLINE_BATCH: "offline.batch",
17
19
  OFFLINE_ACK: "offline.ack",
18
20
  OFFLINE_DONE: "offline.done",
@@ -78,6 +80,11 @@ export interface ConnectCapabilities {
78
80
  multi_device?: boolean;
79
81
  device_replay?: boolean;
80
82
  chat_meta_events?: boolean;
83
+ delivery_receipt?: boolean;
84
+ notify_signals?: boolean;
85
+ permission_events?: boolean;
86
+ history_sync?: boolean;
87
+ e2ee?: boolean;
81
88
  }
82
89
 
83
90
  export interface ConnectPayload {
@@ -207,6 +214,25 @@ export interface ChatMetadataInvalidatedPayload {
207
214
  updated_at?: number;
208
215
  }
209
216
 
217
+ /**
218
+ * Reliable, inbox-coalesced system notification (§9.4). Content-free — only
219
+ * enough identity to dedup and to decide which REST surface to refetch. The
220
+ * agent plugin keeps no friend/roster cache, so this is consumed as an
221
+ * observability signal (see `createNotifySignalObserver`), not a cache refresh.
222
+ */
223
+ export interface NotifySignalPayload {
224
+ /** Logical event type the client routes on, e.g. `friend.added`. */
225
+ type: string;
226
+ /** Id of the changed entity (meaning depends on `type`). */
227
+ entity_id: string;
228
+ /** Monotonic cursor (ms since epoch at mutation time). */
229
+ version: number;
230
+ /** Globally-unique id for this signal occurrence — cross-channel dedup key. */
231
+ event_id: string;
232
+ /** Inbox coalesce key, formatted `notify:{type}:{entity_id}`. */
233
+ message_id: string;
234
+ }
235
+
210
236
  export interface StreamCreatedPayload {
211
237
  message_id: string;
212
238
  message_mode?: string;
@@ -388,8 +388,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
388
388
  } = options;
389
389
  const isGroupTarget = target.chatType === "group";
390
390
  const outputVisibility = effectiveOutputVisibility(account, target.chatId, target.chatType);
391
- const splitFullOutput = outputVisibility === "full";
392
- const splitNormalBlockOutput = outputVisibility === "normal";
391
+ const splitFullOutput = outputVisibility === "full" && !isGroupTarget;
393
392
  const ownerDirectTarget = () => {
394
393
  const ownerUserId = account.ownerUserId?.trim();
395
394
  return ownerUserId ? { chatId: ownerUserId, chatType: "direct" as const } : null;
@@ -671,12 +670,12 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
671
670
  };
672
671
 
673
672
  const emitFullSegment = async (text: string, urls: string[] = []): Promise<void> => {
674
- if (outputVisibility !== "full" && !splitNormalBlockOutput) {
673
+ if (outputVisibility !== "full") {
675
674
  appendBufferedText(text);
676
675
  appendBufferedUrls(urls);
677
676
  return;
678
677
  }
679
- if (!splitFullOutput && !splitNormalBlockOutput) {
678
+ if (!splitFullOutput) {
680
679
  appendBufferedText(text);
681
680
  appendBufferedUrls(urls);
682
681
  return;
@@ -751,8 +750,6 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
751
750
  if (info?.kind === "block") {
752
751
  if (outputVisibility === "full") {
753
752
  await emitFullSegment(text, urls);
754
- } else if (splitNormalBlockOutput) {
755
- await emitFullSegment(text, urls);
756
753
  } else if (outputVisibility === "minimal" || outputVisibility === "normal") {
757
754
  appendBufferedText(text);
758
755
  appendBufferedUrls(urls);
@@ -785,14 +782,6 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
785
782
  }
786
783
  const finalText = richFragment && account.richInteractions ? mergeFinalText("") : mergeFinalText(text);
787
784
  const finalUrls = mergeFinalUrls(urls);
788
- if (
789
- isClawChatNoopResponseText(finalText) &&
790
- !richFragment &&
791
- finalUrls.length === 0
792
- ) {
793
- log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
794
- return;
795
- }
796
785
  const mediaFragments = await uploadMediaUrls(finalUrls);
797
786
  const result = await sendStatic(
798
787
  finalText,
@@ -811,7 +800,13 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
811
800
  log?.error?.(
812
801
  `[${account.accountId}] clawchat-plugin-openclaw ${info.kind} reply failed: ${errorText}`,
813
802
  );
814
- if (outputVisibility === "full") void emitFullRuntimeText("error", errorText);
803
+ if (!isGroupTarget && outputVisibility === "full") void emitFullRuntimeText("error", errorText);
804
+ if (isGroupTarget) {
805
+ log?.error?.(
806
+ `[${account.accountId}] clawchat-plugin-openclaw group runtime failure suppressed from ClawChat clients group=${target.chatId}`,
807
+ );
808
+ return;
809
+ }
815
810
  },
816
811
  onIdle: async () => {
817
812
  emitTyping(false);
@@ -835,10 +830,10 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
835
830
  replyOptions: {
836
831
  ...base.replyOptions,
837
832
  sourceReplyDeliveryMode: "automatic",
838
- disableBlockStreaming: !splitNormalBlockOutput,
833
+ disableBlockStreaming: true,
839
834
  suppressDefaultToolProgressMessages: true,
840
- allowProgressCallbacksWhenSourceDeliverySuppressed: splitFullOutput ? true : undefined,
841
- onReasoningStream: splitFullOutput
835
+ allowProgressCallbacksWhenSourceDeliverySuppressed: outputVisibility === "full" ? true : undefined,
836
+ onReasoningStream: outputVisibility === "full"
842
837
  ? async (payload: ReplyPayload) => {
843
838
  if (consumeTerminalSend("reasoning")) return;
844
839
  const text = resolvePayloadText(payload);
@@ -847,13 +842,13 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
847
842
  if (trimmed) reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
848
843
  }
849
844
  : undefined,
850
- onToolStart: splitFullOutput
845
+ onToolStart: outputVisibility === "full"
851
846
  ? async (payload) => {
852
847
  if (consumeTerminalSend("tool-start")) return;
853
848
  await emitFullSegment(formatToolStartSummary(payload));
854
849
  }
855
850
  : undefined,
856
- onToolResult: splitFullOutput
851
+ onToolResult: outputVisibility === "full"
857
852
  ? async (payload: ReplyPayload) => {
858
853
  if (consumeTerminalSend("tool-result")) return;
859
854
  const text = resolvePayloadText(payload);
@@ -861,38 +856,38 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
861
856
  await emitFullRuntimeText("tool result", text, resolveOutboundMediaUrls(payload).filter(Boolean));
862
857
  }
863
858
  : undefined,
864
- onItemEvent: splitFullOutput
859
+ onItemEvent: outputVisibility === "full"
865
860
  ? async (payload: Record<string, unknown>) => {
866
861
  if (consumeTerminalSend("item-event")) return;
867
862
  if (isToolProgressItem(payload)) return;
868
863
  await emitFullRuntimeText("progress", summarizeProgressPayload(payload));
869
864
  }
870
865
  : undefined,
871
- onPlanUpdate: splitFullOutput
866
+ onPlanUpdate: outputVisibility === "full"
872
867
  ? async (payload: Record<string, unknown>) => {
873
868
  if (consumeTerminalSend("plan-update")) return;
874
869
  await emitFullRuntimeText("plan", summarizeProgressPayload(payload));
875
870
  }
876
871
  : undefined,
877
- onCommandOutput: splitFullOutput
872
+ onCommandOutput: outputVisibility === "full"
878
873
  ? async (payload: Record<string, unknown>) => {
879
874
  if (consumeTerminalSend("command-output")) return;
880
875
  await emitFullSegment(formatCommandOutputSummary(payload));
881
876
  }
882
877
  : undefined,
883
- onPatchSummary: splitFullOutput
878
+ onPatchSummary: outputVisibility === "full"
884
879
  ? async (payload: Record<string, unknown>) => {
885
880
  if (consumeTerminalSend("patch-summary")) return;
886
881
  await emitFullSegment(formatPatchSummary(payload));
887
882
  }
888
883
  : undefined,
889
- onCompactionStart: splitFullOutput
884
+ onCompactionStart: outputVisibility === "full"
890
885
  ? async () => {
891
886
  if (consumeTerminalSend("compaction-start")) return;
892
887
  await emitFullSegment("[compaction] started");
893
888
  }
894
889
  : undefined,
895
- onCompactionEnd: splitFullOutput
890
+ onCompactionEnd: outputVisibility === "full"
896
891
  ? async () => {
897
892
  if (consumeTerminalSend("compaction-end")) return;
898
893
  await emitFullSegment("[compaction] finished");
package/src/runtime.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  setAlignedOutboundLogContext,
36
36
  } from "./outbound.ts";
37
37
  import { formatWsLog } from "./ws-log.ts";
38
- import { createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.ts";
38
+ import { createNotifySignalObserver, createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.ts";
39
39
  import {
40
40
  clawChatDbPathForStateDir,
41
41
  getClawChatStore,
@@ -837,6 +837,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
837
837
  send: () => {},
838
838
  context: wsLogContext,
839
839
  });
840
+ const notifySignalObserver = createNotifySignalObserver({
841
+ accountId,
842
+ log: (msg) => log?.info?.(msg),
843
+ context: wsLogContext,
844
+ });
840
845
  const logAuthFailure = (reason: string) => {
841
846
  if (authFailureLogged) return;
842
847
  authFailureLogged = true;
@@ -1076,6 +1081,21 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1076
1081
  void handleMetadataInvalidation(env);
1077
1082
  });
1078
1083
 
1084
+ client.on("notify:signal", (env: Envelope) => {
1085
+ // §9.4 reliable system notification. The plugin holds no friend/roster
1086
+ // cache (friends are fetched on demand via REST tools), so there is nothing
1087
+ // to invalidate — observe + dedup only. The live frame and its reliable
1088
+ // inbox replay carry the same event_id and collapse to one observation.
1089
+ notifySignalObserver.observe(env);
1090
+ });
1091
+
1092
+ client.on("replay:done", (env: Envelope) => {
1093
+ // §11.5 terminal control frame: device replay drained, live delivery begins.
1094
+ // Fires on every reconnect (even zero-backlog). Replayed messages are
1095
+ // processed inline, so this is a logged boundary marker, not a gate.
1096
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw replay.done trace=${env.trace_id}`);
1097
+ });
1098
+
1079
1099
  client.on("error", (err: unknown) => {
1080
1100
  const classified = classifyClawlingClientError(err);
1081
1101
  if (classified.kind === "auth") {
@@ -273,3 +273,100 @@ export function createProtocolControlHandler(options: CreateProtocolControlHandl
273
273
  },
274
274
  };
275
275
  }
276
+
277
+ export interface NotifySignalEnvelope {
278
+ event?: string;
279
+ trace_id?: string;
280
+ payload?: unknown;
281
+ }
282
+
283
+ export interface CreateNotifySignalObserverOptions {
284
+ accountId: string;
285
+ log: (msg: string) => void;
286
+ context?: () => WsLogContext;
287
+ /** Upper bound on retained event_ids for dedup (FIFO eviction). */
288
+ maxSeen?: number;
289
+ }
290
+
291
+ export type NotifySignalOutcome = "observed" | "duplicate" | "invalid";
292
+
293
+ /**
294
+ * Observes reliable `notify.signal` frames (§9.4). The agent plugin keeps no
295
+ * friend/roster cache (friends are fetched on demand via REST tools), so there
296
+ * is nothing to invalidate — this is a pure observability hook: it dedups by
297
+ * `event_id` (the live frame and its reliable-inbox replay collapse to one),
298
+ * structured-logs the signal, and returns the outcome. It deliberately takes no
299
+ * action on the agent; wire a real reaction here if the product later needs one.
300
+ */
301
+ export function createNotifySignalObserver(options: CreateNotifySignalObserverOptions) {
302
+ const maxSeen = options.maxSeen ?? 512;
303
+ const seen = new Set<string>();
304
+ const order: string[] = [];
305
+
306
+ const context = (): WsLogContext =>
307
+ options.context?.() ?? { attempt: 1, reconnectCount: 0, state: "ready" };
308
+
309
+ const logSignal = (
310
+ event: string,
311
+ action: string,
312
+ fields: Array<[string, string | number | boolean | null | undefined]>,
313
+ ) => {
314
+ const current = context();
315
+ options.log(
316
+ formatWsLog({
317
+ event,
318
+ accountId: options.accountId,
319
+ attempt: current.attempt,
320
+ reconnectCount: current.reconnectCount,
321
+ state: current.state,
322
+ action,
323
+ fields,
324
+ }),
325
+ );
326
+ };
327
+
328
+ return {
329
+ /** Returns whether this signal was newly observed, a duplicate, or malformed. */
330
+ observe(env: NotifySignalEnvelope): NotifySignalOutcome {
331
+ const payload = env.payload && typeof env.payload === "object"
332
+ ? env.payload as Record<string, unknown>
333
+ : undefined;
334
+ const eventId = typeof payload?.event_id === "string" ? payload.event_id : "";
335
+ const type = typeof payload?.type === "string" ? payload.type : "";
336
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
337
+ const version = typeof payload?.version === "number" ? payload.version : undefined;
338
+
339
+ if (!eventId || !type) {
340
+ logSignal("notify_signal_invalid", "ignore", [
341
+ ["trace_id", env.trace_id],
342
+ ["type", type || null],
343
+ ["event_id", eventId || null],
344
+ ]);
345
+ return "invalid";
346
+ }
347
+
348
+ if (seen.has(eventId)) {
349
+ logSignal("notify_signal_duplicate", "ignore", [
350
+ ["type", type],
351
+ ["event_id", eventId],
352
+ ]);
353
+ return "duplicate";
354
+ }
355
+
356
+ seen.add(eventId);
357
+ order.push(eventId);
358
+ while (order.length > maxSeen) {
359
+ const evicted = order.shift();
360
+ if (evicted !== undefined) seen.delete(evicted);
361
+ }
362
+
363
+ logSignal("notify_signal_observed", "observe", [
364
+ ["type", type],
365
+ ["entity_id", entityId || null],
366
+ ["version", version ?? null],
367
+ ["event_id", eventId],
368
+ ]);
369
+ return "observed";
370
+ },
371
+ };
372
+ }
package/src/ws-client.ts CHANGED
@@ -369,6 +369,8 @@ export class ClawChatClient extends EventEmitter {
369
369
  if (env.event === EVENT.MESSAGE_FAILED) this.emit("message:failed", env);
370
370
  if (env.event === EVENT.TYPING_UPDATE) this.emit("typing", env);
371
371
  if (env.event === EVENT.CHAT_METADATA_INVALIDATED) this.emit("metadata:invalidated", env);
372
+ if (env.event === EVENT.NOTIFY_SIGNAL) this.emit("notify:signal", env);
373
+ if (env.event === EVENT.REPLAY_DONE) this.emit("replay:done", env);
372
374
  if (env.event === EVENT.OFFLINE_DONE) this.emit("offline:done");
373
375
  }
374
376
 
@@ -389,7 +391,15 @@ export class ClawChatClient extends EventEmitter {
389
391
  token: this.opts.token,
390
392
  nonce,
391
393
  ...(this.opts.deviceId ? { device_id: this.opts.deviceId } : {}),
392
- capabilities: { multi_device: true, device_replay: true, chat_meta_events: true },
394
+ // Agent runtime is single-device: multi_device stays off so the server
395
+ // never self-fans-out this connection's own messages. notify_signals is
396
+ // advertised because we now handle the notify.signal frame (§9.4).
397
+ capabilities: {
398
+ multi_device: false,
399
+ device_replay: true,
400
+ chat_meta_events: true,
401
+ notify_signals: true,
402
+ },
393
403
  };
394
404
  const traceId = this.nextTraceId();
395
405
  this.expectedConnectTraceId = traceId;
@@ -1,177 +0,0 @@
1
- import { emitStreamAdd, emitStreamCreated, emitStreamDone, emitStreamFailed, } from "./client.js";
2
- /**
3
- * Merge two views of the same progressively-revealed text.
4
- *
5
- * The agent runner may give us either:
6
- * - full snapshots ("Hel", "Hello", "Hello, world") where each item is
7
- * a superset of the previous; or
8
- * - overlapping slices ("hello ", "world hello ") that don't share a
9
- * prefix but share an overlap at the join.
10
- *
11
- * This helper returns a longest-sensible combined string. Ported from
12
- * `clawling-channel/src/reply-dispatcher.ts`.
13
- */
14
- export function mergeStreamingText(previousText, nextText) {
15
- const currentSnapshot = typeof previousText === "string" ? previousText : "";
16
- const incomingText = typeof nextText === "string" ? nextText : "";
17
- if (!incomingText)
18
- return currentSnapshot;
19
- if (!currentSnapshot || incomingText === currentSnapshot)
20
- return incomingText;
21
- if (incomingText.startsWith(currentSnapshot))
22
- return incomingText;
23
- if (currentSnapshot.startsWith(incomingText))
24
- return currentSnapshot;
25
- if (incomingText.includes(currentSnapshot))
26
- return incomingText;
27
- if (currentSnapshot.includes(incomingText))
28
- return currentSnapshot;
29
- const maxOverlap = Math.min(currentSnapshot.length, incomingText.length);
30
- for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
31
- if (currentSnapshot.slice(-overlap) === incomingText.slice(0, overlap)) {
32
- return `${currentSnapshot}${incomingText.slice(overlap)}`;
33
- }
34
- }
35
- return `${currentSnapshot}${incomingText}`;
36
- }
37
- function resolveRouting(options) {
38
- if (options.routing)
39
- return options.routing;
40
- if (options.to)
41
- return { chatId: options.to.id, chatType: options.to.type };
42
- throw new Error("openclaw-clawchat buffered stream requires routing");
43
- }
44
- /**
45
- * Build a streaming session wrapper around message.created/add/done events.
46
- *
47
- * Usage pattern (matching clawling-channel):
48
- * const session = openBufferedStreamingSession({...});
49
- * await session.queueSnapshot("Hel");
50
- * await session.queueSnapshot("Hello");
51
- * await session.queueDelta(", world");
52
- * await session.done();
53
- */
54
- export function openBufferedStreamingSession(options) {
55
- const routing = resolveRouting(options);
56
- const emitTyping = options.emitTyping !== false;
57
- if (emitTyping)
58
- options.client.typing(routing.chatId, true);
59
- emitStreamCreated(options.client, {
60
- messageId: options.messageId,
61
- routing,
62
- });
63
- let bufferedSnapshot = "";
64
- let flushedSnapshot = "";
65
- let sequence = -1;
66
- let flushTimer = null;
67
- let pendingFlush = Promise.resolve();
68
- let closed = false;
69
- const clearTimer = () => {
70
- if (flushTimer) {
71
- clearTimeout(flushTimer);
72
- flushTimer = null;
73
- }
74
- };
75
- const performFlush = async () => {
76
- clearTimer();
77
- if (closed)
78
- return;
79
- if (bufferedSnapshot === flushedSnapshot)
80
- return;
81
- const snapshot = bufferedSnapshot;
82
- const delta = snapshot.slice(flushedSnapshot.length);
83
- if (!delta)
84
- return;
85
- sequence += 1;
86
- emitStreamAdd(options.client, {
87
- messageId: options.messageId,
88
- routing,
89
- sequence,
90
- fullText: snapshot,
91
- textDelta: delta,
92
- });
93
- flushedSnapshot = snapshot;
94
- };
95
- const flush = async () => {
96
- pendingFlush = pendingFlush.then(performFlush);
97
- await pendingFlush;
98
- };
99
- const scheduleFlush = () => {
100
- if (flushTimer || closed)
101
- return;
102
- flushTimer = setTimeout(() => {
103
- flushTimer = null;
104
- void flush();
105
- }, options.flushIntervalMs);
106
- };
107
- const queueSnapshot = async (snapshot) => {
108
- if (closed || !snapshot)
109
- return;
110
- const base = bufferedSnapshot.length >= flushedSnapshot.length ? bufferedSnapshot : flushedSnapshot;
111
- const merged = mergeStreamingText(base, snapshot);
112
- if (merged === bufferedSnapshot)
113
- return;
114
- bufferedSnapshot = merged;
115
- const deltaChars = Math.max(0, bufferedSnapshot.length - flushedSnapshot.length);
116
- if (deltaChars >= options.minChunkChars || bufferedSnapshot.length >= options.maxBufferChars) {
117
- await flush();
118
- }
119
- else {
120
- scheduleFlush();
121
- }
122
- };
123
- const queueDelta = async (delta) => {
124
- if (closed || !delta)
125
- return;
126
- bufferedSnapshot = `${bufferedSnapshot}${delta}`;
127
- const deltaChars = Math.max(0, bufferedSnapshot.length - flushedSnapshot.length);
128
- if (deltaChars >= options.minChunkChars || bufferedSnapshot.length >= options.maxBufferChars) {
129
- await flush();
130
- }
131
- else {
132
- scheduleFlush();
133
- }
134
- };
135
- const done = async () => {
136
- if (closed)
137
- return;
138
- await flush();
139
- closed = true;
140
- clearTimer();
141
- emitStreamDone(options.client, {
142
- messageId: options.messageId,
143
- routing,
144
- finalSequence: Math.max(sequence, 0),
145
- finalText: bufferedSnapshot,
146
- });
147
- if (emitTyping)
148
- options.client.typing(routing.chatId, false);
149
- };
150
- const fail = async (reason) => {
151
- if (closed)
152
- return;
153
- closed = true;
154
- clearTimer();
155
- emitStreamFailed(options.client, {
156
- messageId: options.messageId,
157
- routing,
158
- sequence: Math.max(sequence, 0),
159
- ...(reason !== undefined ? { reason } : {}),
160
- });
161
- if (emitTyping)
162
- options.client.typing(routing.chatId, false);
163
- };
164
- return {
165
- get currentText() {
166
- return bufferedSnapshot;
167
- },
168
- get flushedText() {
169
- return flushedSnapshot;
170
- },
171
- queueSnapshot,
172
- queueDelta,
173
- flush,
174
- done,
175
- fail,
176
- };
177
- }
@@ -1,65 +0,0 @@
1
- import { emitStreamAdd, emitStreamCreated, emitStreamDone, emitStreamFailed, } from "./client.js";
2
- function resolveRouting(params) {
3
- if (params.routing)
4
- return params.routing;
5
- if (params.to)
6
- return { chatId: params.to.id, chatType: params.to.type };
7
- throw new Error("openclaw-clawchat streaming requires routing");
8
- }
9
- /**
10
- * Emit one full streaming lifecycle for a pre-chunked reply.
11
- *
12
- * Sequence:
13
- * typing(true)
14
- * message.created (sequence 0)
15
- * message.add (sequence 1..N, one per chunk)
16
- * message.done (sequence N)
17
- * typing(false)
18
- *
19
- * With zero chunks: typing(true) -> created -> done -> typing(false).
20
- */
21
- export async function sendStreamingText(params) {
22
- const routing = resolveRouting(params);
23
- const emitTyping = params.emitTyping !== false;
24
- if (emitTyping) {
25
- params.client.typing(routing.chatId, true);
26
- }
27
- emitStreamCreated(params.client, {
28
- messageId: params.messageId,
29
- routing,
30
- });
31
- let sequence = -1;
32
- let fullText = "";
33
- for (const chunk of params.chunks) {
34
- sequence += 1;
35
- fullText += chunk;
36
- emitStreamAdd(params.client, {
37
- messageId: params.messageId,
38
- routing,
39
- sequence,
40
- fullText,
41
- textDelta: chunk,
42
- });
43
- }
44
- emitStreamDone(params.client, {
45
- messageId: params.messageId,
46
- routing,
47
- finalSequence: Math.max(sequence, 0),
48
- finalText: fullText,
49
- });
50
- if (emitTyping) {
51
- params.client.typing(routing.chatId, false);
52
- }
53
- }
54
- export async function sendStreamingFailure(params) {
55
- const routing = resolveRouting(params);
56
- emitStreamFailed(params.client, {
57
- messageId: params.messageId,
58
- routing,
59
- sequence: params.currentSequence,
60
- reason: params.reason,
61
- });
62
- if (params.emitTyping !== false) {
63
- params.client.typing(routing.chatId, false);
64
- }
65
- }