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.
- package/dist/RelayAgent.js +25 -6
- package/dist/RelayClient.js +15 -4
- package/dist/chat/ChatHandler.d.ts +146 -13
- package/dist/chat/ChatHandler.js +561 -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.js
CHANGED
|
@@ -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
|
|
214
|
-
|
|
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
|
|
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
|
-
|
|
381
|
-
|
|
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
|
|
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
|
}
|
package/dist/RelayClient.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
/** 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
|
}
|