@newbase-clawchat/openclaw-clawchat 2026.5.4 → 2026.5.12-13

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.
Files changed (85) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +121 -19
  3. package/dist/index.js +10 -19
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +78 -10
  6. package/dist/src/api-types.test-d.js +10 -0
  7. package/dist/src/channel.js +25 -156
  8. package/dist/src/channel.setup.js +120 -0
  9. package/dist/src/client.js +37 -41
  10. package/dist/src/config.js +75 -17
  11. package/dist/src/inbound.js +79 -61
  12. package/dist/src/login.runtime.js +84 -19
  13. package/dist/src/media-runtime.js +8 -8
  14. package/dist/src/message-mapper.js +1 -1
  15. package/dist/src/mock-transport.js +31 -0
  16. package/dist/src/outbound.js +410 -26
  17. package/dist/src/protocol-types.js +63 -0
  18. package/dist/src/protocol-types.typecheck.js +1 -0
  19. package/dist/src/protocol.js +2 -7
  20. package/dist/src/reply-dispatcher.js +157 -54
  21. package/dist/src/runtime.js +795 -119
  22. package/dist/src/storage.js +689 -0
  23. package/dist/src/tools-schema.js +98 -16
  24. package/dist/src/tools.js +422 -135
  25. package/dist/src/ws-alignment.js +178 -0
  26. package/dist/src/ws-client.js +588 -0
  27. package/dist/src/ws-log.js +19 -0
  28. package/index.ts +10 -22
  29. package/openclaw.plugin.json +37 -2
  30. package/package.json +17 -4
  31. package/setup-entry.ts +4 -0
  32. package/skills/clawchat/SKILL.md +88 -0
  33. package/src/api-client.test.ts +274 -14
  34. package/src/api-client.ts +138 -23
  35. package/src/api-types.test-d.ts +12 -0
  36. package/src/api-types.ts +90 -4
  37. package/src/buffered-stream.test.ts +14 -12
  38. package/src/buffered-stream.ts +1 -1
  39. package/src/channel.outbound.test.ts +269 -60
  40. package/src/channel.setup.ts +146 -0
  41. package/src/channel.test.ts +130 -24
  42. package/src/channel.ts +30 -186
  43. package/src/client.test.ts +197 -11
  44. package/src/client.ts +50 -57
  45. package/src/config.test.ts +108 -6
  46. package/src/config.ts +95 -24
  47. package/src/inbound.test.ts +288 -37
  48. package/src/inbound.ts +96 -84
  49. package/src/login.runtime.test.ts +347 -13
  50. package/src/login.runtime.ts +105 -23
  51. package/src/manifest.test.ts +146 -74
  52. package/src/media-runtime.test.ts +57 -2
  53. package/src/media-runtime.ts +26 -17
  54. package/src/message-mapper.test.ts +2 -2
  55. package/src/message-mapper.ts +2 -2
  56. package/src/mock-transport.test.ts +35 -0
  57. package/src/mock-transport.ts +38 -0
  58. package/src/outbound.test.ts +694 -73
  59. package/src/outbound.ts +484 -31
  60. package/src/plugin-entry.test.ts +1 -0
  61. package/src/protocol-types.test.ts +69 -0
  62. package/src/protocol-types.ts +296 -0
  63. package/src/protocol-types.typecheck.ts +89 -0
  64. package/src/protocol.test.ts +1 -6
  65. package/src/protocol.ts +2 -7
  66. package/src/reply-dispatcher.test.ts +819 -119
  67. package/src/reply-dispatcher.ts +202 -60
  68. package/src/runtime.test.ts +2120 -41
  69. package/src/runtime.ts +935 -142
  70. package/src/scripts.test.ts +85 -0
  71. package/src/storage.test.ts +793 -0
  72. package/src/storage.ts +1095 -0
  73. package/src/streaming.test.ts +9 -8
  74. package/src/streaming.ts +1 -1
  75. package/src/tools-schema.ts +148 -20
  76. package/src/tools.test.ts +377 -50
  77. package/src/tools.ts +574 -154
  78. package/src/ws-alignment.test.ts +103 -0
  79. package/src/ws-alignment.ts +275 -0
  80. package/src/ws-client.test.ts +1218 -0
  81. package/src/ws-client.ts +662 -0
  82. package/src/ws-log.test.ts +32 -0
  83. package/src/ws-log.ts +31 -0
  84. package/skills/clawchat-account-tools/SKILL.md +0 -26
  85. package/skills/clawchat-activate/SKILL.md +0 -47
package/src/runtime.ts CHANGED
@@ -4,24 +4,58 @@ import {
4
4
  ProtocolError,
5
5
  StateError,
6
6
  TransportError,
7
- type ClawlingChatClient,
8
- type DownlinkMessageSendPayload,
7
+ EVENT,
9
8
  type Envelope,
10
9
  type Transport,
11
- } from "@newbase-clawchat/sdk";
10
+ } from "./protocol-types.ts";
12
11
  import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
13
12
  import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
14
13
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
15
14
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
16
15
  import { createOpenclawClawlingClient } from "./client.ts";
16
+ import { createOpenclawClawlingApiClient } from "./api-client.ts";
17
+ import { ClawlingApiError, type ConversationDetails, type ConversationParticipant } from "./api-types.ts";
17
18
  import { CHANNEL_ID, type ResolvedOpenclawClawlingAccount } from "./config.ts";
18
- import { dispatchOpenclawClawlingInbound } from "./inbound.ts";
19
+ import type { ClawlingChatClient } from "./ws-client.ts";
20
+ import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inbound.ts";
19
21
  import { fetchInboundMedia } from "./media-runtime.ts";
20
22
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
21
23
  import { sendStreamingText } from "./streaming.ts";
22
- import { sendOpenclawClawlingText } from "./outbound.ts";
24
+ import {
25
+ flushAlignedOutboundQueue,
26
+ getAlignedOutboundQueueSize,
27
+ setAlignedOutboundLogContext,
28
+ } from "./outbound.ts";
29
+ import { formatWsLog } from "./ws-log.ts";
30
+ import { createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.ts";
31
+ import {
32
+ clawChatDbPathForStateDir,
33
+ getClawChatStore,
34
+ type ClawChatStore,
35
+ } from "./storage.ts";
23
36
 
24
37
  type Log = { info?: (m: string) => void; error?: (m: string) => void };
38
+ type RuntimeConnectionStore = Pick<
39
+ ClawChatStore,
40
+ "startConnection" | "markConnectSent" | "markConnectionReady" | "finishConnection"
41
+ > &
42
+ Partial<
43
+ Pick<
44
+ ClawChatStore,
45
+ | "insertMessage"
46
+ | "claimMessageOnce"
47
+ | "updateMessageByIdentity"
48
+ | "claimPendingActivationBootstrap"
49
+ | "releaseActivationBootstrapClaim"
50
+ | "markActivationBootstrapSent"
51
+ | "upsertConversationSummary"
52
+ | "upsertConversationDetails"
53
+ | "deleteConversationCache"
54
+ | "listCachedConversationIds"
55
+ | "getActivationConversation"
56
+ | "getCachedConversation"
57
+ >
58
+ >;
25
59
 
26
60
  const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingRuntime } =
27
61
  createPluginRuntimeStore<PluginRuntime>("openclaw-clawchat runtime not initialized");
@@ -107,6 +141,109 @@ function formatConversationSubject(peer: { kind: "direct" | "group"; id: string
107
141
  return peer.kind === "group" ? `group:${peer.id}` : peer.id;
108
142
  }
109
143
 
144
+ function parseApiTimestamp(value: unknown): number | null {
145
+ if (typeof value !== "string") return null;
146
+ const parsed = Date.parse(value);
147
+ return Number.isFinite(parsed) ? parsed : null;
148
+ }
149
+
150
+ function asRecord(value: unknown): Record<string, unknown> | null {
151
+ return value && typeof value === "object" ? value as Record<string, unknown> : null;
152
+ }
153
+
154
+ function optionalString(value: unknown): string | undefined {
155
+ return typeof value === "string" ? value : undefined;
156
+ }
157
+
158
+ function isConversationNotFoundError(err: unknown): boolean {
159
+ if (!(err instanceof ClawlingApiError)) return false;
160
+ return err.meta?.status === 404 || err.meta?.status === 410 ||
161
+ err.meta?.code === 404 || err.meta?.code === 410 || err.meta?.code === 40401;
162
+ }
163
+
164
+ function metadataVersionFromEnvelope(env: Envelope): number | undefined {
165
+ const payload = asRecord(env.payload);
166
+ const version = payload?.version;
167
+ return typeof version === "number" && Number.isFinite(version) ? version : undefined;
168
+ }
169
+
170
+ function conversationUserProfileFromParticipant(
171
+ participant: ConversationParticipant,
172
+ refreshedAt: number,
173
+ ): {
174
+ userId: string;
175
+ nickname?: string | null;
176
+ avatarUrl?: string | null;
177
+ bio?: string | null;
178
+ raw?: unknown;
179
+ lastRefreshedAt?: number | null;
180
+ } | null {
181
+ const participantRecord = participant as Record<string, unknown>;
182
+ const userRecord = asRecord(participantRecord.user);
183
+ const source = userRecord ?? participantRecord;
184
+ const userId = optionalString(source.id) ?? optionalString(participantRecord.user_id);
185
+ if (!userId) return null;
186
+ const profile: {
187
+ userId: string;
188
+ nickname?: string | null;
189
+ avatarUrl?: string | null;
190
+ bio?: string | null;
191
+ raw?: unknown;
192
+ lastRefreshedAt?: number | null;
193
+ } = { userId, raw: source, lastRefreshedAt: refreshedAt };
194
+ const nickname = optionalString(source.nickname);
195
+ const avatarUrl = optionalString(source.avatar_url);
196
+ const bio = optionalString(source.bio);
197
+ if (nickname !== undefined) profile.nickname = nickname;
198
+ if (avatarUrl !== undefined) profile.avatarUrl = avatarUrl;
199
+ if (bio !== undefined) profile.bio = bio;
200
+ return profile;
201
+ }
202
+
203
+ function buildConversationDetailsCacheInput(params: {
204
+ accountId: string;
205
+ conversation: ConversationDetails;
206
+ metadataVersion?: number;
207
+ }): Parameters<NonNullable<RuntimeConnectionStore["upsertConversationDetails"]>>[0] {
208
+ const { accountId, conversation, metadataVersion } = params;
209
+ const refreshedAt = Date.now();
210
+ const rawConversation = conversation as Record<string, unknown>;
211
+ const participants = Array.isArray(conversation.participants) ? conversation.participants : [];
212
+ return {
213
+ platform: "openclaw",
214
+ accountId,
215
+ conversationId: conversation.id,
216
+ conversationType: conversation.type,
217
+ ...(metadataVersion !== undefined ? { metadataVersion } : {}),
218
+ lastSeenAt: parseApiTimestamp(conversation.updated_at),
219
+ lastRefreshedAt: refreshedAt,
220
+ raw: conversation,
221
+ ...(conversation.type === "group"
222
+ ? {
223
+ groupProfile: {
224
+ title: conversation.title,
225
+ ...(optionalString(rawConversation.description) !== undefined
226
+ ? { description: optionalString(rawConversation.description) }
227
+ : {}),
228
+ ...(metadataVersion !== undefined ? { metadataVersion } : {}),
229
+ raw: conversation,
230
+ lastRefreshedAt: refreshedAt,
231
+ },
232
+ }
233
+ : {}),
234
+ userProfiles: participants
235
+ .map((participant) => conversationUserProfileFromParticipant(participant, refreshedAt))
236
+ .filter((profile): profile is NonNullable<typeof profile> => profile !== null),
237
+ members: participants.map((participant) => ({
238
+ userId: participant.user_id,
239
+ role: participant.role,
240
+ raw: participant,
241
+ lastSeenAt: parseApiTimestamp(participant.joined_at),
242
+ })),
243
+ membersComplete: true,
244
+ };
245
+ }
246
+
110
247
  function withClawChatSessionScope(cfg: OpenClawConfig): OpenClawConfig {
111
248
  return {
112
249
  ...cfg,
@@ -117,6 +254,65 @@ function withClawChatSessionScope(cfg: OpenClawConfig): OpenClawConfig {
117
254
  };
118
255
  }
119
256
 
257
+ function buildActivationBootstrapText(): string {
258
+ return [
259
+ "ClawChat activation bootstrap: You are now connected to this ClawChat direct conversation.",
260
+ "Please do both:",
261
+ "1. Send a brief, friendly greeting to the user in this ClawChat direct conversation.",
262
+ "2. If you have local profile information for yourself, such as display name, bio, or avatar, update the connected ClawChat account profile using the available ClawChat tools. Use `clawchat_update_account_profile` for display name/bio/avatar URL, and use `clawchat_upload_avatar_image` first if the avatar is only available as a local image path. If you do not have local profile information, skip profile updates and only greet the user.",
263
+ "Do not ask the user for profile information just for this bootstrap.",
264
+ ].join("\n");
265
+ }
266
+
267
+ const CLAWCHAT_GROUP_SYSTEM_PROMPT = [
268
+ "When replying in a ClawChat group, treat the conversation as a shared space. Stay concise, respect the group topic, and avoid exposing hidden runtime context.",
269
+ "",
270
+ "**给 Agents**",
271
+ "你是群组里的伙伴。请理解我们在做的事,理解每一位成员。",
272
+ "也请通过群慢慢和每个人建立关系——你是我们的一员。",
273
+ "",
274
+ "**两条底线**",
275
+ "1. 被 @ 时必须回复",
276
+ "2. Agent 之间连续对话不超过 10 轮,除非有人类伙伴明确允许",
277
+ ].join("\n");
278
+
279
+ function buildActivationBootstrapEnvelope(params: {
280
+ account: ResolvedOpenclawClawlingAccount;
281
+ conversationId: string;
282
+ }): Envelope {
283
+ const text = buildActivationBootstrapText();
284
+ const now = Date.now();
285
+ return {
286
+ version: "2",
287
+ event: EVENT.MESSAGE_SEND,
288
+ trace_id: `openclaw-clawchat-bootstrap-${now}`,
289
+ emitted_at: now,
290
+ chat_id: params.conversationId,
291
+ chat_type: "direct",
292
+ to: { id: params.account.userId, type: "direct" },
293
+ sender: {
294
+ id: "clawchat-bootstrap",
295
+ type: "direct",
296
+ nick_name: "ClawChat Activation",
297
+ },
298
+ payload: {
299
+ message_id: `openclaw-clawchat-bootstrap-${params.conversationId}-${now}`,
300
+ message_mode: "normal",
301
+ message: {
302
+ body: { fragments: [{ kind: "text", text }] },
303
+ context: { mentions: [], reply: null },
304
+ streaming: {
305
+ status: "static",
306
+ sequence: 0,
307
+ mutation_policy: "sealed",
308
+ started_at: null,
309
+ completed_at: null,
310
+ },
311
+ },
312
+ },
313
+ };
314
+ }
315
+
120
316
  export interface StartGatewayParams {
121
317
  cfg: OpenClawConfig;
122
318
  account: ResolvedOpenclawClawlingAccount;
@@ -125,29 +321,493 @@ export interface StartGatewayParams {
125
321
  getStatus: () => ChannelAccountSnapshot;
126
322
  log?: Log;
127
323
  /** Test hook only. */
324
+ store?: RuntimeConnectionStore | null;
325
+ /** Test hook only. */
128
326
  transport?: Transport;
129
327
  }
130
328
 
329
+ function resolveConnectionStore(
330
+ params: StartGatewayParams,
331
+ runtime: PluginRuntime,
332
+ ): RuntimeConnectionStore | null {
333
+ if (params.store !== undefined) return params.store;
334
+ if (params.transport) return null;
335
+ try {
336
+ const stateDir = runtime.state?.resolveStateDir?.();
337
+ return getClawChatStore({
338
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
339
+ log: { error: (message) => params.log?.error?.(message) },
340
+ });
341
+ } catch {
342
+ params.log?.error?.("openclaw-clawchat sqlite connection persistence unavailable; continuing.");
343
+ return null;
344
+ }
345
+ }
346
+
131
347
  export async function startOpenclawClawlingGateway(params: StartGatewayParams): Promise<void> {
132
348
  const { cfg, account, abortSignal, setStatus, getStatus, log } = params;
133
349
  // Obtain PluginRuntime from the stored runtime set via setOpenclawClawlingRuntime.
134
350
  const runtime = getOpenclawClawlingRuntime();
135
351
  const accountId = account.accountId;
352
+ const store = resolveConnectionStore(params, runtime);
353
+ let conversationApiClient: ReturnType<typeof createOpenclawClawlingApiClient> | undefined;
354
+ const getConversationApiClient = () => {
355
+ conversationApiClient ??= createOpenclawClawlingApiClient({
356
+ baseUrl: account.baseUrl,
357
+ token: account.token,
358
+ userId: account.userId,
359
+ });
360
+ return conversationApiClient;
361
+ };
136
362
 
363
+ log?.info?.(
364
+ `[${accountId}] openclaw-clawchat 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)"}`,
365
+ );
366
+ let lastHelloFailTraceId = "-";
367
+ let lastHelloFailReason = "";
368
+ let lastConnectTraceId = "-";
369
+ let lastHelloOkDeviceId: string | undefined;
370
+ let lastHelloOkDeliveryMode: string | undefined;
371
+ let currentAttemptStartedAt = 0;
372
+ let authFailureLogged = false;
373
+ let closingForAbort = false;
374
+ let wsReady = false;
375
+ let currentConnectionId: number | null = null;
376
+ let currentConnectionFinished = false;
377
+ const reconnectTracker = createReconnectTracker({
378
+ accountId,
379
+ log: (msg) => log?.info?.(msg),
380
+ maxDelayMs: account.reconnect.maxDelay,
381
+ });
382
+ const wsLogContext = () => {
383
+ const snapshot = reconnectTracker.snapshot();
384
+ return {
385
+ attempt: snapshot.attempt || 1,
386
+ reconnectCount: snapshot.reconnectCount,
387
+ state: snapshot.state === "connected" ? "ready" : snapshot.state,
388
+ };
389
+ };
390
+ const recordConnection = <T>(action: string, fn: () => T): T | undefined => {
391
+ try {
392
+ return fn();
393
+ } catch {
394
+ log?.error?.(`[${accountId}] openclaw-clawchat sqlite ${action} failed; continuing`);
395
+ return undefined;
396
+ }
397
+ };
398
+ const finishCurrentConnection = (input: {
399
+ state: string;
400
+ disconnectedAt?: number;
401
+ closeCode?: number | null;
402
+ closeReason?: string | null;
403
+ error?: string | null;
404
+ }) => {
405
+ if (!store || currentConnectionId == null || currentConnectionFinished) return;
406
+ const connectionId = currentConnectionId;
407
+ recordConnection("finish", () => store.finishConnection(connectionId, input));
408
+ currentConnectionFinished = true;
409
+ currentConnectionId = null;
410
+ };
411
+ const refreshConversationDetails = async (
412
+ conversationId: string,
413
+ options: { metadataVersion?: number; source: string },
414
+ ): Promise<void> => {
415
+ try {
416
+ const data = await getConversationApiClient().getConversation(conversationId);
417
+ if (!store?.upsertConversationDetails) return;
418
+ recordConnection("conversation details upsert", () =>
419
+ store.upsertConversationDetails?.(buildConversationDetailsCacheInput({
420
+ accountId,
421
+ conversation: data.conversation,
422
+ ...(options.metadataVersion !== undefined ? { metadataVersion: options.metadataVersion } : {}),
423
+ })),
424
+ );
425
+ } catch (err) {
426
+ if (isConversationNotFoundError(err)) {
427
+ if (store?.deleteConversationCache) {
428
+ recordConnection("conversation cache delete", () =>
429
+ store.deleteConversationCache?.({
430
+ platform: "openclaw",
431
+ accountId,
432
+ conversationId,
433
+ }),
434
+ );
435
+ }
436
+ return;
437
+ }
438
+ log?.error?.(
439
+ `[${accountId}] openclaw-clawchat metadata refresh failed source=${options.source} conversation=${conversationId}: ${err instanceof Error ? err.message : String(err)}`,
440
+ );
441
+ }
442
+ };
443
+ const refreshConversationCacheAfterReady = async (): Promise<void> => {
444
+ if (!store) return;
445
+ const ids: string[] = [];
446
+ const seen = new Set<string>();
447
+ const addId = (id: unknown) => {
448
+ if (typeof id !== "string" || !id || seen.has(id)) return;
449
+ seen.add(id);
450
+ ids.push(id);
451
+ };
452
+
453
+ const activation = store.getActivationConversation
454
+ ? recordConnection("activation conversation read", () =>
455
+ store.getActivationConversation?.({ platform: "openclaw", accountId }),
456
+ )
457
+ : null;
458
+ addId(activation?.conversationId);
459
+
460
+ const cachedIds = store.listCachedConversationIds
461
+ ? recordConnection("cached conversation ids read", () =>
462
+ store.listCachedConversationIds?.({ platform: "openclaw", accountId, limit: 20 }),
463
+ ) ?? []
464
+ : [];
465
+ for (const id of cachedIds.slice(0, 20)) addId(id);
466
+
467
+ for (const id of ids) {
468
+ await refreshConversationDetails(id, { source: "reconnect" });
469
+ }
470
+ };
471
+ const handleMetadataInvalidation = async (env: Envelope): Promise<void> => {
472
+ const conversationId = typeof env.chat_id === "string" && env.chat_id.trim()
473
+ ? env.chat_id
474
+ : "";
475
+ if (!conversationId) {
476
+ log?.info?.(`[${accountId}] openclaw-clawchat metadata invalidation missing chat_id trace=${env.trace_id}`);
477
+ return;
478
+ }
479
+
480
+ const version = metadataVersionFromEnvelope(env);
481
+ if (version !== undefined && store?.getCachedConversation) {
482
+ const cached = recordConnection("conversation cache read", () =>
483
+ store.getCachedConversation?.({ platform: "openclaw", accountId, conversationId }),
484
+ );
485
+ if (cached?.metadataVersion != null && version <= cached.metadataVersion) {
486
+ log?.info?.(
487
+ `[${accountId}] openclaw-clawchat metadata invalidation stale conversation=${conversationId} version=${version} cached=${cached.metadataVersion}`,
488
+ );
489
+ return;
490
+ }
491
+ }
492
+
493
+ await refreshConversationDetails(conversationId, {
494
+ source: "metadata_invalidation",
495
+ ...(version !== undefined ? { metadataVersion: version } : {}),
496
+ });
497
+ };
498
+ const upsertMessagePathConversation = (env: Envelope): void => {
499
+ if (!store?.upsertConversationSummary) return;
500
+ if (env.event !== "message.send" && env.event !== "message.reply" && env.event !== "message.done") return;
501
+ const conversationId = typeof env.chat_id === "string" && env.chat_id.trim()
502
+ ? env.chat_id
503
+ : "";
504
+ if (!conversationId) return;
505
+ const conversationType = env.chat_type === "direct" || env.chat_type === "group"
506
+ ? env.chat_type
507
+ : undefined;
508
+ recordConnection("conversation summary upsert", () =>
509
+ store.upsertConversationSummary?.({
510
+ platform: "openclaw",
511
+ accountId,
512
+ conversationId,
513
+ ...(conversationType ? { conversationType } : {}),
514
+ lastSeenAt: env.emitted_at,
515
+ }),
516
+ );
517
+ };
137
518
  const client = createOpenclawClawlingClient(account, {
138
519
  ...(params.transport ? { transport: params.transport } : {}),
520
+ wsLifecycle: {
521
+ onConnectFrameSent: (env) => {
522
+ lastConnectTraceId = typeof env.trace_id === "string" ? env.trace_id : "-";
523
+ if (store && currentConnectionId != null) {
524
+ const connectionId = currentConnectionId;
525
+ recordConnection("connect-sent", () => store.markConnectSent(connectionId));
526
+ }
527
+ const deviceId =
528
+ typeof env.payload?.device_id === "string" ? env.payload.device_id : "-";
529
+ const current = wsLogContext();
530
+ log?.info?.(
531
+ formatWsLog({
532
+ event: "connect_sent",
533
+ accountId,
534
+ attempt: current.attempt,
535
+ reconnectCount: current.reconnectCount,
536
+ state: "handshaking",
537
+ action: "await_hello",
538
+ fields: [
539
+ ["trace_id", lastConnectTraceId],
540
+ ["device_id", deviceId],
541
+ ],
542
+ }),
543
+ );
544
+ },
545
+ },
139
546
  });
547
+ log?.info?.(`[${accountId}] openclaw-clawchat runtime client created`);
548
+
549
+ setAlignedOutboundLogContext(client, wsLogContext);
550
+ client.on("hello:ok", (env: Envelope) => {
551
+ const payload = env.payload && typeof env.payload === "object"
552
+ ? env.payload as { device_id?: unknown; delivery_mode?: unknown }
553
+ : {};
554
+ lastHelloOkDeviceId = typeof payload.device_id === "string" ? payload.device_id : undefined;
555
+ lastHelloOkDeliveryMode = typeof payload.delivery_mode === "string" ? payload.delivery_mode : undefined;
556
+ });
557
+ const protocolControlLogger = createProtocolControlHandler({
558
+ accountId,
559
+ log: (msg) => log?.info?.(msg),
560
+ send: () => {},
561
+ context: wsLogContext,
562
+ });
563
+ const logAuthFailure = (reason: string) => {
564
+ if (authFailureLogged) return;
565
+ authFailureLogged = true;
566
+ const current = wsLogContext();
567
+ log?.error?.(
568
+ formatWsLog({
569
+ event: "auth_failed",
570
+ accountId,
571
+ attempt: current.attempt,
572
+ reconnectCount: current.reconnectCount,
573
+ state: "auth_failed",
574
+ action: "stop_reconnect",
575
+ fields: [
576
+ ["trace_id", lastHelloFailTraceId],
577
+ ["reason", reason || lastHelloFailReason],
578
+ ],
579
+ }),
580
+ );
581
+ };
582
+ let dispatchActivationBootstrap: () => Promise<void> = async () => {};
140
583
 
141
584
  client.on("state", ({ from, to }) => {
142
585
  log?.info?.(`[${accountId}] openclaw-clawchat state ${from} -> ${to}`);
586
+ wsReady = to === "connected";
587
+ if (to === "connecting") {
588
+ reconnectTracker.connectStart();
589
+ currentAttemptStartedAt = Date.now();
590
+ const current = wsLogContext();
591
+ if (store) {
592
+ recordConnection("start", () => {
593
+ currentConnectionId = store.startConnection({
594
+ platform: "openclaw",
595
+ accountId,
596
+ attempt: current.attempt,
597
+ reconnectCount: current.reconnectCount,
598
+ connectStartedAt: currentAttemptStartedAt,
599
+ });
600
+ currentConnectionFinished = false;
601
+ });
602
+ }
603
+ log?.info?.(
604
+ formatWsLog({
605
+ event: "connect_start",
606
+ accountId,
607
+ attempt: current.attempt,
608
+ reconnectCount: current.reconnectCount,
609
+ state: "connecting",
610
+ action: "connect",
611
+ fields: [
612
+ ["url", account.websocketUrl],
613
+ ["queue_size", getAlignedOutboundQueueSize(client)],
614
+ ],
615
+ }),
616
+ );
617
+ } else if (to === "connected") {
618
+ const elapsedMs = Math.max(0, Date.now() - currentAttemptStartedAt);
619
+ const queueSize = getAlignedOutboundQueueSize(client);
620
+ reconnectTracker.markReady();
621
+ const current = wsLogContext();
622
+ if (store && currentConnectionId != null) {
623
+ const connectionId = currentConnectionId;
624
+ recordConnection("ready", () => {
625
+ if (lastHelloOkDeviceId === undefined && lastHelloOkDeliveryMode === undefined) {
626
+ store.markConnectionReady(connectionId);
627
+ return;
628
+ }
629
+ store.markConnectionReady(connectionId, {
630
+ ...(lastHelloOkDeviceId !== undefined ? { resolvedDeviceId: lastHelloOkDeviceId } : {}),
631
+ ...(lastHelloOkDeliveryMode !== undefined ? { deliveryMode: lastHelloOkDeliveryMode } : {}),
632
+ });
633
+ });
634
+ }
635
+ log?.info?.(
636
+ formatWsLog({
637
+ event: "handshake_ok",
638
+ accountId,
639
+ attempt: current.attempt,
640
+ reconnectCount: current.reconnectCount,
641
+ state: "ready",
642
+ action: "flush_queue",
643
+ fields: [
644
+ ["trace_id", lastConnectTraceId],
645
+ ["elapsed_ms", elapsedMs],
646
+ ["queue_size", queueSize],
647
+ ],
648
+ }),
649
+ );
650
+ try {
651
+ flushAlignedOutboundQueue(client);
652
+ } catch {
653
+ // The queue keeps the failed frame at the head and will retry after the next reconnect.
654
+ }
655
+ void refreshConversationCacheAfterReady();
656
+ void dispatchActivationBootstrap();
657
+ } else if (to === "disconnected") {
658
+ reconnectTracker.markClosed();
659
+ }
143
660
  const next = { ...getStatus(), ...mapClawlingStateToStatus(to as ClawlingState) };
144
661
  setStatus(next);
145
662
  });
146
663
 
664
+ client.on("close", ({ code, reason }: { code?: number; reason?: string }) => {
665
+ if (closingForAbort || (code === 1000 && reason === "client close")) return;
666
+ finishCurrentConnection({
667
+ state: "disconnected",
668
+ closeCode: code ?? null,
669
+ closeReason: reason ?? null,
670
+ });
671
+ const current = wsLogContext();
672
+ log?.info?.(
673
+ formatWsLog({
674
+ event: "connection_lost",
675
+ accountId,
676
+ attempt: current.attempt,
677
+ reconnectCount: current.reconnectCount,
678
+ state: current.state,
679
+ action: "reconnect",
680
+ fields: [
681
+ ["code", code],
682
+ ["reason", reason],
683
+ ],
684
+ }),
685
+ );
686
+ });
687
+
688
+ client.on("reconnect:scheduled", ({ delay }: { delay?: number }) => {
689
+ reconnectTracker.scheduleReconnect("connection_lost", {
690
+ delayMs: delay,
691
+ maxDelayMs: account.reconnect.maxDelay,
692
+ });
693
+ });
694
+
695
+ client.on("raw", (env: Envelope) => {
696
+ if (env.event === "connect.challenge") {
697
+ const payload = env.payload as { nonce?: unknown } | undefined;
698
+ const current = wsLogContext();
699
+ log?.info?.(
700
+ formatWsLog({
701
+ event: "challenge_received",
702
+ accountId,
703
+ attempt: current.attempt,
704
+ reconnectCount: current.reconnectCount,
705
+ state: "handshaking",
706
+ action: "send_connect",
707
+ fields: [
708
+ ["challenge_trace_id", env.trace_id],
709
+ ["has_nonce", typeof payload?.nonce === "string" && payload.nonce.length > 0],
710
+ ],
711
+ }),
712
+ );
713
+ }
714
+ if (env.event === "ping" || env.event === "pong") {
715
+ protocolControlLogger.handleInbound(env);
716
+ }
717
+ if (wsReady) {
718
+ upsertMessagePathConversation(env);
719
+ const sender = env.sender as { id?: unknown } | undefined;
720
+ const senderId = typeof sender?.id === "string" ? sender.id : "-";
721
+ if (env.event === "message.send" || env.event === "message.reply" || env.event === "message.done") {
722
+ const current = wsLogContext();
723
+ log?.info?.(
724
+ formatWsLog({
725
+ event: "inbound_dispatch",
726
+ accountId,
727
+ attempt: current.attempt,
728
+ reconnectCount: current.reconnectCount,
729
+ state: "ready",
730
+ action: "dispatch",
731
+ fields: [
732
+ ["event_name", env.event],
733
+ ["trace_id", env.trace_id],
734
+ ["chat_id", env.chat_id],
735
+ ["sender_id", senderId],
736
+ ],
737
+ }),
738
+ );
739
+ } else if (env.event === "message.ack") {
740
+ const current = wsLogContext();
741
+ log?.info?.(
742
+ formatWsLog({
743
+ event: "inbound_control",
744
+ accountId,
745
+ attempt: current.attempt,
746
+ reconnectCount: current.reconnectCount,
747
+ state: "ready",
748
+ action: "ack",
749
+ fields: [
750
+ ["event_name", env.event],
751
+ ["trace_id", env.trace_id],
752
+ ],
753
+ }),
754
+ );
755
+ } else if (
756
+ env.event === "offline.batch" ||
757
+ env.event === "offline.ack" ||
758
+ env.event === "offline.done"
759
+ ) {
760
+ const current = wsLogContext();
761
+ log?.info?.(
762
+ formatWsLog({
763
+ event: "inbound_control",
764
+ accountId,
765
+ attempt: current.attempt,
766
+ reconnectCount: current.reconnectCount,
767
+ state: "ready",
768
+ action: "ignore_legacy",
769
+ fields: [
770
+ ["event_name", env.event],
771
+ ["trace_id", env.trace_id],
772
+ ],
773
+ }),
774
+ );
775
+ } else if (env.event !== "ping" && env.event !== "pong") {
776
+ const current = wsLogContext();
777
+ log?.info?.(
778
+ formatWsLog({
779
+ event: "inbound_ignored",
780
+ accountId,
781
+ attempt: current.attempt,
782
+ reconnectCount: current.reconnectCount,
783
+ state: "ready",
784
+ action: "ignore",
785
+ fields: [
786
+ ["event_name", env.event],
787
+ ["trace_id", env.trace_id],
788
+ ],
789
+ }),
790
+ );
791
+ }
792
+ }
793
+ if (env.event !== "hello-fail") return;
794
+ lastHelloFailTraceId = env.trace_id;
795
+ const payload = env.payload as { reason?: unknown } | undefined;
796
+ lastHelloFailReason = typeof payload?.reason === "string" ? payload.reason : "";
797
+ });
798
+
799
+ client.on("metadata:invalidated", (env: Envelope) => {
800
+ void handleMetadataInvalidation(env);
801
+ });
802
+
147
803
  client.on("error", (err: unknown) => {
148
804
  const classified = classifyClawlingClientError(err);
149
805
  if (classified.kind === "auth") {
150
- log?.error?.(`[${accountId}] openclaw-clawchat auth failed: ${classified.message}`);
806
+ finishCurrentConnection({
807
+ state: "auth_failed",
808
+ error: lastHelloFailReason || classified.message,
809
+ });
810
+ logAuthFailure(classified.message);
151
811
  setStatus({
152
812
  ...getStatus(),
153
813
  connected: false,
@@ -156,6 +816,22 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
156
816
  lastError: classified.message,
157
817
  });
158
818
  } else if (classified.kind === "transport") {
819
+ finishCurrentConnection({ state: "transport_error", error: classified.message });
820
+ const current = wsLogContext();
821
+ log?.info?.(
822
+ formatWsLog({
823
+ event: "connection_lost",
824
+ accountId,
825
+ attempt: current.attempt,
826
+ reconnectCount: current.reconnectCount,
827
+ state: current.state,
828
+ action: "reconnect",
829
+ fields: [
830
+ ["code", "-"],
831
+ ["reason", classified.message],
832
+ ],
833
+ }),
834
+ );
159
835
  log?.info?.(
160
836
  `[${accountId}] openclaw-clawchat transport error (reconnecting): ${classified.message}`,
161
837
  );
@@ -167,169 +843,270 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
167
843
  } else if (classified.kind === "state") {
168
844
  log?.info?.(`[${accountId}] openclaw-clawchat state error: ${classified.message}`);
169
845
  } else {
170
- log?.error?.(`[${accountId}] openclaw-clawchat sdk error: ${classified.message}`);
846
+ log?.error?.(`[${accountId}] openclaw-clawchat client error: ${classified.message}`);
171
847
  }
172
848
  });
173
849
 
174
- client.on("message", async (env: Envelope) => {
175
- try {
176
- await dispatchOpenclawClawlingInbound({
177
- envelope: env as Envelope<DownlinkMessageSendPayload>,
178
- cfg,
179
- runtime,
180
- account,
181
- log,
182
- ingest: async (turn) => {
183
- const rt = runtime.channel;
184
- const storePath = rt.session.resolveStorePath(cfg.session?.store);
185
- const routeCfg = withClawChatSessionScope(cfg);
186
- const route = rt.routing.resolveAgentRoute({
187
- cfg: routeCfg,
188
- channel: CHANNEL_ID,
850
+ type IngestTurnResult = "submitted" | "skipped" | "failed";
851
+
852
+ const ingestTurn = async (turn: IngestTurnParams): Promise<IngestTurnResult> => {
853
+ const env = turn.envelope;
854
+ if (store?.claimMessageOnce) {
855
+ const claimed = recordConnection("message claim", () =>
856
+ store.claimMessageOnce?.({
857
+ platform: "openclaw",
189
858
  accountId,
190
- peer: turn.peer,
191
- });
192
- const body = rt.reply.formatAgentEnvelope({
193
- channel: "Clawling Chat",
194
- from: formatConversationSubject(turn.peer),
195
- body: turn.rawBody,
196
- timestamp: turn.timestamp,
197
- ...rt.reply.resolveEnvelopeFormatOptions(cfg),
198
- });
199
- const ctxPayload = rt.reply.finalizeInboundContext({
200
- Body: body,
201
- BodyForAgent: turn.rawBody,
202
- RawBody: turn.rawBody,
203
- CommandBody: turn.rawBody,
204
- // Clawling v2 routes by chat_id. `senderId` is still preserved as
205
- // structured metadata, but the conversation target must be based on
206
- // `peer.id` so follow-up sends address the active chat, not merely
207
- // the human sender identity.
208
- From: `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`,
209
- To: `${CHANNEL_ID}:${account.userId}`,
210
- SessionKey: route.sessionKey,
211
- AccountId: route.accountId ?? accountId,
212
- ChatType: turn.peer.kind,
213
- ConversationLabel: formatConversationSubject(turn.peer),
214
- SenderId: turn.senderId,
215
- Provider: CHANNEL_ID,
216
- Surface: CHANNEL_ID,
217
- MessageSid: turn.messageId,
218
- MessageSidFull: turn.messageId,
219
- Timestamp: turn.timestamp,
220
- OriginatingChannel: CHANNEL_ID,
221
- OriginatingTo: `${CHANNEL_ID}:${account.userId}`,
222
- });
223
- // Fetch any inbound media attachments and populate MediaPath/MediaPaths in context.
224
- const inboundPaths =
225
- turn.mediaItems.length > 0
226
- ? await fetchInboundMedia(turn.mediaItems, {
227
- runtime,
228
- log,
229
- maxBytes: 20 * 1024 * 1024,
230
- })
231
- : [];
232
- if (inboundPaths.length > 0) {
233
- (ctxPayload as Record<string, unknown>).MediaPath = inboundPaths[0];
234
- (ctxPayload as Record<string, unknown>).MediaPaths = inboundPaths;
235
- }
859
+ kind: "message",
860
+ direction: "inbound",
861
+ eventType: String(env.event),
862
+ traceId: turn.traceId,
863
+ chatId: turn.peer.id,
864
+ messageId: turn.messageId,
865
+ text: turn.rawBody,
866
+ raw: env,
867
+ }),
868
+ );
869
+ if (claimed === false) {
870
+ log?.info?.(
871
+ `[${accountId}] openclaw-clawchat skip duplicate stored msg=${turn.messageId}`,
872
+ );
873
+ return "skipped";
874
+ }
875
+ }
876
+ const rt = runtime.channel;
877
+ const storePath = rt.session.resolveStorePath(cfg.session?.store);
878
+ const routeCfg = withClawChatSessionScope(cfg);
879
+ const route = rt.routing.resolveAgentRoute({
880
+ cfg: routeCfg,
881
+ channel: CHANNEL_ID,
882
+ accountId,
883
+ peer: turn.peer,
884
+ });
885
+ const body = rt.reply.formatAgentEnvelope({
886
+ channel: "Clawling Chat",
887
+ from: formatConversationSubject(turn.peer),
888
+ body: turn.rawBody,
889
+ timestamp: turn.timestamp,
890
+ ...rt.reply.resolveEnvelopeFormatOptions(cfg),
891
+ });
892
+ const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
893
+ const ctxPayload = rt.turn.buildContext({
894
+ channel: CHANNEL_ID,
895
+ accountId: route.accountId ?? accountId,
896
+ provider: CHANNEL_ID,
897
+ surface: CHANNEL_ID,
898
+ messageId: turn.messageId,
899
+ messageIdFull: turn.messageId,
900
+ timestamp: turn.timestamp,
901
+ from: conversationTarget,
902
+ sender: {
903
+ id: turn.senderId,
904
+ name: turn.senderNickName || turn.senderId,
905
+ displayLabel: turn.senderNickName || turn.senderId,
906
+ },
907
+ conversation: {
908
+ kind: turn.peer.kind,
909
+ id: turn.peer.id,
910
+ label: formatConversationSubject(turn.peer),
911
+ routePeer: turn.peer,
912
+ },
913
+ route: {
914
+ agentId: route.agentId,
915
+ accountId: route.accountId ?? accountId,
916
+ routeSessionKey: route.sessionKey,
917
+ },
918
+ reply: {
919
+ to: `${CHANNEL_ID}:${account.userId}`,
920
+ originatingTo: conversationTarget,
921
+ },
922
+ message: {
923
+ body,
924
+ rawBody: turn.rawBody,
925
+ bodyForAgent: turn.rawBody,
926
+ commandBody: turn.rawBody,
927
+ envelopeFrom: conversationTarget,
928
+ },
929
+ access: {
930
+ mentions: {
931
+ canDetectMention: true,
932
+ wasMentioned: turn.wasMentioned,
933
+ },
934
+ },
935
+ ...(turn.peer.kind === "group"
936
+ ? { supplemental: { groupSystemPrompt: CLAWCHAT_GROUP_SYSTEM_PROMPT } }
937
+ : {}),
938
+ });
939
+ // Fetch any inbound media attachments and populate MediaPath/MediaPaths in context.
940
+ const inboundPaths =
941
+ turn.mediaItems.length > 0
942
+ ? await fetchInboundMedia(turn.mediaItems, {
943
+ runtime,
944
+ log,
945
+ maxBytes: 20 * 1024 * 1024,
946
+ })
947
+ : [];
948
+ if (inboundPaths.length > 0) {
949
+ (ctxPayload as Record<string, unknown>).MediaPath = inboundPaths[0];
950
+ (ctxPayload as Record<string, unknown>).MediaPaths = inboundPaths;
951
+ }
236
952
 
237
- try {
238
- await rt.session.recordInboundSession({
239
- storePath,
240
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
241
- ctx: ctxPayload,
242
- onRecordError: (err) => {
243
- log?.error?.(
244
- `[${accountId}] openclaw-clawchat failed to record inbound session: ${String(err)}`,
245
- );
246
- },
247
- });
248
- } catch (err) {
953
+ try {
954
+ await rt.session.recordInboundSession({
955
+ storePath,
956
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
957
+ ctx: ctxPayload,
958
+ onRecordError: (err) => {
249
959
  log?.error?.(
250
960
  `[${accountId}] openclaw-clawchat failed to record inbound session: ${String(err)}`,
251
961
  );
252
- }
962
+ },
963
+ });
964
+ } catch (err) {
965
+ log?.error?.(
966
+ `[${accountId}] openclaw-clawchat failed to record inbound session: ${String(err)}`,
967
+ );
968
+ }
253
969
 
254
- const replyCtx = turn.replyCtx;
255
- const { dispatcher, replyOptions, markDispatchIdle } =
256
- createOpenclawClawlingReplyDispatcher({
257
- cfg,
258
- runtime,
259
- account,
260
- client,
261
- target: { chatId: turn.peer.id, chatType: turn.peer.kind },
262
- ...(replyCtx ? { replyCtx } : {}),
263
- inboundMessageId: turn.messageId,
264
- inboundForFinalReply: {
265
- chatId: turn.peer.id,
266
- senderId: turn.senderId,
267
- senderNickName: turn.senderNickName || turn.senderId,
268
- bodyText: turn.rawBody,
269
- },
270
- log,
271
- });
970
+ const replyCtx = turn.replyCtx;
971
+ const { dispatcher, replyOptions, markDispatchIdle } =
972
+ createOpenclawClawlingReplyDispatcher({
973
+ cfg,
974
+ runtime,
975
+ account,
976
+ client,
977
+ target: { chatId: turn.peer.id, chatType: turn.peer.kind },
978
+ ...(replyCtx ? { replyCtx } : {}),
979
+ inboundMessageId: turn.messageId,
980
+ inboundForFinalReply: {
981
+ chatId: turn.peer.id,
982
+ senderId: turn.senderId,
983
+ senderNickName: turn.senderNickName || turn.senderId,
984
+ bodyText: turn.rawBody,
985
+ },
986
+ store: store
987
+ ? {
988
+ insertMessage: (input) => store.insertMessage?.(input) ?? null,
989
+ claimMessageOnce: (input) => store.claimMessageOnce?.(input) ?? null,
990
+ updateMessageByIdentity: (input) => store.updateMessageByIdentity?.(input),
991
+ }
992
+ : null,
993
+ log,
994
+ });
272
995
 
273
- const agentsConfigured = Object.keys((cfg as { agents?: Record<string, unknown> }).agents ?? {});
996
+ const agentsConfigured = Object.keys((cfg as { agents?: Record<string, unknown> }).agents ?? {});
997
+ log?.info?.(
998
+ `[${accountId}] openclaw-clawchat dispatching reply msg=${turn.messageId} session=${ctxPayload.SessionKey ?? route.sessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
999
+ );
1000
+
1001
+ try {
1002
+ const dispatchResult = await rt.reply.withReplyDispatcher({
1003
+ dispatcher,
1004
+ onSettled: () => markDispatchIdle(),
1005
+ run: () => rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
1006
+ });
1007
+ const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
1008
+ const queuedFinal = Boolean(
1009
+ (dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
1010
+ );
1011
+ log?.info?.(
1012
+ `[${accountId}] openclaw-clawchat dispatch complete msg=${turn.messageId} queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)}`,
1013
+ );
1014
+ if (!queuedFinal && Object.values(counts).every((n) => !n)) {
274
1015
  log?.info?.(
275
- `[${accountId}] openclaw-clawchat dispatching reply msg=${turn.messageId} session=${ctxPayload.SessionKey ?? route.sessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
1016
+ `[${accountId}] openclaw-clawchat NO reply was produced (no final / block / tool dispatched). ` +
1017
+ `Likely causes: agent='${route.agentId}' not configured in cfg.agents (configured: [${agentsConfigured.join(",")}]); ` +
1018
+ `or send-policy denied; or a plugin claimed the binding.`,
276
1019
  );
277
-
278
- try {
279
- const dispatchResult = await rt.reply.withReplyDispatcher({
280
- dispatcher,
281
- onSettled: () => markDispatchIdle(),
282
- run: () =>
283
- rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
284
- });
285
- const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
286
- const queuedFinal = Boolean(
287
- (dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
288
- );
289
- log?.info?.(
290
- `[${accountId}] openclaw-clawchat dispatch complete msg=${turn.messageId} queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)}`,
291
- );
292
- if (!queuedFinal && Object.values(counts).every((n) => !n)) {
293
- log?.info?.(
294
- `[${accountId}] openclaw-clawchat NO reply was produced (no final / block / tool dispatched). ` +
295
- `Likely causes: agent='${route.agentId}' not configured in cfg.agents (configured: [${agentsConfigured.join(",")}]); ` +
296
- `or send-policy denied; or a plugin claimed the binding.`,
297
- );
298
- }
299
- } catch (err) {
300
- log?.error?.(
301
- `[${accountId}] openclaw-clawchat dispatch failed msg=${turn.messageId}: ${String(err)}`,
302
- );
303
- await sendOpenclawClawlingText({
304
- client,
305
- account: turn.account,
306
- to: {
307
- chatId: turn.peer.id,
308
- chatType: turn.peer.kind === "group" ? "group" : "direct",
309
- },
310
- text: String(err),
311
- ...(turn.replyCtx ? { replyCtx: turn.replyCtx } : {}),
312
- });
313
- }
314
- },
315
- })
1020
+ }
1021
+ return "submitted";
1022
+ } catch (err) {
1023
+ log?.error?.(
1024
+ `[${accountId}] openclaw-clawchat dispatch failed msg=${turn.messageId}: ${String(err)}`,
1025
+ );
1026
+ return "failed";
1027
+ }
1028
+ };
1029
+
1030
+ const handleInboundEnvelope = async (env: Envelope): Promise<IngestTurnResult | undefined> => {
1031
+ let ingestResult: IngestTurnResult | undefined;
1032
+ try {
1033
+ await dispatchOpenclawClawlingInbound({
1034
+ envelope: env as Envelope<unknown>,
1035
+ cfg,
1036
+ runtime,
1037
+ account,
1038
+ log,
1039
+ ingest: async (turn) => {
1040
+ ingestResult = await ingestTurn(turn);
1041
+ },
1042
+ });
316
1043
  } catch (err) {
317
1044
  log?.error?.(
318
1045
  `[${accountId}] openclaw-clawchat message handler error: ${err instanceof Error ? err.stack || err.message : String(err)}`,
319
1046
  );
1047
+ return "failed";
320
1048
  }
1049
+ return ingestResult;
1050
+ };
1051
+
1052
+ dispatchActivationBootstrap = async (): Promise<void> => {
1053
+ if (!store?.claimPendingActivationBootstrap || !store.markActivationBootstrapSent) return;
1054
+ let bootstrap: { conversationId: string } | null | undefined;
1055
+ const releaseBootstrap = () => {
1056
+ if (!bootstrap || !store.releaseActivationBootstrapClaim) return;
1057
+ const claimedBootstrap = bootstrap;
1058
+ recordConnection("activation bootstrap release", () =>
1059
+ store.releaseActivationBootstrapClaim?.({
1060
+ platform: "openclaw",
1061
+ accountId,
1062
+ conversationId: claimedBootstrap.conversationId,
1063
+ }),
1064
+ );
1065
+ };
1066
+ try {
1067
+ bootstrap = recordConnection("activation bootstrap claim", () =>
1068
+ store.claimPendingActivationBootstrap?.({ platform: "openclaw", accountId }),
1069
+ );
1070
+ if (!bootstrap) return;
1071
+ const claimedBootstrap = bootstrap;
1072
+ const result = await handleInboundEnvelope(
1073
+ buildActivationBootstrapEnvelope({ account, conversationId: claimedBootstrap.conversationId }),
1074
+ );
1075
+ if (result !== "submitted") {
1076
+ releaseBootstrap();
1077
+ return;
1078
+ }
1079
+ recordConnection("activation bootstrap sent", () =>
1080
+ store.markActivationBootstrapSent?.({
1081
+ platform: "openclaw",
1082
+ accountId,
1083
+ conversationId: claimedBootstrap.conversationId,
1084
+ }),
1085
+ );
1086
+ } catch (err) {
1087
+ releaseBootstrap();
1088
+ log?.error?.(
1089
+ `[${accountId}] openclaw-clawchat activation bootstrap failed: ${err instanceof Error ? err.message : String(err)}`,
1090
+ );
1091
+ }
1092
+ };
1093
+
1094
+ client.on("message", (env: Envelope) => {
1095
+ void handleInboundEnvelope(env);
321
1096
  });
322
1097
 
323
1098
  // `client.connect()` resolves on `hello-ok` or rejects on `hello-fail`
324
1099
  // (auth). Transport failures (server unreachable, DNS error, etc.) do
325
- // NOT reject this promise — the SDK catches them internally and drives
326
- // its own exponential-backoff reconnect loop (`initialDelay * 2^attempt`
1100
+ // NOT reject this promise — the local client handles them internally and
1101
+ // drives its own exponential-backoff reconnect loop (`initialDelay * 2^attempt`
327
1102
  // capped at `maxDelay`, with jitter). So we never throw here on anything
328
1103
  // other than auth failure; on auth we tear the account down cleanly and
329
1104
  // return without throwing (which would make the gateway supervisor
330
1105
  // restart us immediately in a tight loop).
331
1106
  try {
1107
+ log?.info?.(`[${accountId}] openclaw-clawchat runtime calling client.connect()`);
332
1108
  await client.connect();
1109
+ log?.info?.(`[${accountId}] openclaw-clawchat runtime client.connect() resolved`);
333
1110
  } catch (err) {
334
1111
  const classified = classifyClawlingClientError(err);
335
1112
  setStatus({
@@ -339,12 +1116,21 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
339
1116
  running: false,
340
1117
  lastError: classified.message,
341
1118
  });
1119
+ if (classified.kind === "auth") {
1120
+ finishCurrentConnection({
1121
+ state: "auth_failed",
1122
+ error: lastHelloFailReason || classified.message,
1123
+ });
1124
+ logAuthFailure(classified.message);
1125
+ return;
1126
+ }
342
1127
  log?.error?.(
343
1128
  `[${accountId}] openclaw-clawchat connect failed (${classified.kind}): ${classified.message}`,
344
1129
  );
345
1130
  return;
346
1131
  }
347
1132
  activeClients.set(accountId, client);
1133
+ log?.info?.(`[${accountId}] openclaw-clawchat runtime active client registered`);
348
1134
  setStatus({
349
1135
  ...getStatus(),
350
1136
  connected: true,
@@ -354,7 +1140,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
354
1140
  log?.info?.(`[${accountId}] openclaw-clawchat connected`);
355
1141
 
356
1142
  await waitUntilAbort(abortSignal, async () => {
1143
+ log?.info?.(`[${accountId}] openclaw-clawchat runtime abort received; closing client`);
357
1144
  activeClients.delete(accountId);
1145
+ closingForAbort = true;
1146
+ finishCurrentConnection({
1147
+ state: "disconnected",
1148
+ closeCode: 1000,
1149
+ closeReason: "client close",
1150
+ });
358
1151
  client.close();
359
1152
  setStatus({
360
1153
  ...getStatus(),