agent-relay 3.1.10 → 3.1.12

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 (93) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +2 -2
  6. package/dist/src/cli/bootstrap.d.ts.map +1 -1
  7. package/dist/src/cli/bootstrap.js +2 -0
  8. package/dist/src/cli/bootstrap.js.map +1 -1
  9. package/dist/src/cli/commands/connect.d.ts +3 -0
  10. package/dist/src/cli/commands/connect.d.ts.map +1 -0
  11. package/dist/src/cli/commands/connect.js +18 -0
  12. package/dist/src/cli/commands/connect.js.map +1 -0
  13. package/dist/src/cli/lib/auth-ssh.d.ts.map +1 -1
  14. package/dist/src/cli/lib/auth-ssh.js +22 -270
  15. package/dist/src/cli/lib/auth-ssh.js.map +1 -1
  16. package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
  17. package/dist/src/cli/lib/broker-lifecycle.js +33 -0
  18. package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
  19. package/dist/src/cli/lib/connect-daytona.d.ts +15 -0
  20. package/dist/src/cli/lib/connect-daytona.d.ts.map +1 -0
  21. package/dist/src/cli/lib/connect-daytona.js +217 -0
  22. package/dist/src/cli/lib/connect-daytona.js.map +1 -0
  23. package/dist/src/cli/lib/ssh-interactive.d.ts +41 -0
  24. package/dist/src/cli/lib/ssh-interactive.d.ts.map +1 -0
  25. package/dist/src/cli/lib/ssh-interactive.js +320 -0
  26. package/dist/src/cli/lib/ssh-interactive.js.map +1 -0
  27. package/install.sh +2 -1
  28. package/package.json +13 -10
  29. package/packages/acp-bridge/package.json +2 -2
  30. package/packages/config/dist/cli-auth-config.d.ts +2 -0
  31. package/packages/config/dist/cli-auth-config.d.ts.map +1 -1
  32. package/packages/config/dist/cli-auth-config.js +1 -0
  33. package/packages/config/dist/cli-auth-config.js.map +1 -1
  34. package/packages/config/package.json +1 -1
  35. package/packages/config/src/cli-auth-config.ts +3 -0
  36. package/packages/hooks/package.json +4 -4
  37. package/packages/memory/package.json +2 -2
  38. package/packages/openclaw/dist/__tests__/gateway-control.test.js +13 -13
  39. package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -1
  40. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.d.ts +2 -0
  41. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.d.ts.map +1 -0
  42. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.js +369 -0
  43. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.js.map +1 -0
  44. package/packages/openclaw/dist/__tests__/gateway-threads.test.js +57 -16
  45. package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -1
  46. package/packages/openclaw/dist/__tests__/ws-client.test.js +2 -0
  47. package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -1
  48. package/packages/openclaw/dist/config.d.ts +2 -0
  49. package/packages/openclaw/dist/config.d.ts.map +1 -1
  50. package/packages/openclaw/dist/config.js +99 -12
  51. package/packages/openclaw/dist/config.js.map +1 -1
  52. package/packages/openclaw/dist/gateway.d.ts +56 -2
  53. package/packages/openclaw/dist/gateway.d.ts.map +1 -1
  54. package/packages/openclaw/dist/gateway.js +819 -127
  55. package/packages/openclaw/dist/gateway.js.map +1 -1
  56. package/packages/openclaw/dist/runtime/openclaw-config.d.ts +2 -0
  57. package/packages/openclaw/dist/runtime/openclaw-config.d.ts.map +1 -1
  58. package/packages/openclaw/dist/runtime/openclaw-config.js +1 -1
  59. package/packages/openclaw/dist/runtime/openclaw-config.js.map +1 -1
  60. package/packages/openclaw/dist/runtime/setup.d.ts +0 -2
  61. package/packages/openclaw/dist/runtime/setup.d.ts.map +1 -1
  62. package/packages/openclaw/dist/runtime/setup.js +53 -8
  63. package/packages/openclaw/dist/runtime/setup.js.map +1 -1
  64. package/packages/openclaw/dist/types.d.ts +28 -0
  65. package/packages/openclaw/dist/types.d.ts.map +1 -1
  66. package/packages/openclaw/package.json +2 -2
  67. package/packages/openclaw/skill/SKILL.md +150 -44
  68. package/packages/openclaw/src/__tests__/gateway-control.test.ts +14 -14
  69. package/packages/openclaw/src/__tests__/gateway-poll-fallback.test.ts +467 -0
  70. package/packages/openclaw/src/__tests__/gateway-threads.test.ts +73 -23
  71. package/packages/openclaw/src/__tests__/ws-client.test.ts +71 -51
  72. package/packages/openclaw/src/config.ts +121 -12
  73. package/packages/openclaw/src/gateway.ts +1155 -252
  74. package/packages/openclaw/src/runtime/openclaw-config.ts +3 -1
  75. package/packages/openclaw/src/runtime/setup.ts +57 -16
  76. package/packages/openclaw/src/types.ts +31 -0
  77. package/packages/openclaw/test/vitest.setup.ts +1 -0
  78. package/packages/policy/package.json +2 -2
  79. package/packages/sdk/dist/__tests__/unit.test.js +131 -129
  80. package/packages/sdk/dist/__tests__/unit.test.js.map +1 -1
  81. package/packages/sdk/dist/relay.js +1 -1
  82. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  83. package/packages/sdk/dist/workflows/runner.js +5 -3
  84. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  85. package/packages/sdk/package.json +2 -2
  86. package/packages/sdk/src/__tests__/unit.test.ts +142 -157
  87. package/packages/sdk/src/relay.ts +1 -1
  88. package/packages/sdk/src/workflows/runner.ts +12 -9
  89. package/packages/sdk-py/pyproject.toml +1 -1
  90. package/packages/telemetry/package.json +1 -1
  91. package/packages/trajectory/package.json +2 -2
  92. package/packages/user-directory/package.json +2 -2
  93. package/packages/utils/package.json +2 -2
@@ -1,6 +1,19 @@
1
- import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, type KeyObject } from 'node:crypto';
1
+ import {
2
+ createHash,
3
+ createPrivateKey,
4
+ createPublicKey,
5
+ generateKeyPairSync,
6
+ sign,
7
+ verify,
8
+ type KeyObject,
9
+ } from 'node:crypto';
2
10
  import { chmod, readFile, rename, writeFile, mkdir } from 'node:fs/promises';
3
- import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
11
+ import {
12
+ createServer,
13
+ type Server as HttpServer,
14
+ type IncomingMessage,
15
+ type ServerResponse,
16
+ } from 'node:http';
4
17
  import { join } from 'node:path';
5
18
 
6
19
  import type { SendMessageInput } from '@agent-relay/sdk';
@@ -16,8 +29,13 @@ import type {
16
29
  } from '@relaycast/sdk';
17
30
  import WebSocket from 'ws';
18
31
 
19
- import { openclawHome, detectOpenClaw } from './config.js';
20
- import { DEFAULT_OPENCLAW_GATEWAY_PORT, type GatewayConfig, type InboundMessage, type DeliveryResult } from './types.js';
32
+ import { openclawHome } from './config.js';
33
+ import {
34
+ DEFAULT_OPENCLAW_GATEWAY_PORT,
35
+ type GatewayConfig,
36
+ type InboundMessage,
37
+ type DeliveryResult,
38
+ } from './types.js';
21
39
  import { SpawnManager } from './spawn/manager.js';
22
40
  import type { SpawnOptions } from './spawn/types.js';
23
41
 
@@ -41,6 +59,120 @@ export interface GatewayOptions {
41
59
  relaySender?: RelaySender;
42
60
  }
43
61
 
62
+ type InboundTransportState = 'WS_ACTIVE' | 'WS_DEGRADED' | 'POLL_ACTIVE' | 'RECOVERING_WS';
63
+ type InboundTransportMode = 'ws' | 'poll';
64
+
65
+ interface PollEventEnvelope {
66
+ id: string;
67
+ sequence: number;
68
+ timestamp: string;
69
+ payload: Record<string, unknown>;
70
+ }
71
+
72
+ interface PollResponseBody {
73
+ events?: PollEventEnvelope[];
74
+ nextCursor?: string;
75
+ hasMore?: boolean;
76
+ }
77
+
78
+ interface PersistedPollCursorState {
79
+ cursor: string;
80
+ lastSequence: number;
81
+ recentEventIds: string[];
82
+ updatedAt: string;
83
+ }
84
+
85
+ interface InboundProcessingResult {
86
+ committed: boolean;
87
+ reason?: 'duplicate' | 'echo';
88
+ result?: DeliveryResult;
89
+ }
90
+
91
+ interface RealtimeHandlingOptions {
92
+ eventId?: string;
93
+ timestamp?: string;
94
+ }
95
+
96
+ const DEFAULT_POLL_ENDPOINT_PATH = '/messages/poll';
97
+ const DEFAULT_POLL_INITIAL_CURSOR = '0';
98
+ const DEFAULT_WS_FAILURE_THRESHOLD = 3;
99
+ const DEFAULT_POLL_TIMEOUT_SECONDS = 25;
100
+ const MAX_POLL_TIMEOUT_SECONDS = 30;
101
+ const DEFAULT_POLL_LIMIT = 100;
102
+ const MAX_POLL_LIMIT = 500;
103
+ const DEFAULT_WS_PROBE_INTERVAL_MS = 60_000;
104
+ const DEFAULT_WS_STABLE_GRACE_MS = 10_000;
105
+ const POLL_CURSOR_RECENT_EVENT_LIMIT = 256;
106
+ const MAX_POLL_CURSOR_LENGTH = 4_096;
107
+ const MAX_EVENT_ID_LENGTH = 512;
108
+ const BACKOFF_BASE_MS = 500;
109
+ const BACKOFF_CAP_MS = 30_000;
110
+
111
+ function pollCursorStatePath(): string {
112
+ return join(openclawHome(), 'workspace', 'relaycast', 'inbound-cursor.json');
113
+ }
114
+
115
+ function sleep(ms: number): Promise<void> {
116
+ return new Promise((resolve) => setTimeout(resolve, ms));
117
+ }
118
+
119
+ function applyJitter(ms: number): number {
120
+ const factor = 1.1 + Math.random() * 0.1;
121
+ return Math.max(0, Math.floor(ms * factor));
122
+ }
123
+
124
+ function hasControlCharacters(value: string): boolean {
125
+ for (const char of value) {
126
+ const code = char.charCodeAt(0);
127
+ if ((code >= 0 && code <= 31) || code === 127) {
128
+ return true;
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+
134
+ function stripControlChars(value: string): string {
135
+ let out = '';
136
+ for (const char of value) {
137
+ const code = char.charCodeAt(0);
138
+ out += code <= 31 || code === 127 ? ' ' : char;
139
+ }
140
+ return out;
141
+ }
142
+
143
+ function sanitizeOpaqueStateValue(value: unknown, maxLength: number): string | null {
144
+ if (typeof value !== 'string') return null;
145
+ if (value.trim().length === 0 || value.length > maxLength) return null;
146
+ if (hasControlCharacters(value)) return null;
147
+ return value;
148
+ }
149
+
150
+ function computeBackoffMs(attempt: number): number {
151
+ const base = Math.min(BACKOFF_BASE_MS * Math.pow(2, Math.max(0, attempt - 1)), BACKOFF_CAP_MS);
152
+ return applyJitter(base);
153
+ }
154
+
155
+ function sanitizePollTimeoutSeconds(value: number | undefined): number {
156
+ if (!Number.isFinite(value)) return DEFAULT_POLL_TIMEOUT_SECONDS;
157
+ return Math.min(MAX_POLL_TIMEOUT_SECONDS, Math.max(0, Math.floor(value!)));
158
+ }
159
+
160
+ function sanitizePollLimit(value: number | undefined): number {
161
+ if (!Number.isFinite(value)) return DEFAULT_POLL_LIMIT;
162
+ return Math.min(MAX_POLL_LIMIT, Math.max(1, Math.floor(value!)));
163
+ }
164
+
165
+ function parseRetryAfterMs(retryAfter: string | null): number | null {
166
+ if (!retryAfter) return null;
167
+ const seconds = Number(retryAfter);
168
+ if (Number.isFinite(seconds)) {
169
+ return Math.max(0, Math.floor(seconds * 1000));
170
+ }
171
+ const asDate = Date.parse(retryAfter);
172
+ if (Number.isNaN(asDate)) return null;
173
+ return Math.max(0, asDate - Date.now());
174
+ }
175
+
44
176
  function normalizeChannelName(channel: string): string {
45
177
  return channel.startsWith('#') ? channel.slice(1) : channel;
46
178
  }
@@ -100,7 +232,11 @@ function resolveAuthProfile(): AuthProfile {
100
232
  // which checks valid parseable config files, not just directory existence.
101
233
  // Strict suffix check avoids false positives from substring matching.
102
234
  const home = openclawHome();
103
- const homeSuffix = home.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '';
235
+ const homeSuffix =
236
+ home
237
+ .replace(/[/\\]+$/, '')
238
+ .split(/[/\\]/)
239
+ .pop() ?? '';
104
240
  if (homeSuffix === '.clawdbot' || homeSuffix === 'clawdbot') {
105
241
  return AUTH_PROFILES['clawdbot-v1'];
106
242
  }
@@ -117,10 +253,10 @@ function getWsAuthCompat(): WsAuthCompat {
117
253
  }
118
254
 
119
255
  interface DeviceIdentity {
120
- publicKeyB64: string; // base64url-encoded raw Ed25519 public key (default mode)
121
- publicKeyPem?: string; // PEM-encoded SPKI public key (clawdbot compat mode)
256
+ publicKeyB64: string; // base64url-encoded raw Ed25519 public key (default mode)
257
+ publicKeyPem?: string; // PEM-encoded SPKI public key (clawdbot compat mode)
122
258
  privateKeyObj: KeyObject; // Node.js KeyObject for signing
123
- deviceId: string; // SHA-256 hex of the raw public key
259
+ deviceId: string; // SHA-256 hex of the raw public key
124
260
  }
125
261
 
126
262
  function generateDeviceIdentity(compat?: WsAuthCompat): DeviceIdentity {
@@ -180,7 +316,9 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
180
316
  });
181
317
  // Ensure permissions are tight even if file was created with looser perms
182
318
  await chmod(filePath, 0o600).catch(() => {});
183
- console.log(`[openclaw-ws] Loaded persisted device identity (deviceId=${persisted.deviceId.slice(0, 12)}...)`);
319
+ console.log(
320
+ `[openclaw-ws] Loaded persisted device identity (deviceId=${persisted.deviceId.slice(0, 12)}...)`
321
+ );
184
322
 
185
323
  const identity: DeviceIdentity = {
186
324
  publicKeyB64: persisted.publicKeyB64,
@@ -209,7 +347,9 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
209
347
  } catch (err) {
210
348
  // ENOENT is expected on first run; other errors mean corruption
211
349
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
212
- console.warn(`[openclaw-ws] Failed to load device identity, generating new: ${err instanceof Error ? err.message : String(err)}`);
350
+ console.warn(
351
+ `[openclaw-ws] Failed to load device identity, generating new: ${err instanceof Error ? err.message : String(err)}`
352
+ );
213
353
  }
214
354
  }
215
355
 
@@ -233,9 +373,13 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
233
373
  const tmpPath = filePath + '.tmp';
234
374
  await writeFile(tmpPath, JSON.stringify(persisted, null, 2) + '\n', { mode: 0o600 });
235
375
  await rename(tmpPath, filePath);
236
- console.log(`[openclaw-ws] Persisted new device identity (deviceId=${identity.deviceId.slice(0, 12)}...)`);
376
+ console.log(
377
+ `[openclaw-ws] Persisted new device identity (deviceId=${identity.deviceId.slice(0, 12)}...)`
378
+ );
237
379
  } catch (err) {
238
- console.warn(`[openclaw-ws] Could not persist device identity: ${err instanceof Error ? err.message : String(err)}`);
380
+ console.warn(
381
+ `[openclaw-ws] Could not persist device identity: ${err instanceof Error ? err.message : String(err)}`
382
+ );
239
383
  }
240
384
 
241
385
  return identity;
@@ -263,7 +407,7 @@ function buildCanonicalVariants(
263
407
  signedAt: number;
264
408
  token: string;
265
409
  nonce: string;
266
- },
410
+ }
267
411
  ): Array<{ name: string; payload: string }> {
268
412
  const signedAtMs = String(params.signedAt);
269
413
  const signedAtSec = String(Math.floor(params.signedAt / 1000));
@@ -273,22 +417,69 @@ function buildCanonicalVariants(
273
417
  // V0: current default order (v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily)
274
418
  {
275
419
  name: 'v3-default-ms',
276
- payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.token || '', params.nonce, params.platform, params.deviceFamily].join('|'),
420
+ payload: [
421
+ 'v3',
422
+ device.deviceId,
423
+ params.clientId,
424
+ params.clientMode,
425
+ params.role,
426
+ scopesCsv,
427
+ signedAtMs,
428
+ params.token || '',
429
+ params.nonce,
430
+ params.platform,
431
+ params.deviceFamily,
432
+ ].join('|'),
277
433
  },
278
434
  // V1: signedAt in seconds instead of milliseconds
279
435
  {
280
436
  name: 'v3-default-sec',
281
- payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.token || '', params.nonce, params.platform, params.deviceFamily].join('|'),
437
+ payload: [
438
+ 'v3',
439
+ device.deviceId,
440
+ params.clientId,
441
+ params.clientMode,
442
+ params.role,
443
+ scopesCsv,
444
+ signedAtSec,
445
+ params.token || '',
446
+ params.nonce,
447
+ params.platform,
448
+ params.deviceFamily,
449
+ ].join('|'),
282
450
  },
283
451
  // V2: no token in payload (token omitted entirely)
284
452
  {
285
453
  name: 'v3-no-token-ms',
286
- payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce, params.platform, params.deviceFamily].join('|'),
454
+ payload: [
455
+ 'v3',
456
+ device.deviceId,
457
+ params.clientId,
458
+ params.clientMode,
459
+ params.role,
460
+ scopesCsv,
461
+ signedAtMs,
462
+ params.nonce,
463
+ params.platform,
464
+ params.deviceFamily,
465
+ ].join('|'),
287
466
  },
288
467
  // V3: nonce before token (swapped positions)
289
468
  {
290
469
  name: 'v3-nonce-first-ms',
291
- payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce, params.token || '', params.platform, params.deviceFamily].join('|'),
470
+ payload: [
471
+ 'v3',
472
+ device.deviceId,
473
+ params.clientId,
474
+ params.clientMode,
475
+ params.role,
476
+ scopesCsv,
477
+ signedAtMs,
478
+ params.nonce,
479
+ params.token || '',
480
+ params.platform,
481
+ params.deviceFamily,
482
+ ].join('|'),
292
483
  },
293
484
  // V4: fewer fields — just core identity + nonce + signedAt (minimal)
294
485
  {
@@ -298,22 +489,62 @@ function buildCanonicalVariants(
298
489
  // V5: signedAt seconds + no token
299
490
  {
300
491
  name: 'v3-no-token-sec',
301
- payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.nonce, params.platform, params.deviceFamily].join('|'),
492
+ payload: [
493
+ 'v3',
494
+ device.deviceId,
495
+ params.clientId,
496
+ params.clientMode,
497
+ params.role,
498
+ scopesCsv,
499
+ signedAtSec,
500
+ params.nonce,
501
+ params.platform,
502
+ params.deviceFamily,
503
+ ].join('|'),
302
504
  },
303
505
  // V6: v2 format (no platform/deviceFamily) — used by older gateway versions
304
506
  {
305
507
  name: 'v2-default-ms',
306
- payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.token || '', params.nonce].join('|'),
508
+ payload: [
509
+ 'v2',
510
+ device.deviceId,
511
+ params.clientId,
512
+ params.clientMode,
513
+ params.role,
514
+ scopesCsv,
515
+ signedAtMs,
516
+ params.token || '',
517
+ params.nonce,
518
+ ].join('|'),
307
519
  },
308
520
  // V7: v2 with signedAt in seconds
309
521
  {
310
522
  name: 'v2-default-sec',
311
- payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.token || '', params.nonce].join('|'),
523
+ payload: [
524
+ 'v2',
525
+ device.deviceId,
526
+ params.clientId,
527
+ params.clientMode,
528
+ params.role,
529
+ scopesCsv,
530
+ signedAtSec,
531
+ params.token || '',
532
+ params.nonce,
533
+ ].join('|'),
312
534
  },
313
535
  // V8: v2 without token
314
536
  {
315
537
  name: 'v2-no-token-ms',
316
- payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce].join('|'),
538
+ payload: [
539
+ 'v2',
540
+ device.deviceId,
541
+ params.clientId,
542
+ params.clientMode,
543
+ params.role,
544
+ scopesCsv,
545
+ signedAtMs,
546
+ params.nonce,
547
+ ].join('|'),
317
548
  },
318
549
  ];
319
550
  }
@@ -334,7 +565,7 @@ function signConnectPayload(
334
565
  token: string;
335
566
  nonce: string;
336
567
  },
337
- versionOverride?: PayloadVersionOverride,
568
+ versionOverride?: PayloadVersionOverride
338
569
  ): string {
339
570
  const profile = resolveAuthProfile();
340
571
 
@@ -353,19 +584,25 @@ function signConnectPayload(
353
584
  } else {
354
585
  primaryName = profile.name === 'clawdbot-v1' ? 'v2-default-ms' : 'v3-default-ms';
355
586
  }
356
- const primary = variants.find(v => v.name === primaryName) ?? variants[0];
587
+ const primary = variants.find((v) => v.name === primaryName) ?? variants[0];
357
588
 
358
589
  const payloadBytes = Buffer.from(primary.payload, 'utf-8');
359
590
 
360
591
  const isDebug = process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1';
361
592
 
362
593
  // Concise production log — one line with essential info
363
- console.log(`[ws-auth] profile=${profile.name} payload=${primary.name} device=${device.deviceId.slice(0, 12)}...${versionOverride ? ` override=${versionOverride}` : ''}`);
594
+ console.log(
595
+ `[ws-auth] profile=${profile.name} payload=${primary.name} device=${device.deviceId.slice(0, 12)}...${versionOverride ? ` override=${versionOverride}` : ''}`
596
+ );
364
597
 
365
598
  // Verbose debug logging — field hashes and canonicalization matrix
366
599
  if (isDebug) {
367
- console.log(`[ws-auth-debug] signedAt=${params.signedAt}ms nonce=${shortHash(params.nonce)} keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding}`);
368
- console.log(`[ws-auth-debug] field hashes: deviceId=${shortHash(device.deviceId)} clientId=${shortHash(params.clientId)} role=${shortHash(params.role)} scopes=${shortHash(params.scopes.join(','))} token=${shortHash(params.token || '')} nonce=${shortHash(params.nonce)}`);
600
+ console.log(
601
+ `[ws-auth-debug] signedAt=${params.signedAt}ms nonce=${shortHash(params.nonce)} keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding}`
602
+ );
603
+ console.log(
604
+ `[ws-auth-debug] field hashes: deviceId=${shortHash(device.deviceId)} clientId=${shortHash(params.clientId)} role=${shortHash(params.role)} scopes=${shortHash(params.scopes.join(','))} token=${shortHash(params.token || '')} nonce=${shortHash(params.nonce)}`
605
+ );
369
606
  console.log('[ws-auth-debug] canonicalization matrix:');
370
607
  for (const v of variants) {
371
608
  console.log(` ${v.name}: hash=${shortHash(v.payload)}`);
@@ -385,7 +622,10 @@ function signConnectPayload(
385
622
  const selfVerifyRaw = verify(null, payloadBytes, pubKey, signature);
386
623
 
387
624
  // Also verify the round-trip: decode our encoded signature like the server would
388
- const decodedSig = Buffer.from(encoded, profile.signatureEncoding === 'base64url' ? 'base64url' : 'base64');
625
+ const decodedSig = Buffer.from(
626
+ encoded,
627
+ profile.signatureEncoding === 'base64url' ? 'base64url' : 'base64'
628
+ );
389
629
  const selfVerifyEncoded = verify(null, payloadBytes, pubKey, decodedSig);
390
630
 
391
631
  // Verify deviceId matches public key
@@ -393,9 +633,13 @@ function signConnectPayload(
393
633
  const derivedDeviceId = createHash('sha256').update(rawPubBytes).digest('hex');
394
634
  const deviceIdMatch = derivedDeviceId === device.deviceId;
395
635
 
396
- console.log(`[ws-auth-debug] self-verify: raw=${selfVerifyRaw} encoded=${selfVerifyEncoded} deviceIdMatch=${deviceIdMatch} derivedId=${derivedDeviceId.slice(0, 16)}...`);
636
+ console.log(
637
+ `[ws-auth-debug] self-verify: raw=${selfVerifyRaw} encoded=${selfVerifyEncoded} deviceIdMatch=${deviceIdMatch} derivedId=${derivedDeviceId.slice(0, 16)}...`
638
+ );
397
639
  if (!deviceIdMatch) {
398
- console.error(`[ws-auth-debug] DEVICE ID MISMATCH: derived=${derivedDeviceId} sent=${device.deviceId}`);
640
+ console.error(
641
+ `[ws-auth-debug] DEVICE ID MISMATCH: derived=${derivedDeviceId} sent=${device.deviceId}`
642
+ );
399
643
  }
400
644
  } catch (err) {
401
645
  console.error(`[ws-auth-debug] self-verify error: ${err instanceof Error ? err.message : String(err)}`);
@@ -405,7 +649,6 @@ function signConnectPayload(
405
649
  return encoded;
406
650
  }
407
651
 
408
-
409
652
  // ---------------------------------------------------------------------------
410
653
  // Persistent OpenClaw Gateway WebSocket client
411
654
  // ---------------------------------------------------------------------------
@@ -489,7 +732,9 @@ export class OpenClawGatewayClient {
489
732
  this.connectTimeout = setTimeout(() => {
490
733
  this.connectTimeout = null;
491
734
  if (!this.authenticated) {
492
- const err = new Error(`Connection to OpenClaw gateway timed out after ${OpenClawGatewayClient.CONNECT_TIMEOUT_MS}ms`);
735
+ const err = new Error(
736
+ `Connection to OpenClaw gateway timed out after ${OpenClawGatewayClient.CONNECT_TIMEOUT_MS}ms`
737
+ );
493
738
  this.connectReject?.(err);
494
739
  this.connectReject = null;
495
740
  this.connectResolve = null;
@@ -526,6 +771,8 @@ export class OpenClawGatewayClient {
526
771
  });
527
772
 
528
773
  ws.on('message', (data) => {
774
+ // Guard: ignore messages from superseded WebSocket instances.
775
+ if (this.ws !== ws) return;
529
776
  this.handleMessage(data.toString());
530
777
  });
531
778
 
@@ -534,7 +781,8 @@ export class OpenClawGatewayClient {
534
781
  // During v3↔v2 fallback, the old WS is replaced before its close fires.
535
782
  if (this.ws !== ws) return;
536
783
 
537
- const reasonStr = reason.toString();
784
+ // Sanitize reason to prevent log injection (newlines, control chars)
785
+ const reasonStr = stripControlChars(reason.toString()).slice(0, 200);
538
786
  console.warn(`[openclaw-ws] Disconnected: ${code} ${reasonStr}`);
539
787
  const wasAuthenticated = this.authenticated;
540
788
  this.authenticated = false;
@@ -543,7 +791,9 @@ export class OpenClawGatewayClient {
543
791
  if (code === 1008 && /pairing|not.paired/i.test(reasonStr)) {
544
792
  console.error('[openclaw-ws] Connection closed due to pairing policy. Device is not paired.');
545
793
  console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`);
546
- console.error('[openclaw-ws] Run: openclaw devices approve <requestId> (check gateway logs for requestId)');
794
+ console.error(
795
+ '[openclaw-ws] Run: openclaw devices approve <requestId> (check gateway logs for requestId)'
796
+ );
547
797
  this.pairingRejected = true;
548
798
  }
549
799
 
@@ -581,6 +831,7 @@ export class OpenClawGatewayClient {
581
831
  });
582
832
  }
583
833
 
834
+ // eslint-disable-next-line complexity
584
835
  private handleMessage(raw: string): void {
585
836
  let msg: Record<string, unknown>;
586
837
  try {
@@ -606,59 +857,66 @@ export class OpenClawGatewayClient {
606
857
  const role = 'operator';
607
858
  const scopes = ['operator.read', 'operator.write'];
608
859
 
609
- const signature = signConnectPayload(this.device, {
610
- clientId,
611
- clientMode,
612
- platform,
613
- deviceFamily,
614
- role,
615
- scopes,
616
- signedAt,
617
- token: this.token,
618
- nonce: payload.nonce,
619
- }, this.payloadVersionOverride);
860
+ const signature = signConnectPayload(
861
+ this.device,
862
+ {
863
+ clientId,
864
+ clientMode,
865
+ platform,
866
+ deviceFamily,
867
+ role,
868
+ scopes,
869
+ signedAt,
870
+ token: this.token,
871
+ nonce: payload.nonce,
872
+ },
873
+ this.payloadVersionOverride
874
+ );
620
875
 
621
876
  // Select public key format based on resolved auth profile.
622
877
  const profile = resolveAuthProfile();
623
- const publicKeyField = profile.publicKeyFormat === 'spki-pem' && this.device.publicKeyPem
624
- ? this.device.publicKeyPem
625
- : this.device.publicKeyB64;
878
+ const publicKeyField =
879
+ profile.publicKeyFormat === 'spki-pem' && this.device.publicKeyPem
880
+ ? this.device.publicKeyPem
881
+ : this.device.publicKeyB64;
626
882
 
627
883
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
628
884
  console.warn('[openclaw-ws] WebSocket not open when trying to send connect');
629
885
  return;
630
886
  }
631
- this.ws.send(JSON.stringify({
632
- type: 'req',
633
- id: 'connect-1',
634
- method: 'connect',
635
- params: {
636
- minProtocol: 3,
637
- maxProtocol: 3,
638
- client: {
639
- id: clientId,
640
- version: '1.0.0',
641
- platform,
642
- mode: clientMode,
643
- deviceFamily,
644
- },
645
- role,
646
- scopes,
647
- caps: [],
648
- commands: [],
649
- permissions: {},
650
- auth: { token: this.token },
651
- locale: 'en-US',
652
- userAgent: 'relaycast-gateway/1.0.0',
653
- device: {
654
- id: this.device.deviceId,
655
- publicKey: publicKeyField,
656
- signature,
657
- signedAt,
658
- nonce: payload.nonce,
887
+ this.ws.send(
888
+ JSON.stringify({
889
+ type: 'req',
890
+ id: 'connect-1',
891
+ method: 'connect',
892
+ params: {
893
+ minProtocol: 3,
894
+ maxProtocol: 3,
895
+ client: {
896
+ id: clientId,
897
+ version: '1.0.0',
898
+ platform,
899
+ mode: clientMode,
900
+ deviceFamily,
901
+ },
902
+ role,
903
+ scopes,
904
+ caps: [],
905
+ commands: [],
906
+ permissions: {},
907
+ auth: { token: this.token },
908
+ locale: 'en-US',
909
+ userAgent: 'relaycast-gateway/1.0.0',
910
+ device: {
911
+ id: this.device.deviceId,
912
+ publicKey: publicKeyField,
913
+ signature,
914
+ signedAt,
915
+ nonce: payload.nonce,
916
+ },
659
917
  },
660
- },
661
- }));
918
+ })
919
+ );
662
920
  return;
663
921
  }
664
922
 
@@ -666,9 +924,11 @@ export class OpenClawGatewayClient {
666
924
  if (msg.type === 'res' && msg.id === 'connect-1') {
667
925
  if (msg.ok) {
668
926
  this.clearConnectTimeout();
669
- const versionUsed = this.payloadVersionOverride
670
- ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3');
671
- console.log(`[openclaw-ws] Authenticated successfully (payload=${versionUsed}${this.fallbackAttempted ? ', via fallback' : ''})`);
927
+ const versionUsed =
928
+ this.payloadVersionOverride ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3');
929
+ console.log(
930
+ `[openclaw-ws] Authenticated successfully (payload=${versionUsed}${this.fallbackAttempted ? ', via fallback' : ''})`
931
+ );
672
932
  this.authenticated = true;
673
933
  this.consecutiveFailures = 0;
674
934
  this.connectResolve?.();
@@ -688,10 +948,11 @@ export class OpenClawGatewayClient {
688
948
  console.error(`[openclaw-ws] Approve this device: openclaw devices approve ${requestId}`);
689
949
  }
690
950
  console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`);
691
- const configHint = getWsAuthCompat() === 'clawdbot'
692
- ? '~/.clawdbot/clawdbot.json'
693
- : '~/.openclaw/openclaw.json';
694
- console.error(`[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ${configHint} gateway.auth.token`);
951
+ const configHint =
952
+ getWsAuthCompat() === 'clawdbot' ? '~/.clawdbot/clawdbot.json' : '~/.openclaw/openclaw.json';
953
+ console.error(
954
+ `[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ${configHint} gateway.auth.token`
955
+ );
695
956
  this.pairingRejected = true;
696
957
  } else if (isSignatureInvalid && !this.fallbackAttempted) {
697
958
  // Signature rejected — try the alternate payload version once.
@@ -699,18 +960,24 @@ export class OpenClawGatewayClient {
699
960
  this.authRejectCount++;
700
961
  this.authFallbackCount++;
701
962
  const profile = resolveAuthProfile();
702
- const currentVersion = this.payloadVersionOverride
703
- ?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3');
963
+ const currentVersion =
964
+ this.payloadVersionOverride ?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3');
704
965
  const fallbackVersion: PayloadVersionOverride = currentVersion === 'v2' ? 'v3' : 'v2';
705
966
 
706
- console.warn(`[ws-auth] Signature rejected with ${currentVersion} payload — retrying with ${fallbackVersion} fallback (rejects=${this.authRejectCount} fallbacks=${this.authFallbackCount})`);
967
+ console.warn(
968
+ `[ws-auth] Signature rejected with ${currentVersion} payload — retrying with ${fallbackVersion} fallback (rejects=${this.authRejectCount} fallbacks=${this.authFallbackCount})`
969
+ );
707
970
  this.payloadVersionOverride = fallbackVersion;
708
971
  this.fallbackAttempted = true;
709
972
 
710
973
  // Close current WS and reconnect with the alternate payload.
711
974
  // Setting this.ws = null ensures the old WS's close/error handlers
712
975
  // no-op via the `this.ws !== ws` guard in doConnect().
713
- try { this.ws?.close(); } catch {}
976
+ try {
977
+ this.ws?.close();
978
+ } catch {
979
+ // Best effort
980
+ }
714
981
  this.ws = null;
715
982
  setTimeout(() => this.doConnect(), 0);
716
983
  return; // Don't reject the connect promise yet — fallback attempt in progress
@@ -735,11 +1002,10 @@ export class OpenClawGatewayClient {
735
1002
  this.pendingRpcs.delete(id);
736
1003
 
737
1004
  if (msg.ok === false || msg.error) {
738
- console.warn(`[openclaw-ws] RPC ${id} error: ${JSON.stringify(msg.error ?? msg)}`);
1005
+ console.warn('[openclaw-ws] RPC error response received');
739
1006
  pending.resolve(false);
740
1007
  } else {
741
- const result = msg.payload as Record<string, unknown> | undefined;
742
- console.log(`[openclaw-ws] RPC ${id} ok: runId=${result?.runId ?? 'n/a'} status=${result?.status ?? 'n/a'}`);
1008
+ console.log('[openclaw-ws] RPC succeeded');
743
1009
  pending.resolve(true);
744
1010
  }
745
1011
  return;
@@ -781,16 +1047,18 @@ export class OpenClawGatewayClient {
781
1047
  resolve(false);
782
1048
  return;
783
1049
  }
784
- this.ws.send(JSON.stringify({
785
- type: 'req',
786
- id,
787
- method: 'chat.send',
788
- params: {
789
- sessionKey: 'agent:main:main',
790
- message: text,
791
- ...(idempotencyKey ? { idempotencyKey } : {}),
792
- },
793
- }));
1050
+ this.ws.send(
1051
+ JSON.stringify({
1052
+ type: 'req',
1053
+ id,
1054
+ method: 'chat.send',
1055
+ params: {
1056
+ sessionKey: 'agent:main:main',
1057
+ message: text,
1058
+ ...(idempotencyKey ? { idempotencyKey } : {}),
1059
+ },
1060
+ })
1061
+ );
794
1062
  });
795
1063
  }
796
1064
 
@@ -801,14 +1069,21 @@ export class OpenClawGatewayClient {
801
1069
  // so the gateway can self-heal once pairing is approved externally.
802
1070
  if (this.pairingRejected || this.consecutiveFailures >= OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) {
803
1071
  if (this.consecutiveFailures === OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) {
804
- console.warn(`[openclaw-ws] ${this.consecutiveFailures} consecutive failures — switching to slow retry (every 60s).`);
805
- console.warn('[openclaw-ws] Check that the OpenClaw gateway is running and OPENCLAW_GATEWAY_TOKEN is correct.');
1072
+ console.warn(
1073
+ `[openclaw-ws] ${this.consecutiveFailures} consecutive failures switching to slow retry (every 60s).`
1074
+ );
1075
+ console.warn(
1076
+ '[openclaw-ws] Check that the OpenClaw gateway is running and OPENCLAW_GATEWAY_TOKEN is correct.'
1077
+ );
806
1078
  }
807
1079
  this.consecutiveFailures++;
808
1080
  console.log(`[openclaw-ws] Slow retry in ${OpenClawGatewayClient.PAIRING_RETRY_MS / 1000}s...`);
809
1081
  this.reconnectTimer = setTimeout(() => {
810
1082
  this.reconnectTimer = null;
811
1083
  this.pairingRejected = false; // Clear flag so connect attempt proceeds
1084
+ // Reset fallback state so reconnect tries primary payload version first
1085
+ this.payloadVersionOverride = null;
1086
+ this.fallbackAttempted = false;
812
1087
  this.doConnect();
813
1088
  }, OpenClawGatewayClient.PAIRING_RETRY_MS);
814
1089
  return;
@@ -818,11 +1093,14 @@ export class OpenClawGatewayClient {
818
1093
 
819
1094
  const delay = Math.min(
820
1095
  OpenClawGatewayClient.BASE_RECONNECT_MS * Math.pow(2, this.consecutiveFailures - 1),
821
- OpenClawGatewayClient.MAX_RECONNECT_MS,
1096
+ OpenClawGatewayClient.MAX_RECONNECT_MS
822
1097
  );
823
1098
  console.log(`[openclaw-ws] Reconnecting in ${delay / 1000}s (attempt ${this.consecutiveFailures})...`);
824
1099
  this.reconnectTimer = setTimeout(() => {
825
1100
  this.reconnectTimer = null;
1101
+ // Reset fallback state so reconnect tries primary payload version first
1102
+ this.payloadVersionOverride = null;
1103
+ this.fallbackAttempted = false;
826
1104
  this.doConnect();
827
1105
  }, delay);
828
1106
  }
@@ -840,7 +1118,11 @@ export class OpenClawGatewayClient {
840
1118
  this.pendingRpcs.delete(id);
841
1119
  }
842
1120
  if (this.ws) {
843
- try { this.ws.close(); } catch {}
1121
+ try {
1122
+ this.ws.close();
1123
+ } catch {
1124
+ // Best effort
1125
+ }
844
1126
  this.ws = null;
845
1127
  }
846
1128
  this.authenticated = false;
@@ -857,6 +1139,7 @@ export class OpenClawGatewayClient {
857
1139
  export class InboundGateway {
858
1140
  private readonly relaySender: RelaySender | null;
859
1141
  private relayAgentClient: AgentClient | null = null;
1142
+ private relayAgentToken: string | null = null;
860
1143
  private readonly relaycast: RelayCast;
861
1144
  private readonly config: GatewayConfig;
862
1145
  private readonly dedupeTtlMs: number;
@@ -876,6 +1159,26 @@ export class InboundGateway {
876
1159
  /** Port the control server listens on. */
877
1160
  controlPort = 0;
878
1161
 
1162
+ private transportState: InboundTransportState = 'WS_DEGRADED';
1163
+ private activeTransportMode: InboundTransportMode = 'ws';
1164
+ private wsFailureCount = 0;
1165
+ private pollLoopPromise: Promise<void> | null = null;
1166
+ private pollAbortController: AbortController | null = null;
1167
+ private pollLoopStopRequested = false;
1168
+ private pollCursorLoaded = false;
1169
+ private pollCursor = DEFAULT_POLL_INITIAL_CURSOR;
1170
+ private pollLastSequence = 0;
1171
+ private pollRecentEventIds: string[] = [];
1172
+ private pollFailureCount = 0;
1173
+ private probeWsTimer: ReturnType<typeof setInterval> | null = null;
1174
+ private wsRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
1175
+ private fallbackCount = 0;
1176
+ private lastFallbackReason: string | null = null;
1177
+ private fallbackStartedAt: number | null = null;
1178
+ private totalFallbackMs = 0;
1179
+ private duplicateDropCount = 0;
1180
+ private cursorResetCount = 0;
1181
+
879
1182
  /** Default control port for the gateway's spawn API. */
880
1183
  static readonly DEFAULT_CONTROL_PORT = 18790;
881
1184
 
@@ -891,124 +1194,688 @@ export class InboundGateway {
891
1194
  });
892
1195
 
893
1196
  const dedupeTtlMs = Number(process.env.RELAYCAST_DEDUPE_TTL_MS ?? 15 * 60 * 1000);
894
- this.dedupeTtlMs = Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000
895
- ? Math.floor(dedupeTtlMs)
896
- : 15 * 60 * 1000;
1197
+ this.dedupeTtlMs =
1198
+ Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000 ? Math.floor(dedupeTtlMs) : 15 * 60 * 1000;
897
1199
 
898
1200
  const parentDepth = Number(process.env.OPENCLAW_SPAWN_DEPTH || 0);
899
1201
  this.spawnManager = new SpawnManager({ spawnDepth: parentDepth + 1 });
900
1202
  }
901
1203
 
902
- /** Start the gateway — register agent and subscribe for realtime events. */
903
- async start(): Promise<void> {
904
- if (this.running) return;
905
- this.running = true;
1204
+ private isPollFallbackEnabled(): boolean {
1205
+ return this.config.transport?.pollFallback?.enabled ?? false;
1206
+ }
906
1207
 
907
- // Connect to the local OpenClaw gateway WebSocket (persistent connection)
908
- const token = this.config.openclawGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN;
909
- const port = this.config.openclawGatewayPort ?? DEFAULT_OPENCLAW_GATEWAY_PORT;
1208
+ private wsFailureThreshold(): number {
1209
+ const configured = this.config.transport?.pollFallback?.wsFailureThreshold;
1210
+ if (!Number.isFinite(configured) || configured === undefined) {
1211
+ return DEFAULT_WS_FAILURE_THRESHOLD;
1212
+ }
1213
+ return Math.max(1, Math.floor(configured));
1214
+ }
910
1215
 
911
- if (token) {
912
- this.openclawClient = await OpenClawGatewayClient.create(token, port);
1216
+ private pollTimeoutSeconds(): number {
1217
+ return sanitizePollTimeoutSeconds(this.config.transport?.pollFallback?.timeoutSeconds);
1218
+ }
1219
+
1220
+ private pollLimit(): number {
1221
+ return sanitizePollLimit(this.config.transport?.pollFallback?.limit);
1222
+ }
1223
+
1224
+ private pollInitialCursor(): string {
1225
+ return this.config.transport?.pollFallback?.initialCursor?.trim() || DEFAULT_POLL_INITIAL_CURSOR;
1226
+ }
1227
+
1228
+ private isWsProbeEnabled(): boolean {
1229
+ return this.config.transport?.pollFallback?.probeWs?.enabled ?? true;
1230
+ }
1231
+
1232
+ private wsProbeIntervalMs(): number {
1233
+ const configured = this.config.transport?.pollFallback?.probeWs?.intervalMs;
1234
+ if (!Number.isFinite(configured) || configured === undefined) {
1235
+ return DEFAULT_WS_PROBE_INTERVAL_MS;
1236
+ }
1237
+ return Math.max(1_000, Math.floor(configured));
1238
+ }
1239
+
1240
+ private wsStableGraceMs(): number {
1241
+ const configured = this.config.transport?.pollFallback?.probeWs?.stableGraceMs;
1242
+ if (!Number.isFinite(configured) || configured === undefined) {
1243
+ return DEFAULT_WS_STABLE_GRACE_MS;
1244
+ }
1245
+ return Math.max(1_000, Math.floor(configured));
1246
+ }
1247
+
1248
+ private transportHealthSnapshot(): Record<string, unknown> {
1249
+ const activeFallbackMs = this.fallbackStartedAt === null ? 0 : Date.now() - this.fallbackStartedAt;
1250
+ return {
1251
+ mode: this.activeTransportMode,
1252
+ state: this.transportState,
1253
+ wsFailureCount: this.wsFailureCount,
1254
+ fallbackCount: this.fallbackCount,
1255
+ lastFallbackReason: this.lastFallbackReason,
1256
+ timeInFallbackMs: this.totalFallbackMs + activeFallbackMs,
1257
+ duplicateDrops: this.duplicateDropCount,
1258
+ cursorResets: this.cursorResetCount,
1259
+ lastSequence: this.pollLastSequence,
1260
+ };
1261
+ }
1262
+
1263
+ private completeFallbackWindow(): void {
1264
+ if (this.fallbackStartedAt !== null) {
1265
+ this.totalFallbackMs += Date.now() - this.fallbackStartedAt;
1266
+ this.fallbackStartedAt = null;
1267
+ }
1268
+ }
1269
+
1270
+ private cleanupRelaySubscriptions(): void {
1271
+ for (const unsubscribe of this.unsubscribeHandlers) {
913
1272
  try {
914
- await this.openclawClient.connect();
915
- console.log('[gateway] OpenClaw gateway WebSocket client ready');
916
- } catch (err) {
917
- console.warn(`[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}`);
1273
+ unsubscribe();
1274
+ } catch {
1275
+ // Best effort
918
1276
  }
919
- } else {
920
- console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled');
921
1277
  }
1278
+ this.unsubscribeHandlers = [];
1279
+ }
922
1280
 
923
- const registered = await this.relaycast.agents.registerOrGet({
924
- name: this.config.clawName,
925
- type: 'agent',
926
- persona: 'Relaycast inbound gateway for OpenClaw',
927
- });
1281
+ private subscribeRelayChannels(): void {
1282
+ if (!this.relayAgentClient) return;
1283
+ try {
1284
+ this.relayAgentClient.subscribe(this.config.channels);
1285
+ } catch {
1286
+ // Will subscribe on the next connected event.
1287
+ }
1288
+ }
1289
+
1290
+ private async connectRelayAgentClient(): Promise<void> {
1291
+ if (!this.relayAgentClient) return;
1292
+ try {
1293
+ await Promise.resolve(this.relayAgentClient.connect());
1294
+ } catch (error) {
1295
+ console.warn(
1296
+ `[gateway] Relaycast WS connect failed: ${error instanceof Error ? error.message : String(error)}`
1297
+ );
1298
+ await this.handleWsFailure('connect_failed');
1299
+ }
1300
+ }
928
1301
 
929
- this.relayAgentClient = this.relaycast.as(registered.token);
1302
+ private bindRelayAgentHandlers(): void {
1303
+ if (!this.relayAgentClient) return;
930
1304
 
931
- // Connect first, then register handlers. The SDK requires connect()
932
- // before subscribe() can be called.
933
- this.relayAgentClient.connect();
1305
+ this.cleanupRelaySubscriptions();
934
1306
 
935
1307
  this.unsubscribeHandlers.push(
936
1308
  this.relayAgentClient.on.connected(() => {
937
- console.log(`[gateway] Relaycast WebSocket connected, subscribing to channels: ${this.config.channels.join(', ')}`);
938
- this.relayAgentClient?.subscribe(this.config.channels);
939
- }),
1309
+ console.log(
1310
+ `[gateway] Relaycast WebSocket connected, subscribing to channels: ${this.config.channels.join(', ')}`
1311
+ );
1312
+ this.wsFailureCount = 0;
1313
+ this.subscribeRelayChannels();
1314
+ if (this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS') {
1315
+ this.beginWsRecovery();
1316
+ return;
1317
+ }
1318
+ this.completeFallbackWindow();
1319
+ this.transportState = 'WS_ACTIVE';
1320
+ this.activeTransportMode = 'ws';
1321
+ })
940
1322
  );
941
1323
  this.unsubscribeHandlers.push(
942
1324
  this.relayAgentClient.on.messageCreated((event: MessageCreatedEvent) => {
1325
+ if (!this.shouldProcessWsInbound()) return;
943
1326
  console.log(`[gateway] Realtime message from @${event.message?.agentName} in #${event.channel}`);
944
1327
  void this.handleRealtimeMessage(event);
945
- }),
1328
+ })
946
1329
  );
947
1330
  this.unsubscribeHandlers.push(
948
1331
  this.relayAgentClient.on.threadReply((event: ThreadReplyEvent) => {
949
- console.log(`[gateway] Thread reply from @${event.message?.agentName} in #${event.channel} (parent: ${event.parentId})`);
1332
+ if (!this.shouldProcessWsInbound()) return;
1333
+ console.log(
1334
+ `[gateway] Thread reply from @${event.message?.agentName} in #${event.channel} (parent: ${event.parentId})`
1335
+ );
950
1336
  void this.handleRealtimeThreadReply(event);
951
- }),
1337
+ })
952
1338
  );
953
1339
  this.unsubscribeHandlers.push(
954
1340
  this.relayAgentClient.on.dmReceived((event: DmReceivedEvent) => {
1341
+ if (!this.shouldProcessWsInbound()) return;
955
1342
  console.log(`[gateway] DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
956
1343
  void this.handleRealtimeDm(event);
957
- }),
1344
+ })
958
1345
  );
959
1346
  this.unsubscribeHandlers.push(
960
1347
  this.relayAgentClient.on.groupDmReceived((event: GroupDmReceivedEvent) => {
1348
+ if (!this.shouldProcessWsInbound()) return;
961
1349
  console.log(`[gateway] Group DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
962
1350
  void this.handleRealtimeGroupDm(event);
963
- }),
1351
+ })
964
1352
  );
965
1353
  this.unsubscribeHandlers.push(
966
1354
  this.relayAgentClient.on.commandInvoked((event: CommandInvokedEvent) => {
967
- console.log(`[gateway] Command /${event.command} invoked by @${event.invokedBy} in #${event.channel}`);
1355
+ if (!this.shouldProcessWsInbound()) return;
1356
+ console.log(
1357
+ `[gateway] Command /${event.command} invoked by @${event.invokedBy} in #${event.channel}`
1358
+ );
968
1359
  void this.handleRealtimeCommand(event);
969
- }),
1360
+ })
970
1361
  );
971
1362
  this.unsubscribeHandlers.push(
972
1363
  this.relayAgentClient.on.reactionAdded((event: ReactionAddedEvent) => {
1364
+ if (!this.shouldProcessWsInbound()) return;
973
1365
  console.log(`[gateway] Reaction :${event.emoji}: added by @${event.agentName} on ${event.messageId}`);
974
1366
  void this.handleRealtimeReaction(event, 'added');
975
- }),
1367
+ })
976
1368
  );
977
1369
  this.unsubscribeHandlers.push(
978
1370
  this.relayAgentClient.on.reactionRemoved((event: ReactionRemovedEvent) => {
979
- console.log(`[gateway] Reaction :${event.emoji}: removed by @${event.agentName} from ${event.messageId}`);
1371
+ if (!this.shouldProcessWsInbound()) return;
1372
+ console.log(
1373
+ `[gateway] Reaction :${event.emoji}: removed by @${event.agentName} from ${event.messageId}`
1374
+ );
980
1375
  void this.handleRealtimeReaction(event, 'removed');
981
- }),
1376
+ })
982
1377
  );
983
1378
  this.unsubscribeHandlers.push(
984
1379
  this.relayAgentClient.on.reconnecting((attempt: number) => {
985
1380
  console.warn(`[gateway] Relaycast reconnecting (attempt ${attempt})`);
986
- }),
1381
+ void this.handleWsFailure(`reconnecting:${attempt}`);
1382
+ })
987
1383
  );
988
1384
  this.unsubscribeHandlers.push(
989
1385
  this.relayAgentClient.on.disconnected(() => {
990
- console.warn(`[gateway] Relaycast disconnected`);
991
- }),
1386
+ console.warn('[gateway] Relaycast disconnected');
1387
+ void this.handleWsFailure('disconnected');
1388
+ })
992
1389
  );
993
1390
  this.unsubscribeHandlers.push(
994
- this.relayAgentClient.on.error(() => {
995
- console.warn(`[gateway] Relaycast socket error`);
996
- }),
1391
+ this.relayAgentClient.on.error((error?: unknown) => {
1392
+ const message = error instanceof Error ? error.message : 'socket error';
1393
+ console.warn(`[gateway] Relaycast socket error${message ? `: ${message}` : ''}`);
1394
+ void this.handleWsFailure(message || 'socket_error');
1395
+ })
997
1396
  );
1397
+ }
998
1398
 
1399
+ private async replaceRelayAgentClient(agentToken: string): Promise<void> {
1400
+ this.cleanupRelaySubscriptions();
1401
+ if (this.relayAgentClient) {
1402
+ try {
1403
+ await this.relayAgentClient.disconnect();
1404
+ } catch {
1405
+ // Best effort
1406
+ }
1407
+ }
1408
+ this.relayAgentToken = agentToken;
1409
+ this.relayAgentClient = this.relaycast.as(agentToken);
1410
+ this.bindRelayAgentHandlers();
1411
+ await this.connectRelayAgentClient();
1412
+ }
1413
+
1414
+ private async refreshRelayAgentRegistration(): Promise<void> {
1415
+ const registered = await this.relaycast.agents.registerOrRotate({
1416
+ name: this.config.clawName,
1417
+ type: 'agent',
1418
+ persona: 'Relaycast inbound gateway for OpenClaw',
1419
+ });
1420
+ await this.replaceRelayAgentClient(registered.token);
999
1421
  await this.ensureChannelMembership();
1422
+ this.subscribeRelayChannels();
1423
+ }
1424
+
1425
+ private shouldProcessWsInbound(): boolean {
1426
+ return (
1427
+ !this.isPollFallbackEnabled() ||
1428
+ this.transportState === 'WS_ACTIVE' ||
1429
+ this.transportState === 'RECOVERING_WS'
1430
+ );
1431
+ }
1432
+
1433
+ private async handleWsFailure(reason: string): Promise<void> {
1434
+ if (!this.running) return;
1435
+
1436
+ if (this.wsRecoveryTimer) {
1437
+ clearTimeout(this.wsRecoveryTimer);
1438
+ this.wsRecoveryTimer = null;
1439
+ }
1440
+
1441
+ if (this.transportState === 'RECOVERING_WS') {
1442
+ console.warn(`[gateway] WS recovery probe failed, remaining on long-poll (${reason})`);
1443
+ this.transportState = 'POLL_ACTIVE';
1444
+ this.activeTransportMode = 'poll';
1445
+ await this.startPollLoop();
1446
+ this.startWsProbeLoop();
1447
+ return;
1448
+ }
1449
+
1450
+ if (this.transportState === 'POLL_ACTIVE') {
1451
+ this.lastFallbackReason = reason;
1452
+ return;
1453
+ }
1454
+
1455
+ this.transportState = 'WS_DEGRADED';
1456
+ this.wsFailureCount += 1;
1457
+
1458
+ if (this.isPollFallbackEnabled() && this.wsFailureCount >= this.wsFailureThreshold()) {
1459
+ await this.activatePollFallback(reason);
1460
+ }
1461
+ }
1462
+
1463
+ private async activatePollFallback(reason: string): Promise<void> {
1464
+ if (!this.running || !this.isPollFallbackEnabled()) return;
1465
+ if (this.transportState === 'POLL_ACTIVE') return;
1466
+
1467
+ await this.ensurePollCursorLoaded();
1468
+ this.transportState = 'POLL_ACTIVE';
1469
+ this.activeTransportMode = 'poll';
1470
+ this.fallbackCount += 1;
1471
+ this.lastFallbackReason = reason;
1472
+ if (this.fallbackStartedAt === null) {
1473
+ this.fallbackStartedAt = Date.now();
1474
+ }
1475
+
1476
+ console.warn(`[gateway] Realtime degraded: using long-poll fallback (${reason})`);
1477
+ await this.startPollLoop();
1478
+ this.startWsProbeLoop();
1479
+ }
1480
+
1481
+ private startWsProbeLoop(): void {
1482
+ if (!this.isWsProbeEnabled() || this.probeWsTimer) return;
1483
+
1484
+ this.probeWsTimer = setInterval(() => {
1485
+ if (!this.running || this.transportState !== 'POLL_ACTIVE') return;
1486
+ void this.connectRelayAgentClient();
1487
+ }, this.wsProbeIntervalMs());
1488
+ }
1489
+
1490
+ private stopWsProbeLoop(): void {
1491
+ if (!this.probeWsTimer) return;
1492
+ clearInterval(this.probeWsTimer);
1493
+ this.probeWsTimer = null;
1494
+ }
1495
+
1496
+ private beginWsRecovery(): void {
1497
+ if (!this.running) return;
1498
+
1499
+ this.transportState = 'RECOVERING_WS';
1500
+ this.stopWsProbeLoop();
1501
+
1502
+ if (this.wsRecoveryTimer) {
1503
+ clearTimeout(this.wsRecoveryTimer);
1504
+ }
1505
+
1506
+ console.log(`[gateway] WS probe connected, waiting ${this.wsStableGraceMs()}ms before promotion`);
1507
+ this.wsRecoveryTimer = setTimeout(() => {
1508
+ this.wsRecoveryTimer = null;
1509
+ void this.promoteWsTransport();
1510
+ }, this.wsStableGraceMs());
1511
+ }
1512
+
1513
+ private async promoteWsTransport(): Promise<void> {
1514
+ if (!this.running || this.transportState !== 'RECOVERING_WS') return;
1515
+
1516
+ await this.stopPollLoop();
1517
+ const catchupDelayMs = await this.pollOnce(0);
1518
+ if (catchupDelayMs > 0) {
1519
+ console.warn('[gateway] WS promotion catch-up poll failed, remaining on long-poll');
1520
+ this.transportState = 'POLL_ACTIVE';
1521
+ this.activeTransportMode = 'poll';
1522
+ await this.startPollLoop();
1523
+ this.startWsProbeLoop();
1524
+ return;
1525
+ }
1526
+
1527
+ this.completeFallbackWindow();
1528
+ this.transportState = 'WS_ACTIVE';
1529
+ this.activeTransportMode = 'ws';
1530
+ this.wsFailureCount = 0;
1531
+ console.log('[gateway] Relaycast WebSocket recovered; promoting WS to active transport');
1532
+ }
1533
+
1534
+ private async ensurePollCursorLoaded(): Promise<void> {
1535
+ if (this.pollCursorLoaded) return;
1536
+
1537
+ this.pollCursorLoaded = true;
1538
+ this.pollCursor = this.pollInitialCursor();
1000
1539
 
1001
- // Also subscribe explicitly in case the `connected` event already fired
1002
- // before we registered the handler above.
1003
1540
  try {
1004
- this.relayAgentClient.subscribe(this.config.channels);
1005
- } catch {
1006
- // Will subscribe on next connected event
1541
+ const raw = await readFile(pollCursorStatePath(), 'utf-8');
1542
+ const parsed = JSON.parse(raw) as Partial<PersistedPollCursorState>;
1543
+ const persistedCursor = sanitizeOpaqueStateValue(parsed.cursor, MAX_POLL_CURSOR_LENGTH);
1544
+ if (persistedCursor) {
1545
+ this.pollCursor = persistedCursor;
1546
+ }
1547
+ if (Number.isFinite(parsed.lastSequence)) {
1548
+ this.pollLastSequence = Math.max(0, Math.floor(parsed.lastSequence ?? 0));
1549
+ }
1550
+ if (Array.isArray(parsed.recentEventIds)) {
1551
+ this.pollRecentEventIds = parsed.recentEventIds
1552
+ .map((value) => sanitizeOpaqueStateValue(value, MAX_EVENT_ID_LENGTH))
1553
+ .filter((value): value is string => value !== null)
1554
+ .slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
1555
+ const now = Date.now();
1556
+ for (const eventId of this.pollRecentEventIds) {
1557
+ this.seenMessageIds.set(eventId, now);
1558
+ }
1559
+ }
1560
+ } catch (error) {
1561
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
1562
+ console.warn(
1563
+ `[gateway] Failed to load poll cursor state: ${error instanceof Error ? error.message : String(error)}`
1564
+ );
1565
+ }
1007
1566
  }
1567
+ }
1008
1568
 
1009
- console.log(
1010
- `[gateway] Realtime listening on channels: ${this.config.channels.join(', ')}`,
1011
- );
1569
+ private async persistPollCursorState(): Promise<void> {
1570
+ const cursor =
1571
+ sanitizeOpaqueStateValue(this.pollCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollInitialCursor();
1572
+ const recentEventIds = this.pollRecentEventIds
1573
+ .map((eventId) => sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH))
1574
+ .filter((eventId): eventId is string => eventId !== null)
1575
+ .slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
1576
+ this.pollCursor = cursor;
1577
+ this.pollRecentEventIds = recentEventIds;
1578
+
1579
+ const state: PersistedPollCursorState = {
1580
+ cursor,
1581
+ lastSequence: this.pollLastSequence,
1582
+ recentEventIds,
1583
+ updatedAt: new Date().toISOString(),
1584
+ };
1585
+
1586
+ const filePath = pollCursorStatePath();
1587
+ const tmpPath = `${filePath}.tmp`;
1588
+ await mkdir(join(openclawHome(), 'workspace', 'relaycast'), { recursive: true });
1589
+ await writeFile(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
1590
+ await rename(tmpPath, filePath);
1591
+ }
1592
+
1593
+ private rememberPollEventId(eventId: string): void {
1594
+ const sanitizedEventId = sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH);
1595
+ if (!sanitizedEventId) return;
1596
+ this.pollRecentEventIds = [
1597
+ ...this.pollRecentEventIds.filter((id) => id !== sanitizedEventId),
1598
+ sanitizedEventId,
1599
+ ].slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
1600
+ }
1601
+
1602
+ private hasRecentPollEventId(eventId: string): boolean {
1603
+ return this.pollRecentEventIds.includes(eventId);
1604
+ }
1605
+
1606
+ private async commitPollCursorState(nextCursor: string, lastSequence: number): Promise<void> {
1607
+ const sanitizedCursor = sanitizeOpaqueStateValue(nextCursor, MAX_POLL_CURSOR_LENGTH);
1608
+ if (sanitizedCursor) {
1609
+ this.pollCursor = sanitizedCursor;
1610
+ }
1611
+ this.pollLastSequence = Math.max(this.pollLastSequence, lastSequence);
1612
+ await this.persistPollCursorState();
1613
+ }
1614
+
1615
+ private async resetPollCursorState(reason: string): Promise<void> {
1616
+ this.cursorResetCount += 1;
1617
+ this.lastFallbackReason = reason;
1618
+ this.pollCursor = this.pollInitialCursor();
1619
+ this.pollLastSequence = 0;
1620
+ await this.persistPollCursorState();
1621
+ }
1622
+
1623
+ private async startPollLoop(): Promise<void> {
1624
+ if (this.pollLoopPromise) return;
1625
+
1626
+ this.pollLoopStopRequested = false;
1627
+ this.pollLoopPromise = (async () => {
1628
+ while (
1629
+ this.running &&
1630
+ !this.pollLoopStopRequested &&
1631
+ (this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS')
1632
+ ) {
1633
+ const delayMs = await this.pollOnce(this.pollTimeoutSeconds());
1634
+ if (
1635
+ !this.running ||
1636
+ this.pollLoopStopRequested ||
1637
+ !(this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS')
1638
+ ) {
1639
+ break;
1640
+ }
1641
+ if (delayMs > 0) {
1642
+ await sleep(delayMs);
1643
+ }
1644
+ }
1645
+ })().finally(() => {
1646
+ this.pollLoopPromise = null;
1647
+ this.pollAbortController = null;
1648
+ });
1649
+ }
1650
+
1651
+ private async stopPollLoop(): Promise<void> {
1652
+ this.pollLoopStopRequested = true;
1653
+ if (this.pollAbortController) {
1654
+ this.pollAbortController.abort();
1655
+ this.pollAbortController = null;
1656
+ }
1657
+ if (this.pollLoopPromise) {
1658
+ await this.pollLoopPromise.catch(() => undefined);
1659
+ this.pollLoopPromise = null;
1660
+ }
1661
+ }
1662
+
1663
+ // eslint-disable-next-line complexity
1664
+ private async pollOnce(timeoutSeconds: number): Promise<number> {
1665
+ await this.ensurePollCursorLoaded();
1666
+
1667
+ const baseUrl = new URL(DEFAULT_POLL_ENDPOINT_PATH, this.config.baseUrl);
1668
+ baseUrl.searchParams.set('cursor', this.pollCursor);
1669
+ baseUrl.searchParams.set('timeout', String(timeoutSeconds));
1670
+ baseUrl.searchParams.set('limit', String(this.pollLimit()));
1671
+
1672
+ const timeoutMs = Math.max(5_000, (timeoutSeconds + 5) * 1_000);
1673
+ const abortController = new AbortController();
1674
+ this.pollAbortController = abortController;
1675
+ const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs);
1676
+
1677
+ try {
1678
+ const response = await fetch(baseUrl, {
1679
+ method: 'GET',
1680
+ headers: {
1681
+ Accept: 'application/json',
1682
+ Authorization: this.relayAgentToken ? `Bearer ${this.relayAgentToken}` : '',
1683
+ 'x-api-key': this.config.apiKey,
1684
+ },
1685
+ signal: abortController.signal,
1686
+ });
1687
+
1688
+ if (response.status === 401 || response.status === 403) {
1689
+ console.warn(`[gateway] Poll auth rejected (${response.status}); refreshing token`);
1690
+ try {
1691
+ await this.refreshRelayAgentRegistration();
1692
+ this.pollFailureCount = 0;
1693
+ return 0;
1694
+ } catch (error) {
1695
+ console.warn(
1696
+ `[gateway] Poll auth refresh failed: ${error instanceof Error ? error.message : String(error)}`
1697
+ );
1698
+ this.pollFailureCount += 1;
1699
+ return computeBackoffMs(this.pollFailureCount);
1700
+ }
1701
+ }
1702
+
1703
+ if (response.status === 409) {
1704
+ console.warn('[gateway] Poll cursor invalid/stale; resetting cursor state');
1705
+ this.pollFailureCount = 0;
1706
+ await this.resetPollCursorState('cursor_reset');
1707
+ return 0;
1708
+ }
1709
+
1710
+ if (response.status === 429) {
1711
+ this.pollFailureCount += 1;
1712
+ const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After'));
1713
+ return retryAfterMs !== null ? applyJitter(retryAfterMs) : computeBackoffMs(this.pollFailureCount);
1714
+ }
1715
+
1716
+ if (response.status >= 500) {
1717
+ this.pollFailureCount += 1;
1718
+ return computeBackoffMs(this.pollFailureCount);
1719
+ }
1720
+
1721
+ if (!response.ok) {
1722
+ this.pollFailureCount += 1;
1723
+ console.warn(`[gateway] Poll request failed: HTTP ${response.status}`);
1724
+ return computeBackoffMs(this.pollFailureCount);
1725
+ }
1726
+
1727
+ const body = (await response.json()) as PollResponseBody;
1728
+ this.pollFailureCount = 0;
1729
+ const processed = await this.processPollResponse(body);
1730
+ return processed ? 0 : computeBackoffMs(1);
1731
+ } catch (error) {
1732
+ if (abortController.signal.aborted) {
1733
+ return 0;
1734
+ }
1735
+ this.pollFailureCount += 1;
1736
+ console.warn(
1737
+ `[gateway] Poll request failed: ${error instanceof Error ? error.message : String(error)}`
1738
+ );
1739
+ return computeBackoffMs(this.pollFailureCount);
1740
+ } finally {
1741
+ clearTimeout(timeoutHandle);
1742
+ if (this.pollAbortController === abortController) {
1743
+ this.pollAbortController = null;
1744
+ }
1745
+ }
1746
+ }
1747
+
1748
+ private async processPollResponse(body: PollResponseBody): Promise<boolean> {
1749
+ const events = Array.isArray(body.events) ? [...body.events] : [];
1750
+ events.sort((left, right) => left.sequence - right.sequence);
1751
+
1752
+ let lastSequence = this.pollLastSequence;
1753
+
1754
+ for (const event of events) {
1755
+ if (!event || typeof event.id !== 'string' || !Number.isFinite(event.sequence)) {
1756
+ continue;
1757
+ }
1758
+
1759
+ lastSequence = Math.max(lastSequence, event.sequence);
1760
+
1761
+ if (
1762
+ event.sequence <= this.pollLastSequence ||
1763
+ this.hasRecentPollEventId(event.id) ||
1764
+ this.isSeen(event.id)
1765
+ ) {
1766
+ this.duplicateDropCount += 1;
1767
+ continue;
1768
+ }
1769
+
1770
+ const committed = await this.handlePolledEvent(event);
1771
+ if (!committed) {
1772
+ return false;
1773
+ }
1774
+
1775
+ this.rememberPollEventId(event.id);
1776
+ }
1777
+
1778
+ const nextCursor = sanitizeOpaqueStateValue(body.nextCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollCursor;
1779
+ await this.commitPollCursorState(nextCursor, lastSequence);
1780
+ return true;
1781
+ }
1782
+
1783
+ // eslint-disable-next-line complexity
1784
+ private async handlePolledEvent(event: PollEventEnvelope): Promise<boolean> {
1785
+ const type = typeof event.payload.type === 'string' ? event.payload.type : '';
1786
+ const baseOptions: RealtimeHandlingOptions = {
1787
+ timestamp: event.timestamp,
1788
+ };
1789
+
1790
+ switch (type) {
1791
+ case 'message.created':
1792
+ case 'message.received':
1793
+ case 'message.new':
1794
+ case 'message.sent':
1795
+ return (
1796
+ await this.handleRealtimeMessage(event.payload as unknown as MessageCreatedEvent, baseOptions)
1797
+ ).committed;
1798
+ case 'thread.reply':
1799
+ case 'thread.message.created':
1800
+ case 'thread.message.sent':
1801
+ return (
1802
+ await this.handleRealtimeThreadReply(event.payload as unknown as ThreadReplyEvent, baseOptions)
1803
+ ).committed;
1804
+ case 'dm.received':
1805
+ case 'dm.message.created':
1806
+ case 'direct_message.created':
1807
+ return (await this.handleRealtimeDm(event.payload as unknown as DmReceivedEvent, baseOptions))
1808
+ .committed;
1809
+ case 'group_dm.received':
1810
+ case 'group_dm.message.created':
1811
+ return (
1812
+ await this.handleRealtimeGroupDm(event.payload as unknown as GroupDmReceivedEvent, baseOptions)
1813
+ ).committed;
1814
+ case 'command.invoked':
1815
+ return (
1816
+ await this.handleRealtimeCommand(event.payload as unknown as CommandInvokedEvent, {
1817
+ ...baseOptions,
1818
+ eventId: event.id,
1819
+ })
1820
+ ).committed;
1821
+ case 'reaction.added':
1822
+ return (
1823
+ await this.handleRealtimeReaction(event.payload as unknown as ReactionAddedEvent, 'added', {
1824
+ ...baseOptions,
1825
+ eventId: event.id,
1826
+ })
1827
+ ).committed;
1828
+ case 'reaction.removed':
1829
+ return (
1830
+ await this.handleRealtimeReaction(event.payload as unknown as ReactionRemovedEvent, 'removed', {
1831
+ ...baseOptions,
1832
+ eventId: event.id,
1833
+ })
1834
+ ).committed;
1835
+ default:
1836
+ console.warn(`[gateway] Ignoring unknown polled event type: ${type || 'unknown'}`);
1837
+ return true;
1838
+ }
1839
+ }
1840
+
1841
+ /** Start the gateway — register agent and subscribe for realtime events. */
1842
+ async start(): Promise<void> {
1843
+ if (this.running) return;
1844
+ this.running = true;
1845
+
1846
+ // Connect to the local OpenClaw gateway WebSocket (persistent connection)
1847
+ const token = this.config.openclawGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN;
1848
+ const port = this.config.openclawGatewayPort ?? DEFAULT_OPENCLAW_GATEWAY_PORT;
1849
+
1850
+ if (token) {
1851
+ this.openclawClient = await OpenClawGatewayClient.create(token, port);
1852
+ try {
1853
+ await this.openclawClient.connect();
1854
+ console.log('[gateway] OpenClaw gateway WebSocket client ready');
1855
+ } catch (err) {
1856
+ console.warn(
1857
+ `[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}`
1858
+ );
1859
+ }
1860
+ } else {
1861
+ console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled');
1862
+ }
1863
+
1864
+ const registered = await this.relaycast.agents.registerOrGet({
1865
+ name: this.config.clawName,
1866
+ type: 'agent',
1867
+ persona: 'Relaycast inbound gateway for OpenClaw',
1868
+ });
1869
+
1870
+ await this.replaceRelayAgentClient(registered.token);
1871
+
1872
+ await this.ensureChannelMembership();
1873
+
1874
+ // Also subscribe explicitly in case the `connected` event fired before
1875
+ // the handler ran, or the SDK defers connection readiness.
1876
+ this.subscribeRelayChannels();
1877
+
1878
+ console.log(`[gateway] Realtime listening on channels: ${this.config.channels.join(', ')}`);
1012
1879
 
1013
1880
  // Start spawn control HTTP server
1014
1881
  await this.startControlServer();
@@ -1017,15 +1884,14 @@ export class InboundGateway {
1017
1884
  /** Stop the gateway — clean up websocket and relay clients. */
1018
1885
  async stop(): Promise<void> {
1019
1886
  this.running = false;
1020
-
1021
- for (const unsubscribe of this.unsubscribeHandlers) {
1022
- try {
1023
- unsubscribe();
1024
- } catch {
1025
- // Best effort
1026
- }
1887
+ this.stopWsProbeLoop();
1888
+ if (this.wsRecoveryTimer) {
1889
+ clearTimeout(this.wsRecoveryTimer);
1890
+ this.wsRecoveryTimer = null;
1027
1891
  }
1028
- this.unsubscribeHandlers = [];
1892
+ this.completeFallbackWindow();
1893
+ await this.stopPollLoop();
1894
+ this.cleanupRelaySubscriptions();
1029
1895
 
1030
1896
  if (this.relayAgentClient) {
1031
1897
  try {
@@ -1089,80 +1955,95 @@ export class InboundGateway {
1089
1955
  }
1090
1956
  }
1091
1957
 
1092
- private async handleRealtimeMessage(event: MessageCreatedEvent): Promise<void> {
1958
+ private async handleRealtimeMessage(
1959
+ event: MessageCreatedEvent,
1960
+ options: RealtimeHandlingOptions = {}
1961
+ ): Promise<InboundProcessingResult> {
1093
1962
  const channel = normalizeChannelName(event.channel);
1094
- if (!this.config.channels.includes(channel)) return;
1963
+ if (!this.config.channels.includes(channel)) return { committed: true };
1095
1964
 
1096
- const messageId = event.message?.id;
1097
- if (!messageId) return;
1965
+ const messageId = options.eventId ?? event.message?.id;
1966
+ if (!messageId) return { committed: true };
1098
1967
 
1099
1968
  const inbound: InboundMessage = {
1100
1969
  id: messageId,
1101
1970
  channel,
1102
- from: event.message.agentName,
1103
- text: event.message.text,
1104
- timestamp: new Date().toISOString(),
1971
+ from: event.message?.agentName ?? 'unknown',
1972
+ text: event.message?.text ?? '',
1973
+ timestamp: options.timestamp ?? new Date().toISOString(),
1105
1974
  };
1106
1975
 
1107
- await this.handleInbound(inbound);
1976
+ return this.processInbound(inbound);
1108
1977
  }
1109
1978
 
1110
- private async handleRealtimeThreadReply(event: ThreadReplyEvent): Promise<void> {
1979
+ private async handleRealtimeThreadReply(
1980
+ event: ThreadReplyEvent,
1981
+ options: RealtimeHandlingOptions = {}
1982
+ ): Promise<InboundProcessingResult> {
1111
1983
  const channel = normalizeChannelName(event.channel);
1112
- if (!this.config.channels.includes(channel)) return;
1984
+ if (!this.config.channels.includes(channel)) return { committed: true };
1113
1985
 
1114
- const messageId = event.message?.id;
1115
- if (!messageId) return;
1986
+ const messageId = options.eventId ?? event.message?.id;
1987
+ if (!messageId) return { committed: true };
1116
1988
 
1117
1989
  const inbound: InboundMessage = {
1118
1990
  id: messageId,
1119
1991
  channel,
1120
- from: event.message.agentName,
1121
- text: event.message.text,
1122
- timestamp: new Date().toISOString(),
1992
+ from: event.message?.agentName ?? 'unknown',
1993
+ text: event.message?.text ?? '',
1994
+ timestamp: options.timestamp ?? new Date().toISOString(),
1123
1995
  threadParentId: event.parentId,
1124
1996
  };
1125
1997
 
1126
- await this.handleInbound(inbound);
1998
+ return this.processInbound(inbound);
1127
1999
  }
1128
2000
 
1129
- private async handleRealtimeDm(event: DmReceivedEvent): Promise<void> {
1130
- const messageId = event.message?.id;
1131
- if (!messageId) return;
2001
+ private async handleRealtimeDm(
2002
+ event: DmReceivedEvent,
2003
+ options: RealtimeHandlingOptions = {}
2004
+ ): Promise<InboundProcessingResult> {
2005
+ const messageId = options.eventId ?? event.message?.id;
2006
+ if (!messageId) return { committed: true };
1132
2007
 
1133
2008
  const inbound: InboundMessage = {
1134
2009
  id: messageId,
1135
2010
  channel: 'dm',
1136
- from: event.message.agentName,
1137
- text: event.message.text,
1138
- timestamp: new Date().toISOString(),
2011
+ from: event.message?.agentName ?? 'unknown',
2012
+ text: event.message?.text ?? '',
2013
+ timestamp: options.timestamp ?? new Date().toISOString(),
1139
2014
  conversationId: event.conversationId,
1140
2015
  kind: 'dm',
1141
2016
  };
1142
2017
 
1143
- await this.handleInbound(inbound);
2018
+ return this.processInbound(inbound);
1144
2019
  }
1145
2020
 
1146
- private async handleRealtimeGroupDm(event: GroupDmReceivedEvent): Promise<void> {
1147
- const messageId = event.message?.id;
1148
- if (!messageId) return;
2021
+ private async handleRealtimeGroupDm(
2022
+ event: GroupDmReceivedEvent,
2023
+ options: RealtimeHandlingOptions = {}
2024
+ ): Promise<InboundProcessingResult> {
2025
+ const messageId = options.eventId ?? event.message?.id;
2026
+ if (!messageId) return { committed: true };
1149
2027
 
1150
2028
  const inbound: InboundMessage = {
1151
2029
  id: messageId,
1152
2030
  channel: `groupdm:${event.conversationId}`,
1153
- from: event.message.agentName,
1154
- text: event.message.text,
1155
- timestamp: new Date().toISOString(),
2031
+ from: event.message?.agentName ?? 'unknown',
2032
+ text: event.message?.text ?? '',
2033
+ timestamp: options.timestamp ?? new Date().toISOString(),
1156
2034
  conversationId: event.conversationId,
1157
2035
  kind: 'groupdm',
1158
2036
  };
1159
2037
 
1160
- await this.handleInbound(inbound);
2038
+ return this.processInbound(inbound);
1161
2039
  }
1162
2040
 
1163
- private async handleRealtimeCommand(event: CommandInvokedEvent): Promise<void> {
2041
+ private async handleRealtimeCommand(
2042
+ event: CommandInvokedEvent,
2043
+ options: RealtimeHandlingOptions = {}
2044
+ ): Promise<InboundProcessingResult> {
1164
2045
  const channel = normalizeChannelName(event.channel);
1165
- if (!this.config.channels.includes(channel)) return;
2046
+ if (!this.config.channels.includes(channel)) return { committed: true };
1166
2047
 
1167
2048
  // Commands lack a server-assigned event ID, so we synthesize one.
1168
2049
  // We include args + timestamp to avoid silently dropping legitimate
@@ -1170,7 +2051,8 @@ export class InboundGateway {
1170
2051
  // reconnection replays may deliver a duplicate, but that's less
1171
2052
  // harmful than silently swallowing a real command.
1172
2053
  const argsSlug = event.args ? `_${event.args}` : '';
1173
- const syntheticId = `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`;
2054
+ const syntheticId =
2055
+ options.eventId ?? `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`;
1174
2056
  const argsText = event.args ? ` ${event.args}` : '';
1175
2057
 
1176
2058
  const inbound: InboundMessage = {
@@ -1178,76 +2060,91 @@ export class InboundGateway {
1178
2060
  channel,
1179
2061
  from: event.invokedBy,
1180
2062
  text: `[relaycast:command:${channel}] @${event.invokedBy} /${event.command}${argsText}`,
1181
- timestamp: new Date().toISOString(),
2063
+ timestamp: options.timestamp ?? new Date().toISOString(),
1182
2064
  kind: 'command',
1183
2065
  };
1184
2066
 
1185
- await this.handleInbound(inbound);
2067
+ return this.processInbound(inbound);
1186
2068
  }
1187
2069
 
1188
2070
  private async handleRealtimeReaction(
1189
2071
  event: ReactionAddedEvent | ReactionRemovedEvent,
1190
2072
  action: 'added' | 'removed',
1191
- ): Promise<void> {
2073
+ options: RealtimeHandlingOptions = {}
2074
+ ): Promise<InboundProcessingResult> {
1192
2075
  // Include timestamp so add→remove→re-add of the same emoji isn't
1193
2076
  // silently dropped within the 15-min dedup window. Reactions are soft
1194
2077
  // notifications, so a rare duplicate on SDK reconnect is acceptable.
1195
- const syntheticId = `reaction_${event.messageId}_${event.emoji}_${event.agentName}_${action}_${Date.now()}`;
1196
- const text = action === 'added'
1197
- ? `[relaycast:reaction] @${event.agentName} reacted ${event.emoji} to message ${event.messageId} (soft notification, no action required)`
1198
- : `[relaycast:reaction] @${event.agentName} removed ${event.emoji} from message ${event.messageId} (soft notification, no action required)`;
2078
+ const syntheticId =
2079
+ options.eventId ??
2080
+ `reaction_${event.messageId}_${event.emoji}_${event.agentName}_${action}_${Date.now()}`;
2081
+ const text =
2082
+ action === 'added'
2083
+ ? `[relaycast:reaction] @${event.agentName} reacted ${event.emoji} to message ${event.messageId} (soft notification, no action required)`
2084
+ : `[relaycast:reaction] @${event.agentName} removed ${event.emoji} from message ${event.messageId} (soft notification, no action required)`;
1199
2085
 
1200
2086
  const inbound: InboundMessage = {
1201
2087
  id: syntheticId,
1202
2088
  channel: 'reaction',
1203
2089
  from: event.agentName,
1204
2090
  text,
1205
- timestamp: new Date().toISOString(),
2091
+ timestamp: options.timestamp ?? new Date().toISOString(),
1206
2092
  kind: 'reaction',
1207
2093
  };
1208
2094
 
1209
- await this.handleInbound(inbound);
2095
+ return this.processInbound(inbound);
1210
2096
  }
1211
2097
 
1212
- private async handleInbound(message: InboundMessage): Promise<void> {
1213
- if (!this.running) return;
1214
- if (this.processingMessageIds.has(message.id) || this.isSeen(message.id)) return;
2098
+ private async processInbound(message: InboundMessage): Promise<InboundProcessingResult> {
2099
+ if (!this.running) return { committed: false };
2100
+ if (this.processingMessageIds.has(message.id) || this.isSeen(message.id)) {
2101
+ this.duplicateDropCount += 1;
2102
+ return { committed: true, reason: 'duplicate' };
2103
+ }
1215
2104
 
1216
2105
  // Avoid echo loops — skip messages from this claw.
1217
2106
  if (message.from === this.config.clawName) {
1218
- // Only update cursor for real channels with real (non-synthetic) message IDs.
1219
2107
  this.markSeen(message.id);
1220
- return;
2108
+ return { committed: true, reason: 'echo' };
1221
2109
  }
1222
2110
 
1223
- // Mark as seen immediately to prevent duplicate delivery from concurrent
1224
- // realtime events processing the same message.
1225
- this.markSeen(message.id);
1226
2111
  this.processingMessageIds.add(message.id);
1227
2112
 
1228
2113
  console.log(`[gateway] Delivering message ${message.id} from @${message.from}: "${message.text}"`);
1229
2114
  try {
1230
2115
  const result = await this.onMessage(message);
1231
- console.log(`[gateway] Delivery result: ${result.method} ok=${result.ok}${result.error ? ' error=' + result.error : ''}`);
2116
+ console.log(
2117
+ `[gateway] Delivery result: ${result.method} ok=${result.ok}${result.error ? ' error=' + result.error : ''}`
2118
+ );
2119
+ if (!result.ok) {
2120
+ return { committed: false, result };
2121
+ }
2122
+ this.markSeen(message.id);
2123
+ return { committed: true, result };
1232
2124
  } finally {
1233
2125
  this.processingMessageIds.delete(message.id);
1234
2126
  }
1235
2127
  }
1236
2128
 
1237
- /** Format delivery text with channel, sender, and optional thread prefix. */
2129
+ /** Format delivery text with channel, sender, and response hint. */
1238
2130
  private formatDeliveryText(message: InboundMessage): string {
1239
- // Pre-formatted kinds (command, reaction) already have the full text.
1240
- if (message.kind === 'command' || message.kind === 'reaction') {
2131
+ // Pre-formatted kinds (reaction) already have the full text with hints.
2132
+ if (message.kind === 'reaction') {
1241
2133
  return message.text;
1242
2134
  }
2135
+ if (message.kind === 'command') {
2136
+ return `${message.text}\n(command invocation — respond with: post_message channel="${message.channel}")`;
2137
+ }
1243
2138
  if (message.kind === 'dm') {
1244
- return `[relaycast:dm] @${message.from}: ${message.text}`;
2139
+ return `[relaycast:dm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`;
1245
2140
  }
1246
2141
  if (message.kind === 'groupdm') {
1247
- return `[relaycast:groupdm] @${message.from}: ${message.text}`;
2142
+ return `[relaycast:groupdm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`;
2143
+ }
2144
+ if (message.threadParentId) {
2145
+ return `[thread] [relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: reply_to_thread message_id="${message.threadParentId}")`;
1248
2146
  }
1249
- const threadPrefix = message.threadParentId ? '[thread] ' : '';
1250
- return `${threadPrefix}[relaycast:${message.channel}] @${message.from}: ${message.text}`;
2147
+ return `[relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: post_message channel="${message.channel}" or reply_to_thread message_id="${message.id}")`;
1251
2148
  }
1252
2149
 
1253
2150
  /** Handle an inbound Relaycast message. */
@@ -1269,9 +2166,7 @@ export class InboundGateway {
1269
2166
  }
1270
2167
  }
1271
2168
 
1272
- console.warn(
1273
- `[gateway] Failed to deliver message ${message.id} from @${message.from}`,
1274
- );
2169
+ console.warn(`[gateway] Failed to deliver message ${message.id} from @${message.from}`);
1275
2170
  return { ok: false, method: 'failed', error: 'All delivery methods failed' };
1276
2171
  }
1277
2172
 
@@ -1323,6 +2218,7 @@ export class InboundGateway {
1323
2218
  });
1324
2219
  }
1325
2220
 
2221
+ // eslint-disable-next-line complexity
1326
2222
  private async handleControlRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
1327
2223
  const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
1328
2224
  const path = url.pathname;
@@ -1332,12 +2228,15 @@ export class InboundGateway {
1332
2228
 
1333
2229
  if (req.method === 'GET' && path === '/health') {
1334
2230
  res.writeHead(200);
1335
- res.end(JSON.stringify({
1336
- ok: true,
1337
- status: 'running',
1338
- active: this.spawnManager.size,
1339
- uptime: process.uptime(),
1340
- }));
2231
+ res.end(
2232
+ JSON.stringify({
2233
+ ok: true,
2234
+ status: 'running',
2235
+ active: this.spawnManager.size,
2236
+ uptime: process.uptime(),
2237
+ transport: this.transportHealthSnapshot(),
2238
+ })
2239
+ );
1341
2240
  return;
1342
2241
  }
1343
2242
 
@@ -1366,14 +2265,16 @@ export class InboundGateway {
1366
2265
 
1367
2266
  const handle = await this.spawnManager.spawn(spawnOpts);
1368
2267
  res.writeHead(200);
1369
- res.end(JSON.stringify({
1370
- ok: true,
1371
- name: handle.displayName,
1372
- agentName: handle.agentName,
1373
- id: handle.id,
1374
- gatewayPort: handle.gatewayPort,
1375
- active: this.spawnManager.size,
1376
- }));
2268
+ res.end(
2269
+ JSON.stringify({
2270
+ ok: true,
2271
+ name: handle.displayName,
2272
+ agentName: handle.agentName,
2273
+ id: handle.id,
2274
+ gatewayPort: handle.gatewayPort,
2275
+ active: this.spawnManager.size,
2276
+ })
2277
+ );
1377
2278
  } catch (err) {
1378
2279
  res.writeHead(500);
1379
2280
  res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
@@ -1384,16 +2285,18 @@ export class InboundGateway {
1384
2285
  if (req.method === 'GET' && path === '/list') {
1385
2286
  const handles = this.spawnManager.list();
1386
2287
  res.writeHead(200);
1387
- res.end(JSON.stringify({
1388
- ok: true,
1389
- active: handles.length,
1390
- claws: handles.map(h => ({
1391
- name: h.displayName,
1392
- agentName: h.agentName,
1393
- id: h.id,
1394
- gatewayPort: h.gatewayPort,
1395
- })),
1396
- }));
2288
+ res.end(
2289
+ JSON.stringify({
2290
+ ok: true,
2291
+ active: handles.length,
2292
+ claws: handles.map((h) => ({
2293
+ name: h.displayName,
2294
+ agentName: h.agentName,
2295
+ id: h.id,
2296
+ gatewayPort: h.gatewayPort,
2297
+ })),
2298
+ })
2299
+ );
1397
2300
  return;
1398
2301
  }
1399
2302