@clawling/clawchat-plugin-openclaw 2026.5.12-39 → 2026.5.13-dev.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.5.12-39",
3
+ "version": "2026.5.13-dev.1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -13,7 +13,6 @@ This skill guides agent behavior for ClawChat-aware tasks. Use the registered Cl
13
13
 
14
14
  - Use registered ClawChat plugin tools for account/profile, friends, users, moments, comments, reactions, avatar, media, and read-only conversation lookup.
15
15
  - If a requested ClawChat tool is unavailable or returns a config error, report that result and stop instead of bypassing the plugin.
16
- - Use the `/clawchat-output` slash command when the user asks to change how much ClawChat runtime output is shown in the current conversation.
17
16
 
18
17
  ## OpenClaw CLI
19
18
 
@@ -31,18 +30,6 @@ Use `update --force` only when local ClawChat plugin or skill files look corrupt
31
30
 
32
31
  If `channels add` reports `Unknown channel: clawchat-plugin-openclaw`, use the runtime slash command `/clawchat-activate CODE` after the operator ensures the plugin is loaded.
33
32
 
34
- ## Output Visibility
35
-
36
- When the user asks to change ClawChat output verbosity, use the runtime slash command for the current conversation. Treat natural-language wording as aliases for the three supported modes:
37
-
38
- | User wording | Command |
39
- | --- | --- |
40
- | quiet mode, silent mode, minimal output, final-only output, `minimal` | `/clawchat-output minimal` |
41
- | conversation mode, normal mode, regular mode, default output, `normal` | `/clawchat-output normal` |
42
- | dev mode, developer mode, verbose mode, full output, `full` | `/clawchat-output full` |
43
-
44
- Do not edit config files directly for this request. If the slash command returns an error, report that error instead of claiming the mode changed.
45
-
46
33
  ## Plugin Tool Routing
47
34
 
48
35
  Tool descriptions are authoritative. These routing hints resolve common ambiguity:
package/src/api-client.ts CHANGED
@@ -28,6 +28,47 @@ export interface ApiClientOptions {
28
28
  fetchImpl?: typeof fetch;
29
29
  }
30
30
 
31
+ /**
32
+ * §A.0 — decode the access token's `exp` claim locally (base64url-decode the
33
+ * JWT payload segment, read `exp` as epoch seconds). Returns `null` when the
34
+ * token is not a parseable JWT or carries no numeric `exp`, in which case the
35
+ * caller falls back to `activated_at + 24h`. We never persist a separate
36
+ * expiry column; this is derived from the token on every load.
37
+ */
38
+ export function decodeJwtExp(token: string): number | null {
39
+ if (typeof token !== "string") return null;
40
+ const segments = token.split(".");
41
+ if (segments.length < 2) return null;
42
+ const payloadSegment = segments[1];
43
+ if (!payloadSegment) return null;
44
+ try {
45
+ const json = Buffer.from(payloadSegment, "base64url").toString("utf8");
46
+ const parsed = JSON.parse(json) as { exp?: unknown };
47
+ const exp = parsed?.exp;
48
+ return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * §0/§B — outcome classes for `POST /v1/auth/refresh`. The endpoint is
56
+ * always HTTP 200; callers branch on the envelope `code`. Distinct kinds keep
57
+ * the permanent/transient decision (and auto-logout vs. backoff) explicit.
58
+ */
59
+ export type AuthRefreshResult =
60
+ | { kind: "success"; accessToken: string; refreshToken: string }
61
+ // `code:10003` invalid refresh OR `code:400` bad request — PERMANENT.
62
+ | { kind: "permanent"; code: number; message: string }
63
+ // `code:1` internal, any non-200, or a network error — TRANSIENT.
64
+ | { kind: "transient"; message: string; status?: number; code?: number };
65
+
66
+ export interface AuthRefreshParams {
67
+ refreshToken: string;
68
+ /** The connect-time `X-Device-Id` (OpenClaw: `CHANNEL_ID`). */
69
+ deviceId: string;
70
+ }
71
+
31
72
  export interface OpenclawClawlingApiClient {
32
73
  getMyProfile(): Promise<Profile>;
33
74
  getAgentProfile(agentId: string): Promise<{ agent: AgentProfile }>;
@@ -92,6 +133,102 @@ export interface OpenclawClawlingApiClient {
92
133
  }): Promise<AvatarUploadResult>;
93
134
  }
94
135
 
136
+ /** Backend envelope codes for `POST /v1/auth/refresh` (§0). */
137
+ const CODE_OK = 0;
138
+ const CODE_INTERNAL = 1; // CodeInternal — transient.
139
+ const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
140
+ const CODE_INVALID_REFRESH = 10003; // CodeInvalidRefresh — permanent.
141
+
142
+ /**
143
+ * §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
144
+ *
145
+ * Unauthenticated: the refresh token in the body IS the credential, so we send
146
+ * NO `Authorization` header. `X-Device-Id` MUST equal the connect-time device
147
+ * id (the backend rejects on `sess.DeviceID != X-Device-Id`). The endpoint is
148
+ * always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
149
+ * a standalone function (not a method on the token-bearing client) precisely
150
+ * because no bearer token participates.
151
+ */
152
+ export async function authRefresh(
153
+ opts: { baseUrl: string; fetchImpl?: typeof fetch },
154
+ params: AuthRefreshParams,
155
+ ): Promise<AuthRefreshResult> {
156
+ const baseUrl = opts.baseUrl.replace(/\/+$/, "");
157
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
158
+ if (!params.refreshToken?.trim()) {
159
+ return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
160
+ }
161
+ let res: Response;
162
+ try {
163
+ res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
164
+ method: "POST",
165
+ // No `Authorization` header — refresh is unauthenticated by design.
166
+ headers: {
167
+ "content-type": "application/json",
168
+ "x-device-id": params.deviceId,
169
+ },
170
+ body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
171
+ });
172
+ } catch (err) {
173
+ // Network error / timeout / DNS — TRANSIENT (no rotation committed).
174
+ return {
175
+ kind: "transient",
176
+ message: `refresh fetch failed: ${err instanceof Error ? err.message : String(err)}`,
177
+ };
178
+ }
179
+
180
+ if (res.status !== 200) {
181
+ // Any non-200 (500/LB/transport) — TRANSIENT, regardless of body.
182
+ const text = await res.text().catch(() => "");
183
+ return {
184
+ kind: "transient",
185
+ status: res.status,
186
+ message: `refresh http ${res.status} ${text.slice(0, 200)}`,
187
+ };
188
+ }
189
+
190
+ const text = await res.text().catch(() => "");
191
+ let parsed: { code?: unknown; msg?: unknown; message?: unknown; data?: unknown } | undefined;
192
+ try {
193
+ parsed = text ? (JSON.parse(text) as typeof parsed) : undefined;
194
+ } catch {
195
+ parsed = undefined;
196
+ }
197
+ const code = typeof parsed?.code === "number" ? parsed.code : Number.NaN;
198
+ const message =
199
+ (typeof parsed?.msg === "string" && parsed.msg) ||
200
+ (typeof parsed?.message === "string" && parsed.message) ||
201
+ `code=${code}`;
202
+
203
+ if (!Number.isFinite(code)) {
204
+ // 200 with no usable envelope — treat as transient (do not auto-logout).
205
+ return { kind: "transient", status: 200, message: "refresh: missing numeric code" };
206
+ }
207
+ if (code === CODE_OK) {
208
+ const data = parsed?.data && typeof parsed.data === "object"
209
+ ? (parsed.data as { access_token?: unknown; refresh_token?: unknown })
210
+ : {};
211
+ const accessToken = typeof data.access_token === "string" ? data.access_token : "";
212
+ const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
213
+ if (!accessToken || !refreshToken) {
214
+ // Rotation succeeded server-side but the body is malformed — transient so
215
+ // we retry; the next attempt will return 10003 (rotation single-use) and
216
+ // escalate to permanent (§B transient→permanent).
217
+ return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
218
+ }
219
+ return { kind: "success", accessToken, refreshToken };
220
+ }
221
+ if (code === CODE_INVALID_REFRESH || code === CODE_BAD_REQUEST) {
222
+ return { kind: "permanent", code, message };
223
+ }
224
+ if (code === CODE_INTERNAL) {
225
+ return { kind: "transient", status: 200, code, message };
226
+ }
227
+ // Unknown non-zero code — conservatively transient (never auto-logout on an
228
+ // unrecognized code; only 10003/400 are permanent per §0).
229
+ return { kind: "transient", status: 200, code, message };
230
+ }
231
+
95
232
  export function createOpenclawClawlingApiClient(opts: ApiClientOptions): OpenclawClawlingApiClient {
96
233
  if (!/^https?:\/\//i.test(opts.baseUrl)) {
97
234
  throw new ClawlingApiError(
@@ -134,48 +271,54 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
134
271
  path,
135
272
  });
136
273
  }
137
- if (!res.ok) {
138
- const snippet = await res.text().catch(() => "");
139
- throw new ClawlingApiError("transport", `http ${res.status} ${snippet.slice(0, 200)}`, {
140
- status: res.status,
141
- path,
142
- });
143
- }
274
+ // §15.3: HTTP status stays meaningful for proxies, but application code
275
+ // should branch on the business `code`, not the HTTP status. Parse the body
276
+ // FIRST even on a non-2xx — so callers receive the precise business code
277
+ // (e.g. 41301 vs 41501) rather than a generic transport error. Only fall
278
+ // back to the HTTP status when no structured envelope is present.
279
+ const text = await res.text().catch(() => "");
144
280
  let parsed: unknown;
145
281
  try {
146
- parsed = await res.json();
147
- } catch (err) {
148
- throw new ClawlingApiError(
149
- "transport",
150
- `non-JSON response: ${err instanceof Error ? err.message : String(err)}`,
151
- { status: res.status, path },
152
- );
282
+ parsed = text ? JSON.parse(text) : undefined;
283
+ } catch {
284
+ parsed = undefined;
153
285
  }
154
286
  // Unified envelope: `{ code: number, msg: string, data: T }`.
155
287
  // `code === 0` means success; any other value is a business error whose
156
288
  // `msg` is surfaced to callers and `code` is preserved on the error meta.
157
- const env = parsed as { code?: unknown; msg?: unknown; message?: unknown; data?: T };
158
- const code = typeof env.code === "number" ? env.code : Number.NaN;
159
- const msg =
160
- typeof env.msg === "string"
161
- ? env.msg
162
- : typeof env.message === "string"
163
- ? env.message
164
- : "";
165
- if (!Number.isFinite(code)) {
166
- throw new ClawlingApiError("transport", "invalid envelope: missing numeric `code`", {
167
- status: res.status,
168
- path,
169
- });
289
+ const env =
290
+ parsed && typeof parsed === "object"
291
+ ? (parsed as { code?: unknown; msg?: unknown; message?: unknown; data?: T })
292
+ : undefined;
293
+ const code = typeof env?.code === "number" ? env.code : Number.NaN;
294
+ if (env && Number.isFinite(code)) {
295
+ const msg =
296
+ typeof env.msg === "string"
297
+ ? env.msg
298
+ : typeof env.message === "string"
299
+ ? env.message
300
+ : "";
301
+ if (code !== 0) {
302
+ throw new ClawlingApiError("api", msg || `code=${code}`, {
303
+ code,
304
+ status: res.status,
305
+ path,
306
+ });
307
+ }
308
+ return env.data as T;
170
309
  }
171
- if (code !== 0) {
172
- throw new ClawlingApiError("api", msg || `code=${code}`, {
173
- code,
310
+ // No usable envelope — fall back to the HTTP status signal.
311
+ if (!res.ok) {
312
+ throw new ClawlingApiError("transport", `http ${res.status} ${text.slice(0, 200)}`, {
174
313
  status: res.status,
175
314
  path,
176
315
  });
177
316
  }
178
- return env.data as T;
317
+ throw new ClawlingApiError(
318
+ "transport",
319
+ text ? "invalid envelope: missing numeric `code`" : "non-JSON response: empty body",
320
+ { status: res.status, path },
321
+ );
179
322
  }
180
323
 
181
324
  async function call<T>(
package/src/client.ts CHANGED
@@ -9,6 +9,13 @@ export type { ChatType } from "./protocol-types.ts";
9
9
  export interface CreateClientOverrides {
10
10
  /** Transport override — only intended for tests (e.g. MockTransport). */
11
11
  transport?: Transport;
12
+ /**
13
+ * Device id to present on `connect`, overriding the hostname-derived default.
14
+ * Supplied with a previously server-resolved `device_id` (persisted from an
15
+ * earlier `hello-ok`) so a pod restart reuses the same device identity
16
+ * instead of minting a new one and forcing a full inbox replay.
17
+ */
18
+ deviceIdOverride?: string;
12
19
  wsLifecycle?: {
13
20
  onConnectFrameSent?: (env: {
14
21
  trace_id?: unknown;
@@ -27,10 +34,14 @@ export function createOpenclawClawlingClient(
27
34
  account: ResolvedOpenclawClawlingAccount,
28
35
  overrides: CreateClientOverrides = {},
29
36
  ): ClawlingChatClient {
37
+ const deviceId =
38
+ overrides.deviceIdOverride && overrides.deviceIdOverride.trim()
39
+ ? overrides.deviceIdOverride.trim()
40
+ : resolveOpenclawClawlingDeviceId(account);
30
41
  const client = createClawChatClient({
31
42
  url: account.websocketUrl,
32
43
  token: account.token,
33
- deviceId: resolveOpenclawClawlingDeviceId(account),
44
+ deviceId,
34
45
  ...(overrides.transport ? { transport: overrides.transport } : {}),
35
46
  reconnect: {
36
47
  enabled: true,
package/src/inbound.ts CHANGED
@@ -72,14 +72,16 @@ type SenderLike = {
72
72
  type?: unknown;
73
73
  };
74
74
 
75
- function normalizeSender(sender: unknown): { id: string; nickName: string; profileType: string | null } | null {
75
+ function normalizeSender(sender: unknown): { id: string; nickName: string } | null {
76
76
  if (!sender || typeof sender !== "object") return null;
77
77
  const s = sender as SenderLike;
78
78
  const id = typeof s.id === "string" ? s.id : "";
79
79
  if (!id) return null;
80
80
  const nickName = typeof s.nick_name === "string" ? s.nick_name : id;
81
- const profileType = s.type === "agent" || s.type === "user" ? s.type : null;
82
- return { id, nickName, profileType };
81
+ // §4.1: `sender.type` is the server-stamped routing type and is always
82
+ // "direct" it never carries the human/agent distinction. The sender's
83
+ // profile_type is resolved downstream from chat metadata, not from the wire.
84
+ return { id, nickName };
83
85
  }
84
86
 
85
87
  function requireChatId(envelope: Envelope<unknown>): string | null {
@@ -166,6 +168,20 @@ export async function dispatchOpenclawClawlingInbound(
166
168
  );
167
169
  return;
168
170
  }
171
+ // Fail-closed self-echo guard: the self-echo check below
172
+ // (`sender.id === account.userId`) is only meaningful when `account.userId`
173
+ // is known. A reactivation auth failure can leave `account.userId` as an
174
+ // empty string (see runtime.ts reactivation edge); with an empty userId the
175
+ // `account.userId && …` short-circuit would silently treat EVERY frame —
176
+ // including our own echoed messages — as non-self and feed it back into the
177
+ // LLM pipeline (self-reply loop). Refuse to process materialized messages in
178
+ // that state rather than risk echoing our own output.
179
+ if (!account.userId) {
180
+ log?.error?.(
181
+ `[${account.accountId}] clawchat-plugin-openclaw skip (fail-closed): empty account.userId, cannot apply self-echo guard event=${envelope.event} trace=${envelope.trace_id}`,
182
+ );
183
+ return;
184
+ }
169
185
  if (isMaterializedMessage && !isInboundMessagePayload(envelope.payload)) {
170
186
  log?.info?.(
171
187
  `[${account.accountId}] clawchat-plugin-openclaw skip: invalid payload trace=${envelope.trace_id}`,
@@ -214,7 +230,11 @@ export async function dispatchOpenclawClawlingInbound(
214
230
  }
215
231
  const chatType: ChatType = envelope.chat_type === "group" ? "group" : "direct";
216
232
  const isGroup = chatType === "group";
217
- if (isMaterializedMessage && payload.message_mode !== "normal") {
233
+ // §7.5: the server does not default message_mode an omitted field arrives
234
+ // as "" on the downlink. Empty/absent is equivalent to "normal", so only
235
+ // skip genuinely non-normal modes (e.g. "thinking").
236
+ const messageMode = payload.message_mode ?? "";
237
+ if (isMaterializedMessage && messageMode !== "normal" && messageMode !== "") {
218
238
  log?.info?.(
219
239
  `[${account.accountId}] clawchat-plugin-openclaw skip non-normal mode=${payload.message_mode}`,
220
240
  );
@@ -271,7 +291,6 @@ export async function dispatchOpenclawClawlingInbound(
271
291
  peer: { kind: isGroup ? "group" : "direct", id: chatId },
272
292
  senderId: sender.id,
273
293
  senderNickName: sender.nickName,
274
- ...(sender.profileType ? { senderProfileType: sender.profileType } : {}),
275
294
  rawBody,
276
295
  messageId: payload.message_id,
277
296
  traceId: envelope.trace_id,