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.
- package/dist/RelayAgent.d.ts +1 -0
- package/dist/RelayAgent.js +49 -6
- package/dist/RelayClient.d.ts +1 -0
- package/dist/RelayClient.js +40 -5
- package/dist/chat/ChatHandler.d.ts +149 -13
- package/dist/chat/ChatHandler.js +578 -64
- package/dist/cli.js +59 -15
- package/dist/gateway/OpenClawWsClient.d.ts +110 -0
- package/dist/gateway/OpenClawWsClient.js +741 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/dist/relay/PairingProtocol.d.ts +1 -1
- package/dist/relay/PairingProtocol.js +5 -3
- package/dist/relay/RelayTransport.js +17 -8
- package/dist/relay/SessionStore.js +2 -2
- package/package.json +1 -1
package/dist/RelayAgent.d.ts
CHANGED
package/dist/RelayAgent.js
CHANGED
|
@@ -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
|
|
214
|
-
|
|
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
|
|
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
|
-
|
|
381
|
-
|
|
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
|
|
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
|
}
|
package/dist/RelayClient.d.ts
CHANGED
package/dist/RelayClient.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 =>
|
|
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
|
|
2
|
+
* ChatHandler — Routes chat messages to local OpenClaw via WebSocket Gateway.
|
|
3
3
|
*
|
|
4
|
-
* Receives decrypted messages from desktop, forwards to
|
|
5
|
-
*
|
|
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
|
-
* -
|
|
9
|
-
* -
|
|
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
|
|
17
|
-
private readonly
|
|
18
|
-
|
|
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
|
-
*
|
|
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
|
|
27
|
-
private
|
|
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
|
}
|