@clawling/clawchat-plugin-openclaw 2026.5.13-dev.2 → 2026.5.14-2

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.
package/dist/index.js CHANGED
@@ -16,5 +16,12 @@ export default defineChannelPluginEntry({
16
16
  registerOpenclawClawlingCommands(api);
17
17
  registerClawChatPromptInjection(api);
18
18
  registerOpenclawClawlingTools(api);
19
+ // NOTE: the legacy-rename config migration is intentionally NOT registered
20
+ // here. The host's setup-migration runner only loads a plugin's SETUP source
21
+ // (`openclaw.setupEntry`/`runtimeSetupEntry` → setup-entry.ts) and calls its
22
+ // `register(api)` in "setup-only" mode; registrations made in `registerFull`
23
+ // (full/tool-discovery modes) are never collected for migrations, and this
24
+ // gated full-load path doesn't run for the rename scenario anyway. The
25
+ // migration is wired into setup-entry.ts instead.
19
26
  },
20
27
  });
@@ -1,3 +1,33 @@
1
1
  import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
2
+ import { CHANNEL_ID } from "./src/config.js";
2
3
  import { openclawClawlingSetupPlugin } from "./src/channel.setup.js";
3
- export default defineSetupPluginEntry(openclawClawlingSetupPlugin);
4
+ import { migrateLegacyClawChatChannelConfig } from "./src/config-compat.js";
5
+ /**
6
+ * Host setup-source `register` hook.
7
+ *
8
+ * The OpenClaw setup-migration runner (`setup-registry`) loads this module as
9
+ * the plugin's setup source (resolved from `package.json` `openclaw.setupEntry`
10
+ * / `runtimeSetupEntry`), unwraps the default export, and — when that export is
11
+ * an object exposing a `register` function whose `id` matches the plugin id —
12
+ * calls `register(api)` in setup-only mode. This is the ONLY ungated path that
13
+ * collects config migrations for the plugin-rename scenario.
14
+ *
15
+ * Mirrors the host's own built-in pattern (e.g. amazon-bedrock setup-api.js:
16
+ * `api.registerConfigMigration((config) => migrateAmazonBedrockLegacyConfig(config))`).
17
+ */
18
+ export function register(api) {
19
+ api.registerConfigMigration?.((config) => migrateLegacyClawChatChannelConfig(config));
20
+ }
21
+ /**
22
+ * Default export consumed by BOTH host loaders of this setup source:
23
+ * - the channel-setup loader unwraps `default` and reads `.plugin`
24
+ * (`defineSetupPluginEntry` yields `{ plugin }`);
25
+ * - the setup-migration runner unwraps `default` and reads `.register`,
26
+ * rejecting it unless `.id` matches the plugin id.
27
+ * So the default export must carry `plugin`, `id`, and `register` together.
28
+ */
29
+ export default {
30
+ ...defineSetupPluginEntry(openclawClawlingSetupPlugin),
31
+ id: CHANNEL_ID,
32
+ register,
33
+ };
@@ -1,5 +1,14 @@
1
1
  import { ClawlingApiError, } from "./api-types.js";
2
2
  import { CHANNEL_ID } from "./config.js";
3
+ export function buildPluginReportBody(input) {
4
+ return {
5
+ device_id: input.deviceId,
6
+ platform: input.platform,
7
+ plugin_version: input.pluginVersion,
8
+ runtime_name: input.runtimeName,
9
+ runtime_version: input.runtimeVersion,
10
+ };
11
+ }
3
12
  /**
4
13
  * §A.0 — decode the access token's `exp` claim locally (base64url-decode the
5
14
  * JWT payload segment, read `exp` as epoch seconds). Returns `null` when the
@@ -416,5 +425,14 @@ export function createOpenclawClawlingApiClient(opts) {
416
425
  fd.set("file", file);
417
426
  return await call("POST", "/v1/files/upload-url", { body: fd });
418
427
  },
428
+ async reportPlugin(input, opts) {
429
+ const path = opts?.authenticated
430
+ ? "/v1/agents/me/plugin-report"
431
+ : "/v1/agents/plugin-report";
432
+ await call("POST", path, {
433
+ headers: { "content-type": "application/json" },
434
+ body: JSON.stringify(buildPluginReportBody(input)),
435
+ });
436
+ },
419
437
  };
420
438
  }
@@ -0,0 +1,120 @@
1
+ import { CHANNEL_ID } from "./config.js";
2
+ /**
3
+ * Compatibility migration for the plugin rename (commit 260044f): the plugin
4
+ * `id` and channel key changed from the OLD `openclaw-clawchat` to the NEW
5
+ * `clawchat-plugin-openclaw` (= {@link CHANNEL_ID}).
6
+ *
7
+ * Users upgrading from the old version have all their state — channel block
8
+ * (token/userId/ownerUserId/refreshToken/…), `plugins.allow`,
9
+ * `plugins.entries`, and `tools.allow`/`tools.alsoAllow` — keyed under the old
10
+ * id. The new code only reads the new id, so the channel silently fails to
11
+ * load. This migration moves the old-keyed state onto the new id.
12
+ *
13
+ * The function is PURE: it clones the input via `structuredClone` and never
14
+ * mutates its argument. It is IDEMPOTENT: a config with no old id anywhere
15
+ * returns an equivalent config with `changes: []`.
16
+ */
17
+ export const LEGACY_CHANNEL_ID = "openclaw-clawchat";
18
+ export const TARGET_CHANNEL_ID = CHANNEL_ID;
19
+ const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
20
+ function isBlockedObjectKey(key) {
21
+ return BLOCKED_OBJECT_KEYS.has(key);
22
+ }
23
+ function isRecord(value) {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+ function isNonEmptyRecord(value) {
27
+ return isRecord(value) && Object.keys(value).length > 0;
28
+ }
29
+ /**
30
+ * Copy keys from `source` into `target` only when they are absent/undefined in
31
+ * `target` (target is more current — never overwrite an existing value).
32
+ * Recurses into nested records. Skips prototype-pollution keys.
33
+ */
34
+ function mergeMissing(target, source) {
35
+ for (const [key, value] of Object.entries(source)) {
36
+ if (value === undefined || isBlockedObjectKey(key))
37
+ continue;
38
+ const existing = target[key];
39
+ if (existing === undefined) {
40
+ target[key] = value;
41
+ continue;
42
+ }
43
+ if (isRecord(existing) && isRecord(value))
44
+ mergeMissing(existing, value);
45
+ }
46
+ }
47
+ /** Replace `from` → `to` in a string array, preserving order and deduping. */
48
+ function replaceAndDedup(list, from, to) {
49
+ const out = [];
50
+ for (const raw of list) {
51
+ const value = raw === from ? to : raw;
52
+ if (!out.includes(value))
53
+ out.push(value);
54
+ }
55
+ return out;
56
+ }
57
+ export function migrateLegacyClawChatChannelConfig(config) {
58
+ const changes = [];
59
+ if (!isRecord(config))
60
+ return { config, changes };
61
+ const next = structuredClone(config);
62
+ // 1. channels — move/merge the old channel block onto the new id.
63
+ const channels = next.channels;
64
+ if (isRecord(channels)) {
65
+ const oldChannel = channels[LEGACY_CHANNEL_ID];
66
+ if (isNonEmptyRecord(oldChannel)) {
67
+ const newChannel = channels[TARGET_CHANNEL_ID];
68
+ if (!isNonEmptyRecord(newChannel)) {
69
+ channels[TARGET_CHANNEL_ID] = oldChannel;
70
+ changes.push(`Moved channel "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`);
71
+ }
72
+ else {
73
+ mergeMissing(newChannel, oldChannel);
74
+ changes.push(`Merged channel "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}" (filled missing fields; kept existing values).`);
75
+ }
76
+ delete channels[LEGACY_CHANNEL_ID];
77
+ }
78
+ }
79
+ // plugins.* live under a single `plugins` record.
80
+ const plugins = next.plugins;
81
+ if (isRecord(plugins)) {
82
+ // 2. plugins.allow — replace old id with new id (append if missing), dedup.
83
+ const allow = plugins.allow;
84
+ if (Array.isArray(allow) && allow.includes(LEGACY_CHANNEL_ID)) {
85
+ const replaced = replaceAndDedup(allow.filter((v) => typeof v === "string"), LEGACY_CHANNEL_ID, TARGET_CHANNEL_ID);
86
+ plugins.allow = replaced;
87
+ changes.push(`Updated plugins.allow: "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`);
88
+ }
89
+ // 3. plugins.entries — merge-missing the old entry into the new one.
90
+ const entries = plugins.entries;
91
+ if (isRecord(entries)) {
92
+ const oldEntry = entries[LEGACY_CHANNEL_ID];
93
+ if (oldEntry !== undefined) {
94
+ if (isRecord(oldEntry)) {
95
+ const newEntry = entries[TARGET_CHANNEL_ID];
96
+ if (!isRecord(newEntry)) {
97
+ entries[TARGET_CHANNEL_ID] = oldEntry;
98
+ }
99
+ else {
100
+ mergeMissing(newEntry, oldEntry);
101
+ }
102
+ }
103
+ delete entries[LEGACY_CHANNEL_ID];
104
+ changes.push(`Merged plugins.entries["${LEGACY_CHANNEL_ID}"] → plugins.entries["${TARGET_CHANNEL_ID}"].`);
105
+ }
106
+ }
107
+ }
108
+ // 4. tools.allow + tools.alsoAllow — replace old id with new id, dedup.
109
+ const tools = next.tools;
110
+ if (isRecord(tools)) {
111
+ for (const key of ["allow", "alsoAllow"]) {
112
+ const list = tools[key];
113
+ if (Array.isArray(list) && list.includes(LEGACY_CHANNEL_ID)) {
114
+ tools[key] = replaceAndDedup(list.filter((v) => typeof v === "string"), LEGACY_CHANNEL_ID, TARGET_CHANNEL_ID);
115
+ changes.push(`Updated tools.${key}: "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`);
116
+ }
117
+ }
118
+ }
119
+ return { config: next, changes };
120
+ }
@@ -0,0 +1,36 @@
1
+ import { createRequire } from "node:module";
2
+ import { createOpenclawClawlingApiClient } from "./api-client.js";
3
+ /** Best-effort read of this plugin's package version; "unknown" on failure. */
4
+ export function resolvePluginVersion() {
5
+ try {
6
+ const require = createRequire(import.meta.url);
7
+ const pkg = require("../package.json");
8
+ return pkg.version ?? "unknown";
9
+ }
10
+ catch {
11
+ return "unknown";
12
+ }
13
+ }
14
+ /**
15
+ * Fire one plugin-version report. Best-effort: any failure is logged at debug
16
+ * and swallowed so it can never block, delay, or crash gateway startup.
17
+ */
18
+ export async function reportPluginVersionSafe(p) {
19
+ try {
20
+ const client = createOpenclawClawlingApiClient({
21
+ baseUrl: p.baseUrl,
22
+ mediaBaseUrl: p.mediaBaseUrl,
23
+ token: p.token,
24
+ });
25
+ await client.reportPlugin({
26
+ deviceId: p.deviceId,
27
+ platform: "openclaw",
28
+ pluginVersion: p.pluginVersion,
29
+ runtimeName: "node",
30
+ runtimeVersion: process.version,
31
+ }, { authenticated: p.authenticated });
32
+ }
33
+ catch (err) {
34
+ p.log?.debug?.(`clawchat-plugin-openclaw plugin report failed (authenticated=${p.authenticated}): ${err instanceof Error ? err.message : String(err)}`);
35
+ }
36
+ }
@@ -259,7 +259,8 @@ 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" && !isGroupTarget;
262
+ const splitFullOutput = outputVisibility === "full";
263
+ const splitNormalBlockOutput = outputVisibility === "normal";
263
264
  const ownerDirectTarget = () => {
264
265
  const ownerUserId = account.ownerUserId?.trim();
265
266
  return ownerUserId ? { chatId: ownerUserId, chatType: "direct" } : null;
@@ -526,12 +527,12 @@ export function createOpenclawClawlingReplyDispatcher(options) {
526
527
  return result;
527
528
  };
528
529
  const emitFullSegment = async (text, urls = []) => {
529
- if (outputVisibility !== "full") {
530
+ if (outputVisibility !== "full" && !splitNormalBlockOutput) {
530
531
  appendBufferedText(text);
531
532
  appendBufferedUrls(urls);
532
533
  return;
533
534
  }
534
- if (!splitFullOutput) {
535
+ if (!splitFullOutput && !splitNormalBlockOutput) {
535
536
  appendBufferedText(text);
536
537
  appendBufferedUrls(urls);
537
538
  return;
@@ -599,6 +600,9 @@ export function createOpenclawClawlingReplyDispatcher(options) {
599
600
  if (outputVisibility === "full") {
600
601
  await emitFullSegment(text, urls);
601
602
  }
603
+ else if (splitNormalBlockOutput) {
604
+ await emitFullSegment(text, urls);
605
+ }
602
606
  else if (outputVisibility === "minimal" || outputVisibility === "normal") {
603
607
  appendBufferedText(text);
604
608
  appendBufferedUrls(urls);
@@ -630,6 +634,12 @@ export function createOpenclawClawlingReplyDispatcher(options) {
630
634
  }
631
635
  const finalText = richFragment && account.richInteractions ? mergeFinalText("") : mergeFinalText(text);
632
636
  const finalUrls = mergeFinalUrls(urls);
637
+ if (isClawChatNoopResponseText(finalText) &&
638
+ !richFragment &&
639
+ finalUrls.length === 0) {
640
+ log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
641
+ return;
642
+ }
633
643
  const mediaFragments = await uploadMediaUrls(finalUrls);
634
644
  const result = await sendStatic(finalText, mediaFragments, richFragment && account.richInteractions ? [richFragment] : [], { recordMessage: true });
635
645
  if (result?.messageId)
@@ -641,12 +651,8 @@ export function createOpenclawClawlingReplyDispatcher(options) {
641
651
  onError: (error, info) => {
642
652
  const errorText = normalizeReplyErrorText(error);
643
653
  log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw ${info.kind} reply failed: ${errorText}`);
644
- if (!isGroupTarget && outputVisibility === "full")
654
+ if (outputVisibility === "full")
645
655
  void emitFullRuntimeText("error", errorText);
646
- if (isGroupTarget) {
647
- log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw group runtime failure suppressed from ClawChat clients group=${target.chatId}`);
648
- return;
649
- }
650
656
  },
651
657
  onIdle: async () => {
652
658
  emitTyping(false);
@@ -673,10 +679,10 @@ export function createOpenclawClawlingReplyDispatcher(options) {
673
679
  replyOptions: {
674
680
  ...base.replyOptions,
675
681
  sourceReplyDeliveryMode: "automatic",
676
- disableBlockStreaming: true,
682
+ disableBlockStreaming: !splitNormalBlockOutput,
677
683
  suppressDefaultToolProgressMessages: true,
678
- allowProgressCallbacksWhenSourceDeliverySuppressed: outputVisibility === "full" ? true : undefined,
679
- onReasoningStream: outputVisibility === "full"
684
+ allowProgressCallbacksWhenSourceDeliverySuppressed: splitFullOutput ? true : undefined,
685
+ onReasoningStream: splitFullOutput
680
686
  ? async (payload) => {
681
687
  if (consumeTerminalSend("reasoning"))
682
688
  return;
@@ -687,14 +693,14 @@ export function createOpenclawClawlingReplyDispatcher(options) {
687
693
  reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
688
694
  }
689
695
  : undefined,
690
- onToolStart: outputVisibility === "full"
696
+ onToolStart: splitFullOutput
691
697
  ? async (payload) => {
692
698
  if (consumeTerminalSend("tool-start"))
693
699
  return;
694
700
  await emitFullSegment(formatToolStartSummary(payload));
695
701
  }
696
702
  : undefined,
697
- onToolResult: outputVisibility === "full"
703
+ onToolResult: splitFullOutput
698
704
  ? async (payload) => {
699
705
  if (consumeTerminalSend("tool-result"))
700
706
  return;
@@ -704,7 +710,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
704
710
  await emitFullRuntimeText("tool result", text, resolveOutboundMediaUrls(payload).filter(Boolean));
705
711
  }
706
712
  : undefined,
707
- onItemEvent: outputVisibility === "full"
713
+ onItemEvent: splitFullOutput
708
714
  ? async (payload) => {
709
715
  if (consumeTerminalSend("item-event"))
710
716
  return;
@@ -713,35 +719,35 @@ export function createOpenclawClawlingReplyDispatcher(options) {
713
719
  await emitFullRuntimeText("progress", summarizeProgressPayload(payload));
714
720
  }
715
721
  : undefined,
716
- onPlanUpdate: outputVisibility === "full"
722
+ onPlanUpdate: splitFullOutput
717
723
  ? async (payload) => {
718
724
  if (consumeTerminalSend("plan-update"))
719
725
  return;
720
726
  await emitFullRuntimeText("plan", summarizeProgressPayload(payload));
721
727
  }
722
728
  : undefined,
723
- onCommandOutput: outputVisibility === "full"
729
+ onCommandOutput: splitFullOutput
724
730
  ? async (payload) => {
725
731
  if (consumeTerminalSend("command-output"))
726
732
  return;
727
733
  await emitFullSegment(formatCommandOutputSummary(payload));
728
734
  }
729
735
  : undefined,
730
- onPatchSummary: outputVisibility === "full"
736
+ onPatchSummary: splitFullOutput
731
737
  ? async (payload) => {
732
738
  if (consumeTerminalSend("patch-summary"))
733
739
  return;
734
740
  await emitFullSegment(formatPatchSummary(payload));
735
741
  }
736
742
  : undefined,
737
- onCompactionStart: outputVisibility === "full"
743
+ onCompactionStart: splitFullOutput
738
744
  ? async () => {
739
745
  if (consumeTerminalSend("compaction-start"))
740
746
  return;
741
747
  await emitFullSegment("[compaction] started");
742
748
  }
743
749
  : undefined,
744
- onCompactionEnd: outputVisibility === "full"
750
+ onCompactionEnd: splitFullOutput
745
751
  ? async () => {
746
752
  if (consumeTerminalSend("compaction-end"))
747
753
  return;
@@ -2,8 +2,9 @@ import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError,
2
2
  import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
3
3
  import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
4
4
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
5
- import { createOpenclawClawlingClient } from "./client.js";
5
+ import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.js";
6
6
  import { createOpenclawClawlingApiClient } from "./api-client.js";
7
+ import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
7
8
  import { ClawlingApiError } from "./api-types.js";
8
9
  import { RefreshManager } from "./refresh-manager.js";
9
10
  import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, hasOpenclawClawlingConnectCredentials, } from "./config.js";
@@ -425,6 +426,19 @@ export async function startOpenclawClawlingGateway(params) {
425
426
  const accountId = account.accountId;
426
427
  const store = resolveConnectionStore(params, runtime);
427
428
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime start entered configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} hasOwnerUserId=${Boolean(account.ownerUserId)} websocketUrl=${account.websocketUrl || "(empty)"}`);
429
+ // Freeze one report device_id for this process; reused for the paired report
430
+ // so the backend links both to the same row. Imported from ./client.ts.
431
+ const reportDeviceId = resolveOpenclawClawlingDeviceId(account);
432
+ const pluginVersion = resolvePluginVersion();
433
+ void reportPluginVersionSafe({
434
+ baseUrl: account.baseUrl,
435
+ mediaBaseUrl: account.mediaBaseUrl,
436
+ token: "",
437
+ deviceId: reportDeviceId,
438
+ pluginVersion,
439
+ authenticated: false,
440
+ log,
441
+ });
428
442
  const activationAccount = await waitForActivationCredentials({
429
443
  account,
430
444
  abortSignal,
@@ -438,6 +452,17 @@ export async function startOpenclawClawlingGateway(params) {
438
452
  if (!activationAccount)
439
453
  return;
440
454
  account = activationAccount.account;
455
+ // Paired: link the report row via the authenticated endpoint, reusing the
456
+ // SAME frozen device_id so the backend upserts the existing unpaired row.
457
+ void reportPluginVersionSafe({
458
+ baseUrl: account.baseUrl,
459
+ mediaBaseUrl: account.mediaBaseUrl,
460
+ token: account.token,
461
+ deviceId: reportDeviceId,
462
+ pluginVersion,
463
+ authenticated: true,
464
+ log,
465
+ });
441
466
  // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null when
442
467
  // the credentials came from config (no activation row yet) — in that case the
443
468
  // refresh manager relies on the JWT `exp` alone.
@@ -588,9 +613,18 @@ export async function startOpenclawClawlingGateway(params) {
588
613
  // The refresh token is not part of the resolved account; source it from
589
614
  // SQLite first (authoritative after a rotation) then the config channel
590
615
  // section. Kept in a mutable cell so a swap updates it in place.
591
- let latestRefreshToken = (activationAccount.source === "sqlite" && store?.getActivationCredentials
592
- ? store.getActivationCredentials({ platform: "openclaw", accountId })?.refreshToken
593
- : null) ?? readConfigRefreshToken(cfg);
616
+ // §0/§D a rotated refresh token threaded across a re-enter wins over the
617
+ // stores. The config write that persists a rotation uses `afterWrite:{mode:
618
+ // "none"}`, so the in-memory `cfg` here is the STALE pre-rotation snapshot;
619
+ // re-reading it (config-sourced agents) would submit an already-consumed
620
+ // token on the next refresh → spurious 10003 auto-logout.
621
+ let latestRefreshToken = (typeof params.refreshTokenOverride === "string" && params.refreshTokenOverride.trim()
622
+ ? params.refreshTokenOverride.trim()
623
+ : null) ??
624
+ (activationAccount.source === "sqlite" && store?.getActivationCredentials
625
+ ? store.getActivationCredentials({ platform: "openclaw", accountId })?.refreshToken
626
+ : null) ??
627
+ readConfigRefreshToken(cfg);
594
628
  const refreshManager = new RefreshManager({
595
629
  baseUrl: account.baseUrl,
596
630
  deviceId: refreshDeviceId,
@@ -639,6 +673,7 @@ export async function startOpenclawClawlingGateway(params) {
639
673
  ...(params.refreshSetTimer ? { setTimer: params.refreshSetTimer } : {}),
640
674
  ...(params.refreshClearTimer ? { clearTimer: params.refreshClearTimer } : {}),
641
675
  ...(params.refreshJitter ? { jitter: params.refreshJitter } : {}),
676
+ ...(params.refreshNow ? { now: params.refreshNow } : {}),
642
677
  log,
643
678
  });
644
679
  // §A.3/§A.4 — carry the single-flight latch + min-interval across re-enters so
@@ -939,6 +974,9 @@ export async function startOpenclawClawlingGateway(params) {
939
974
  void startOpenclawClawlingGateway({
940
975
  ...params,
941
976
  account: { ...params.account },
977
+ // Carry the live refresh token forward (a rotation earlier this lifecycle
978
+ // leaves the in-memory `cfg` stale; see refreshTokenOverride).
979
+ ...(latestRefreshToken ? { refreshTokenOverride: latestRefreshToken } : {}),
942
980
  transportBackoffReconnect: true,
943
981
  refreshReconnectDepth: streak.depth,
944
982
  refreshReconnectWindowStartedAt: streak.windowStartedAt,
@@ -976,8 +1014,10 @@ export async function startOpenclawClawlingGateway(params) {
976
1014
  if (abortSignal.aborted)
977
1015
  return;
978
1016
  const streak = nextRefreshReconnectStreak();
979
- // Re-enter with the rotated in-memory account; SQLite/config already hold
980
- // the rotated pair (persisted before the swap). Reuse the same device id.
1017
+ // Re-enter with the rotated in-memory account. SQLite (if present) holds the
1018
+ // rotated pair, but the in-memory `cfg` is the STALE pre-rotation snapshot
1019
+ // (the config write used `afterWrite:{mode:"none"}`), so a config-sourced
1020
+ // agent must NOT re-read it — thread the live refresh token forward instead.
981
1021
  await startOpenclawClawlingGateway({
982
1022
  ...params,
983
1023
  account: {
@@ -987,6 +1027,7 @@ export async function startOpenclawClawlingGateway(params) {
987
1027
  userId: account.userId,
988
1028
  ownerUserId: account.ownerUserId,
989
1029
  },
1030
+ ...(latestRefreshToken ? { refreshTokenOverride: latestRefreshToken } : {}),
990
1031
  refreshReconnectDepth: streak.depth,
991
1032
  refreshReconnectWindowStartedAt: streak.windowStartedAt,
992
1033
  refreshManagerState: managerState,
package/index.ts CHANGED
@@ -20,5 +20,12 @@ export default defineChannelPluginEntry({
20
20
  registerOpenclawClawlingCommands(api);
21
21
  registerClawChatPromptInjection(api as unknown as ClawChatPromptInjectionApi);
22
22
  registerOpenclawClawlingTools(api);
23
+ // NOTE: the legacy-rename config migration is intentionally NOT registered
24
+ // here. The host's setup-migration runner only loads a plugin's SETUP source
25
+ // (`openclaw.setupEntry`/`runtimeSetupEntry` → setup-entry.ts) and calls its
26
+ // `register(api)` in "setup-only" mode; registrations made in `registerFull`
27
+ // (full/tool-discovery modes) are never collected for migrations, and this
28
+ // gated full-load path doesn't run for the rename scenario anyway. The
29
+ // migration is wired into setup-entry.ts instead.
23
30
  },
24
31
  });
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "id": "clawchat-plugin-openclaw",
3
3
  "kind": "channel",
4
+ "legacyPluginIds": ["openclaw-clawchat"],
5
+ "configContracts": {
6
+ "compatibilityMigrationPaths": [
7
+ "channels.openclaw-clawchat",
8
+ "plugins.entries.openclaw-clawchat"
9
+ ]
10
+ },
4
11
  "channels": ["clawchat-plugin-openclaw"],
5
12
  "skills": ["./skills"],
6
13
  "activation": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.5.13-dev.2",
3
+ "version": "2026.5.14-2",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "files": [
package/setup-entry.ts CHANGED
@@ -1,4 +1,54 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
1
2
  import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
3
+ import { CHANNEL_ID } from "./src/config.ts";
2
4
  import { openclawClawlingSetupPlugin } from "./src/channel.setup.ts";
5
+ import { migrateLegacyClawChatChannelConfig } from "./src/config-compat.ts";
3
6
 
4
- export default defineSetupPluginEntry(openclawClawlingSetupPlugin);
7
+ /**
8
+ * Minimal shape of the host setup-registration api we touch. The host calls
9
+ * `register(api)` on the SETUP source (this module) in `registrationMode:
10
+ * "setup-only"`; `registerConfigMigration` is the hook that collects
11
+ * compatibility migrations (run by `openclaw doctor` / setup). It is optional
12
+ * here so a host that doesn't expose it can't throw.
13
+ */
14
+ interface SetupRegisterApi {
15
+ registerConfigMigration?: (
16
+ migration: (config: OpenClawConfig) => {
17
+ config: OpenClawConfig;
18
+ changes: string[];
19
+ },
20
+ ) => void;
21
+ }
22
+
23
+ /**
24
+ * Host setup-source `register` hook.
25
+ *
26
+ * The OpenClaw setup-migration runner (`setup-registry`) loads this module as
27
+ * the plugin's setup source (resolved from `package.json` `openclaw.setupEntry`
28
+ * / `runtimeSetupEntry`), unwraps the default export, and — when that export is
29
+ * an object exposing a `register` function whose `id` matches the plugin id —
30
+ * calls `register(api)` in setup-only mode. This is the ONLY ungated path that
31
+ * collects config migrations for the plugin-rename scenario.
32
+ *
33
+ * Mirrors the host's own built-in pattern (e.g. amazon-bedrock setup-api.js:
34
+ * `api.registerConfigMigration((config) => migrateAmazonBedrockLegacyConfig(config))`).
35
+ */
36
+ export function register(api: SetupRegisterApi): void {
37
+ api.registerConfigMigration?.((config) =>
38
+ migrateLegacyClawChatChannelConfig(config),
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Default export consumed by BOTH host loaders of this setup source:
44
+ * - the channel-setup loader unwraps `default` and reads `.plugin`
45
+ * (`defineSetupPluginEntry` yields `{ plugin }`);
46
+ * - the setup-migration runner unwraps `default` and reads `.register`,
47
+ * rejecting it unless `.id` matches the plugin id.
48
+ * So the default export must carry `plugin`, `id`, and `register` together.
49
+ */
50
+ export default {
51
+ ...defineSetupPluginEntry(openclawClawlingSetupPlugin),
52
+ id: CHANNEL_ID,
53
+ register,
54
+ };
@@ -13,6 +13,7 @@ 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.
16
17
 
17
18
  ## OpenClaw CLI
18
19
 
@@ -30,6 +31,18 @@ Use `update --force` only when local ClawChat plugin or skill files look corrupt
30
31
 
31
32
  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.
32
33
 
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
+
33
46
  ## Plugin Tool Routing
34
47
 
35
48
  Tool descriptions are authoritative. These routing hints resolve common ambiguity:
package/src/api-client.ts CHANGED
@@ -17,6 +17,24 @@ import {
17
17
  } from "./api-types.ts";
18
18
  import { CHANNEL_ID } from "./config.ts";
19
19
 
20
+ export interface PluginReportInput {
21
+ deviceId: string;
22
+ platform: string;
23
+ pluginVersion: string;
24
+ runtimeName: string;
25
+ runtimeVersion: string;
26
+ }
27
+
28
+ export function buildPluginReportBody(input: PluginReportInput): Record<string, string> {
29
+ return {
30
+ device_id: input.deviceId,
31
+ platform: input.platform,
32
+ plugin_version: input.pluginVersion,
33
+ runtime_name: input.runtimeName,
34
+ runtime_version: input.runtimeVersion,
35
+ };
36
+ }
37
+
20
38
  export interface ApiClientOptions {
21
39
  baseUrl: string;
22
40
  token: string;
@@ -131,6 +149,15 @@ export interface OpenclawClawlingApiClient {
131
149
  filename: string;
132
150
  mime?: string;
133
151
  }): Promise<AvatarUploadResult>;
152
+ /**
153
+ * Report this plugin's version + runtime to member-backend. When
154
+ * `authenticated`, posts to the agent-JWT self-report endpoint (links the row
155
+ * to the caller's agent/owner); otherwise the public unpaired endpoint.
156
+ */
157
+ reportPlugin(
158
+ input: PluginReportInput,
159
+ opts?: { authenticated?: boolean },
160
+ ): Promise<void>;
134
161
  }
135
162
 
136
163
  /** Backend envelope codes for `POST /v1/auth/refresh` (§0). */
@@ -615,5 +642,14 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
615
642
  fd.set("file", file);
616
643
  return await call<AvatarUploadResult>("POST", "/v1/files/upload-url", { body: fd });
617
644
  },
645
+ async reportPlugin(input, opts): Promise<void> {
646
+ const path = opts?.authenticated
647
+ ? "/v1/agents/me/plugin-report"
648
+ : "/v1/agents/plugin-report";
649
+ await call<unknown>("POST", path, {
650
+ headers: { "content-type": "application/json" },
651
+ body: JSON.stringify(buildPluginReportBody(input)),
652
+ });
653
+ },
618
654
  };
619
655
  }
@@ -0,0 +1,154 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
+ import { CHANNEL_ID } from "./config.ts";
3
+
4
+ /**
5
+ * Compatibility migration for the plugin rename (commit 260044f): the plugin
6
+ * `id` and channel key changed from the OLD `openclaw-clawchat` to the NEW
7
+ * `clawchat-plugin-openclaw` (= {@link CHANNEL_ID}).
8
+ *
9
+ * Users upgrading from the old version have all their state — channel block
10
+ * (token/userId/ownerUserId/refreshToken/…), `plugins.allow`,
11
+ * `plugins.entries`, and `tools.allow`/`tools.alsoAllow` — keyed under the old
12
+ * id. The new code only reads the new id, so the channel silently fails to
13
+ * load. This migration moves the old-keyed state onto the new id.
14
+ *
15
+ * The function is PURE: it clones the input via `structuredClone` and never
16
+ * mutates its argument. It is IDEMPOTENT: a config with no old id anywhere
17
+ * returns an equivalent config with `changes: []`.
18
+ */
19
+
20
+ export const LEGACY_CHANNEL_ID = "openclaw-clawchat" as const;
21
+ export const TARGET_CHANNEL_ID = CHANNEL_ID;
22
+
23
+ const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
24
+
25
+ function isBlockedObjectKey(key: string): boolean {
26
+ return BLOCKED_OBJECT_KEYS.has(key);
27
+ }
28
+
29
+ function isRecord(value: unknown): value is Record<string, unknown> {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+
33
+ function isNonEmptyRecord(value: unknown): value is Record<string, unknown> {
34
+ return isRecord(value) && Object.keys(value).length > 0;
35
+ }
36
+
37
+ /**
38
+ * Copy keys from `source` into `target` only when they are absent/undefined in
39
+ * `target` (target is more current — never overwrite an existing value).
40
+ * Recurses into nested records. Skips prototype-pollution keys.
41
+ */
42
+ function mergeMissing(
43
+ target: Record<string, unknown>,
44
+ source: Record<string, unknown>,
45
+ ): void {
46
+ for (const [key, value] of Object.entries(source)) {
47
+ if (value === undefined || isBlockedObjectKey(key)) continue;
48
+ const existing = target[key];
49
+ if (existing === undefined) {
50
+ target[key] = value;
51
+ continue;
52
+ }
53
+ if (isRecord(existing) && isRecord(value)) mergeMissing(existing, value);
54
+ }
55
+ }
56
+
57
+ /** Replace `from` → `to` in a string array, preserving order and deduping. */
58
+ function replaceAndDedup(list: string[], from: string, to: string): string[] {
59
+ const out: string[] = [];
60
+ for (const raw of list) {
61
+ const value = raw === from ? to : raw;
62
+ if (!out.includes(value)) out.push(value);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ export function migrateLegacyClawChatChannelConfig(config: OpenClawConfig): {
68
+ config: OpenClawConfig;
69
+ changes: string[];
70
+ } {
71
+ const changes: string[] = [];
72
+ if (!isRecord(config)) return { config, changes };
73
+
74
+ const next = structuredClone(config) as Record<string, unknown>;
75
+
76
+ // 1. channels — move/merge the old channel block onto the new id.
77
+ const channels = next.channels;
78
+ if (isRecord(channels)) {
79
+ const oldChannel = channels[LEGACY_CHANNEL_ID];
80
+ if (isNonEmptyRecord(oldChannel)) {
81
+ const newChannel = channels[TARGET_CHANNEL_ID];
82
+ if (!isNonEmptyRecord(newChannel)) {
83
+ channels[TARGET_CHANNEL_ID] = oldChannel;
84
+ changes.push(
85
+ `Moved channel "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`,
86
+ );
87
+ } else {
88
+ mergeMissing(newChannel, oldChannel);
89
+ changes.push(
90
+ `Merged channel "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}" (filled missing fields; kept existing values).`,
91
+ );
92
+ }
93
+ delete channels[LEGACY_CHANNEL_ID];
94
+ }
95
+ }
96
+
97
+ // plugins.* live under a single `plugins` record.
98
+ const plugins = next.plugins;
99
+ if (isRecord(plugins)) {
100
+ // 2. plugins.allow — replace old id with new id (append if missing), dedup.
101
+ const allow = plugins.allow;
102
+ if (Array.isArray(allow) && allow.includes(LEGACY_CHANNEL_ID)) {
103
+ const replaced = replaceAndDedup(
104
+ allow.filter((v): v is string => typeof v === "string"),
105
+ LEGACY_CHANNEL_ID,
106
+ TARGET_CHANNEL_ID,
107
+ );
108
+ plugins.allow = replaced;
109
+ changes.push(
110
+ `Updated plugins.allow: "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`,
111
+ );
112
+ }
113
+
114
+ // 3. plugins.entries — merge-missing the old entry into the new one.
115
+ const entries = plugins.entries;
116
+ if (isRecord(entries)) {
117
+ const oldEntry = entries[LEGACY_CHANNEL_ID];
118
+ if (oldEntry !== undefined) {
119
+ if (isRecord(oldEntry)) {
120
+ const newEntry = entries[TARGET_CHANNEL_ID];
121
+ if (!isRecord(newEntry)) {
122
+ entries[TARGET_CHANNEL_ID] = oldEntry;
123
+ } else {
124
+ mergeMissing(newEntry, oldEntry);
125
+ }
126
+ }
127
+ delete entries[LEGACY_CHANNEL_ID];
128
+ changes.push(
129
+ `Merged plugins.entries["${LEGACY_CHANNEL_ID}"] → plugins.entries["${TARGET_CHANNEL_ID}"].`,
130
+ );
131
+ }
132
+ }
133
+ }
134
+
135
+ // 4. tools.allow + tools.alsoAllow — replace old id with new id, dedup.
136
+ const tools = next.tools;
137
+ if (isRecord(tools)) {
138
+ for (const key of ["allow", "alsoAllow"] as const) {
139
+ const list = tools[key];
140
+ if (Array.isArray(list) && list.includes(LEGACY_CHANNEL_ID)) {
141
+ tools[key] = replaceAndDedup(
142
+ list.filter((v): v is string => typeof v === "string"),
143
+ LEGACY_CHANNEL_ID,
144
+ TARGET_CHANNEL_ID,
145
+ );
146
+ changes.push(
147
+ `Updated tools.${key}: "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`,
148
+ );
149
+ }
150
+ }
151
+ }
152
+
153
+ return { config: next as OpenClawConfig, changes };
154
+ }
@@ -0,0 +1,53 @@
1
+ import { createRequire } from "node:module";
2
+ import { createOpenclawClawlingApiClient } from "./api-client.ts";
3
+
4
+ /** Best-effort read of this plugin's package version; "unknown" on failure. */
5
+ export function resolvePluginVersion(): string {
6
+ try {
7
+ const require = createRequire(import.meta.url);
8
+ const pkg = require("../package.json") as { version?: string };
9
+ return pkg.version ?? "unknown";
10
+ } catch {
11
+ return "unknown";
12
+ }
13
+ }
14
+
15
+ export interface ReportParams {
16
+ baseUrl: string;
17
+ mediaBaseUrl: string;
18
+ token: string;
19
+ deviceId: string;
20
+ pluginVersion: string;
21
+ authenticated: boolean;
22
+ log?: { debug?: (msg: string) => void };
23
+ }
24
+
25
+ /**
26
+ * Fire one plugin-version report. Best-effort: any failure is logged at debug
27
+ * and swallowed so it can never block, delay, or crash gateway startup.
28
+ */
29
+ export async function reportPluginVersionSafe(p: ReportParams): Promise<void> {
30
+ try {
31
+ const client = createOpenclawClawlingApiClient({
32
+ baseUrl: p.baseUrl,
33
+ mediaBaseUrl: p.mediaBaseUrl,
34
+ token: p.token,
35
+ });
36
+ await client.reportPlugin(
37
+ {
38
+ deviceId: p.deviceId,
39
+ platform: "openclaw",
40
+ pluginVersion: p.pluginVersion,
41
+ runtimeName: "node",
42
+ runtimeVersion: process.version,
43
+ },
44
+ { authenticated: p.authenticated },
45
+ );
46
+ } catch (err) {
47
+ p.log?.debug?.(
48
+ `clawchat-plugin-openclaw plugin report failed (authenticated=${p.authenticated}): ${
49
+ err instanceof Error ? err.message : String(err)
50
+ }`,
51
+ );
52
+ }
53
+ }
@@ -389,7 +389,8 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
389
389
  } = options;
390
390
  const isGroupTarget = target.chatType === "group";
391
391
  const outputVisibility = effectiveOutputVisibility(account, target.chatId, target.chatType);
392
- const splitFullOutput = outputVisibility === "full" && !isGroupTarget;
392
+ const splitFullOutput = outputVisibility === "full";
393
+ const splitNormalBlockOutput = outputVisibility === "normal";
393
394
  const ownerDirectTarget = () => {
394
395
  const ownerUserId = account.ownerUserId?.trim();
395
396
  return ownerUserId ? { chatId: ownerUserId, chatType: "direct" as const } : null;
@@ -673,12 +674,12 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
673
674
  };
674
675
 
675
676
  const emitFullSegment = async (text: string, urls: string[] = []): Promise<void> => {
676
- if (outputVisibility !== "full") {
677
+ if (outputVisibility !== "full" && !splitNormalBlockOutput) {
677
678
  appendBufferedText(text);
678
679
  appendBufferedUrls(urls);
679
680
  return;
680
681
  }
681
- if (!splitFullOutput) {
682
+ if (!splitFullOutput && !splitNormalBlockOutput) {
682
683
  appendBufferedText(text);
683
684
  appendBufferedUrls(urls);
684
685
  return;
@@ -753,6 +754,8 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
753
754
  if (info?.kind === "block") {
754
755
  if (outputVisibility === "full") {
755
756
  await emitFullSegment(text, urls);
757
+ } else if (splitNormalBlockOutput) {
758
+ await emitFullSegment(text, urls);
756
759
  } else if (outputVisibility === "minimal" || outputVisibility === "normal") {
757
760
  appendBufferedText(text);
758
761
  appendBufferedUrls(urls);
@@ -785,6 +788,14 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
785
788
  }
786
789
  const finalText = richFragment && account.richInteractions ? mergeFinalText("") : mergeFinalText(text);
787
790
  const finalUrls = mergeFinalUrls(urls);
791
+ if (
792
+ isClawChatNoopResponseText(finalText) &&
793
+ !richFragment &&
794
+ finalUrls.length === 0
795
+ ) {
796
+ log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
797
+ return;
798
+ }
788
799
  const mediaFragments = await uploadMediaUrls(finalUrls);
789
800
  const result = await sendStatic(
790
801
  finalText,
@@ -803,13 +814,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
803
814
  log?.error?.(
804
815
  `[${account.accountId}] clawchat-plugin-openclaw ${info.kind} reply failed: ${errorText}`,
805
816
  );
806
- if (!isGroupTarget && outputVisibility === "full") void emitFullRuntimeText("error", errorText);
807
- if (isGroupTarget) {
808
- log?.error?.(
809
- `[${account.accountId}] clawchat-plugin-openclaw group runtime failure suppressed from ClawChat clients group=${target.chatId}`,
810
- );
811
- return;
812
- }
817
+ if (outputVisibility === "full") void emitFullRuntimeText("error", errorText);
813
818
  },
814
819
  onIdle: async () => {
815
820
  emitTyping(false);
@@ -833,10 +838,10 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
833
838
  replyOptions: {
834
839
  ...base.replyOptions,
835
840
  sourceReplyDeliveryMode: "automatic",
836
- disableBlockStreaming: true,
841
+ disableBlockStreaming: !splitNormalBlockOutput,
837
842
  suppressDefaultToolProgressMessages: true,
838
- allowProgressCallbacksWhenSourceDeliverySuppressed: outputVisibility === "full" ? true : undefined,
839
- onReasoningStream: outputVisibility === "full"
843
+ allowProgressCallbacksWhenSourceDeliverySuppressed: splitFullOutput ? true : undefined,
844
+ onReasoningStream: splitFullOutput
840
845
  ? async (payload: ReplyPayload) => {
841
846
  if (consumeTerminalSend("reasoning")) return;
842
847
  const text = resolvePayloadText(payload);
@@ -845,13 +850,13 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
845
850
  if (trimmed) reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
846
851
  }
847
852
  : undefined,
848
- onToolStart: outputVisibility === "full"
853
+ onToolStart: splitFullOutput
849
854
  ? async (payload) => {
850
855
  if (consumeTerminalSend("tool-start")) return;
851
856
  await emitFullSegment(formatToolStartSummary(payload));
852
857
  }
853
858
  : undefined,
854
- onToolResult: outputVisibility === "full"
859
+ onToolResult: splitFullOutput
855
860
  ? async (payload: ReplyPayload) => {
856
861
  if (consumeTerminalSend("tool-result")) return;
857
862
  const text = resolvePayloadText(payload);
@@ -859,38 +864,38 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
859
864
  await emitFullRuntimeText("tool result", text, resolveOutboundMediaUrls(payload).filter(Boolean));
860
865
  }
861
866
  : undefined,
862
- onItemEvent: outputVisibility === "full"
867
+ onItemEvent: splitFullOutput
863
868
  ? async (payload: Record<string, unknown>) => {
864
869
  if (consumeTerminalSend("item-event")) return;
865
870
  if (isToolProgressItem(payload)) return;
866
871
  await emitFullRuntimeText("progress", summarizeProgressPayload(payload));
867
872
  }
868
873
  : undefined,
869
- onPlanUpdate: outputVisibility === "full"
874
+ onPlanUpdate: splitFullOutput
870
875
  ? async (payload: Record<string, unknown>) => {
871
876
  if (consumeTerminalSend("plan-update")) return;
872
877
  await emitFullRuntimeText("plan", summarizeProgressPayload(payload));
873
878
  }
874
879
  : undefined,
875
- onCommandOutput: outputVisibility === "full"
880
+ onCommandOutput: splitFullOutput
876
881
  ? async (payload: Record<string, unknown>) => {
877
882
  if (consumeTerminalSend("command-output")) return;
878
883
  await emitFullSegment(formatCommandOutputSummary(payload));
879
884
  }
880
885
  : undefined,
881
- onPatchSummary: outputVisibility === "full"
886
+ onPatchSummary: splitFullOutput
882
887
  ? async (payload: Record<string, unknown>) => {
883
888
  if (consumeTerminalSend("patch-summary")) return;
884
889
  await emitFullSegment(formatPatchSummary(payload));
885
890
  }
886
891
  : undefined,
887
- onCompactionStart: outputVisibility === "full"
892
+ onCompactionStart: splitFullOutput
888
893
  ? async () => {
889
894
  if (consumeTerminalSend("compaction-start")) return;
890
895
  await emitFullSegment("[compaction] started");
891
896
  }
892
897
  : undefined,
893
- onCompactionEnd: outputVisibility === "full"
898
+ onCompactionEnd: splitFullOutput
894
899
  ? async () => {
895
900
  if (consumeTerminalSend("compaction-end")) return;
896
901
  await emitFullSegment("[compaction] finished");
package/src/runtime.ts CHANGED
@@ -13,8 +13,9 @@ import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
13
13
  import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
14
14
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
15
15
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
16
- import { createOpenclawClawlingClient } from "./client.ts";
16
+ import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.ts";
17
17
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
18
+ import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
18
19
  import { ClawlingApiError } from "./api-types.ts";
19
20
  import { RefreshManager } from "./refresh-manager.ts";
20
21
  import type { OpenclawClawchatMutateConfigFile } from "./login.runtime.ts";
@@ -487,6 +488,20 @@ export interface StartGatewayParams {
487
488
  * of resetting on every fresh `RefreshManager`.
488
489
  */
489
490
  refreshManagerState?: { rejectedToken: string | null; lastAttemptAt: number };
491
+ /**
492
+ * Internal — the live (possibly rotated) refresh token, carried across a
493
+ * §D/§B gateway re-enter. A rotation persists config with `afterWrite:{mode:
494
+ * "none"}`, so the in-memory `cfg` handed to the re-enter is NOT reloaded and
495
+ * still holds the pre-rotation refresh token. For a "configured" (config-
496
+ * sourced) agent the re-enter would otherwise re-read that stale token and
497
+ * submit an already-consumed refresh token on the next refresh → a spurious
498
+ * `10003` auto-logout. Threading the live token forward closes that gap.
499
+ * SQLite-sourced agents are unaffected (their row already holds the rotated
500
+ * pair), but threading is harmless there too.
501
+ */
502
+ refreshTokenOverride?: string;
503
+ /** Test hook only — clock override for the refresh manager (min-interval / proactive timer). */
504
+ refreshNow?: () => number;
490
505
  /**
491
506
  * Internal — set when the current attempt is a plain transport-backoff
492
507
  * re-enter (transient/skipped reactive refresh). Carries the current (unchanged)
@@ -641,6 +656,19 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
641
656
  log?.info?.(
642
657
  `[${accountId}] clawchat-plugin-openclaw runtime start entered configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} hasOwnerUserId=${Boolean(account.ownerUserId)} websocketUrl=${account.websocketUrl || "(empty)"}`,
643
658
  );
659
+ // Freeze one report device_id for this process; reused for the paired report
660
+ // so the backend links both to the same row. Imported from ./client.ts.
661
+ const reportDeviceId = resolveOpenclawClawlingDeviceId(account);
662
+ const pluginVersion = resolvePluginVersion();
663
+ void reportPluginVersionSafe({
664
+ baseUrl: account.baseUrl,
665
+ mediaBaseUrl: account.mediaBaseUrl,
666
+ token: "",
667
+ deviceId: reportDeviceId,
668
+ pluginVersion,
669
+ authenticated: false,
670
+ log,
671
+ });
644
672
  const activationAccount = await waitForActivationCredentials({
645
673
  account,
646
674
  abortSignal,
@@ -653,6 +681,17 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
653
681
  });
654
682
  if (!activationAccount) return;
655
683
  account = activationAccount.account;
684
+ // Paired: link the report row via the authenticated endpoint, reusing the
685
+ // SAME frozen device_id so the backend upserts the existing unpaired row.
686
+ void reportPluginVersionSafe({
687
+ baseUrl: account.baseUrl,
688
+ mediaBaseUrl: account.mediaBaseUrl,
689
+ token: account.token,
690
+ deviceId: reportDeviceId,
691
+ pluginVersion,
692
+ authenticated: true,
693
+ log,
694
+ });
656
695
  // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null when
657
696
  // the credentials came from config (no activation row yet) — in that case the
658
697
  // refresh manager relies on the JWT `exp` alone.
@@ -828,10 +867,19 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
828
867
  // The refresh token is not part of the resolved account; source it from
829
868
  // SQLite first (authoritative after a rotation) then the config channel
830
869
  // section. Kept in a mutable cell so a swap updates it in place.
870
+ // §0/§D — a rotated refresh token threaded across a re-enter wins over the
871
+ // stores. The config write that persists a rotation uses `afterWrite:{mode:
872
+ // "none"}`, so the in-memory `cfg` here is the STALE pre-rotation snapshot;
873
+ // re-reading it (config-sourced agents) would submit an already-consumed
874
+ // token on the next refresh → spurious 10003 auto-logout.
831
875
  let latestRefreshToken: string | null =
876
+ (typeof params.refreshTokenOverride === "string" && params.refreshTokenOverride.trim()
877
+ ? params.refreshTokenOverride.trim()
878
+ : null) ??
832
879
  (activationAccount.source === "sqlite" && store?.getActivationCredentials
833
880
  ? store.getActivationCredentials({ platform: "openclaw", accountId })?.refreshToken
834
- : null) ?? readConfigRefreshToken(cfg);
881
+ : null) ??
882
+ readConfigRefreshToken(cfg);
835
883
 
836
884
  const refreshManager = new RefreshManager({
837
885
  baseUrl: account.baseUrl,
@@ -881,6 +929,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
881
929
  ...(params.refreshSetTimer ? { setTimer: params.refreshSetTimer } : {}),
882
930
  ...(params.refreshClearTimer ? { clearTimer: params.refreshClearTimer } : {}),
883
931
  ...(params.refreshJitter ? { jitter: params.refreshJitter } : {}),
932
+ ...(params.refreshNow ? { now: params.refreshNow } : {}),
884
933
  log,
885
934
  });
886
935
  // §A.3/§A.4 — carry the single-flight latch + min-interval across re-enters so
@@ -1216,6 +1265,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1216
1265
  void startOpenclawClawlingGateway({
1217
1266
  ...params,
1218
1267
  account: { ...params.account },
1268
+ // Carry the live refresh token forward (a rotation earlier this lifecycle
1269
+ // leaves the in-memory `cfg` stale; see refreshTokenOverride).
1270
+ ...(latestRefreshToken ? { refreshTokenOverride: latestRefreshToken } : {}),
1219
1271
  transportBackoffReconnect: true,
1220
1272
  refreshReconnectDepth: streak.depth,
1221
1273
  refreshReconnectWindowStartedAt: streak.windowStartedAt,
@@ -1253,8 +1305,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1253
1305
  }
1254
1306
  if (abortSignal.aborted) return;
1255
1307
  const streak = nextRefreshReconnectStreak();
1256
- // Re-enter with the rotated in-memory account; SQLite/config already hold
1257
- // the rotated pair (persisted before the swap). Reuse the same device id.
1308
+ // Re-enter with the rotated in-memory account. SQLite (if present) holds the
1309
+ // rotated pair, but the in-memory `cfg` is the STALE pre-rotation snapshot
1310
+ // (the config write used `afterWrite:{mode:"none"}`), so a config-sourced
1311
+ // agent must NOT re-read it — thread the live refresh token forward instead.
1258
1312
  await startOpenclawClawlingGateway({
1259
1313
  ...params,
1260
1314
  account: {
@@ -1264,6 +1318,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1264
1318
  userId: account.userId,
1265
1319
  ownerUserId: account.ownerUserId,
1266
1320
  },
1321
+ ...(latestRefreshToken ? { refreshTokenOverride: latestRefreshToken } : {}),
1267
1322
  refreshReconnectDepth: streak.depth,
1268
1323
  refreshReconnectWindowStartedAt: streak.windowStartedAt,
1269
1324
  refreshManagerState: managerState,