echoclaw-relay-agent 0.8.2 → 0.9.4

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.
@@ -30,6 +30,7 @@ export declare class RelayAgent extends EventEmitter {
30
30
  private _stopped;
31
31
  private _starting;
32
32
  private _paired;
33
+ private _rekeyRetryCount;
33
34
  private _activePairing;
34
35
  private readonly config;
35
36
  private readonly subscribers;
@@ -97,6 +97,12 @@ export class RelayAgent extends EventEmitter {
97
97
  writable: true,
98
98
  value: false
99
99
  });
100
+ Object.defineProperty(this, "_rekeyRetryCount", {
101
+ enumerable: true,
102
+ configurable: true,
103
+ writable: true,
104
+ value: 0
105
+ });
100
106
  Object.defineProperty(this, "_activePairing", {
101
107
  enumerable: true,
102
108
  configurable: true,
@@ -150,6 +156,7 @@ export class RelayAgent extends EventEmitter {
150
156
  this._stopped = false;
151
157
  // Teardown previous transport before starting new
152
158
  if (this.transport) {
159
+ this.transport.removeAllListeners(); // Prevent listener accumulation
153
160
  this.transport.disconnect();
154
161
  this.transport = null;
155
162
  }
@@ -210,10 +217,14 @@ export class RelayAgent extends EventEmitter {
210
217
  this._activePairing?.abort();
211
218
  this._activePairing = null;
212
219
  this.gatewayManager?.stop();
213
- this.transport?.disconnect();
214
- this.transport = null;
220
+ if (this.transport) {
221
+ this.transport.removeAllListeners(); // Prevent leak
222
+ this.transport.disconnect();
223
+ this.transport = null;
224
+ }
215
225
  this.sessionKey = null;
216
226
  this.frameCrypto = null;
227
+ this._dataHandler = null;
217
228
  this.setStatus('disconnected');
218
229
  }
219
230
  // ── Private ────────────────────────────────────────────────
@@ -285,6 +296,11 @@ export class RelayAgent extends EventEmitter {
285
296
  this.frameCrypto = new FrameCrypto(result.sessionKey);
286
297
  this._paired = true;
287
298
  this._activePairing = null;
299
+ // Update transport URL so auto-reconnect uses ?resume=sessionId
300
+ // instead of the original ?code=XXXX (which was consumed after first pairing)
301
+ if (this.transport) {
302
+ this.transport.setUrl(`${this.config.relayServer}/agent/connect?resume=${result.sessionId}`);
303
+ }
288
304
  // Persist session for future auto-resume
289
305
  await this.sessionStore.save({
290
306
  pairingCode: result.pairingCode,
@@ -375,15 +391,20 @@ export class RelayAgent extends EventEmitter {
375
391
  if (this._paired && this.transport) {
376
392
  // Transport reconnected after prior successful pairing —
377
393
  // redo ECDH because desktop may have new ephemeral keys.
378
- // Cancel any in-flight pairing and invalidate old crypto state.
394
+ // Cancel any in-flight pairing but KEEP old crypto state
395
+ // so data handler can still decrypt until re-key succeeds.
379
396
  this._activePairing?.abort();
380
- this.sessionKey = null;
381
- this.frameCrypto = null;
397
+ // CRITICAL: Do NOT null sessionKey/frameCrypto here.
398
+ // If re-key fails, we want the data handler to keep working
399
+ // with the old keys rather than dropping all messages.
382
400
  this.setStatus('connecting');
383
401
  const pairing = new PairingProtocol(this.transport);
384
402
  this._activePairing = pairing;
385
- pairing.pairAsAgent()
403
+ // Use shorter timeout for re-key (30s) vs initial pairing (10 min).
404
+ // If desktop is online, ECDH completes in <1s; 30s gives ample margin.
405
+ pairing.pairAsAgent(undefined, 30000)
386
406
  .then(result => {
407
+ this._rekeyRetryCount = 0; // Reset on success
387
408
  this.onPaired(result);
388
409
  // V2: Reattach gateway callbacks after reconnect
389
410
  if (this.gatewayManager) {
@@ -397,6 +418,28 @@ export class RelayAgent extends EventEmitter {
397
418
  })
398
419
  .catch(err => {
399
420
  this._activePairing = null;
421
+ this._rekeyRetryCount++;
422
+ // Circuit breaker: after 5 consecutive failures, force full reconnect cycle
423
+ // (disconnect + restart resets the relay server connection from scratch)
424
+ if (this._rekeyRetryCount >= 5) {
425
+ this._rekeyRetryCount = 0; // Reset for next cycle
426
+ this.emit('error', new Error(`Re-key failed 5 times, forcing fresh reconnect`));
427
+ if (this.transport) {
428
+ this.transport.disconnect();
429
+ this.transport.restart();
430
+ }
431
+ return;
432
+ }
433
+ // Re-key failed — keep old crypto intact, mark as connected
434
+ // (data handler still works with old keys if desktop didn't re-key either)
435
+ if (this._paired && this.sessionKey) {
436
+ this.setStatus('connected');
437
+ }
438
+ // If WS is still OPEN, force reconnect to trigger fresh on('open')
439
+ if (this.transport?.isConnected) {
440
+ this.transport.disconnect();
441
+ this.transport.restart();
442
+ }
400
443
  this.emit('error', err);
401
444
  });
402
445
  }
@@ -36,6 +36,7 @@ export declare class RelayClient extends EventEmitter {
36
36
  private _stopped;
37
37
  private _paired;
38
38
  private _needsRekey;
39
+ private _rekeyRetryCount;
39
40
  private _activePairing;
40
41
  private readonly config;
41
42
  private readonly sessionStore;
@@ -91,6 +91,12 @@ export class RelayClient extends EventEmitter {
91
91
  writable: true,
92
92
  value: false
93
93
  });
94
+ Object.defineProperty(this, "_rekeyRetryCount", {
95
+ enumerable: true,
96
+ configurable: true,
97
+ writable: true,
98
+ value: 0
99
+ });
94
100
  Object.defineProperty(this, "_activePairing", {
95
101
  enumerable: true,
96
102
  configurable: true,
@@ -253,13 +259,21 @@ export class RelayClient extends EventEmitter {
253
259
  await this.onPaired(result, 'resumed');
254
260
  }
255
261
  catch (err) {
256
- // Resume failed — clean up transport, clear stale session to prevent infinite retry loop,
257
- // and notify caller so UI can prompt for fresh pairing code.
262
+ // Resume failed — clean up transport
258
263
  this.transport?.disconnect();
259
264
  this.transport = null;
260
265
  this.frameCrypto = null;
261
266
  this.setStatus('disconnected');
262
- await this.sessionStore.clear();
267
+ // Only clear session for permanent errors (server says session is dead).
268
+ // Transient errors (network, timeout) keep session.json for retry.
269
+ const errMsg = err instanceof Error ? err.message : String(err);
270
+ const isPermanent = errMsg.includes('DEVICE_TOKEN_MISMATCH') ||
271
+ errMsg.includes('SESSION_NOT_FOUND') ||
272
+ errMsg.includes('SESSION_EXPIRED') ||
273
+ errMsg.includes('INVALID_SESSION');
274
+ if (isPermanent) {
275
+ await this.sessionStore.clear();
276
+ }
263
277
  this.emit('resume_failed', {
264
278
  error: err instanceof Error ? err : new Error(String(err)),
265
279
  sessionId: session.relaySessionId,
@@ -354,7 +368,7 @@ export class RelayClient extends EventEmitter {
354
368
  if (this._paired && this._needsRekey && this.pairingCode && this.transport) {
355
369
  // Server requested re-ECDH (e.g. AGENT_RESTARTED) —
356
370
  // cancel any in-flight pairing and start fresh handshake.
357
- // Invalidate old crypto state to prevent sending with stale keys.
371
+ // Invalidate old crypto state: agent has new keys, old ones are useless.
358
372
  this._needsRekey = false;
359
373
  this._activePairing?.abort();
360
374
  this.sessionKey = null;
@@ -363,9 +377,30 @@ export class RelayClient extends EventEmitter {
363
377
  const pairing = new PairingProtocol(this.transport);
364
378
  this._activePairing = pairing;
365
379
  pairing.pairAsClient(this.pairingCode)
366
- .then(result => this.onPaired(result, 'resumed'))
380
+ .then(result => {
381
+ this._rekeyRetryCount = 0; // Reset on success
382
+ this.onPaired(result, 'resumed');
383
+ })
367
384
  .catch(err => {
368
385
  this._activePairing = null;
386
+ this._rekeyRetryCount++;
387
+ // Circuit breaker: stop after 5 consecutive re-key failures
388
+ if (this._rekeyRetryCount >= 5) {
389
+ this._needsRekey = false;
390
+ this.emit('error', new Error(`Re-key failed after ${this._rekeyRetryCount} retries, giving up`));
391
+ // Force transport to close and reconnect from scratch
392
+ this.transport?.disconnect();
393
+ return;
394
+ }
395
+ // Re-key failed — set _needsRekey back to true for retry.
396
+ this._needsRekey = true;
397
+ // If WS is still OPEN (pairing failed at protocol level, not transport level),
398
+ // _needsRekey won't be consumed until next on('open').
399
+ // Force a reconnect to trigger a fresh on('open').
400
+ if (this.transport?.isConnected) {
401
+ this.transport.disconnect();
402
+ this.transport.restart();
403
+ }
369
404
  this.emit('error', err);
370
405
  });
371
406
  }
@@ -1,28 +1,164 @@
1
1
  /**
2
- * ChatHandler — Routes chat messages to local OpenClaw via OpenAI-compatible API.
2
+ * ChatHandler — Routes chat messages to local OpenClaw via WebSocket Gateway.
3
3
  *
4
- * Receives decrypted messages from desktop, forwards to localhost:18789,
5
- * returns AI responses back through the relay.
4
+ * Receives decrypted messages from desktop, forwards to OpenClaw WS gateway
5
+ * (localhost:18789) using Ed25519 challenge-response auth, returns AI responses
6
+ * back through the relay.
7
+ *
8
+ * Zero-config: uses OpenClaw's silent local pairing — no user settings needed.
9
+ *
10
+ * **Async model:**
11
+ * OpenClaw chat is fully async — chat.send RPC returns immediately with
12
+ * { status:"started", runId:"..." }. AI responses arrive as streaming 'chat' events
13
+ * sometime later (could be seconds or minutes for complex tasks).
14
+ *
15
+ * This handler:
16
+ * 1. Registers a persistent sendBack callback (set once when desktop connects)
17
+ * 2. Listens for ALL chat events from OpenClaw, forwarding to desktop continuously
18
+ * 3. Tracks run IDs so we can correlate and send typing indicators
6
19
  *
7
20
  * Message types handled:
8
- * - system_prompt: stored as conversation context
9
- * - chat: forwarded to OpenClaw /v1/chat/completions
21
+ * - chat: forwarded to OpenClaw via chat.send RPC
22
+ * - system_prompt: stored as conversation context (future use)
23
+ * - chat_abort: cancels current AI generation
24
+ *
25
+ * Response types sent back to desktop:
26
+ * - chat_delta: streaming text fragment (incremental)
27
+ * - chat_reply: final complete response
28
+ * - chat_typing: typing indicator
29
+ * - chat_error: error message
10
30
  */
31
+ import { OpenClawWsClient } from '../gateway/OpenClawWsClient.js';
11
32
  export interface ChatMessage {
12
33
  type: string;
13
34
  [key: string]: unknown;
14
35
  }
36
+ export interface ChatHandlerConfig {
37
+ /** OpenClaw gateway port. Default: 18789 */
38
+ port?: number;
39
+ /** OpenClaw gateway host. Default: '127.0.0.1' */
40
+ host?: string;
41
+ /** State directory for device identity. Default: ~/.echoclaw/ */
42
+ stateDir?: string;
43
+ /** Session key for OpenClaw conversations. Default: 'main' */
44
+ sessionKey?: string;
45
+ /** Request timeout in ms. Default: 30_000 (30s for RPC only, events are async) */
46
+ requestTimeoutMs?: number;
47
+ }
15
48
  export declare class ChatHandler {
16
- private history;
17
- private readonly openClawUrl;
18
- constructor(openClawPort?: number);
49
+ private readonly wsClient;
50
+ private readonly sessionKey;
51
+ private readonly requestTimeoutMs;
52
+ private _connecting;
53
+ private _connected;
54
+ /**
55
+ * Persistent callback to send messages back to desktop.
56
+ * Set via setSendBack() when desktop connects to relay.
57
+ * Cleared when desktop disconnects.
58
+ */
59
+ private _sendBack;
60
+ /**
61
+ * Active chat runs — tracks ongoing AI responses.
62
+ * Key = runId, Value = run state.
63
+ */
64
+ private _activeRuns;
65
+ /** Periodic cleanup timer for stale runs. */
66
+ private _runCleanupTimer;
67
+ /** Pending agent lifecycle cleanup timers (for proper cleanup on disconnect). */
68
+ private _lifecycleTimers;
69
+ /** Buffered messages when _sendBack is null (relay disconnected but OpenClaw still active). */
70
+ private _messageBuffer;
71
+ constructor(config?: ChatHandlerConfig);
72
+ /** Expose the underlying WS client for event listening. */
73
+ get client(): OpenClawWsClient;
74
+ /** Whether the OpenClaw gateway connection is active. */
75
+ get isConnected(): boolean;
76
+ /**
77
+ * Set the persistent callback for sending messages back to desktop.
78
+ * Call this when the desktop-side relay connection is established.
79
+ */
80
+ setSendBack(fn: (msg: ChatMessage) => Promise<void>): void;
81
+ /**
82
+ * Clear the send-back callback (call when desktop disconnects).
83
+ */
84
+ clearSendBack(): void;
85
+ /**
86
+ * Connect to OpenClaw gateway. Non-blocking if already connected/connecting.
87
+ * Throws on failure (caller should handle gracefully).
88
+ */
89
+ connect(): Promise<void>;
19
90
  /**
20
91
  * Handle an incoming message from desktop.
21
- * Returns an array of response messages to send back.
92
+ * sendBack is used for immediate error responses only;
93
+ * streaming responses use the persistent _sendBack callback.
94
+ */
95
+ handle(payload: any, sendBack: (msg: ChatMessage) => Promise<void>): Promise<void>;
96
+ /** Disconnect from OpenClaw gateway. */
97
+ disconnect(): void;
98
+ /**
99
+ * Clear pending run state (call on relay disconnect).
100
+ * Does NOT clear sendBack or message buffer — OpenClaw may still be processing
101
+ * and we want to buffer responses for when relay reconnects.
22
102
  */
23
- handle(payload: any): Promise<ChatMessage[]>;
24
- /** Clear conversation history (call on disconnect/reconnect). */
25
103
  clearHistory(): void;
26
- private forwardToOpenClaw;
27
- private trimHistory;
104
+ private _handleChat;
105
+ private _handleChatAbort;
106
+ private _handleChatHistory;
107
+ /**
108
+ * Handle streaming chat events from OpenClaw gateway.
109
+ *
110
+ * Event structure (verified from OpenClaw source):
111
+ * {
112
+ * runId: string,
113
+ * sessionKey: string,
114
+ * seq: number,
115
+ * state: "delta" | "final",
116
+ * message?: {
117
+ * role: "assistant",
118
+ * content: [{ type: "text", text: string }],
119
+ * timestamp: number
120
+ * }
121
+ * }
122
+ *
123
+ * IMPORTANT: delta events contain ACCUMULATED text (full text so far),
124
+ * NOT incremental deltas. We compute the incremental part ourselves.
125
+ */
126
+ private _handleChatEvent;
127
+ /**
128
+ * Handle side result events — "btw" async task completions.
129
+ * These arrive when OpenClaw finishes a background task and wants
130
+ * to proactively inform the user.
131
+ */
132
+ private _handleSideResult;
133
+ /**
134
+ * Handle agent lifecycle events.
135
+ * - stream: "lifecycle", data.phase: "start" → typing indicator ON
136
+ * - stream: "lifecycle", data.phase: "end" | "error" → typing indicator OFF (if no chat final)
137
+ * - stream: "assistant" → streaming text from agent (also comes as 'chat' delta)
138
+ */
139
+ private _handleAgentEvent;
140
+ /** Cancel all pending agent lifecycle cleanup timers. */
141
+ private _clearLifecycleTimers;
142
+ /** Start periodic cleanup of stale runs (prevents memory leak). */
143
+ private _startRunCleanup;
144
+ private _stopRunCleanup;
145
+ /**
146
+ * Extract text from OpenClaw message format.
147
+ * message.content is an array of content blocks; we concatenate text blocks.
148
+ */
149
+ private _extractText;
150
+ /** Send a message back to desktop via the persistent callback. Buffers if disconnected. */
151
+ private _send;
152
+ /** Sequential send queue to prevent message reordering between flush and real-time messages. */
153
+ private _sendQueue;
154
+ private _enqueueSend;
155
+ /** Flush buffered messages to desktop after relay reconnects (sequential, preserves order). */
156
+ private _flushMessageBuffer;
157
+ /**
158
+ * Send a message via a captured sendBack reference.
159
+ * Used to avoid race conditions: the callback is captured synchronously
160
+ * at the top of event handlers, so clearSendBack() at an await boundary
161
+ * won't null it between the check and the call.
162
+ */
163
+ private _sendVia;
28
164
  }