echoclaw-relay-agent 0.8.1 → 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.
@@ -29,6 +29,8 @@ export declare class RelayAgent extends EventEmitter {
29
29
  private _dataHandler;
30
30
  private _stopped;
31
31
  private _starting;
32
+ private _paired;
33
+ private _activePairing;
32
34
  private readonly config;
33
35
  private readonly subscribers;
34
36
  private gatewayManager;
@@ -91,6 +91,18 @@ export class RelayAgent extends EventEmitter {
91
91
  writable: true,
92
92
  value: false
93
93
  });
94
+ Object.defineProperty(this, "_paired", {
95
+ enumerable: true,
96
+ configurable: true,
97
+ writable: true,
98
+ value: false
99
+ });
100
+ Object.defineProperty(this, "_activePairing", {
101
+ enumerable: true,
102
+ configurable: true,
103
+ writable: true,
104
+ value: null
105
+ });
94
106
  Object.defineProperty(this, "config", {
95
107
  enumerable: true,
96
108
  configurable: true,
@@ -138,6 +150,7 @@ export class RelayAgent extends EventEmitter {
138
150
  this._stopped = false;
139
151
  // Teardown previous transport before starting new
140
152
  if (this.transport) {
153
+ this.transport.removeAllListeners(); // Prevent listener accumulation
141
154
  this.transport.disconnect();
142
155
  this.transport = null;
143
156
  }
@@ -194,11 +207,18 @@ export class RelayAgent extends EventEmitter {
194
207
  /** Gracefully stop the agent. */
195
208
  async stop() {
196
209
  this._stopped = true;
210
+ this._paired = false;
211
+ this._activePairing?.abort();
212
+ this._activePairing = null;
197
213
  this.gatewayManager?.stop();
198
- this.transport?.disconnect();
199
- this.transport = null;
214
+ if (this.transport) {
215
+ this.transport.removeAllListeners(); // Prevent leak
216
+ this.transport.disconnect();
217
+ this.transport = null;
218
+ }
200
219
  this.sessionKey = null;
201
220
  this.frameCrypto = null;
221
+ this._dataHandler = null;
202
222
  this.setStatus('disconnected');
203
223
  }
204
224
  // ── Private ────────────────────────────────────────────────
@@ -268,6 +288,13 @@ export class RelayAgent extends EventEmitter {
268
288
  this.sessionId = result.sessionId;
269
289
  this.pairingCode = result.pairingCode;
270
290
  this.frameCrypto = new FrameCrypto(result.sessionKey);
291
+ this._paired = true;
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
+ }
271
298
  // Persist session for future auto-resume
272
299
  await this.sessionStore.save({
273
300
  pairingCode: result.pairingCode,
@@ -355,18 +382,42 @@ export class RelayAgent extends EventEmitter {
355
382
  this.emit('error', err);
356
383
  });
357
384
  this.transport.on('open', () => {
358
- if (this.sessionKey) {
359
- this.setStatus('connected');
360
- this.emit('connected');
361
- // V2: Reattach gateway callbacks after reconnect
362
- if (this.gatewayManager) {
363
- this.gatewayManager.onReconnected(async (r) => {
364
- try {
365
- await this.send(r);
366
- }
367
- catch { /* send may fail */ }
368
- });
369
- }
385
+ if (this._paired && this.transport) {
386
+ // Transport reconnected after prior successful pairing —
387
+ // redo ECDH because desktop may have new ephemeral keys.
388
+ // Cancel any in-flight pairing but KEEP old crypto state
389
+ // so data handler can still decrypt until re-key succeeds.
390
+ this._activePairing?.abort();
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.
394
+ this.setStatus('connecting');
395
+ const pairing = new PairingProtocol(this.transport);
396
+ this._activePairing = pairing;
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)
400
+ .then(result => {
401
+ this.onPaired(result);
402
+ // V2: Reattach gateway callbacks after reconnect
403
+ if (this.gatewayManager) {
404
+ this.gatewayManager.onReconnected(async (r) => {
405
+ try {
406
+ await this.send(r);
407
+ }
408
+ catch { /* send may fail */ }
409
+ });
410
+ }
411
+ })
412
+ .catch(err => {
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
+ }
419
+ this.emit('error', err);
420
+ });
370
421
  }
371
422
  });
372
423
  }
@@ -34,6 +34,9 @@ export declare class RelayClient extends EventEmitter {
34
34
  private _status;
35
35
  private _connecting;
36
36
  private _stopped;
37
+ private _paired;
38
+ private _needsRekey;
39
+ private _activePairing;
37
40
  private readonly config;
38
41
  private readonly sessionStore;
39
42
  private readonly subscribers;
@@ -79,6 +79,24 @@ export class RelayClient extends EventEmitter {
79
79
  writable: true,
80
80
  value: false
81
81
  });
82
+ Object.defineProperty(this, "_paired", {
83
+ enumerable: true,
84
+ configurable: true,
85
+ writable: true,
86
+ value: false
87
+ });
88
+ Object.defineProperty(this, "_needsRekey", {
89
+ enumerable: true,
90
+ configurable: true,
91
+ writable: true,
92
+ value: false
93
+ });
94
+ Object.defineProperty(this, "_activePairing", {
95
+ enumerable: true,
96
+ configurable: true,
97
+ writable: true,
98
+ value: null
99
+ });
82
100
  Object.defineProperty(this, "config", {
83
101
  enumerable: true,
84
102
  configurable: true,
@@ -178,6 +196,10 @@ export class RelayClient extends EventEmitter {
178
196
  /** Disconnect from the relay. */
179
197
  async disconnect() {
180
198
  this._stopped = true;
199
+ this._paired = false;
200
+ this._needsRekey = false;
201
+ this._activePairing?.abort();
202
+ this._activePairing = null;
181
203
  this.transport?.disconnect();
182
204
  this.transport = null;
183
205
  this.sessionKey = null;
@@ -231,13 +253,21 @@ export class RelayClient extends EventEmitter {
231
253
  await this.onPaired(result, 'resumed');
232
254
  }
233
255
  catch (err) {
234
- // Resume failed — clean up transport, clear stale session to prevent infinite retry loop,
235
- // and notify caller so UI can prompt for fresh pairing code.
256
+ // Resume failed — clean up transport
236
257
  this.transport?.disconnect();
237
258
  this.transport = null;
238
259
  this.frameCrypto = null;
239
260
  this.setStatus('disconnected');
240
- 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
+ }
241
271
  this.emit('resume_failed', {
242
272
  error: err instanceof Error ? err : new Error(String(err)),
243
273
  sessionId: session.relaySessionId,
@@ -250,6 +280,8 @@ export class RelayClient extends EventEmitter {
250
280
  this.sessionId = result.sessionId;
251
281
  this.pairingCode = result.pairingCode;
252
282
  this.frameCrypto = new FrameCrypto(result.sessionKey);
283
+ this._paired = true;
284
+ this._activePairing = null;
253
285
  // Update transport URL so auto-reconnect uses ?resume=sessionId
254
286
  // instead of the original /client/connect (which would create a new session)
255
287
  if (this.transport) {
@@ -319,8 +351,37 @@ export class RelayClient extends EventEmitter {
319
351
  this.transport.on('error', (err) => {
320
352
  this.emit('error', err);
321
353
  });
354
+ // Listen for server CLOSE messages (e.g. AGENT_RESTARTED) to know re-ECDH is needed.
355
+ // Without this, normal network drops would unnecessarily trigger re-ECDH.
356
+ this.transport.on('message', (msg) => {
357
+ if (msg.type === 'CLOSE' && msg.sender_role === 'server') {
358
+ this._needsRekey = true;
359
+ }
360
+ });
322
361
  this.transport.on('open', () => {
323
- if (this.sessionKey) {
362
+ if (this._paired && this._needsRekey && this.pairingCode && this.transport) {
363
+ // Server requested re-ECDH (e.g. AGENT_RESTARTED) —
364
+ // cancel any in-flight pairing and start fresh handshake.
365
+ // Invalidate old crypto state: agent has new keys, old ones are useless.
366
+ this._needsRekey = false;
367
+ this._activePairing?.abort();
368
+ this.sessionKey = null;
369
+ this.frameCrypto = null;
370
+ this.setStatus('connecting');
371
+ const pairing = new PairingProtocol(this.transport);
372
+ this._activePairing = pairing;
373
+ pairing.pairAsClient(this.pairingCode)
374
+ .then(result => this.onPaired(result, 'resumed'))
375
+ .catch(err => {
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;
380
+ this.emit('error', err);
381
+ });
382
+ }
383
+ else if (this.sessionKey) {
384
+ // Normal reconnect (network drop) — old session key still valid
324
385
  this.setStatus('connected');
325
386
  this.emit('connected');
326
387
  }
@@ -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
  }