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,12 +1,88 @@
1
- import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, } from 'node:crypto';
2
2
  import { chmod, readFile, rename, writeFile, mkdir } from 'node:fs/promises';
3
- import { createServer } from 'node:http';
3
+ import { createServer, } from 'node:http';
4
4
  import { join } from 'node:path';
5
5
  import { RelayCast } from '@relaycast/sdk';
6
6
  import WebSocket from 'ws';
7
7
  import { openclawHome } from './config.js';
8
- import { DEFAULT_OPENCLAW_GATEWAY_PORT } from './types.js';
8
+ import { DEFAULT_OPENCLAW_GATEWAY_PORT, } from './types.js';
9
9
  import { SpawnManager } from './spawn/manager.js';
10
+ const DEFAULT_POLL_ENDPOINT_PATH = '/messages/poll';
11
+ const DEFAULT_POLL_INITIAL_CURSOR = '0';
12
+ const DEFAULT_WS_FAILURE_THRESHOLD = 3;
13
+ const DEFAULT_POLL_TIMEOUT_SECONDS = 25;
14
+ const MAX_POLL_TIMEOUT_SECONDS = 30;
15
+ const DEFAULT_POLL_LIMIT = 100;
16
+ const MAX_POLL_LIMIT = 500;
17
+ const DEFAULT_WS_PROBE_INTERVAL_MS = 60_000;
18
+ const DEFAULT_WS_STABLE_GRACE_MS = 10_000;
19
+ const POLL_CURSOR_RECENT_EVENT_LIMIT = 256;
20
+ const MAX_POLL_CURSOR_LENGTH = 4_096;
21
+ const MAX_EVENT_ID_LENGTH = 512;
22
+ const BACKOFF_BASE_MS = 500;
23
+ const BACKOFF_CAP_MS = 30_000;
24
+ function pollCursorStatePath() {
25
+ return join(openclawHome(), 'workspace', 'relaycast', 'inbound-cursor.json');
26
+ }
27
+ function sleep(ms) {
28
+ return new Promise((resolve) => setTimeout(resolve, ms));
29
+ }
30
+ function applyJitter(ms) {
31
+ const factor = 1.1 + Math.random() * 0.1;
32
+ return Math.max(0, Math.floor(ms * factor));
33
+ }
34
+ function hasControlCharacters(value) {
35
+ for (const char of value) {
36
+ const code = char.charCodeAt(0);
37
+ if ((code >= 0 && code <= 31) || code === 127) {
38
+ return true;
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+ function stripControlChars(value) {
44
+ let out = '';
45
+ for (const char of value) {
46
+ const code = char.charCodeAt(0);
47
+ out += code <= 31 || code === 127 ? ' ' : char;
48
+ }
49
+ return out;
50
+ }
51
+ function sanitizeOpaqueStateValue(value, maxLength) {
52
+ if (typeof value !== 'string')
53
+ return null;
54
+ if (value.trim().length === 0 || value.length > maxLength)
55
+ return null;
56
+ if (hasControlCharacters(value))
57
+ return null;
58
+ return value;
59
+ }
60
+ function computeBackoffMs(attempt) {
61
+ const base = Math.min(BACKOFF_BASE_MS * Math.pow(2, Math.max(0, attempt - 1)), BACKOFF_CAP_MS);
62
+ return applyJitter(base);
63
+ }
64
+ function sanitizePollTimeoutSeconds(value) {
65
+ if (!Number.isFinite(value))
66
+ return DEFAULT_POLL_TIMEOUT_SECONDS;
67
+ return Math.min(MAX_POLL_TIMEOUT_SECONDS, Math.max(0, Math.floor(value)));
68
+ }
69
+ function sanitizePollLimit(value) {
70
+ if (!Number.isFinite(value))
71
+ return DEFAULT_POLL_LIMIT;
72
+ return Math.min(MAX_POLL_LIMIT, Math.max(1, Math.floor(value)));
73
+ }
74
+ function parseRetryAfterMs(retryAfter) {
75
+ if (!retryAfter)
76
+ return null;
77
+ const seconds = Number(retryAfter);
78
+ if (Number.isFinite(seconds)) {
79
+ return Math.max(0, Math.floor(seconds * 1000));
80
+ }
81
+ const asDate = Date.parse(retryAfter);
82
+ if (Number.isNaN(asDate))
83
+ return null;
84
+ return Math.max(0, asDate - Date.now());
85
+ }
10
86
  function normalizeChannelName(channel) {
11
87
  return channel.startsWith('#') ? channel.slice(1) : channel;
12
88
  }
@@ -44,7 +120,10 @@ function resolveAuthProfile() {
44
120
  // which checks valid parseable config files, not just directory existence.
45
121
  // Strict suffix check avoids false positives from substring matching.
46
122
  const home = openclawHome();
47
- const homeSuffix = home.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '';
123
+ const homeSuffix = home
124
+ .replace(/[/\\]+$/, '')
125
+ .split(/[/\\]/)
126
+ .pop() ?? '';
48
127
  if (homeSuffix === '.clawdbot' || homeSuffix === 'clawdbot') {
49
128
  return AUTH_PROFILES['clawdbot-v1'];
50
129
  }
@@ -167,22 +246,69 @@ function buildCanonicalVariants(device, params) {
167
246
  // V0: current default order (v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily)
168
247
  {
169
248
  name: 'v3-default-ms',
170
- payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.token || '', params.nonce, params.platform, params.deviceFamily].join('|'),
249
+ payload: [
250
+ 'v3',
251
+ device.deviceId,
252
+ params.clientId,
253
+ params.clientMode,
254
+ params.role,
255
+ scopesCsv,
256
+ signedAtMs,
257
+ params.token || '',
258
+ params.nonce,
259
+ params.platform,
260
+ params.deviceFamily,
261
+ ].join('|'),
171
262
  },
172
263
  // V1: signedAt in seconds instead of milliseconds
173
264
  {
174
265
  name: 'v3-default-sec',
175
- payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.token || '', params.nonce, params.platform, params.deviceFamily].join('|'),
266
+ payload: [
267
+ 'v3',
268
+ device.deviceId,
269
+ params.clientId,
270
+ params.clientMode,
271
+ params.role,
272
+ scopesCsv,
273
+ signedAtSec,
274
+ params.token || '',
275
+ params.nonce,
276
+ params.platform,
277
+ params.deviceFamily,
278
+ ].join('|'),
176
279
  },
177
280
  // V2: no token in payload (token omitted entirely)
178
281
  {
179
282
  name: 'v3-no-token-ms',
180
- payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce, params.platform, params.deviceFamily].join('|'),
283
+ payload: [
284
+ 'v3',
285
+ device.deviceId,
286
+ params.clientId,
287
+ params.clientMode,
288
+ params.role,
289
+ scopesCsv,
290
+ signedAtMs,
291
+ params.nonce,
292
+ params.platform,
293
+ params.deviceFamily,
294
+ ].join('|'),
181
295
  },
182
296
  // V3: nonce before token (swapped positions)
183
297
  {
184
298
  name: 'v3-nonce-first-ms',
185
- payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce, params.token || '', params.platform, params.deviceFamily].join('|'),
299
+ payload: [
300
+ 'v3',
301
+ device.deviceId,
302
+ params.clientId,
303
+ params.clientMode,
304
+ params.role,
305
+ scopesCsv,
306
+ signedAtMs,
307
+ params.nonce,
308
+ params.token || '',
309
+ params.platform,
310
+ params.deviceFamily,
311
+ ].join('|'),
186
312
  },
187
313
  // V4: fewer fields — just core identity + nonce + signedAt (minimal)
188
314
  {
@@ -192,22 +318,62 @@ function buildCanonicalVariants(device, params) {
192
318
  // V5: signedAt seconds + no token
193
319
  {
194
320
  name: 'v3-no-token-sec',
195
- payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.nonce, params.platform, params.deviceFamily].join('|'),
321
+ payload: [
322
+ 'v3',
323
+ device.deviceId,
324
+ params.clientId,
325
+ params.clientMode,
326
+ params.role,
327
+ scopesCsv,
328
+ signedAtSec,
329
+ params.nonce,
330
+ params.platform,
331
+ params.deviceFamily,
332
+ ].join('|'),
196
333
  },
197
334
  // V6: v2 format (no platform/deviceFamily) — used by older gateway versions
198
335
  {
199
336
  name: 'v2-default-ms',
200
- payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.token || '', params.nonce].join('|'),
337
+ payload: [
338
+ 'v2',
339
+ device.deviceId,
340
+ params.clientId,
341
+ params.clientMode,
342
+ params.role,
343
+ scopesCsv,
344
+ signedAtMs,
345
+ params.token || '',
346
+ params.nonce,
347
+ ].join('|'),
201
348
  },
202
349
  // V7: v2 with signedAt in seconds
203
350
  {
204
351
  name: 'v2-default-sec',
205
- payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.token || '', params.nonce].join('|'),
352
+ payload: [
353
+ 'v2',
354
+ device.deviceId,
355
+ params.clientId,
356
+ params.clientMode,
357
+ params.role,
358
+ scopesCsv,
359
+ signedAtSec,
360
+ params.token || '',
361
+ params.nonce,
362
+ ].join('|'),
206
363
  },
207
364
  // V8: v2 without token
208
365
  {
209
366
  name: 'v2-no-token-ms',
210
- payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce].join('|'),
367
+ payload: [
368
+ 'v2',
369
+ device.deviceId,
370
+ params.clientId,
371
+ params.clientMode,
372
+ params.role,
373
+ scopesCsv,
374
+ signedAtMs,
375
+ params.nonce,
376
+ ].join('|'),
211
377
  },
212
378
  ];
213
379
  }
@@ -229,7 +395,7 @@ function signConnectPayload(device, params, versionOverride) {
229
395
  else {
230
396
  primaryName = profile.name === 'clawdbot-v1' ? 'v2-default-ms' : 'v3-default-ms';
231
397
  }
232
- const primary = variants.find(v => v.name === primaryName) ?? variants[0];
398
+ const primary = variants.find((v) => v.name === primaryName) ?? variants[0];
233
399
  const payloadBytes = Buffer.from(primary.payload, 'utf-8');
234
400
  const isDebug = process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1';
235
401
  // Concise production log — one line with essential info
@@ -371,6 +537,9 @@ export class OpenClawGatewayClient {
371
537
  console.log('[openclaw-ws] Connected to OpenClaw gateway');
372
538
  });
373
539
  ws.on('message', (data) => {
540
+ // Guard: ignore messages from superseded WebSocket instances.
541
+ if (this.ws !== ws)
542
+ return;
374
543
  this.handleMessage(data.toString());
375
544
  });
376
545
  ws.on('close', (code, reason) => {
@@ -378,7 +547,8 @@ export class OpenClawGatewayClient {
378
547
  // During v3↔v2 fallback, the old WS is replaced before its close fires.
379
548
  if (this.ws !== ws)
380
549
  return;
381
- const reasonStr = reason.toString();
550
+ // Sanitize reason to prevent log injection (newlines, control chars)
551
+ const reasonStr = stripControlChars(reason.toString()).slice(0, 200);
382
552
  console.warn(`[openclaw-ws] Disconnected: ${code} ${reasonStr}`);
383
553
  const wasAuthenticated = this.authenticated;
384
554
  this.authenticated = false;
@@ -421,6 +591,7 @@ export class OpenClawGatewayClient {
421
591
  }
422
592
  });
423
593
  }
594
+ // eslint-disable-next-line complexity
424
595
  handleMessage(raw) {
425
596
  let msg;
426
597
  try {
@@ -501,8 +672,7 @@ export class OpenClawGatewayClient {
501
672
  if (msg.type === 'res' && msg.id === 'connect-1') {
502
673
  if (msg.ok) {
503
674
  this.clearConnectTimeout();
504
- const versionUsed = this.payloadVersionOverride
505
- ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3');
675
+ const versionUsed = this.payloadVersionOverride ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3');
506
676
  console.log(`[openclaw-ws] Authenticated successfully (payload=${versionUsed}${this.fallbackAttempted ? ', via fallback' : ''})`);
507
677
  this.authenticated = true;
508
678
  this.consecutiveFailures = 0;
@@ -523,9 +693,7 @@ export class OpenClawGatewayClient {
523
693
  console.error(`[openclaw-ws] Approve this device: openclaw devices approve ${requestId}`);
524
694
  }
525
695
  console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`);
526
- const configHint = getWsAuthCompat() === 'clawdbot'
527
- ? '~/.clawdbot/clawdbot.json'
528
- : '~/.openclaw/openclaw.json';
696
+ const configHint = getWsAuthCompat() === 'clawdbot' ? '~/.clawdbot/clawdbot.json' : '~/.openclaw/openclaw.json';
529
697
  console.error(`[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ${configHint} gateway.auth.token`);
530
698
  this.pairingRejected = true;
531
699
  }
@@ -535,8 +703,7 @@ export class OpenClawGatewayClient {
535
703
  this.authRejectCount++;
536
704
  this.authFallbackCount++;
537
705
  const profile = resolveAuthProfile();
538
- const currentVersion = this.payloadVersionOverride
539
- ?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3');
706
+ const currentVersion = this.payloadVersionOverride ?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3');
540
707
  const fallbackVersion = currentVersion === 'v2' ? 'v3' : 'v2';
541
708
  console.warn(`[ws-auth] Signature rejected with ${currentVersion} payload — retrying with ${fallbackVersion} fallback (rejects=${this.authRejectCount} fallbacks=${this.authFallbackCount})`);
542
709
  this.payloadVersionOverride = fallbackVersion;
@@ -547,7 +714,9 @@ export class OpenClawGatewayClient {
547
714
  try {
548
715
  this.ws?.close();
549
716
  }
550
- catch { }
717
+ catch {
718
+ // Best effort
719
+ }
551
720
  this.ws = null;
552
721
  setTimeout(() => this.doConnect(), 0);
553
722
  return; // Don't reject the connect promise yet — fallback attempt in progress
@@ -570,12 +739,11 @@ export class OpenClawGatewayClient {
570
739
  clearTimeout(pending.timer);
571
740
  this.pendingRpcs.delete(id);
572
741
  if (msg.ok === false || msg.error) {
573
- console.warn(`[openclaw-ws] RPC ${id} error: ${JSON.stringify(msg.error ?? msg)}`);
742
+ console.warn('[openclaw-ws] RPC error response received');
574
743
  pending.resolve(false);
575
744
  }
576
745
  else {
577
- const result = msg.payload;
578
- console.log(`[openclaw-ws] RPC ${id} ok: runId=${result?.runId ?? 'n/a'} status=${result?.status ?? 'n/a'}`);
746
+ console.log('[openclaw-ws] RPC succeeded');
579
747
  pending.resolve(true);
580
748
  }
581
749
  return;
@@ -641,6 +809,9 @@ export class OpenClawGatewayClient {
641
809
  this.reconnectTimer = setTimeout(() => {
642
810
  this.reconnectTimer = null;
643
811
  this.pairingRejected = false; // Clear flag so connect attempt proceeds
812
+ // Reset fallback state so reconnect tries primary payload version first
813
+ this.payloadVersionOverride = null;
814
+ this.fallbackAttempted = false;
644
815
  this.doConnect();
645
816
  }, OpenClawGatewayClient.PAIRING_RETRY_MS);
646
817
  return;
@@ -650,6 +821,9 @@ export class OpenClawGatewayClient {
650
821
  console.log(`[openclaw-ws] Reconnecting in ${delay / 1000}s (attempt ${this.consecutiveFailures})...`);
651
822
  this.reconnectTimer = setTimeout(() => {
652
823
  this.reconnectTimer = null;
824
+ // Reset fallback state so reconnect tries primary payload version first
825
+ this.payloadVersionOverride = null;
826
+ this.fallbackAttempted = false;
653
827
  this.doConnect();
654
828
  }, delay);
655
829
  }
@@ -669,7 +843,9 @@ export class OpenClawGatewayClient {
669
843
  try {
670
844
  this.ws.close();
671
845
  }
672
- catch { }
846
+ catch {
847
+ // Best effort
848
+ }
673
849
  this.ws = null;
674
850
  }
675
851
  this.authenticated = false;
@@ -684,6 +860,7 @@ export class OpenClawGatewayClient {
684
860
  export class InboundGateway {
685
861
  relaySender;
686
862
  relayAgentClient = null;
863
+ relayAgentToken = null;
687
864
  relaycast;
688
865
  config;
689
866
  dedupeTtlMs;
@@ -699,6 +876,25 @@ export class InboundGateway {
699
876
  controlServer = null;
700
877
  /** Port the control server listens on. */
701
878
  controlPort = 0;
879
+ transportState = 'WS_DEGRADED';
880
+ activeTransportMode = 'ws';
881
+ wsFailureCount = 0;
882
+ pollLoopPromise = null;
883
+ pollAbortController = null;
884
+ pollLoopStopRequested = false;
885
+ pollCursorLoaded = false;
886
+ pollCursor = DEFAULT_POLL_INITIAL_CURSOR;
887
+ pollLastSequence = 0;
888
+ pollRecentEventIds = [];
889
+ pollFailureCount = 0;
890
+ probeWsTimer = null;
891
+ wsRecoveryTimer = null;
892
+ fallbackCount = 0;
893
+ lastFallbackReason = null;
894
+ fallbackStartedAt = null;
895
+ totalFallbackMs = 0;
896
+ duplicateDropCount = 0;
897
+ cursorResetCount = 0;
702
898
  /** Default control port for the gateway's spawn API. */
703
899
  static DEFAULT_CONTROL_PORT = 18790;
704
900
  constructor(options) {
@@ -712,92 +908,578 @@ export class InboundGateway {
712
908
  baseUrl: this.config.baseUrl,
713
909
  });
714
910
  const dedupeTtlMs = Number(process.env.RELAYCAST_DEDUPE_TTL_MS ?? 15 * 60 * 1000);
715
- this.dedupeTtlMs = Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000
716
- ? Math.floor(dedupeTtlMs)
717
- : 15 * 60 * 1000;
911
+ this.dedupeTtlMs =
912
+ Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000 ? Math.floor(dedupeTtlMs) : 15 * 60 * 1000;
718
913
  const parentDepth = Number(process.env.OPENCLAW_SPAWN_DEPTH || 0);
719
914
  this.spawnManager = new SpawnManager({ spawnDepth: parentDepth + 1 });
720
915
  }
721
- /** Start the gateway — register agent and subscribe for realtime events. */
722
- async start() {
723
- if (this.running)
724
- return;
725
- this.running = true;
726
- // Connect to the local OpenClaw gateway WebSocket (persistent connection)
727
- const token = this.config.openclawGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN;
728
- const port = this.config.openclawGatewayPort ?? DEFAULT_OPENCLAW_GATEWAY_PORT;
729
- if (token) {
730
- this.openclawClient = await OpenClawGatewayClient.create(token, port);
916
+ isPollFallbackEnabled() {
917
+ return this.config.transport?.pollFallback?.enabled ?? false;
918
+ }
919
+ wsFailureThreshold() {
920
+ const configured = this.config.transport?.pollFallback?.wsFailureThreshold;
921
+ if (!Number.isFinite(configured) || configured === undefined) {
922
+ return DEFAULT_WS_FAILURE_THRESHOLD;
923
+ }
924
+ return Math.max(1, Math.floor(configured));
925
+ }
926
+ pollTimeoutSeconds() {
927
+ return sanitizePollTimeoutSeconds(this.config.transport?.pollFallback?.timeoutSeconds);
928
+ }
929
+ pollLimit() {
930
+ return sanitizePollLimit(this.config.transport?.pollFallback?.limit);
931
+ }
932
+ pollInitialCursor() {
933
+ return this.config.transport?.pollFallback?.initialCursor?.trim() || DEFAULT_POLL_INITIAL_CURSOR;
934
+ }
935
+ isWsProbeEnabled() {
936
+ return this.config.transport?.pollFallback?.probeWs?.enabled ?? true;
937
+ }
938
+ wsProbeIntervalMs() {
939
+ const configured = this.config.transport?.pollFallback?.probeWs?.intervalMs;
940
+ if (!Number.isFinite(configured) || configured === undefined) {
941
+ return DEFAULT_WS_PROBE_INTERVAL_MS;
942
+ }
943
+ return Math.max(1_000, Math.floor(configured));
944
+ }
945
+ wsStableGraceMs() {
946
+ const configured = this.config.transport?.pollFallback?.probeWs?.stableGraceMs;
947
+ if (!Number.isFinite(configured) || configured === undefined) {
948
+ return DEFAULT_WS_STABLE_GRACE_MS;
949
+ }
950
+ return Math.max(1_000, Math.floor(configured));
951
+ }
952
+ transportHealthSnapshot() {
953
+ const activeFallbackMs = this.fallbackStartedAt === null ? 0 : Date.now() - this.fallbackStartedAt;
954
+ return {
955
+ mode: this.activeTransportMode,
956
+ state: this.transportState,
957
+ wsFailureCount: this.wsFailureCount,
958
+ fallbackCount: this.fallbackCount,
959
+ lastFallbackReason: this.lastFallbackReason,
960
+ timeInFallbackMs: this.totalFallbackMs + activeFallbackMs,
961
+ duplicateDrops: this.duplicateDropCount,
962
+ cursorResets: this.cursorResetCount,
963
+ lastSequence: this.pollLastSequence,
964
+ };
965
+ }
966
+ completeFallbackWindow() {
967
+ if (this.fallbackStartedAt !== null) {
968
+ this.totalFallbackMs += Date.now() - this.fallbackStartedAt;
969
+ this.fallbackStartedAt = null;
970
+ }
971
+ }
972
+ cleanupRelaySubscriptions() {
973
+ for (const unsubscribe of this.unsubscribeHandlers) {
731
974
  try {
732
- await this.openclawClient.connect();
733
- console.log('[gateway] OpenClaw gateway WebSocket client ready');
975
+ unsubscribe();
734
976
  }
735
- catch (err) {
736
- console.warn(`[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}`);
977
+ catch {
978
+ // Best effort
737
979
  }
738
980
  }
739
- else {
740
- console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled');
981
+ this.unsubscribeHandlers = [];
982
+ }
983
+ subscribeRelayChannels() {
984
+ if (!this.relayAgentClient)
985
+ return;
986
+ try {
987
+ this.relayAgentClient.subscribe(this.config.channels);
741
988
  }
742
- const registered = await this.relaycast.agents.registerOrGet({
743
- name: this.config.clawName,
744
- type: 'agent',
745
- persona: 'Relaycast inbound gateway for OpenClaw',
746
- });
747
- this.relayAgentClient = this.relaycast.as(registered.token);
748
- // Connect first, then register handlers. The SDK requires connect()
749
- // before subscribe() can be called.
750
- this.relayAgentClient.connect();
989
+ catch {
990
+ // Will subscribe on the next connected event.
991
+ }
992
+ }
993
+ async connectRelayAgentClient() {
994
+ if (!this.relayAgentClient)
995
+ return;
996
+ try {
997
+ await Promise.resolve(this.relayAgentClient.connect());
998
+ }
999
+ catch (error) {
1000
+ console.warn(`[gateway] Relaycast WS connect failed: ${error instanceof Error ? error.message : String(error)}`);
1001
+ await this.handleWsFailure('connect_failed');
1002
+ }
1003
+ }
1004
+ bindRelayAgentHandlers() {
1005
+ if (!this.relayAgentClient)
1006
+ return;
1007
+ this.cleanupRelaySubscriptions();
751
1008
  this.unsubscribeHandlers.push(this.relayAgentClient.on.connected(() => {
752
1009
  console.log(`[gateway] Relaycast WebSocket connected, subscribing to channels: ${this.config.channels.join(', ')}`);
753
- this.relayAgentClient?.subscribe(this.config.channels);
1010
+ this.wsFailureCount = 0;
1011
+ this.subscribeRelayChannels();
1012
+ if (this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS') {
1013
+ this.beginWsRecovery();
1014
+ return;
1015
+ }
1016
+ this.completeFallbackWindow();
1017
+ this.transportState = 'WS_ACTIVE';
1018
+ this.activeTransportMode = 'ws';
754
1019
  }));
755
1020
  this.unsubscribeHandlers.push(this.relayAgentClient.on.messageCreated((event) => {
1021
+ if (!this.shouldProcessWsInbound())
1022
+ return;
756
1023
  console.log(`[gateway] Realtime message from @${event.message?.agentName} in #${event.channel}`);
757
1024
  void this.handleRealtimeMessage(event);
758
1025
  }));
759
1026
  this.unsubscribeHandlers.push(this.relayAgentClient.on.threadReply((event) => {
1027
+ if (!this.shouldProcessWsInbound())
1028
+ return;
760
1029
  console.log(`[gateway] Thread reply from @${event.message?.agentName} in #${event.channel} (parent: ${event.parentId})`);
761
1030
  void this.handleRealtimeThreadReply(event);
762
1031
  }));
763
1032
  this.unsubscribeHandlers.push(this.relayAgentClient.on.dmReceived((event) => {
1033
+ if (!this.shouldProcessWsInbound())
1034
+ return;
764
1035
  console.log(`[gateway] DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
765
1036
  void this.handleRealtimeDm(event);
766
1037
  }));
767
1038
  this.unsubscribeHandlers.push(this.relayAgentClient.on.groupDmReceived((event) => {
1039
+ if (!this.shouldProcessWsInbound())
1040
+ return;
768
1041
  console.log(`[gateway] Group DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
769
1042
  void this.handleRealtimeGroupDm(event);
770
1043
  }));
771
1044
  this.unsubscribeHandlers.push(this.relayAgentClient.on.commandInvoked((event) => {
1045
+ if (!this.shouldProcessWsInbound())
1046
+ return;
772
1047
  console.log(`[gateway] Command /${event.command} invoked by @${event.invokedBy} in #${event.channel}`);
773
1048
  void this.handleRealtimeCommand(event);
774
1049
  }));
775
1050
  this.unsubscribeHandlers.push(this.relayAgentClient.on.reactionAdded((event) => {
1051
+ if (!this.shouldProcessWsInbound())
1052
+ return;
776
1053
  console.log(`[gateway] Reaction :${event.emoji}: added by @${event.agentName} on ${event.messageId}`);
777
1054
  void this.handleRealtimeReaction(event, 'added');
778
1055
  }));
779
1056
  this.unsubscribeHandlers.push(this.relayAgentClient.on.reactionRemoved((event) => {
1057
+ if (!this.shouldProcessWsInbound())
1058
+ return;
780
1059
  console.log(`[gateway] Reaction :${event.emoji}: removed by @${event.agentName} from ${event.messageId}`);
781
1060
  void this.handleRealtimeReaction(event, 'removed');
782
1061
  }));
783
1062
  this.unsubscribeHandlers.push(this.relayAgentClient.on.reconnecting((attempt) => {
784
1063
  console.warn(`[gateway] Relaycast reconnecting (attempt ${attempt})`);
1064
+ void this.handleWsFailure(`reconnecting:${attempt}`);
785
1065
  }));
786
1066
  this.unsubscribeHandlers.push(this.relayAgentClient.on.disconnected(() => {
787
- console.warn(`[gateway] Relaycast disconnected`);
1067
+ console.warn('[gateway] Relaycast disconnected');
1068
+ void this.handleWsFailure('disconnected');
788
1069
  }));
789
- this.unsubscribeHandlers.push(this.relayAgentClient.on.error(() => {
790
- console.warn(`[gateway] Relaycast socket error`);
1070
+ this.unsubscribeHandlers.push(this.relayAgentClient.on.error((error) => {
1071
+ const message = error instanceof Error ? error.message : 'socket error';
1072
+ console.warn(`[gateway] Relaycast socket error${message ? `: ${message}` : ''}`);
1073
+ void this.handleWsFailure(message || 'socket_error');
791
1074
  }));
1075
+ }
1076
+ async replaceRelayAgentClient(agentToken) {
1077
+ this.cleanupRelaySubscriptions();
1078
+ if (this.relayAgentClient) {
1079
+ try {
1080
+ await this.relayAgentClient.disconnect();
1081
+ }
1082
+ catch {
1083
+ // Best effort
1084
+ }
1085
+ }
1086
+ this.relayAgentToken = agentToken;
1087
+ this.relayAgentClient = this.relaycast.as(agentToken);
1088
+ this.bindRelayAgentHandlers();
1089
+ await this.connectRelayAgentClient();
1090
+ }
1091
+ async refreshRelayAgentRegistration() {
1092
+ const registered = await this.relaycast.agents.registerOrRotate({
1093
+ name: this.config.clawName,
1094
+ type: 'agent',
1095
+ persona: 'Relaycast inbound gateway for OpenClaw',
1096
+ });
1097
+ await this.replaceRelayAgentClient(registered.token);
792
1098
  await this.ensureChannelMembership();
793
- // Also subscribe explicitly in case the `connected` event already fired
794
- // before we registered the handler above.
1099
+ this.subscribeRelayChannels();
1100
+ }
1101
+ shouldProcessWsInbound() {
1102
+ return (!this.isPollFallbackEnabled() ||
1103
+ this.transportState === 'WS_ACTIVE' ||
1104
+ this.transportState === 'RECOVERING_WS');
1105
+ }
1106
+ async handleWsFailure(reason) {
1107
+ if (!this.running)
1108
+ return;
1109
+ if (this.wsRecoveryTimer) {
1110
+ clearTimeout(this.wsRecoveryTimer);
1111
+ this.wsRecoveryTimer = null;
1112
+ }
1113
+ if (this.transportState === 'RECOVERING_WS') {
1114
+ console.warn(`[gateway] WS recovery probe failed, remaining on long-poll (${reason})`);
1115
+ this.transportState = 'POLL_ACTIVE';
1116
+ this.activeTransportMode = 'poll';
1117
+ await this.startPollLoop();
1118
+ this.startWsProbeLoop();
1119
+ return;
1120
+ }
1121
+ if (this.transportState === 'POLL_ACTIVE') {
1122
+ this.lastFallbackReason = reason;
1123
+ return;
1124
+ }
1125
+ this.transportState = 'WS_DEGRADED';
1126
+ this.wsFailureCount += 1;
1127
+ if (this.isPollFallbackEnabled() && this.wsFailureCount >= this.wsFailureThreshold()) {
1128
+ await this.activatePollFallback(reason);
1129
+ }
1130
+ }
1131
+ async activatePollFallback(reason) {
1132
+ if (!this.running || !this.isPollFallbackEnabled())
1133
+ return;
1134
+ if (this.transportState === 'POLL_ACTIVE')
1135
+ return;
1136
+ await this.ensurePollCursorLoaded();
1137
+ this.transportState = 'POLL_ACTIVE';
1138
+ this.activeTransportMode = 'poll';
1139
+ this.fallbackCount += 1;
1140
+ this.lastFallbackReason = reason;
1141
+ if (this.fallbackStartedAt === null) {
1142
+ this.fallbackStartedAt = Date.now();
1143
+ }
1144
+ console.warn(`[gateway] Realtime degraded: using long-poll fallback (${reason})`);
1145
+ await this.startPollLoop();
1146
+ this.startWsProbeLoop();
1147
+ }
1148
+ startWsProbeLoop() {
1149
+ if (!this.isWsProbeEnabled() || this.probeWsTimer)
1150
+ return;
1151
+ this.probeWsTimer = setInterval(() => {
1152
+ if (!this.running || this.transportState !== 'POLL_ACTIVE')
1153
+ return;
1154
+ void this.connectRelayAgentClient();
1155
+ }, this.wsProbeIntervalMs());
1156
+ }
1157
+ stopWsProbeLoop() {
1158
+ if (!this.probeWsTimer)
1159
+ return;
1160
+ clearInterval(this.probeWsTimer);
1161
+ this.probeWsTimer = null;
1162
+ }
1163
+ beginWsRecovery() {
1164
+ if (!this.running)
1165
+ return;
1166
+ this.transportState = 'RECOVERING_WS';
1167
+ this.stopWsProbeLoop();
1168
+ if (this.wsRecoveryTimer) {
1169
+ clearTimeout(this.wsRecoveryTimer);
1170
+ }
1171
+ console.log(`[gateway] WS probe connected, waiting ${this.wsStableGraceMs()}ms before promotion`);
1172
+ this.wsRecoveryTimer = setTimeout(() => {
1173
+ this.wsRecoveryTimer = null;
1174
+ void this.promoteWsTransport();
1175
+ }, this.wsStableGraceMs());
1176
+ }
1177
+ async promoteWsTransport() {
1178
+ if (!this.running || this.transportState !== 'RECOVERING_WS')
1179
+ return;
1180
+ await this.stopPollLoop();
1181
+ const catchupDelayMs = await this.pollOnce(0);
1182
+ if (catchupDelayMs > 0) {
1183
+ console.warn('[gateway] WS promotion catch-up poll failed, remaining on long-poll');
1184
+ this.transportState = 'POLL_ACTIVE';
1185
+ this.activeTransportMode = 'poll';
1186
+ await this.startPollLoop();
1187
+ this.startWsProbeLoop();
1188
+ return;
1189
+ }
1190
+ this.completeFallbackWindow();
1191
+ this.transportState = 'WS_ACTIVE';
1192
+ this.activeTransportMode = 'ws';
1193
+ this.wsFailureCount = 0;
1194
+ console.log('[gateway] Relaycast WebSocket recovered; promoting WS to active transport');
1195
+ }
1196
+ async ensurePollCursorLoaded() {
1197
+ if (this.pollCursorLoaded)
1198
+ return;
1199
+ this.pollCursorLoaded = true;
1200
+ this.pollCursor = this.pollInitialCursor();
795
1201
  try {
796
- this.relayAgentClient.subscribe(this.config.channels);
1202
+ const raw = await readFile(pollCursorStatePath(), 'utf-8');
1203
+ const parsed = JSON.parse(raw);
1204
+ const persistedCursor = sanitizeOpaqueStateValue(parsed.cursor, MAX_POLL_CURSOR_LENGTH);
1205
+ if (persistedCursor) {
1206
+ this.pollCursor = persistedCursor;
1207
+ }
1208
+ if (Number.isFinite(parsed.lastSequence)) {
1209
+ this.pollLastSequence = Math.max(0, Math.floor(parsed.lastSequence ?? 0));
1210
+ }
1211
+ if (Array.isArray(parsed.recentEventIds)) {
1212
+ this.pollRecentEventIds = parsed.recentEventIds
1213
+ .map((value) => sanitizeOpaqueStateValue(value, MAX_EVENT_ID_LENGTH))
1214
+ .filter((value) => value !== null)
1215
+ .slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
1216
+ const now = Date.now();
1217
+ for (const eventId of this.pollRecentEventIds) {
1218
+ this.seenMessageIds.set(eventId, now);
1219
+ }
1220
+ }
797
1221
  }
798
- catch {
799
- // Will subscribe on next connected event
1222
+ catch (error) {
1223
+ if (error.code !== 'ENOENT') {
1224
+ console.warn(`[gateway] Failed to load poll cursor state: ${error instanceof Error ? error.message : String(error)}`);
1225
+ }
1226
+ }
1227
+ }
1228
+ async persistPollCursorState() {
1229
+ const cursor = sanitizeOpaqueStateValue(this.pollCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollInitialCursor();
1230
+ const recentEventIds = this.pollRecentEventIds
1231
+ .map((eventId) => sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH))
1232
+ .filter((eventId) => eventId !== null)
1233
+ .slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
1234
+ this.pollCursor = cursor;
1235
+ this.pollRecentEventIds = recentEventIds;
1236
+ const state = {
1237
+ cursor,
1238
+ lastSequence: this.pollLastSequence,
1239
+ recentEventIds,
1240
+ updatedAt: new Date().toISOString(),
1241
+ };
1242
+ const filePath = pollCursorStatePath();
1243
+ const tmpPath = `${filePath}.tmp`;
1244
+ await mkdir(join(openclawHome(), 'workspace', 'relaycast'), { recursive: true });
1245
+ await writeFile(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
1246
+ await rename(tmpPath, filePath);
1247
+ }
1248
+ rememberPollEventId(eventId) {
1249
+ const sanitizedEventId = sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH);
1250
+ if (!sanitizedEventId)
1251
+ return;
1252
+ this.pollRecentEventIds = [
1253
+ ...this.pollRecentEventIds.filter((id) => id !== sanitizedEventId),
1254
+ sanitizedEventId,
1255
+ ].slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
1256
+ }
1257
+ hasRecentPollEventId(eventId) {
1258
+ return this.pollRecentEventIds.includes(eventId);
1259
+ }
1260
+ async commitPollCursorState(nextCursor, lastSequence) {
1261
+ const sanitizedCursor = sanitizeOpaqueStateValue(nextCursor, MAX_POLL_CURSOR_LENGTH);
1262
+ if (sanitizedCursor) {
1263
+ this.pollCursor = sanitizedCursor;
1264
+ }
1265
+ this.pollLastSequence = Math.max(this.pollLastSequence, lastSequence);
1266
+ await this.persistPollCursorState();
1267
+ }
1268
+ async resetPollCursorState(reason) {
1269
+ this.cursorResetCount += 1;
1270
+ this.lastFallbackReason = reason;
1271
+ this.pollCursor = this.pollInitialCursor();
1272
+ this.pollLastSequence = 0;
1273
+ await this.persistPollCursorState();
1274
+ }
1275
+ async startPollLoop() {
1276
+ if (this.pollLoopPromise)
1277
+ return;
1278
+ this.pollLoopStopRequested = false;
1279
+ this.pollLoopPromise = (async () => {
1280
+ while (this.running &&
1281
+ !this.pollLoopStopRequested &&
1282
+ (this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS')) {
1283
+ const delayMs = await this.pollOnce(this.pollTimeoutSeconds());
1284
+ if (!this.running ||
1285
+ this.pollLoopStopRequested ||
1286
+ !(this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS')) {
1287
+ break;
1288
+ }
1289
+ if (delayMs > 0) {
1290
+ await sleep(delayMs);
1291
+ }
1292
+ }
1293
+ })().finally(() => {
1294
+ this.pollLoopPromise = null;
1295
+ this.pollAbortController = null;
1296
+ });
1297
+ }
1298
+ async stopPollLoop() {
1299
+ this.pollLoopStopRequested = true;
1300
+ if (this.pollAbortController) {
1301
+ this.pollAbortController.abort();
1302
+ this.pollAbortController = null;
1303
+ }
1304
+ if (this.pollLoopPromise) {
1305
+ await this.pollLoopPromise.catch(() => undefined);
1306
+ this.pollLoopPromise = null;
1307
+ }
1308
+ }
1309
+ // eslint-disable-next-line complexity
1310
+ async pollOnce(timeoutSeconds) {
1311
+ await this.ensurePollCursorLoaded();
1312
+ const baseUrl = new URL(DEFAULT_POLL_ENDPOINT_PATH, this.config.baseUrl);
1313
+ baseUrl.searchParams.set('cursor', this.pollCursor);
1314
+ baseUrl.searchParams.set('timeout', String(timeoutSeconds));
1315
+ baseUrl.searchParams.set('limit', String(this.pollLimit()));
1316
+ const timeoutMs = Math.max(5_000, (timeoutSeconds + 5) * 1_000);
1317
+ const abortController = new AbortController();
1318
+ this.pollAbortController = abortController;
1319
+ const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs);
1320
+ try {
1321
+ const response = await fetch(baseUrl, {
1322
+ method: 'GET',
1323
+ headers: {
1324
+ Accept: 'application/json',
1325
+ Authorization: this.relayAgentToken ? `Bearer ${this.relayAgentToken}` : '',
1326
+ 'x-api-key': this.config.apiKey,
1327
+ },
1328
+ signal: abortController.signal,
1329
+ });
1330
+ if (response.status === 401 || response.status === 403) {
1331
+ console.warn(`[gateway] Poll auth rejected (${response.status}); refreshing token`);
1332
+ try {
1333
+ await this.refreshRelayAgentRegistration();
1334
+ this.pollFailureCount = 0;
1335
+ return 0;
1336
+ }
1337
+ catch (error) {
1338
+ console.warn(`[gateway] Poll auth refresh failed: ${error instanceof Error ? error.message : String(error)}`);
1339
+ this.pollFailureCount += 1;
1340
+ return computeBackoffMs(this.pollFailureCount);
1341
+ }
1342
+ }
1343
+ if (response.status === 409) {
1344
+ console.warn('[gateway] Poll cursor invalid/stale; resetting cursor state');
1345
+ this.pollFailureCount = 0;
1346
+ await this.resetPollCursorState('cursor_reset');
1347
+ return 0;
1348
+ }
1349
+ if (response.status === 429) {
1350
+ this.pollFailureCount += 1;
1351
+ const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After'));
1352
+ return retryAfterMs !== null ? applyJitter(retryAfterMs) : computeBackoffMs(this.pollFailureCount);
1353
+ }
1354
+ if (response.status >= 500) {
1355
+ this.pollFailureCount += 1;
1356
+ return computeBackoffMs(this.pollFailureCount);
1357
+ }
1358
+ if (!response.ok) {
1359
+ this.pollFailureCount += 1;
1360
+ console.warn(`[gateway] Poll request failed: HTTP ${response.status}`);
1361
+ return computeBackoffMs(this.pollFailureCount);
1362
+ }
1363
+ const body = (await response.json());
1364
+ this.pollFailureCount = 0;
1365
+ const processed = await this.processPollResponse(body);
1366
+ return processed ? 0 : computeBackoffMs(1);
1367
+ }
1368
+ catch (error) {
1369
+ if (abortController.signal.aborted) {
1370
+ return 0;
1371
+ }
1372
+ this.pollFailureCount += 1;
1373
+ console.warn(`[gateway] Poll request failed: ${error instanceof Error ? error.message : String(error)}`);
1374
+ return computeBackoffMs(this.pollFailureCount);
1375
+ }
1376
+ finally {
1377
+ clearTimeout(timeoutHandle);
1378
+ if (this.pollAbortController === abortController) {
1379
+ this.pollAbortController = null;
1380
+ }
1381
+ }
1382
+ }
1383
+ async processPollResponse(body) {
1384
+ const events = Array.isArray(body.events) ? [...body.events] : [];
1385
+ events.sort((left, right) => left.sequence - right.sequence);
1386
+ let lastSequence = this.pollLastSequence;
1387
+ for (const event of events) {
1388
+ if (!event || typeof event.id !== 'string' || !Number.isFinite(event.sequence)) {
1389
+ continue;
1390
+ }
1391
+ lastSequence = Math.max(lastSequence, event.sequence);
1392
+ if (event.sequence <= this.pollLastSequence ||
1393
+ this.hasRecentPollEventId(event.id) ||
1394
+ this.isSeen(event.id)) {
1395
+ this.duplicateDropCount += 1;
1396
+ continue;
1397
+ }
1398
+ const committed = await this.handlePolledEvent(event);
1399
+ if (!committed) {
1400
+ return false;
1401
+ }
1402
+ this.rememberPollEventId(event.id);
1403
+ }
1404
+ const nextCursor = sanitizeOpaqueStateValue(body.nextCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollCursor;
1405
+ await this.commitPollCursorState(nextCursor, lastSequence);
1406
+ return true;
1407
+ }
1408
+ // eslint-disable-next-line complexity
1409
+ async handlePolledEvent(event) {
1410
+ const type = typeof event.payload.type === 'string' ? event.payload.type : '';
1411
+ const baseOptions = {
1412
+ timestamp: event.timestamp,
1413
+ };
1414
+ switch (type) {
1415
+ case 'message.created':
1416
+ case 'message.received':
1417
+ case 'message.new':
1418
+ case 'message.sent':
1419
+ return (await this.handleRealtimeMessage(event.payload, baseOptions)).committed;
1420
+ case 'thread.reply':
1421
+ case 'thread.message.created':
1422
+ case 'thread.message.sent':
1423
+ return (await this.handleRealtimeThreadReply(event.payload, baseOptions)).committed;
1424
+ case 'dm.received':
1425
+ case 'dm.message.created':
1426
+ case 'direct_message.created':
1427
+ return (await this.handleRealtimeDm(event.payload, baseOptions))
1428
+ .committed;
1429
+ case 'group_dm.received':
1430
+ case 'group_dm.message.created':
1431
+ return (await this.handleRealtimeGroupDm(event.payload, baseOptions)).committed;
1432
+ case 'command.invoked':
1433
+ return (await this.handleRealtimeCommand(event.payload, {
1434
+ ...baseOptions,
1435
+ eventId: event.id,
1436
+ })).committed;
1437
+ case 'reaction.added':
1438
+ return (await this.handleRealtimeReaction(event.payload, 'added', {
1439
+ ...baseOptions,
1440
+ eventId: event.id,
1441
+ })).committed;
1442
+ case 'reaction.removed':
1443
+ return (await this.handleRealtimeReaction(event.payload, 'removed', {
1444
+ ...baseOptions,
1445
+ eventId: event.id,
1446
+ })).committed;
1447
+ default:
1448
+ console.warn(`[gateway] Ignoring unknown polled event type: ${type || 'unknown'}`);
1449
+ return true;
1450
+ }
1451
+ }
1452
+ /** Start the gateway — register agent and subscribe for realtime events. */
1453
+ async start() {
1454
+ if (this.running)
1455
+ return;
1456
+ this.running = true;
1457
+ // Connect to the local OpenClaw gateway WebSocket (persistent connection)
1458
+ const token = this.config.openclawGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN;
1459
+ const port = this.config.openclawGatewayPort ?? DEFAULT_OPENCLAW_GATEWAY_PORT;
1460
+ if (token) {
1461
+ this.openclawClient = await OpenClawGatewayClient.create(token, port);
1462
+ try {
1463
+ await this.openclawClient.connect();
1464
+ console.log('[gateway] OpenClaw gateway WebSocket client ready');
1465
+ }
1466
+ catch (err) {
1467
+ console.warn(`[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}`);
1468
+ }
1469
+ }
1470
+ else {
1471
+ console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled');
800
1472
  }
1473
+ const registered = await this.relaycast.agents.registerOrGet({
1474
+ name: this.config.clawName,
1475
+ type: 'agent',
1476
+ persona: 'Relaycast inbound gateway for OpenClaw',
1477
+ });
1478
+ await this.replaceRelayAgentClient(registered.token);
1479
+ await this.ensureChannelMembership();
1480
+ // Also subscribe explicitly in case the `connected` event fired before
1481
+ // the handler ran, or the SDK defers connection readiness.
1482
+ this.subscribeRelayChannels();
801
1483
  console.log(`[gateway] Realtime listening on channels: ${this.config.channels.join(', ')}`);
802
1484
  // Start spawn control HTTP server
803
1485
  await this.startControlServer();
@@ -805,15 +1487,14 @@ export class InboundGateway {
805
1487
  /** Stop the gateway — clean up websocket and relay clients. */
806
1488
  async stop() {
807
1489
  this.running = false;
808
- for (const unsubscribe of this.unsubscribeHandlers) {
809
- try {
810
- unsubscribe();
811
- }
812
- catch {
813
- // Best effort
814
- }
1490
+ this.stopWsProbeLoop();
1491
+ if (this.wsRecoveryTimer) {
1492
+ clearTimeout(this.wsRecoveryTimer);
1493
+ this.wsRecoveryTimer = null;
815
1494
  }
816
- this.unsubscribeHandlers = [];
1495
+ this.completeFallbackWindow();
1496
+ await this.stopPollLoop();
1497
+ this.cleanupRelaySubscriptions();
817
1498
  if (this.relayAgentClient) {
818
1499
  try {
819
1500
  await this.relayAgentClient.disconnect();
@@ -871,96 +1552,97 @@ export class InboundGateway {
871
1552
  }
872
1553
  }
873
1554
  }
874
- async handleRealtimeMessage(event) {
1555
+ async handleRealtimeMessage(event, options = {}) {
875
1556
  const channel = normalizeChannelName(event.channel);
876
1557
  if (!this.config.channels.includes(channel))
877
- return;
878
- const messageId = event.message?.id;
1558
+ return { committed: true };
1559
+ const messageId = options.eventId ?? event.message?.id;
879
1560
  if (!messageId)
880
- return;
1561
+ return { committed: true };
881
1562
  const inbound = {
882
1563
  id: messageId,
883
1564
  channel,
884
- from: event.message.agentName,
885
- text: event.message.text,
886
- timestamp: new Date().toISOString(),
1565
+ from: event.message?.agentName ?? 'unknown',
1566
+ text: event.message?.text ?? '',
1567
+ timestamp: options.timestamp ?? new Date().toISOString(),
887
1568
  };
888
- await this.handleInbound(inbound);
1569
+ return this.processInbound(inbound);
889
1570
  }
890
- async handleRealtimeThreadReply(event) {
1571
+ async handleRealtimeThreadReply(event, options = {}) {
891
1572
  const channel = normalizeChannelName(event.channel);
892
1573
  if (!this.config.channels.includes(channel))
893
- return;
894
- const messageId = event.message?.id;
1574
+ return { committed: true };
1575
+ const messageId = options.eventId ?? event.message?.id;
895
1576
  if (!messageId)
896
- return;
1577
+ return { committed: true };
897
1578
  const inbound = {
898
1579
  id: messageId,
899
1580
  channel,
900
- from: event.message.agentName,
901
- text: event.message.text,
902
- timestamp: new Date().toISOString(),
1581
+ from: event.message?.agentName ?? 'unknown',
1582
+ text: event.message?.text ?? '',
1583
+ timestamp: options.timestamp ?? new Date().toISOString(),
903
1584
  threadParentId: event.parentId,
904
1585
  };
905
- await this.handleInbound(inbound);
1586
+ return this.processInbound(inbound);
906
1587
  }
907
- async handleRealtimeDm(event) {
908
- const messageId = event.message?.id;
1588
+ async handleRealtimeDm(event, options = {}) {
1589
+ const messageId = options.eventId ?? event.message?.id;
909
1590
  if (!messageId)
910
- return;
1591
+ return { committed: true };
911
1592
  const inbound = {
912
1593
  id: messageId,
913
1594
  channel: 'dm',
914
- from: event.message.agentName,
915
- text: event.message.text,
916
- timestamp: new Date().toISOString(),
1595
+ from: event.message?.agentName ?? 'unknown',
1596
+ text: event.message?.text ?? '',
1597
+ timestamp: options.timestamp ?? new Date().toISOString(),
917
1598
  conversationId: event.conversationId,
918
1599
  kind: 'dm',
919
1600
  };
920
- await this.handleInbound(inbound);
1601
+ return this.processInbound(inbound);
921
1602
  }
922
- async handleRealtimeGroupDm(event) {
923
- const messageId = event.message?.id;
1603
+ async handleRealtimeGroupDm(event, options = {}) {
1604
+ const messageId = options.eventId ?? event.message?.id;
924
1605
  if (!messageId)
925
- return;
1606
+ return { committed: true };
926
1607
  const inbound = {
927
1608
  id: messageId,
928
1609
  channel: `groupdm:${event.conversationId}`,
929
- from: event.message.agentName,
930
- text: event.message.text,
931
- timestamp: new Date().toISOString(),
1610
+ from: event.message?.agentName ?? 'unknown',
1611
+ text: event.message?.text ?? '',
1612
+ timestamp: options.timestamp ?? new Date().toISOString(),
932
1613
  conversationId: event.conversationId,
933
1614
  kind: 'groupdm',
934
1615
  };
935
- await this.handleInbound(inbound);
1616
+ return this.processInbound(inbound);
936
1617
  }
937
- async handleRealtimeCommand(event) {
1618
+ async handleRealtimeCommand(event, options = {}) {
938
1619
  const channel = normalizeChannelName(event.channel);
939
1620
  if (!this.config.channels.includes(channel))
940
- return;
1621
+ return { committed: true };
941
1622
  // Commands lack a server-assigned event ID, so we synthesize one.
942
1623
  // We include args + timestamp to avoid silently dropping legitimate
943
1624
  // repeat invocations (e.g. /deploy twice in 15 min). This means SDK
944
1625
  // reconnection replays may deliver a duplicate, but that's less
945
1626
  // harmful than silently swallowing a real command.
946
1627
  const argsSlug = event.args ? `_${event.args}` : '';
947
- const syntheticId = `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`;
1628
+ const syntheticId = options.eventId ?? `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`;
948
1629
  const argsText = event.args ? ` ${event.args}` : '';
949
1630
  const inbound = {
950
1631
  id: syntheticId,
951
1632
  channel,
952
1633
  from: event.invokedBy,
953
1634
  text: `[relaycast:command:${channel}] @${event.invokedBy} /${event.command}${argsText}`,
954
- timestamp: new Date().toISOString(),
1635
+ timestamp: options.timestamp ?? new Date().toISOString(),
955
1636
  kind: 'command',
956
1637
  };
957
- await this.handleInbound(inbound);
1638
+ return this.processInbound(inbound);
958
1639
  }
959
- async handleRealtimeReaction(event, action) {
1640
+ async handleRealtimeReaction(event, action, options = {}) {
960
1641
  // Include timestamp so add→remove→re-add of the same emoji isn't
961
1642
  // silently dropped within the 15-min dedup window. Reactions are soft
962
1643
  // notifications, so a rare duplicate on SDK reconnect is acceptable.
963
- const syntheticId = `reaction_${event.messageId}_${event.emoji}_${event.agentName}_${action}_${Date.now()}`;
1644
+ const syntheticId = options.eventId ??
1645
+ `reaction_${event.messageId}_${event.emoji}_${event.agentName}_${action}_${Date.now()}`;
964
1646
  const text = action === 'added'
965
1647
  ? `[relaycast:reaction] @${event.agentName} reacted ${event.emoji} to message ${event.messageId} (soft notification, no action required)`
966
1648
  : `[relaycast:reaction] @${event.agentName} removed ${event.emoji} from message ${event.messageId} (soft notification, no action required)`;
@@ -969,49 +1651,57 @@ export class InboundGateway {
969
1651
  channel: 'reaction',
970
1652
  from: event.agentName,
971
1653
  text,
972
- timestamp: new Date().toISOString(),
1654
+ timestamp: options.timestamp ?? new Date().toISOString(),
973
1655
  kind: 'reaction',
974
1656
  };
975
- await this.handleInbound(inbound);
1657
+ return this.processInbound(inbound);
976
1658
  }
977
- async handleInbound(message) {
1659
+ async processInbound(message) {
978
1660
  if (!this.running)
979
- return;
980
- if (this.processingMessageIds.has(message.id) || this.isSeen(message.id))
981
- return;
1661
+ return { committed: false };
1662
+ if (this.processingMessageIds.has(message.id) || this.isSeen(message.id)) {
1663
+ this.duplicateDropCount += 1;
1664
+ return { committed: true, reason: 'duplicate' };
1665
+ }
982
1666
  // Avoid echo loops — skip messages from this claw.
983
1667
  if (message.from === this.config.clawName) {
984
- // Only update cursor for real channels with real (non-synthetic) message IDs.
985
1668
  this.markSeen(message.id);
986
- return;
1669
+ return { committed: true, reason: 'echo' };
987
1670
  }
988
- // Mark as seen immediately to prevent duplicate delivery from concurrent
989
- // realtime events processing the same message.
990
- this.markSeen(message.id);
991
1671
  this.processingMessageIds.add(message.id);
992
1672
  console.log(`[gateway] Delivering message ${message.id} from @${message.from}: "${message.text}"`);
993
1673
  try {
994
1674
  const result = await this.onMessage(message);
995
1675
  console.log(`[gateway] Delivery result: ${result.method} ok=${result.ok}${result.error ? ' error=' + result.error : ''}`);
1676
+ if (!result.ok) {
1677
+ return { committed: false, result };
1678
+ }
1679
+ this.markSeen(message.id);
1680
+ return { committed: true, result };
996
1681
  }
997
1682
  finally {
998
1683
  this.processingMessageIds.delete(message.id);
999
1684
  }
1000
1685
  }
1001
- /** Format delivery text with channel, sender, and optional thread prefix. */
1686
+ /** Format delivery text with channel, sender, and response hint. */
1002
1687
  formatDeliveryText(message) {
1003
- // Pre-formatted kinds (command, reaction) already have the full text.
1004
- if (message.kind === 'command' || message.kind === 'reaction') {
1688
+ // Pre-formatted kinds (reaction) already have the full text with hints.
1689
+ if (message.kind === 'reaction') {
1005
1690
  return message.text;
1006
1691
  }
1692
+ if (message.kind === 'command') {
1693
+ return `${message.text}\n(command invocation — respond with: post_message channel="${message.channel}")`;
1694
+ }
1007
1695
  if (message.kind === 'dm') {
1008
- return `[relaycast:dm] @${message.from}: ${message.text}`;
1696
+ return `[relaycast:dm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`;
1009
1697
  }
1010
1698
  if (message.kind === 'groupdm') {
1011
- return `[relaycast:groupdm] @${message.from}: ${message.text}`;
1699
+ return `[relaycast:groupdm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`;
1700
+ }
1701
+ if (message.threadParentId) {
1702
+ return `[thread] [relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: reply_to_thread message_id="${message.threadParentId}")`;
1012
1703
  }
1013
- const threadPrefix = message.threadParentId ? '[thread] ' : '';
1014
- return `${threadPrefix}[relaycast:${message.channel}] @${message.from}: ${message.text}`;
1704
+ return `[relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: post_message channel="${message.channel}" or reply_to_thread message_id="${message.id}")`;
1015
1705
  }
1016
1706
  /** Handle an inbound Relaycast message. */
1017
1707
  async onMessage(message) {
@@ -1076,6 +1766,7 @@ export class InboundGateway {
1076
1766
  });
1077
1767
  });
1078
1768
  }
1769
+ // eslint-disable-next-line complexity
1079
1770
  async handleControlRequest(req, res) {
1080
1771
  const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
1081
1772
  const path = url.pathname;
@@ -1088,6 +1779,7 @@ export class InboundGateway {
1088
1779
  status: 'running',
1089
1780
  active: this.spawnManager.size,
1090
1781
  uptime: process.uptime(),
1782
+ transport: this.transportHealthSnapshot(),
1091
1783
  }));
1092
1784
  return;
1093
1785
  }
@@ -1135,7 +1827,7 @@ export class InboundGateway {
1135
1827
  res.end(JSON.stringify({
1136
1828
  ok: true,
1137
1829
  active: handles.length,
1138
- claws: handles.map(h => ({
1830
+ claws: handles.map((h) => ({
1139
1831
  name: h.displayName,
1140
1832
  agentName: h.agentName,
1141
1833
  id: h.id,