echoclaw-relay-agent 0.8.2 → 0.9.3

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.
@@ -150,6 +150,7 @@ export class RelayAgent extends EventEmitter {
150
150
  this._stopped = false;
151
151
  // Teardown previous transport before starting new
152
152
  if (this.transport) {
153
+ this.transport.removeAllListeners(); // Prevent listener accumulation
153
154
  this.transport.disconnect();
154
155
  this.transport = null;
155
156
  }
@@ -210,10 +211,14 @@ export class RelayAgent extends EventEmitter {
210
211
  this._activePairing?.abort();
211
212
  this._activePairing = null;
212
213
  this.gatewayManager?.stop();
213
- this.transport?.disconnect();
214
- this.transport = null;
214
+ if (this.transport) {
215
+ this.transport.removeAllListeners(); // Prevent leak
216
+ this.transport.disconnect();
217
+ this.transport = null;
218
+ }
215
219
  this.sessionKey = null;
216
220
  this.frameCrypto = null;
221
+ this._dataHandler = null;
217
222
  this.setStatus('disconnected');
218
223
  }
219
224
  // ── Private ────────────────────────────────────────────────
@@ -285,6 +290,11 @@ export class RelayAgent extends EventEmitter {
285
290
  this.frameCrypto = new FrameCrypto(result.sessionKey);
286
291
  this._paired = true;
287
292
  this._activePairing = null;
293
+ // Update transport URL so auto-reconnect uses ?resume=sessionId
294
+ // instead of the original ?code=XXXX (which was consumed after first pairing)
295
+ if (this.transport) {
296
+ this.transport.setUrl(`${this.config.relayServer}/agent/connect?resume=${result.sessionId}`);
297
+ }
288
298
  // Persist session for future auto-resume
289
299
  await this.sessionStore.save({
290
300
  pairingCode: result.pairingCode,
@@ -375,14 +385,18 @@ export class RelayAgent extends EventEmitter {
375
385
  if (this._paired && this.transport) {
376
386
  // Transport reconnected after prior successful pairing —
377
387
  // redo ECDH because desktop may have new ephemeral keys.
378
- // Cancel any in-flight pairing and invalidate old crypto state.
388
+ // Cancel any in-flight pairing but KEEP old crypto state
389
+ // so data handler can still decrypt until re-key succeeds.
379
390
  this._activePairing?.abort();
380
- this.sessionKey = null;
381
- this.frameCrypto = null;
391
+ // CRITICAL: Do NOT null sessionKey/frameCrypto here.
392
+ // If re-key fails, we want the data handler to keep working
393
+ // with the old keys rather than dropping all messages.
382
394
  this.setStatus('connecting');
383
395
  const pairing = new PairingProtocol(this.transport);
384
396
  this._activePairing = pairing;
385
- pairing.pairAsAgent()
397
+ // Use shorter timeout for re-key (30s) vs initial pairing (10 min).
398
+ // If desktop is online, ECDH completes in <1s; 30s gives ample margin.
399
+ pairing.pairAsAgent(undefined, 30000)
386
400
  .then(result => {
387
401
  this.onPaired(result);
388
402
  // V2: Reattach gateway callbacks after reconnect
@@ -397,6 +411,11 @@ export class RelayAgent extends EventEmitter {
397
411
  })
398
412
  .catch(err => {
399
413
  this._activePairing = null;
414
+ // Re-key failed — keep old crypto intact, mark as connected
415
+ // (data handler still works with old keys if desktop didn't re-key either)
416
+ if (this._paired && this.sessionKey) {
417
+ this.setStatus('connected');
418
+ }
400
419
  this.emit('error', err);
401
420
  });
402
421
  }
@@ -253,13 +253,21 @@ export class RelayClient extends EventEmitter {
253
253
  await this.onPaired(result, 'resumed');
254
254
  }
255
255
  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.
256
+ // Resume failed — clean up transport
258
257
  this.transport?.disconnect();
259
258
  this.transport = null;
260
259
  this.frameCrypto = null;
261
260
  this.setStatus('disconnected');
262
- await this.sessionStore.clear();
261
+ // Only clear session for permanent errors (server says session is dead).
262
+ // Transient errors (network, timeout) keep session.json for retry.
263
+ const errMsg = err instanceof Error ? err.message : String(err);
264
+ const isPermanent = errMsg.includes('DEVICE_TOKEN_MISMATCH') ||
265
+ errMsg.includes('SESSION_NOT_FOUND') ||
266
+ errMsg.includes('SESSION_EXPIRED') ||
267
+ errMsg.includes('INVALID_SESSION');
268
+ if (isPermanent) {
269
+ await this.sessionStore.clear();
270
+ }
263
271
  this.emit('resume_failed', {
264
272
  error: err instanceof Error ? err : new Error(String(err)),
265
273
  sessionId: session.relaySessionId,
@@ -354,7 +362,7 @@ export class RelayClient extends EventEmitter {
354
362
  if (this._paired && this._needsRekey && this.pairingCode && this.transport) {
355
363
  // Server requested re-ECDH (e.g. AGENT_RESTARTED) —
356
364
  // cancel any in-flight pairing and start fresh handshake.
357
- // Invalidate old crypto state to prevent sending with stale keys.
365
+ // Invalidate old crypto state: agent has new keys, old ones are useless.
358
366
  this._needsRekey = false;
359
367
  this._activePairing?.abort();
360
368
  this.sessionKey = null;
@@ -366,6 +374,9 @@ export class RelayClient extends EventEmitter {
366
374
  .then(result => this.onPaired(result, 'resumed'))
367
375
  .catch(err => {
368
376
  this._activePairing = null;
377
+ // Re-key failed — set _needsRekey back to true so next reconnect retries.
378
+ // Without this, the client gets stuck with no crypto and no recovery path.
379
+ this._needsRekey = true;
369
380
  this.emit('error', err);
370
381
  });
371
382
  }
@@ -1,28 +1,161 @@
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
+ /** Flush buffered messages to desktop after relay reconnects. */
153
+ private _flushMessageBuffer;
154
+ /**
155
+ * Send a message via a captured sendBack reference.
156
+ * Used to avoid race conditions: the callback is captured synchronously
157
+ * at the top of event handlers, so clearSendBack() at an await boundary
158
+ * won't null it between the check and the call.
159
+ */
160
+ private _sendVia;
28
161
  }