@clawling/clawchat-plugin-openclaw 2026.5.12-39 → 2026.5.13-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.
@@ -126,6 +126,13 @@ ALTER TABLE clawchat_messages ADD COLUMN send_status TEXT;
126
126
  ALTER TABLE clawchat_messages ADD COLUMN protocol_message_id TEXT;
127
127
  ALTER TABLE clawchat_messages ADD COLUMN acked_at INTEGER;
128
128
  ALTER TABLE clawchat_messages ADD COLUMN send_error TEXT;
129
+ `,
130
+ },
131
+ {
132
+ version: 7,
133
+ name: "activation_device_id",
134
+ sql: `
135
+ ALTER TABLE activations ADD COLUMN device_id TEXT;
129
136
  `,
130
137
  },
131
138
  ];
@@ -193,12 +200,13 @@ export class ClawChatStore {
193
200
  const ownerUserId = input.ownerUserId?.trim() || null;
194
201
  const accessToken = input.accessToken?.trim() || null;
195
202
  const refreshToken = input.refreshToken?.trim() || null;
203
+ const deviceId = input.deviceId?.trim() || null;
196
204
  this.requireDb()
197
205
  .prepare(`INSERT INTO activations(
198
206
  platform, account_id, user_id, owner_user_id, access_token, refresh_token,
199
207
  activated_at, login_method, conversation_id, bootstrap_sent,
200
- bootstrap_claimed_at, updated_at
201
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
208
+ bootstrap_claimed_at, device_id, updated_at
209
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
202
210
  ON CONFLICT(platform, account_id) DO UPDATE SET
203
211
  user_id = excluded.user_id,
204
212
  owner_user_id = excluded.owner_user_id,
@@ -209,15 +217,53 @@ export class ClawChatStore {
209
217
  conversation_id = excluded.conversation_id,
210
218
  bootstrap_sent = excluded.bootstrap_sent,
211
219
  bootstrap_claimed_at = NULL,
220
+ device_id = excluded.device_id,
212
221
  updated_at = excluded.updated_at`)
213
- .run(input.platform, input.accountId, userId, ownerUserId, accessToken, refreshToken, now, input.loginMethod ?? null, conversationId, conversationId ? 0 : 1, null, now);
222
+ .run(input.platform, input.accountId, userId, ownerUserId, accessToken, refreshToken, now, input.loginMethod ?? null, conversationId, conversationId ? 0 : 1, null, deviceId, now);
214
223
  void conversationId;
215
224
  });
216
225
  }
226
+ /**
227
+ * §0/§A.3 — persist a rotated access+refresh pair durably BEFORE the caller
228
+ * swaps the in-memory token. Identity columns are untouched. Returns whether
229
+ * a row was updated (false ⇒ no activation row exists yet).
230
+ */
231
+ rotateActivationTokens(input) {
232
+ return this.write(() => {
233
+ const now = input.rotatedAt ?? Date.now();
234
+ const result = this.requireDb()
235
+ .prepare(`UPDATE activations SET
236
+ access_token = ?,
237
+ refresh_token = ?,
238
+ activated_at = ?,
239
+ updated_at = ?
240
+ WHERE platform = ? AND account_id = ?`)
241
+ .run(input.accessToken, input.refreshToken, now, now, input.platform, input.accountId);
242
+ return result.changes > 0;
243
+ });
244
+ }
245
+ /**
246
+ * §C.1 — auto-logout: blank the `access_token` / `refresh_token` columns so
247
+ * the agent can no longer mint tokens, but KEEP `user_id` / `owner_user_id` /
248
+ * `device_id` so a `/clawchat-activate <code>` re-pair reuses the identity.
249
+ */
250
+ clearActivationCredentials(input) {
251
+ return this.write(() => {
252
+ const now = Date.now();
253
+ const result = this.requireDb()
254
+ .prepare(`UPDATE activations SET
255
+ access_token = NULL,
256
+ refresh_token = NULL,
257
+ updated_at = ?
258
+ WHERE platform = ? AND account_id = ?`)
259
+ .run(now, input.platform, input.accountId);
260
+ return result.changes > 0;
261
+ });
262
+ }
217
263
  getActivationCredentials(input) {
218
264
  return this.read(() => {
219
265
  const row = this.requireDb()
220
- .prepare(`SELECT user_id, owner_user_id, access_token, refresh_token
266
+ .prepare(`SELECT user_id, owner_user_id, access_token, refresh_token, activated_at, device_id
221
267
  FROM activations
222
268
  WHERE platform = ?
223
269
  AND account_id = ?`)
@@ -228,9 +274,15 @@ export class ClawChatStore {
228
274
  const refreshToken = typeof row?.refresh_token === "string" && row.refresh_token.trim()
229
275
  ? row.refresh_token.trim()
230
276
  : null;
277
+ const activatedAt = typeof row?.activated_at === "number" && Number.isFinite(row.activated_at)
278
+ ? row.activated_at
279
+ : null;
280
+ const deviceId = typeof row?.device_id === "string" && row.device_id.trim()
281
+ ? row.device_id.trim()
282
+ : null;
231
283
  if (!userId || !ownerUserId || !accessToken)
232
284
  return null;
233
- return { userId, ownerUserId, accessToken, refreshToken };
285
+ return { userId, ownerUserId, accessToken, refreshToken, activatedAt, deviceId };
234
286
  }) ?? null;
235
287
  }
236
288
  getActivationConversation(input) {
@@ -398,6 +450,30 @@ export class ClawChatStore {
398
450
  .run(input.state, now, input.closeCode ?? null, input.closeReason ?? null, input.error ?? null, now, connectionId);
399
451
  });
400
452
  }
453
+ /**
454
+ * Return the most recent server-resolved `device_id` recorded from a
455
+ * `hello-ok` for this account, if any. Reused on reconnect so a pod restart
456
+ * (which mints a fresh hostname and therefore a fresh derived device id)
457
+ * does not present a brand-new device to the server — that would trigger a
458
+ * full inbox replay and orphan the previous device's cursor.
459
+ */
460
+ getLastResolvedDeviceId(input) {
461
+ return this.read(() => {
462
+ const row = this.requireDb()
463
+ .prepare(`SELECT resolved_device_id
464
+ FROM connections
465
+ WHERE platform = ?
466
+ AND account_id = ?
467
+ AND resolved_device_id IS NOT NULL
468
+ AND resolved_device_id <> ''
469
+ ORDER BY id DESC
470
+ LIMIT 1`)
471
+ .get(input.platform, input.accountId);
472
+ return typeof row?.resolved_device_id === "string" && row.resolved_device_id.trim()
473
+ ? row.resolved_device_id.trim()
474
+ : null;
475
+ }) ?? null;
476
+ }
401
477
  recordToolCall(input) {
402
478
  this.write(() => {
403
479
  const startedAt = input.startedAt ?? Date.now();
@@ -159,7 +159,8 @@ export function createProtocolControlHandler(options) {
159
159
  version: "2",
160
160
  event: "pong",
161
161
  trace_id: env.trace_id ?? "-",
162
- emitted_at: Date.now(),
162
+ // §12: echo the sender's emitted_at verbatim (do not restamp).
163
+ emitted_at: env.emitted_at ?? Date.now(),
163
164
  payload: {},
164
165
  }));
165
166
  return true;
@@ -176,3 +177,70 @@ export function createProtocolControlHandler(options) {
176
177
  },
177
178
  };
178
179
  }
180
+ /**
181
+ * Observes reliable `notify.signal` frames (§9.4). The agent plugin keeps no
182
+ * friend/roster cache (friends are fetched on demand via REST tools), so there
183
+ * is nothing to invalidate — this is a pure observability hook: it dedups by
184
+ * `event_id` (the live frame and its reliable-inbox replay collapse to one),
185
+ * structured-logs the signal, and returns the outcome. It deliberately takes no
186
+ * action on the agent; wire a real reaction here if the product later needs one.
187
+ */
188
+ export function createNotifySignalObserver(options) {
189
+ const maxSeen = options.maxSeen ?? 512;
190
+ const seen = new Set();
191
+ const order = [];
192
+ const context = () => options.context?.() ?? { attempt: 1, reconnectCount: 0, state: "ready" };
193
+ const logSignal = (event, action, fields) => {
194
+ const current = context();
195
+ options.log(formatWsLog({
196
+ event,
197
+ accountId: options.accountId,
198
+ attempt: current.attempt,
199
+ reconnectCount: current.reconnectCount,
200
+ state: current.state,
201
+ action,
202
+ fields,
203
+ }));
204
+ };
205
+ return {
206
+ /** Returns whether this signal was newly observed, a duplicate, or malformed. */
207
+ observe(env) {
208
+ const payload = env.payload && typeof env.payload === "object"
209
+ ? env.payload
210
+ : undefined;
211
+ const eventId = typeof payload?.event_id === "string" ? payload.event_id : "";
212
+ const type = typeof payload?.type === "string" ? payload.type : "";
213
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
214
+ const version = typeof payload?.version === "number" ? payload.version : undefined;
215
+ if (!eventId || !type) {
216
+ logSignal("notify_signal_invalid", "ignore", [
217
+ ["trace_id", env.trace_id],
218
+ ["type", type || null],
219
+ ["event_id", eventId || null],
220
+ ]);
221
+ return "invalid";
222
+ }
223
+ if (seen.has(eventId)) {
224
+ logSignal("notify_signal_duplicate", "ignore", [
225
+ ["type", type],
226
+ ["event_id", eventId],
227
+ ]);
228
+ return "duplicate";
229
+ }
230
+ seen.add(eventId);
231
+ order.push(eventId);
232
+ while (order.length > maxSeen) {
233
+ const evicted = order.shift();
234
+ if (evicted !== undefined)
235
+ seen.delete(evicted);
236
+ }
237
+ logSignal("notify_signal_observed", "observe", [
238
+ ["type", type],
239
+ ["entity_id", entityId || null],
240
+ ["version", version ?? null],
241
+ ["event_id", eventId],
242
+ ]);
243
+ return "observed";
244
+ },
245
+ };
246
+ }
@@ -301,6 +301,10 @@ export class ClawChatClient extends EventEmitter {
301
301
  this.emit("typing", env);
302
302
  if (env.event === EVENT.CHAT_METADATA_INVALIDATED)
303
303
  this.emit("metadata:invalidated", env);
304
+ if (env.event === EVENT.NOTIFY_SIGNAL)
305
+ this.emit("notify:signal", env);
306
+ if (env.event === EVENT.REPLAY_DONE)
307
+ this.emit("replay:done", env);
304
308
  if (env.event === EVENT.OFFLINE_DONE)
305
309
  this.emit("offline:done");
306
310
  }
@@ -322,7 +326,19 @@ export class ClawChatClient extends EventEmitter {
322
326
  token: this.opts.token,
323
327
  nonce,
324
328
  ...(this.opts.deviceId ? { device_id: this.opts.deviceId } : {}),
325
- capabilities: { multi_device: true, device_replay: true, chat_meta_events: true },
329
+ // Agent runtime is single-device: multi_device stays off so the server
330
+ // never self-fans-out this connection's own messages. notify_signals is
331
+ // advertised because we now handle the notify.signal frame (§9.4).
332
+ // history_sync (§11.4) is intentionally omitted: it is only required to
333
+ // *send* history.transit, which a single-device agent never does, and we
334
+ // do not handle inbound history.transit — advertising it would invite
335
+ // frames we cannot process. This is a deliberate, spec-legal omission.
336
+ capabilities: {
337
+ multi_device: false,
338
+ device_replay: true,
339
+ chat_meta_events: true,
340
+ notify_signals: true,
341
+ },
326
342
  };
327
343
  const traceId = this.nextTraceId();
328
344
  this.expectedConnectTraceId = traceId;
@@ -374,7 +390,34 @@ export class ClawChatClient extends EventEmitter {
374
390
  this.failHandshake(new ProtocolError("invalid hello-fail payload", env), 4002, "protocol error");
375
391
  return;
376
392
  }
377
- const err = new AuthError(typeof reason === "string" ? reason : "authentication failed");
393
+ // §14.1: distinguish upstream auth-service unavailability (5xx) from token
394
+ // rejection (4xx). On a 5xx the token may still be valid and the auth backend
395
+ // (member-backend) is down — backoff-reconnect with the SAME token and do
396
+ // NOT refresh (a 5xx storm must not become a mass token-refresh storm). Until
397
+ // the server emits the distinct 5xx reason, every other hello-fail is treated
398
+ // as a terminal token rejection (the caller acquires a fresh token first).
399
+ if (/auth service unavailable/i.test(reason)) {
400
+ const err = new TransportError(reason);
401
+ this.expectedConnectTraceId = undefined;
402
+ this.clearTimers();
403
+ this.rejectPending(err);
404
+ this.connectReject?.(err);
405
+ this.connectResolve = undefined;
406
+ this.connectReject = undefined;
407
+ this.emitError(err);
408
+ if (this.opts.transport.state !== "closed") {
409
+ // Close WITHOUT marking closing/authFailed so handleClose backoff-reconnects.
410
+ this.opts.transport.close(4001, "auth service unavailable");
411
+ }
412
+ else if (!this.closing && this.opts.reconnect.enabled) {
413
+ this.scheduleReconnect(reason);
414
+ }
415
+ else {
416
+ this.transition("disconnected");
417
+ }
418
+ return;
419
+ }
420
+ const err = new AuthError(reason);
378
421
  this.authFailed = true;
379
422
  this.expectedConnectTraceId = undefined;
380
423
  this.sendQueue.length = 0;
@@ -421,10 +464,17 @@ export class ClawChatClient extends EventEmitter {
421
464
  }
422
465
  clearTimeout(entry.timer);
423
466
  this.pending.delete(env.trace_id);
424
- const payload = env.payload && typeof env.payload === "object" ? env.payload : undefined;
467
+ const payload = env.payload && typeof env.payload === "object"
468
+ ? env.payload
469
+ : undefined;
425
470
  const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
426
- const message = typeof payload?.message === "string" && payload.message ? payload.message : "message send failed";
427
- entry.reject(new MessageSendError(env.trace_id, code, message, env.chat_id));
471
+ // §14.3: the human-readable hint is `reason` (fall back to legacy `message`).
472
+ const hint = typeof payload?.reason === "string" && payload.reason
473
+ ? payload.reason
474
+ : typeof payload?.message === "string" && payload.message
475
+ ? payload.message
476
+ : "message send failed";
477
+ entry.reject(new MessageSendError(env.trace_id, code, hint, env.chat_id));
428
478
  }
429
479
  startHeartbeat() {
430
480
  if (!this.opts.heartbeat.enabled)
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.12-39",
3
+ "version": "2026.5.13-1",
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
+ };