@clawling/clawchat-plugin-openclaw 2026.8.22-1 → 2026.8.27-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ OpenClaw channel plugin that connects an agent to ClawChat over ClawChat Protoco
6
6
 
7
7
  - Plugin-owned WebSocket transport with auto-reconnect (exponential backoff + jitter), heartbeat, and ack tracking
8
8
  - Inbound `message.send` / `message.reply` with reply context
9
- - Outbound text replies in `static` or `stream` mode, with a consolidated final `message.reply`
9
+ - Outbound text replies as complete `message.send` / `message.reply` frames — the plugin consumes inbound streams but never emits streaming lifecycle frames
10
10
  - Typing indicators and filtered forwarding for thinking / tool-call content
11
11
  - Media fragments (image / file / audio / video) in either direction
12
12
  - Invite-code onboarding (no raw credentials) via `/clawchat-activate` or supported `openclaw channels add`, plus always-registered `clawchat_*` account/media tools
@@ -95,7 +95,7 @@ src/
95
95
  per-group idle/max batching for non-mention turns
96
96
  group-settings.ts per-group agent settings cache (GET /v1/agents/me/
97
97
  group-settings)
98
- reply-dispatcher.ts static vs stream routing
98
+ reply-dispatcher.ts outbound reply routing (complete frames only)
99
99
  no-reply.ts clawchat:no-reply / :silent suppression token guard
100
100
  login.runtime.ts invite-code exchange flow
101
101
  refresh-manager.ts ClawChat token refresh + auto-logout orchestration
@@ -153,7 +153,7 @@ The main reference is
153
153
  - Full configuration reference
154
154
  - Onboarding / activation details
155
155
  - REST endpoint table
156
- - Streaming frame shapes (`message.created` / `message.add` / `message.done` / `message.reply`)
156
+ - Inbound streaming frame shapes (`message.created` / `message.add` / `message.done` / `message.reply`) — consumed, never produced
157
157
  - End-to-end sequence diagram
158
158
  - Media pipeline (inbound download / outbound upload)
159
159
  - Troubleshooting
@@ -108,20 +108,26 @@ function isRecord(value) {
108
108
  }
109
109
  /**
110
110
  * §A.2 — classify a WS `hello-fail` reason for refresh gating.
111
- * - "token-rejected": reason names an authentication failure → refresh.
111
+ * - "token-rejected": the ONE terminal reason → refresh.
112
112
  * - "auth-unavailable": 5xx auth-backend outage → backoff, DO NOT refresh.
113
- * - "generic": unattributed → refresh only if the token is at/near expiry.
113
+ * - "generic": anything else → transient; backoff with the current token.
114
114
  *
115
- * `auth service unavailable` is already split off by the ws-client into a
116
- * TransportError (backoff), but we classify defensively here too.
115
+ * msghub Protocol v2 §3.5 pins the matching contract: `"authentication failed"`
116
+ * is matched by exact equality (case / surrounding whitespace tolerant, like the
117
+ * hermes plugin's `_is_token_rejected`), never by substring or prefix, and no
118
+ * speculative matchers (`invalid token`, `token expired`, `unauthorized`, …) may
119
+ * be added — msghub does not emit them, and matching them would turn a reason
120
+ * added later into a forced refresh and, on a rotated refresh token, a re-pair.
121
+ * The ws-client already routes every non-terminal reason as a TransportError
122
+ * (backoff), so only the exact terminal string normally reaches this classifier;
123
+ * it stays defensive for the live-session path.
117
124
  */
118
125
  export function classifyHelloFailReason(reason) {
119
- const r = (reason || "").toLowerCase();
126
+ const r = (reason || "").trim().toLowerCase();
120
127
  if (/auth service unavailable|temporarily unavailable/.test(r))
121
128
  return "auth-unavailable";
122
- if (/authentication failed|invalid token|token expired|unauthorized|auth failed|invalid credentials/.test(r)) {
129
+ if (r === "authentication failed")
123
130
  return "token-rejected";
124
- }
125
131
  return "generic";
126
132
  }
127
133
  /** Read `channels.<CHANNEL_ID>.refreshToken` from a live config, or null. */
@@ -1534,7 +1540,6 @@ export async function startOpenclawClawlingGateway(params) {
1534
1540
  const protocolControlLogger = createProtocolControlHandler({
1535
1541
  accountId,
1536
1542
  log: (msg) => log?.info?.(msg),
1537
- send: () => { },
1538
1543
  context: wsLogContext,
1539
1544
  });
1540
1545
  const notifySignalObserver = createNotifySignalObserver({
@@ -2914,18 +2919,23 @@ export async function startOpenclawClawlingGateway(params) {
2914
2919
  // Ids only: the frame carries no content, and `payload.message_id` is the
2915
2920
  // server's `rcl:<target>` slot — the id to delete is `target_message_id`.
2916
2921
  //
2917
- // Synchronous and fire-and-forget on purpose. There is no ack to send (the
2918
- // frame is BestEffort and non-ackable), and no tombstone to write: a Kafka
2919
- // redelivery of the original can resurrect the row, which is an accepted
2920
- // known limitation rather than a fourth reimplementation of the mobile
2921
- // client's tombstone table.
2922
+ // Synchronous and fire-and-forget on purpose: there is no ack to send (the
2923
+ // frame is BestEffort and non-ackable).
2924
+ //
2925
+ // §9.8 requires a permanent tombstone consulted on every ingest path, and
2926
+ // `store.recallMessage` writes one in the same transaction as the delete.
2927
+ // That is what makes this handler's timing irrelevant: the inbound ledger
2928
+ // write sits behind an await, so on a replay carrying the original and its
2929
+ // recall back-to-back the DELETE runs first — and the tombstone then refuses
2930
+ // the INSERT. It also closes the Kafka-redelivery resurrection this comment
2931
+ // previously accepted as a known limitation.
2922
2932
  client.on("message:recall", (env) => {
2923
2933
  const target = recallTargetMessageId(env.payload);
2924
2934
  if (!target) {
2925
2935
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`);
2926
2936
  return;
2927
2937
  }
2928
- const removed = store?.deleteMessagesByMessageId?.({
2938
+ const removed = store?.recallMessage?.({
2929
2939
  platform: "openclaw",
2930
2940
  accountId,
2931
2941
  messageId: target,
@@ -171,6 +171,19 @@ CREATE TABLE IF NOT EXISTS owner_profile (
171
171
  updated_at INTEGER NOT NULL,
172
172
  PRIMARY KEY (platform, account_id)
173
173
  );
174
+ `,
175
+ },
176
+ {
177
+ version: 10,
178
+ name: "recalled_messages",
179
+ sql: `
180
+ CREATE TABLE IF NOT EXISTS recalled_messages (
181
+ platform TEXT NOT NULL,
182
+ account_id TEXT NOT NULL,
183
+ message_id TEXT NOT NULL,
184
+ recalled_at INTEGER NOT NULL,
185
+ PRIMARY KEY (platform, account_id, message_id)
186
+ );
174
187
  `,
175
188
  },
176
189
  ];
@@ -412,6 +425,14 @@ export class ClawChatStore {
412
425
  }
413
426
  claimMessageOnce(input) {
414
427
  return this.write(() => {
428
+ // Protocol v2 §9.8: consult the recall tombstone on every ingest path.
429
+ // Doing it inside this same write transaction is what makes the delete
430
+ // and the insert order-independent: whichever frame the server delivers
431
+ // first, a recalled message_id can never end up in the ledger. Without
432
+ // it, a replay carrying the original and its recall back-to-back ran the
433
+ // DELETE before the INSERT and the row survived.
434
+ if (input.messageId && this.isMessageRecalled(input))
435
+ return false;
415
436
  const result = this.requireDb()
416
437
  .prepare(`INSERT OR IGNORE INTO clawchat_messages(
417
438
  platform, account_id, kind, direction, event_type, trace_id, chat_id,
@@ -462,6 +483,43 @@ export class ClawChatStore {
462
483
  * account was paired, or one dropped by the inbound filter). `null` means the
463
484
  * store is unavailable.
464
485
  */
486
+ /** True when `message_id` was recalled for this (platform, account). */
487
+ isMessageRecalled(input) {
488
+ if (!input.messageId)
489
+ return false;
490
+ const row = this.requireDb()
491
+ .prepare(`SELECT 1 FROM recalled_messages
492
+ WHERE platform = ? AND account_id = ? AND message_id = ?`)
493
+ .get(input.platform, input.accountId, input.messageId);
494
+ return row !== undefined;
495
+ }
496
+ /**
497
+ * Record the permanent recall tombstone and purge any row already stored,
498
+ * atomically. Returns the number of ledger rows deleted (0 when the recall
499
+ * arrived before the message did — the tombstone still stands and the later
500
+ * ingest is refused by {@link claimMessageOnce}).
501
+ */
502
+ recallMessage(input) {
503
+ // An empty id would tombstone and delete rows keyed by '' — every row the
504
+ // store never managed to key. Refuse rather than guess.
505
+ if (!input.messageId)
506
+ return 0;
507
+ return this.write(() => {
508
+ const db = this.requireDb();
509
+ db.prepare(`INSERT OR IGNORE INTO recalled_messages(platform, account_id, message_id, recalled_at)
510
+ VALUES (?, ?, ?, ?)`).run(input.platform, input.accountId, input.messageId, input.recalledAt ?? Date.now());
511
+ const result = db
512
+ .prepare(`DELETE FROM clawchat_messages
513
+ WHERE platform = ? AND account_id = ? AND message_id = ?`)
514
+ .run(input.platform, input.accountId, input.messageId);
515
+ return Number(result.changes);
516
+ });
517
+ }
518
+ /**
519
+ * Low-level purge by message id. NOT the recall path: it writes no tombstone,
520
+ * so a recall routed through here loses to a redelivery or to an ingest that
521
+ * is still behind an await. Use {@link recallMessage} for `message.recall`.
522
+ */
465
523
  deleteMessagesByMessageId(input) {
466
524
  // An empty id would match every row whose message_id is '' — deleting
467
525
  // everything the store never managed to key. Refuse rather than guess.
@@ -171,15 +171,12 @@ export function createProtocolControlHandler(options) {
171
171
  return {
172
172
  handleInbound(env) {
173
173
  if (env.event === "ping") {
174
- logControl("protocol_ping_received", "send_pong", [["trace_id", env.trace_id]]);
175
- options.send(JSON.stringify({
176
- version: "2",
177
- event: "pong",
178
- trace_id: env.trace_id ?? "-",
179
- // §12: echo the sender's emitted_at verbatim (do not restamp).
180
- emitted_at: env.emitted_at ?? Date.now(),
181
- payload: {},
182
- }));
174
+ // §12: the server never emits a JSON-level `ping`, so this is an
175
+ // anomaly worth seeing — but answering it would send exactly the
176
+ // unsolicited `pong` the spec bans, which the server drops as an
177
+ // unknown uplink event. Log it and drop it; this handler has no send
178
+ // capability, so the banned frame cannot be produced here at all.
179
+ logControl("protocol_ping_received", "drop", [["trace_id", env.trace_id]]);
183
180
  return true;
184
181
  }
185
182
  if (env.event === "pong") {
@@ -185,6 +185,62 @@ export function isValidChatId(chatId) {
185
185
  }
186
186
  return true;
187
187
  }
188
+ /**
189
+ * §3.5 terminal `hello-fail` reason. Exact equality after trimming and
190
+ * lower-casing (parity with the hermes plugin's `_is_token_rejected`); never a
191
+ * substring or prefix match — see `onHelloFail`.
192
+ */
193
+ export const TERMINAL_HELLO_FAIL_REASON = "authentication failed";
194
+ export function isTerminalHelloFailReason(reason) {
195
+ return (reason ?? "").trim().toLowerCase() === TERMINAL_HELLO_FAIL_REASON;
196
+ }
197
+ /**
198
+ * §3.6 duplicate-session refusal. msghub turns away the NEWER connection with
199
+ * this close code and a JSON reason `{reason, retry_after_ms}` when the opt-in
200
+ * guard is on. The server owns the 60s → 300s escalation, so the client only
201
+ * has to wait the value it is handed.
202
+ */
203
+ export const CLOSE_CODE_DUPLICATE_THROTTLED = 4002;
204
+ /** Floor used when a 4002 close carries no parseable retry_after_ms. */
205
+ export const DEFAULT_REFUSAL_FLOOR_MS = 60_000;
206
+ /**
207
+ * §3.6 duplicate-session takeover. msghub closes the OLDER socket with 4001
208
+ * when a newer session for the same (user, device) takes over. Reconnecting at
209
+ * the ordinary initial delay races that session and kicks it back — the
210
+ * mutual-eviction storm.
211
+ *
212
+ * A single eviction waits the base floor; consecutive evictions of the same
213
+ * connection escalate it (doubling, capped), so whichever instance keeps losing
214
+ * withdraws further each round until the other simply stays connected. Prod
215
+ * measured 9–163 s per eviction (msghub 2026-08-18), longer than the base, so a
216
+ * flat floor cannot break a persistent loop — only the escalation does. The
217
+ * streak survives `hello-ok` (an eviction happens *after* we connect) and is
218
+ * reset only by a non-4001 close, mirroring the hermes plugin.
219
+ */
220
+ export const CLOSE_CODE_REPLACED = 4001;
221
+ /** Base floor for the first takeover eviction (the §3.6 minimum). */
222
+ export const TAKEOVER_FLOOR_MS = 5_000;
223
+ /** Ceiling for the escalating takeover floor. */
224
+ export const TAKEOVER_FLOOR_MAX_MS = 600_000;
225
+ /** Reconnect floor for `streak` consecutive 4001 evictions (0 → no floor). */
226
+ export function takeoverFloorMs(streak) {
227
+ if (streak <= 0)
228
+ return 0;
229
+ return Math.min(TAKEOVER_FLOOR_MS * 2 ** (streak - 1), TAKEOVER_FLOOR_MAX_MS);
230
+ }
231
+ export function parseRefusalRetryAfterMs(reason) {
232
+ if (reason) {
233
+ try {
234
+ const value = JSON.parse(reason).retry_after_ms;
235
+ if (typeof value === "number" && Number.isFinite(value) && value > 0)
236
+ return value;
237
+ }
238
+ catch {
239
+ // fall through to the default floor
240
+ }
241
+ }
242
+ return DEFAULT_REFUSAL_FLOOR_MS;
243
+ }
188
244
  export class ClawChatClient extends EventEmitter {
189
245
  opts;
190
246
  currentState = "idle";
@@ -194,6 +250,10 @@ export class ClawChatClient extends EventEmitter {
194
250
  pongTimer;
195
251
  reconnectTimer;
196
252
  reconnectAttempts = 0;
253
+ // Consecutive 4001 duplicate-session evictions. Unlike reconnectAttempts it
254
+ // is NOT reset on hello-ok (the eviction lands after we connect); a non-4001
255
+ // close resets it. Drives the escalating takeover floor (§3.6).
256
+ takeoverStreak = 0;
197
257
  closing = false;
198
258
  authFailed = false;
199
259
  expectedConnectTraceId;
@@ -548,8 +608,10 @@ export class ClawChatClient extends EventEmitter {
548
608
  return this.onHelloOk(env);
549
609
  if (env.event === EVENT.HELLO_FAIL)
550
610
  return this.onHelloFail(env);
551
- if (env.event === EVENT.PING)
552
- return this.onPing(env);
611
+ // §12: the server never emits a JSON-level `ping`, and an unsolicited
612
+ // `pong` is an unknown uplink event the server drops. We therefore do NOT
613
+ // answer a downlink ping. `pong` below is still handled: it is the reply
614
+ // to the client-initiated heartbeat probe §12 does allow.
553
615
  if (env.event === EVENT.PONG)
554
616
  return this.onPong();
555
617
  if (env.event === EVENT.MESSAGE_ACK)
@@ -662,13 +724,16 @@ export class ClawChatClient extends EventEmitter {
662
724
  this.failHandshake(new ProtocolError("invalid hello-fail payload", env), 4002, "protocol error");
663
725
  return;
664
726
  }
665
- // §14.1: distinguish upstream auth-service unavailability (5xx) from token
666
- // rejection (4xx). On a 5xx the token may still be valid and the auth backend
667
- // is down — backoff-reconnect with the SAME token and do
668
- // NOT refresh (a 5xx storm must not become a mass token-refresh storm). Until
669
- // the server emits the distinct 5xx reason, every other hello-fail is treated
670
- // as a terminal token rejection (the caller acquires a fresh token first).
671
- if (/auth service unavailable/i.test(reason)) {
727
+ // §3.5: `"authentication failed"` — matched by EXACT equality, never a
728
+ // substring — is the one terminal reason (the token was rejected by upstream
729
+ // auth). Every other reason is transient: `remote auth service unavailable`
730
+ // (the auth backend is down, the token may still be valid — §14.1), `nonce
731
+ // mismatch`, `invalid connect …`, and any string msghub adds after this
732
+ // client shipped. For all of those: backoff-reconnect with the SAME token,
733
+ // keep the outbound queue, and do NOT refresh (a 5xx storm must not become
734
+ // a mass token-refresh storm; a new server-side reason must not brick old
735
+ // clients). Widening the terminal branch breaks that forward-compat rule.
736
+ if (!isTerminalHelloFailReason(reason)) {
672
737
  const err = new TransportError(reason);
673
738
  this.expectedConnectTraceId = undefined;
674
739
  this.clearTimers();
@@ -678,8 +743,12 @@ export class ClawChatClient extends EventEmitter {
678
743
  this.connectReject = undefined;
679
744
  this.emitError(err);
680
745
  if (this.opts.transport.state !== "closed") {
681
- // Close WITHOUT marking closing/authFailed so handleClose backoff-reconnects.
682
- this.opts.transport.close(4001, "auth service unavailable");
746
+ // Close WITHOUT marking closing/authFailed so handleClose
747
+ // backoff-reconnects. Use 1000, NOT 4001: 4001 is the server's
748
+ // duplicate-session takeover code that handleClose now floors to 5s
749
+ // (§3.6), and this client-initiated transient teardown must reconnect
750
+ // on the ordinary backoff instead of inheriting that floor.
751
+ this.opts.transport.close(1000, reason);
683
752
  }
684
753
  else if (!this.closing && this.opts.reconnect.enabled) {
685
754
  this.scheduleReconnect(reason);
@@ -704,15 +773,6 @@ export class ClawChatClient extends EventEmitter {
704
773
  this.opts.transport.close(4001, "auth failed");
705
774
  }
706
775
  }
707
- onPing(env) {
708
- this.sendRawEnvelope({
709
- version: "2",
710
- event: EVENT.PONG,
711
- trace_id: env.trace_id,
712
- emitted_at: env.emitted_at,
713
- payload: {},
714
- });
715
- }
716
776
  onPong() {
717
777
  if (this.pongTimer)
718
778
  clearTimeout(this.pongTimer);
@@ -803,7 +863,27 @@ export class ClawChatClient extends EventEmitter {
803
863
  this.connectReject?.(closeError);
804
864
  return;
805
865
  }
806
- this.scheduleReconnect(reason || `close ${code}`);
866
+ // §3.6: raise an explicit reconnect floor for the two server-timed
867
+ // duplicate-session closes. 4002 (refusal) waits the server's
868
+ // retry_after_ms, which exceeds the maxDelay cap; 4001 (takeover) waits at
869
+ // least 5s so an instant reconnect doesn't re-enter the eviction storm.
870
+ let floorMs;
871
+ if (code === CLOSE_CODE_DUPLICATE_THROTTLED) {
872
+ // A refusal is not a takeover-loss — reset the streak and wait the
873
+ // server's retry_after_ms.
874
+ this.takeoverStreak = 0;
875
+ floorMs = parseRefusalRetryAfterMs(reason);
876
+ }
877
+ else if (code === CLOSE_CODE_REPLACED) {
878
+ this.takeoverStreak += 1;
879
+ floorMs = takeoverFloorMs(this.takeoverStreak);
880
+ }
881
+ else {
882
+ // Any other close (ordinary drop, backpressure kick) is not an eviction
883
+ // loss — reset so the next takeover starts from the base floor again.
884
+ this.takeoverStreak = 0;
885
+ }
886
+ this.scheduleReconnect(reason || `close ${code}`, floorMs);
807
887
  }
808
888
  failHandshake(err, code, reason) {
809
889
  this.clearTimers();
@@ -820,7 +900,7 @@ export class ClawChatClient extends EventEmitter {
820
900
  this.opts.transport.close(code, reason);
821
901
  }
822
902
  }
823
- scheduleReconnect(reason) {
903
+ scheduleReconnect(reason, floorMs) {
824
904
  if (this.reconnectTimer)
825
905
  return;
826
906
  if (this.reconnectAttempts >= this.opts.reconnect.maxRetries) {
@@ -830,7 +910,10 @@ export class ClawChatClient extends EventEmitter {
830
910
  return;
831
911
  }
832
912
  this.reconnectAttempts += 1;
833
- const baseDelay = Math.min(this.opts.reconnect.maxDelay, this.opts.reconnect.initialDelay * 2 ** Math.max(0, this.reconnectAttempts - 1));
913
+ const cappedDelay = Math.min(this.opts.reconnect.maxDelay, this.opts.reconnect.initialDelay * 2 ** Math.max(0, this.reconnectAttempts - 1));
914
+ // An explicit floor (a 4002 retry_after_ms) overrides the maxDelay cap: the
915
+ // server's wait is deliberately longer than the ordinary backoff ceiling.
916
+ const baseDelay = floorMs !== undefined ? Math.max(cappedDelay, floorMs) : cappedDelay;
834
917
  const jitter = baseDelay * this.opts.reconnect.jitterRatio * Math.random();
835
918
  const delay = Math.round(baseDelay + jitter);
836
919
  this.transition("reconnecting");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.8.22-1",
3
+ "version": "2026.8.27-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
package/src/runtime.ts CHANGED
@@ -124,7 +124,7 @@ type RuntimeConnectionStore = Pick<
124
124
  | "getActivationConversation"
125
125
  | "getLastResolvedDeviceId"
126
126
  | "listRecentGroupMessages"
127
- | "deleteMessagesByMessageId"
127
+ | "recallMessage"
128
128
  | "upsertOwnerProfile"
129
129
  >
130
130
  >;
@@ -210,21 +210,26 @@ function isRecord(value: unknown): value is Record<string, unknown> {
210
210
 
211
211
  /**
212
212
  * §A.2 — classify a WS `hello-fail` reason for refresh gating.
213
- * - "token-rejected": reason names an authentication failure → refresh.
213
+ * - "token-rejected": the ONE terminal reason → refresh.
214
214
  * - "auth-unavailable": 5xx auth-backend outage → backoff, DO NOT refresh.
215
- * - "generic": unattributed → refresh only if the token is at/near expiry.
215
+ * - "generic": anything else → transient; backoff with the current token.
216
216
  *
217
- * `auth service unavailable` is already split off by the ws-client into a
218
- * TransportError (backoff), but we classify defensively here too.
217
+ * msghub Protocol v2 §3.5 pins the matching contract: `"authentication failed"`
218
+ * is matched by exact equality (case / surrounding whitespace tolerant, like the
219
+ * hermes plugin's `_is_token_rejected`), never by substring or prefix, and no
220
+ * speculative matchers (`invalid token`, `token expired`, `unauthorized`, …) may
221
+ * be added — msghub does not emit them, and matching them would turn a reason
222
+ * added later into a forced refresh and, on a rotated refresh token, a re-pair.
223
+ * The ws-client already routes every non-terminal reason as a TransportError
224
+ * (backoff), so only the exact terminal string normally reaches this classifier;
225
+ * it stays defensive for the live-session path.
219
226
  */
220
227
  export function classifyHelloFailReason(
221
228
  reason: string,
222
229
  ): "token-rejected" | "auth-unavailable" | "generic" {
223
- const r = (reason || "").toLowerCase();
230
+ const r = (reason || "").trim().toLowerCase();
224
231
  if (/auth service unavailable|temporarily unavailable/.test(r)) return "auth-unavailable";
225
- if (/authentication failed|invalid token|token expired|unauthorized|auth failed|invalid credentials/.test(r)) {
226
- return "token-rejected";
227
- }
232
+ if (r === "authentication failed") return "token-rejected";
228
233
  return "generic";
229
234
  }
230
235
 
@@ -1922,7 +1927,6 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1922
1927
  const protocolControlLogger = createProtocolControlHandler({
1923
1928
  accountId,
1924
1929
  log: (msg) => log?.info?.(msg),
1925
- send: () => {},
1926
1930
  context: wsLogContext,
1927
1931
  });
1928
1932
  const notifySignalObserver = createNotifySignalObserver({
@@ -3456,11 +3460,16 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3456
3460
  // Ids only: the frame carries no content, and `payload.message_id` is the
3457
3461
  // server's `rcl:<target>` slot — the id to delete is `target_message_id`.
3458
3462
  //
3459
- // Synchronous and fire-and-forget on purpose. There is no ack to send (the
3460
- // frame is BestEffort and non-ackable), and no tombstone to write: a Kafka
3461
- // redelivery of the original can resurrect the row, which is an accepted
3462
- // known limitation rather than a fourth reimplementation of the mobile
3463
- // client's tombstone table.
3463
+ // Synchronous and fire-and-forget on purpose: there is no ack to send (the
3464
+ // frame is BestEffort and non-ackable).
3465
+ //
3466
+ // §9.8 requires a permanent tombstone consulted on every ingest path, and
3467
+ // `store.recallMessage` writes one in the same transaction as the delete.
3468
+ // That is what makes this handler's timing irrelevant: the inbound ledger
3469
+ // write sits behind an await, so on a replay carrying the original and its
3470
+ // recall back-to-back the DELETE runs first — and the tombstone then refuses
3471
+ // the INSERT. It also closes the Kafka-redelivery resurrection this comment
3472
+ // previously accepted as a known limitation.
3464
3473
  client.on("message:recall", (env: Envelope) => {
3465
3474
  const target = recallTargetMessageId(env.payload);
3466
3475
  if (!target) {
@@ -3469,7 +3478,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3469
3478
  );
3470
3479
  return;
3471
3480
  }
3472
- const removed = store?.deleteMessagesByMessageId?.({
3481
+ const removed = store?.recallMessage?.({
3473
3482
  platform: "openclaw",
3474
3483
  accountId,
3475
3484
  messageId: target,
package/src/storage.ts CHANGED
@@ -389,6 +389,19 @@ CREATE TABLE IF NOT EXISTS owner_profile (
389
389
  updated_at INTEGER NOT NULL,
390
390
  PRIMARY KEY (platform, account_id)
391
391
  );
392
+ `,
393
+ },
394
+ {
395
+ version: 10,
396
+ name: "recalled_messages",
397
+ sql: `
398
+ CREATE TABLE IF NOT EXISTS recalled_messages (
399
+ platform TEXT NOT NULL,
400
+ account_id TEXT NOT NULL,
401
+ message_id TEXT NOT NULL,
402
+ recalled_at INTEGER NOT NULL,
403
+ PRIMARY KEY (platform, account_id, message_id)
404
+ );
392
405
  `,
393
406
  },
394
407
  ];
@@ -722,6 +735,13 @@ export class ClawChatStore {
722
735
 
723
736
  claimMessageOnce(input: MessageInput): true | false | null {
724
737
  return this.write(() => {
738
+ // Protocol v2 §9.8: consult the recall tombstone on every ingest path.
739
+ // Doing it inside this same write transaction is what makes the delete
740
+ // and the insert order-independent: whichever frame the server delivers
741
+ // first, a recalled message_id can never end up in the ledger. Without
742
+ // it, a replay carrying the original and its recall back-to-back ran the
743
+ // DELETE before the INSERT and the row survived.
744
+ if (input.messageId && this.isMessageRecalled(input)) return false;
725
745
  const result = this.requireDb()
726
746
  .prepare(
727
747
  `INSERT OR IGNORE INTO clawchat_messages(
@@ -821,6 +841,54 @@ export class ClawChatStore {
821
841
  * account was paired, or one dropped by the inbound filter). `null` means the
822
842
  * store is unavailable.
823
843
  */
844
+ /** True when `message_id` was recalled for this (platform, account). */
845
+ isMessageRecalled(input: { platform: string; accountId: string; messageId?: string | null }): boolean {
846
+ if (!input.messageId) return false;
847
+ const row = this.requireDb()
848
+ .prepare(
849
+ `SELECT 1 FROM recalled_messages
850
+ WHERE platform = ? AND account_id = ? AND message_id = ?`,
851
+ )
852
+ .get(input.platform, input.accountId, input.messageId);
853
+ return row !== undefined;
854
+ }
855
+
856
+ /**
857
+ * Record the permanent recall tombstone and purge any row already stored,
858
+ * atomically. Returns the number of ledger rows deleted (0 when the recall
859
+ * arrived before the message did — the tombstone still stands and the later
860
+ * ingest is refused by {@link claimMessageOnce}).
861
+ */
862
+ recallMessage(input: {
863
+ platform: string;
864
+ accountId: string;
865
+ messageId: string;
866
+ recalledAt?: number;
867
+ }): number | null {
868
+ // An empty id would tombstone and delete rows keyed by '' — every row the
869
+ // store never managed to key. Refuse rather than guess.
870
+ if (!input.messageId) return 0;
871
+ return this.write(() => {
872
+ const db = this.requireDb();
873
+ db.prepare(
874
+ `INSERT OR IGNORE INTO recalled_messages(platform, account_id, message_id, recalled_at)
875
+ VALUES (?, ?, ?, ?)`,
876
+ ).run(input.platform, input.accountId, input.messageId, input.recalledAt ?? Date.now());
877
+ const result = db
878
+ .prepare(
879
+ `DELETE FROM clawchat_messages
880
+ WHERE platform = ? AND account_id = ? AND message_id = ?`,
881
+ )
882
+ .run(input.platform, input.accountId, input.messageId);
883
+ return Number(result.changes);
884
+ });
885
+ }
886
+
887
+ /**
888
+ * Low-level purge by message id. NOT the recall path: it writes no tombstone,
889
+ * so a recall routed through here loses to a redelivery or to an ingest that
890
+ * is still behind an await. Use {@link recallMessage} for `message.recall`.
891
+ */
824
892
  deleteMessagesByMessageId(input: {
825
893
  platform: string;
826
894
  accountId: string;
@@ -238,7 +238,6 @@ export interface ProtocolControlEnvelope {
238
238
  export interface CreateProtocolControlHandlerOptions {
239
239
  accountId: string;
240
240
  log: (msg: string) => void;
241
- send: (wire: string) => void;
242
241
  scheduleReconnect?: (reason: string) => void;
243
242
  attempt?: number;
244
243
  reconnectCount?: number;
@@ -275,17 +274,12 @@ export function createProtocolControlHandler(options: CreateProtocolControlHandl
275
274
  return {
276
275
  handleInbound(env: ProtocolControlEnvelope): boolean {
277
276
  if (env.event === "ping") {
278
- logControl("protocol_ping_received", "send_pong", [["trace_id", env.trace_id]]);
279
- options.send(
280
- JSON.stringify({
281
- version: "2",
282
- event: "pong",
283
- trace_id: env.trace_id ?? "-",
284
- // §12: echo the sender's emitted_at verbatim (do not restamp).
285
- emitted_at: env.emitted_at ?? Date.now(),
286
- payload: {},
287
- }),
288
- );
277
+ // §12: the server never emits a JSON-level `ping`, so this is an
278
+ // anomaly worth seeing — but answering it would send exactly the
279
+ // unsolicited `pong` the spec bans, which the server drops as an
280
+ // unknown uplink event. Log it and drop it; this handler has no send
281
+ // capability, so the banned frame cannot be produced here at all.
282
+ logControl("protocol_ping_received", "drop", [["trace_id", env.trace_id]]);
289
283
  return true;
290
284
  }
291
285
  if (env.event === "pong") {
package/src/ws-client.ts CHANGED
@@ -270,6 +270,65 @@ export function isValidChatId(chatId: unknown): chatId is string {
270
270
  */
271
271
  export type DeadChatSource = "signal" | "server";
272
272
 
273
+ /**
274
+ * §3.5 terminal `hello-fail` reason. Exact equality after trimming and
275
+ * lower-casing (parity with the hermes plugin's `_is_token_rejected`); never a
276
+ * substring or prefix match — see `onHelloFail`.
277
+ */
278
+ export const TERMINAL_HELLO_FAIL_REASON = "authentication failed";
279
+
280
+ export function isTerminalHelloFailReason(reason: string | undefined): boolean {
281
+ return (reason ?? "").trim().toLowerCase() === TERMINAL_HELLO_FAIL_REASON;
282
+ }
283
+
284
+ /**
285
+ * §3.6 duplicate-session refusal. msghub turns away the NEWER connection with
286
+ * this close code and a JSON reason `{reason, retry_after_ms}` when the opt-in
287
+ * guard is on. The server owns the 60s → 300s escalation, so the client only
288
+ * has to wait the value it is handed.
289
+ */
290
+ export const CLOSE_CODE_DUPLICATE_THROTTLED = 4002;
291
+ /** Floor used when a 4002 close carries no parseable retry_after_ms. */
292
+ export const DEFAULT_REFUSAL_FLOOR_MS = 60_000;
293
+
294
+ /**
295
+ * §3.6 duplicate-session takeover. msghub closes the OLDER socket with 4001
296
+ * when a newer session for the same (user, device) takes over. Reconnecting at
297
+ * the ordinary initial delay races that session and kicks it back — the
298
+ * mutual-eviction storm.
299
+ *
300
+ * A single eviction waits the base floor; consecutive evictions of the same
301
+ * connection escalate it (doubling, capped), so whichever instance keeps losing
302
+ * withdraws further each round until the other simply stays connected. Prod
303
+ * measured 9–163 s per eviction (msghub 2026-08-18), longer than the base, so a
304
+ * flat floor cannot break a persistent loop — only the escalation does. The
305
+ * streak survives `hello-ok` (an eviction happens *after* we connect) and is
306
+ * reset only by a non-4001 close, mirroring the hermes plugin.
307
+ */
308
+ export const CLOSE_CODE_REPLACED = 4001;
309
+ /** Base floor for the first takeover eviction (the §3.6 minimum). */
310
+ export const TAKEOVER_FLOOR_MS = 5_000;
311
+ /** Ceiling for the escalating takeover floor. */
312
+ export const TAKEOVER_FLOOR_MAX_MS = 600_000;
313
+
314
+ /** Reconnect floor for `streak` consecutive 4001 evictions (0 → no floor). */
315
+ export function takeoverFloorMs(streak: number): number {
316
+ if (streak <= 0) return 0;
317
+ return Math.min(TAKEOVER_FLOOR_MS * 2 ** (streak - 1), TAKEOVER_FLOOR_MAX_MS);
318
+ }
319
+
320
+ export function parseRefusalRetryAfterMs(reason: string | undefined): number {
321
+ if (reason) {
322
+ try {
323
+ const value = (JSON.parse(reason) as { retry_after_ms?: unknown }).retry_after_ms;
324
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
325
+ } catch {
326
+ // fall through to the default floor
327
+ }
328
+ }
329
+ return DEFAULT_REFUSAL_FLOOR_MS;
330
+ }
331
+
273
332
  export class ClawChatClient extends EventEmitter {
274
333
  private currentState: ConnState = "idle";
275
334
  private connectResolve?: () => void;
@@ -278,6 +337,10 @@ export class ClawChatClient extends EventEmitter {
278
337
  private pongTimer?: ReturnType<typeof setTimeout>;
279
338
  private reconnectTimer?: ReturnType<typeof setTimeout>;
280
339
  private reconnectAttempts = 0;
340
+ // Consecutive 4001 duplicate-session evictions. Unlike reconnectAttempts it
341
+ // is NOT reset on hello-ok (the eviction lands after we connect); a non-4001
342
+ // close resets it. Drives the escalating takeover floor (§3.6).
343
+ private takeoverStreak = 0;
281
344
  private closing = false;
282
345
  private authFailed = false;
283
346
  private expectedConnectTraceId?: string;
@@ -646,7 +709,10 @@ export class ClawChatClient extends EventEmitter {
646
709
  if (env.event === EVENT.CONNECT_CHALLENGE) return this.onChallenge(env);
647
710
  if (env.event === EVENT.HELLO_OK) return this.onHelloOk(env);
648
711
  if (env.event === EVENT.HELLO_FAIL) return this.onHelloFail(env);
649
- if (env.event === EVENT.PING) return this.onPing(env);
712
+ // §12: the server never emits a JSON-level `ping`, and an unsolicited
713
+ // `pong` is an unknown uplink event the server drops. We therefore do NOT
714
+ // answer a downlink ping. `pong` below is still handled: it is the reply
715
+ // to the client-initiated heartbeat probe §12 does allow.
650
716
  if (env.event === EVENT.PONG) return this.onPong();
651
717
  if (env.event === EVENT.MESSAGE_ACK) return this.onAck(env as Envelope<MessageAckPayload>);
652
718
  if (env.event === EVENT.MESSAGE_ERROR) return this.onMessageError(env as Envelope<MessageErrorPayload>);
@@ -744,13 +810,16 @@ export class ClawChatClient extends EventEmitter {
744
810
  this.failHandshake(new ProtocolError("invalid hello-fail payload", env), 4002, "protocol error");
745
811
  return;
746
812
  }
747
- // §14.1: distinguish upstream auth-service unavailability (5xx) from token
748
- // rejection (4xx). On a 5xx the token may still be valid and the auth backend
749
- // is down — backoff-reconnect with the SAME token and do
750
- // NOT refresh (a 5xx storm must not become a mass token-refresh storm). Until
751
- // the server emits the distinct 5xx reason, every other hello-fail is treated
752
- // as a terminal token rejection (the caller acquires a fresh token first).
753
- if (/auth service unavailable/i.test(reason)) {
813
+ // §3.5: `"authentication failed"` — matched by EXACT equality, never a
814
+ // substring — is the one terminal reason (the token was rejected by upstream
815
+ // auth). Every other reason is transient: `remote auth service unavailable`
816
+ // (the auth backend is down, the token may still be valid — §14.1), `nonce
817
+ // mismatch`, `invalid connect …`, and any string msghub adds after this
818
+ // client shipped. For all of those: backoff-reconnect with the SAME token,
819
+ // keep the outbound queue, and do NOT refresh (a 5xx storm must not become
820
+ // a mass token-refresh storm; a new server-side reason must not brick old
821
+ // clients). Widening the terminal branch breaks that forward-compat rule.
822
+ if (!isTerminalHelloFailReason(reason)) {
754
823
  const err = new TransportError(reason);
755
824
  this.expectedConnectTraceId = undefined;
756
825
  this.clearTimers();
@@ -760,8 +829,12 @@ export class ClawChatClient extends EventEmitter {
760
829
  this.connectReject = undefined;
761
830
  this.emitError(err);
762
831
  if (this.opts.transport.state !== "closed") {
763
- // Close WITHOUT marking closing/authFailed so handleClose backoff-reconnects.
764
- this.opts.transport.close(4001, "auth service unavailable");
832
+ // Close WITHOUT marking closing/authFailed so handleClose
833
+ // backoff-reconnects. Use 1000, NOT 4001: 4001 is the server's
834
+ // duplicate-session takeover code that handleClose now floors to 5s
835
+ // (§3.6), and this client-initiated transient teardown must reconnect
836
+ // on the ordinary backoff instead of inheriting that floor.
837
+ this.opts.transport.close(1000, reason);
765
838
  } else if (!this.closing && this.opts.reconnect.enabled) {
766
839
  this.scheduleReconnect(reason);
767
840
  } else {
@@ -785,16 +858,6 @@ export class ClawChatClient extends EventEmitter {
785
858
  }
786
859
  }
787
860
 
788
- private onPing(env: Envelope): void {
789
- this.sendRawEnvelope({
790
- version: "2",
791
- event: EVENT.PONG,
792
- trace_id: env.trace_id,
793
- emitted_at: env.emitted_at,
794
- payload: {} satisfies EmptyPayload,
795
- });
796
- }
797
-
798
861
  private onPong(): void {
799
862
  if (this.pongTimer) clearTimeout(this.pongTimer);
800
863
  this.pongTimer = undefined;
@@ -882,7 +945,25 @@ export class ClawChatClient extends EventEmitter {
882
945
  this.connectReject?.(closeError);
883
946
  return;
884
947
  }
885
- this.scheduleReconnect(reason || `close ${code}`);
948
+ // §3.6: raise an explicit reconnect floor for the two server-timed
949
+ // duplicate-session closes. 4002 (refusal) waits the server's
950
+ // retry_after_ms, which exceeds the maxDelay cap; 4001 (takeover) waits at
951
+ // least 5s so an instant reconnect doesn't re-enter the eviction storm.
952
+ let floorMs: number | undefined;
953
+ if (code === CLOSE_CODE_DUPLICATE_THROTTLED) {
954
+ // A refusal is not a takeover-loss — reset the streak and wait the
955
+ // server's retry_after_ms.
956
+ this.takeoverStreak = 0;
957
+ floorMs = parseRefusalRetryAfterMs(reason);
958
+ } else if (code === CLOSE_CODE_REPLACED) {
959
+ this.takeoverStreak += 1;
960
+ floorMs = takeoverFloorMs(this.takeoverStreak);
961
+ } else {
962
+ // Any other close (ordinary drop, backpressure kick) is not an eviction
963
+ // loss — reset so the next takeover starts from the base floor again.
964
+ this.takeoverStreak = 0;
965
+ }
966
+ this.scheduleReconnect(reason || `close ${code}`, floorMs);
886
967
  }
887
968
 
888
969
  private failHandshake(err: Error, code: number, reason: string): void {
@@ -901,7 +982,7 @@ export class ClawChatClient extends EventEmitter {
901
982
  }
902
983
  }
903
984
 
904
- private scheduleReconnect(reason: string): void {
985
+ private scheduleReconnect(reason: string, floorMs?: number): void {
905
986
  if (this.reconnectTimer) return;
906
987
  if (this.reconnectAttempts >= this.opts.reconnect.maxRetries) {
907
988
  this.sendQueue.length = 0;
@@ -910,10 +991,13 @@ export class ClawChatClient extends EventEmitter {
910
991
  return;
911
992
  }
912
993
  this.reconnectAttempts += 1;
913
- const baseDelay = Math.min(
994
+ const cappedDelay = Math.min(
914
995
  this.opts.reconnect.maxDelay,
915
996
  this.opts.reconnect.initialDelay * 2 ** Math.max(0, this.reconnectAttempts - 1),
916
997
  );
998
+ // An explicit floor (a 4002 retry_after_ms) overrides the maxDelay cap: the
999
+ // server's wait is deliberately longer than the ordinary backoff ceiling.
1000
+ const baseDelay = floorMs !== undefined ? Math.max(cappedDelay, floorMs) : cappedDelay;
917
1001
  const jitter = baseDelay * this.opts.reconnect.jitterRatio * Math.random();
918
1002
  const delay = Math.round(baseDelay + jitter);
919
1003
  this.transition("reconnecting");