echoclaw-relay-agent 0.22.3 → 0.22.5
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 +43 -4
- package/dist/RelayClient.js +34 -6
- package/dist/cli.js +32 -0
- package/dist/service/launchd.d.ts +1 -0
- package/dist/service/launchd.js +8 -0
- package/dist/service/platform.d.ts +1 -0
- package/dist/service/systemd.d.ts +1 -0
- package/dist/service/systemd.js +7 -0
- package/dist/service/windows.d.ts +1 -0
- package/dist/service/windows.js +7 -0
- package/package.json +1 -1
package/dist/RelayAgent.js
CHANGED
|
@@ -157,6 +157,8 @@ export class RelayAgent extends EventEmitter {
|
|
|
157
157
|
this.transport = null;
|
|
158
158
|
}
|
|
159
159
|
this.frameCrypto = null;
|
|
160
|
+
this._dataHandler = null;
|
|
161
|
+
this._dataHandlerInner = null;
|
|
160
162
|
try {
|
|
161
163
|
if (pairingCode) {
|
|
162
164
|
// New pairing — overwrite any existing session
|
|
@@ -173,6 +175,21 @@ export class RelayAgent extends EventEmitter {
|
|
|
173
175
|
}
|
|
174
176
|
}
|
|
175
177
|
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
// Clean up zombie transport on failure (prevents auto-reconnect loop)
|
|
180
|
+
const t = this.transport;
|
|
181
|
+
if (t) {
|
|
182
|
+
t.removeAllListeners();
|
|
183
|
+
t.disconnect();
|
|
184
|
+
this.transport = null;
|
|
185
|
+
}
|
|
186
|
+
this.frameCrypto = null;
|
|
187
|
+
this.sessionKey = null;
|
|
188
|
+
this._dataHandler = null;
|
|
189
|
+
this._dataHandlerInner = null;
|
|
190
|
+
this.setStatus('disconnected');
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
176
193
|
finally {
|
|
177
194
|
this._starting = false;
|
|
178
195
|
}
|
|
@@ -236,7 +253,7 @@ export class RelayAgent extends EventEmitter {
|
|
|
236
253
|
async freshPairing(code) {
|
|
237
254
|
this.setStatus('connecting');
|
|
238
255
|
// Connect to relay server as agent
|
|
239
|
-
const agentUrl = `${this.config.relayServer}/agent/connect?code=${code}`;
|
|
256
|
+
const agentUrl = `${this.config.relayServer}/agent/connect?code=${code}&protocol=2`;
|
|
240
257
|
this.transport = new RelayTransport({
|
|
241
258
|
url: agentUrl,
|
|
242
259
|
reconnect: this.config.reconnect,
|
|
@@ -281,7 +298,7 @@ export class RelayAgent extends EventEmitter {
|
|
|
281
298
|
this.frameCrypto = new FrameCrypto(this.sessionKey);
|
|
282
299
|
// _paired stays false until HELLO confirmed — prevents on('open') from
|
|
283
300
|
// emitting 'connected' before server confirms the session exists.
|
|
284
|
-
const resumeUrl = `${this.config.relayServer}/agent/connect?resume=${session.relaySessionId}`;
|
|
301
|
+
const resumeUrl = `${this.config.relayServer}/agent/connect?resume=${session.relaySessionId}&protocol=2`;
|
|
285
302
|
this.transport = new RelayTransport({
|
|
286
303
|
url: resumeUrl,
|
|
287
304
|
reconnect: this.config.reconnect,
|
|
@@ -318,7 +335,7 @@ export class RelayAgent extends EventEmitter {
|
|
|
318
335
|
}
|
|
319
336
|
else if (msg.type === 'CLOSE' && msg.sender_role === 'server') {
|
|
320
337
|
const payload = msg.payload || 'unknown';
|
|
321
|
-
if (['SESSION_NOT_FOUND', 'SESSION_EXPIRED', 'INVALID_SESSION', 'DEVICE_TOKEN_MISMATCH'].includes(payload)) {
|
|
338
|
+
if (['SESSION_NOT_FOUND', 'SESSION_EXPIRED', 'INVALID_SESSION', 'DEVICE_TOKEN_MISMATCH', 'SESSION_PROTOCOL_MISMATCH'].includes(payload)) {
|
|
322
339
|
this.sessionStore.clear().catch(() => { });
|
|
323
340
|
}
|
|
324
341
|
settle(() => reject(new Error(`Relay server closed: ${payload}`)));
|
|
@@ -356,7 +373,7 @@ export class RelayAgent extends EventEmitter {
|
|
|
356
373
|
this._paired = true;
|
|
357
374
|
// Update transport URL so auto-reconnect uses ?resume=sessionId
|
|
358
375
|
if (this.transport) {
|
|
359
|
-
this.transport.setUrl(`${this.config.relayServer}/agent/connect?resume=${result.sessionId}`);
|
|
376
|
+
this.transport.setUrl(`${this.config.relayServer}/agent/connect?resume=${result.sessionId}&protocol=2`);
|
|
360
377
|
}
|
|
361
378
|
// Build session data with sessionKey for persistence
|
|
362
379
|
const sessionData = {
|
|
@@ -452,6 +469,28 @@ export class RelayAgent extends EventEmitter {
|
|
|
452
469
|
this.transport.on('connect_timeout', (timeoutMs) => {
|
|
453
470
|
this.emit('error', Object.assign(new Error(`Connection timed out after ${timeoutMs / 1000}s`), { code: 'CONNECT_TIMEOUT' }));
|
|
454
471
|
});
|
|
472
|
+
// Handle server fatal CLOSE messages (SESSION_NOT_FOUND, SESSION_PROTOCOL_MISMATCH, etc.)
|
|
473
|
+
const SESSION_FATAL = new Set(['SESSION_NOT_FOUND', 'INVALID_SESSION', 'SESSION_PROTOCOL_MISMATCH']);
|
|
474
|
+
this.transport.on('message', (msg) => {
|
|
475
|
+
if ((msg.type === 'CLOSE' || msg.type === 'STATUS') && msg.sender_role === 'server') {
|
|
476
|
+
const payload = typeof msg.payload === 'string' ? msg.payload : '';
|
|
477
|
+
if (payload === 'UNPAIRED' || SESSION_FATAL.has(payload)) {
|
|
478
|
+
this.sessionStore.clear().catch(() => { });
|
|
479
|
+
this._paired = false;
|
|
480
|
+
this._stopped = true;
|
|
481
|
+
this.gatewayManager?.stop();
|
|
482
|
+
this.transport?.disconnect();
|
|
483
|
+
this.transport = null;
|
|
484
|
+
this.sessionKey = null;
|
|
485
|
+
this.frameCrypto = null;
|
|
486
|
+
this._dataHandler = null;
|
|
487
|
+
this._dataHandlerInner = null;
|
|
488
|
+
this.setStatus('disconnected');
|
|
489
|
+
this.emit(payload === 'UNPAIRED' ? 'unpaired' : 'session_fatal', { reason: payload });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
});
|
|
455
494
|
this.transport.on('open', () => {
|
|
456
495
|
if (!this.transport || !this._paired || !this.sessionKey)
|
|
457
496
|
return;
|
package/dist/RelayClient.js
CHANGED
|
@@ -126,6 +126,7 @@ export class RelayClient extends EventEmitter {
|
|
|
126
126
|
this._stopped = false;
|
|
127
127
|
// Teardown previous transport before starting new
|
|
128
128
|
if (this.transport) {
|
|
129
|
+
this.transport.removeAllListeners();
|
|
129
130
|
this.transport.disconnect();
|
|
130
131
|
this.transport = null;
|
|
131
132
|
}
|
|
@@ -139,6 +140,19 @@ export class RelayClient extends EventEmitter {
|
|
|
139
140
|
await this.freshPairing();
|
|
140
141
|
}
|
|
141
142
|
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
// Clean up zombie transport on failure (prevents auto-reconnect loop)
|
|
145
|
+
const t = this.transport;
|
|
146
|
+
if (t) {
|
|
147
|
+
t.removeAllListeners();
|
|
148
|
+
t.disconnect();
|
|
149
|
+
this.transport = null;
|
|
150
|
+
}
|
|
151
|
+
this.frameCrypto = null;
|
|
152
|
+
this.sessionKey = null;
|
|
153
|
+
this.setStatus('disconnected');
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
142
156
|
finally {
|
|
143
157
|
this._connecting = false;
|
|
144
158
|
}
|
|
@@ -200,7 +214,7 @@ export class RelayClient extends EventEmitter {
|
|
|
200
214
|
}
|
|
201
215
|
async freshPairing() {
|
|
202
216
|
this.setStatus('connecting');
|
|
203
|
-
const clientUrl = `${this.config.relayServer}/client/connect`;
|
|
217
|
+
const clientUrl = `${this.config.relayServer}/client/connect?protocol=2`;
|
|
204
218
|
this.transport = new RelayTransport({
|
|
205
219
|
url: clientUrl,
|
|
206
220
|
reconnect: this.config.reconnect,
|
|
@@ -244,7 +258,7 @@ export class RelayClient extends EventEmitter {
|
|
|
244
258
|
this.frameCrypto = new FrameCrypto(this.sessionKey);
|
|
245
259
|
// _paired stays false until HELLO confirmed — prevents on('open') from
|
|
246
260
|
// emitting 'connected' before server confirms the session exists.
|
|
247
|
-
const resumeUrl = `${this.config.relayServer}/client/connect?resume=${session.relaySessionId}`;
|
|
261
|
+
const resumeUrl = `${this.config.relayServer}/client/connect?resume=${session.relaySessionId}&protocol=2`;
|
|
248
262
|
this.transport = new RelayTransport({
|
|
249
263
|
url: resumeUrl,
|
|
250
264
|
reconnect: this.config.reconnect,
|
|
@@ -281,7 +295,7 @@ export class RelayClient extends EventEmitter {
|
|
|
281
295
|
}
|
|
282
296
|
else if (msg.type === 'CLOSE' && msg.sender_role === 'server') {
|
|
283
297
|
const payload = msg.payload || 'unknown';
|
|
284
|
-
if (['SESSION_NOT_FOUND', 'SESSION_EXPIRED', 'INVALID_SESSION', 'DEVICE_TOKEN_MISMATCH'].includes(payload)) {
|
|
298
|
+
if (['SESSION_NOT_FOUND', 'SESSION_EXPIRED', 'INVALID_SESSION', 'DEVICE_TOKEN_MISMATCH', 'SESSION_PROTOCOL_MISMATCH'].includes(payload)) {
|
|
285
299
|
this.sessionStore.clear().catch(() => { });
|
|
286
300
|
}
|
|
287
301
|
settle(() => reject(new Error(`Relay server closed: ${payload}`)));
|
|
@@ -308,7 +322,7 @@ export class RelayClient extends EventEmitter {
|
|
|
308
322
|
this._paired = true;
|
|
309
323
|
// Update transport URL so auto-reconnect uses ?resume=sessionId
|
|
310
324
|
if (this.transport) {
|
|
311
|
-
this.transport.setUrl(`${this.config.relayServer}/client/connect?resume=${result.sessionId}`);
|
|
325
|
+
this.transport.setUrl(`${this.config.relayServer}/client/connect?resume=${result.sessionId}&protocol=2`);
|
|
312
326
|
}
|
|
313
327
|
// Persist session with sessionKey for future resume
|
|
314
328
|
const sessionData = {
|
|
@@ -381,7 +395,7 @@ export class RelayClient extends EventEmitter {
|
|
|
381
395
|
});
|
|
382
396
|
// Handle server messages (CLOSE/STATUS)
|
|
383
397
|
const PEER_STATUS = new Set(['AGENT_WARNING', 'AGENT_ONLINE', 'DESKTOP_DISCONNECTED']);
|
|
384
|
-
const SESSION_FATAL = new Set(['SESSION_NOT_FOUND', 'INVALID_SESSION']);
|
|
398
|
+
const SESSION_FATAL = new Set(['SESSION_NOT_FOUND', 'INVALID_SESSION', 'SESSION_PROTOCOL_MISMATCH']);
|
|
385
399
|
this.transport.on('message', (msg) => {
|
|
386
400
|
if ((msg.type === 'CLOSE' || msg.type === 'STATUS') && msg.sender_role === 'server') {
|
|
387
401
|
const payload = typeof msg.payload === 'string' ? msg.payload : '';
|
|
@@ -394,12 +408,26 @@ export class RelayClient extends EventEmitter {
|
|
|
394
408
|
}
|
|
395
409
|
if (payload === 'UNPAIRED') {
|
|
396
410
|
this.sessionStore.clear().catch(() => { });
|
|
397
|
-
this.
|
|
411
|
+
this._paired = false;
|
|
412
|
+
this._stopped = true;
|
|
398
413
|
this.transport?.disconnect();
|
|
414
|
+
this.transport = null;
|
|
415
|
+
this.sessionKey = null;
|
|
416
|
+
this.frameCrypto = null;
|
|
417
|
+
this.setStatus('disconnected');
|
|
418
|
+
this.emit('unpaired');
|
|
399
419
|
return;
|
|
400
420
|
}
|
|
401
421
|
if (SESSION_FATAL.has(payload)) {
|
|
402
422
|
this.sessionStore.clear().catch(() => { });
|
|
423
|
+
this._paired = false;
|
|
424
|
+
this._stopped = true;
|
|
425
|
+
this.transport?.disconnect();
|
|
426
|
+
this.transport = null;
|
|
427
|
+
this.sessionKey = null;
|
|
428
|
+
this.frameCrypto = null;
|
|
429
|
+
this.setStatus('disconnected');
|
|
430
|
+
this.emit('session_fatal', { reason: payload });
|
|
403
431
|
return;
|
|
404
432
|
}
|
|
405
433
|
}
|
package/dist/cli.js
CHANGED
|
@@ -97,6 +97,9 @@ function parseArgs(argv) {
|
|
|
97
97
|
else if (arg === 'status') {
|
|
98
98
|
command = 'status';
|
|
99
99
|
}
|
|
100
|
+
else if (arg === 'restart') {
|
|
101
|
+
command = 'restart';
|
|
102
|
+
}
|
|
100
103
|
else if (arg === 'uninstall') {
|
|
101
104
|
command = 'uninstall';
|
|
102
105
|
}
|
|
@@ -171,6 +174,7 @@ ${BOLD}EchoClaw Relay Agent${RESET}
|
|
|
171
174
|
${BOLD}Usage:${RESET}
|
|
172
175
|
${CYAN}echoclaw-relay setup <CODE>${RESET} Pair + install as system service (${BOLD}recommended${RESET})
|
|
173
176
|
${CYAN}echoclaw-relay status${RESET} Show service status
|
|
177
|
+
${CYAN}echoclaw-relay restart${RESET} Restart system service
|
|
174
178
|
${CYAN}echoclaw-relay uninstall${RESET} Remove system service
|
|
175
179
|
|
|
176
180
|
${BOLD}Setup Options:${RESET}
|
|
@@ -423,6 +427,31 @@ async function runStatus() {
|
|
|
423
427
|
}
|
|
424
428
|
console.log();
|
|
425
429
|
}
|
|
430
|
+
async function runRestart() {
|
|
431
|
+
printLogo();
|
|
432
|
+
const svc = await getServiceManager();
|
|
433
|
+
const st = await svc.status();
|
|
434
|
+
if (!st.installed) {
|
|
435
|
+
console.log(` ${RED}Service not installed.${RESET}`);
|
|
436
|
+
console.log(` Run ${CYAN}echoclaw-relay setup <CODE>${RESET} first.\n`);
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
console.log(` Restarting service...`);
|
|
440
|
+
await svc.restart();
|
|
441
|
+
// Brief wait for process to come up
|
|
442
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
443
|
+
const after = await svc.status();
|
|
444
|
+
if (after.running) {
|
|
445
|
+
console.log(` ${GREEN}${BOLD}Service restarted successfully.${RESET}`);
|
|
446
|
+
if (after.pid)
|
|
447
|
+
console.log(` PID: ${after.pid}`);
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
console.log(` ${YELLOW}Service restarted but may still be starting up.${RESET}`);
|
|
451
|
+
console.log(` Run ${CYAN}echoclaw-relay status${RESET} to check.`);
|
|
452
|
+
}
|
|
453
|
+
console.log();
|
|
454
|
+
}
|
|
426
455
|
async function runUninstall() {
|
|
427
456
|
printLogo();
|
|
428
457
|
const svc = await getServiceManager();
|
|
@@ -745,6 +774,9 @@ async function main() {
|
|
|
745
774
|
else if (opts.command === 'status') {
|
|
746
775
|
await runStatus();
|
|
747
776
|
}
|
|
777
|
+
else if (opts.command === 'restart') {
|
|
778
|
+
await runRestart();
|
|
779
|
+
}
|
|
748
780
|
else if (opts.command === 'uninstall') {
|
|
749
781
|
await runUninstall();
|
|
750
782
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { ServiceManager, ServiceStatus, ServiceInstallOptions } from './platform.js';
|
|
11
11
|
export declare class LaunchdService implements ServiceManager {
|
|
12
12
|
install(relayServer: string, options?: ServiceInstallOptions): Promise<void>;
|
|
13
|
+
restart(): Promise<void>;
|
|
13
14
|
uninstall(): Promise<void>;
|
|
14
15
|
status(): Promise<ServiceStatus>;
|
|
15
16
|
}
|
package/dist/service/launchd.js
CHANGED
|
@@ -105,6 +105,14 @@ ${args.map(a => ` <string>${escapeXml(a)}</string>`).join('\n')}
|
|
|
105
105
|
await fs.writeFile(PLIST_PATH, plist, 'utf-8');
|
|
106
106
|
await bootstrapService();
|
|
107
107
|
}
|
|
108
|
+
async restart() {
|
|
109
|
+
const st = await this.status();
|
|
110
|
+
if (!st.installed) {
|
|
111
|
+
throw new Error('Service not installed. Run setup first.');
|
|
112
|
+
}
|
|
113
|
+
await bootoutService();
|
|
114
|
+
await bootstrapService();
|
|
115
|
+
}
|
|
108
116
|
async uninstall() {
|
|
109
117
|
await bootoutService();
|
|
110
118
|
try {
|
|
@@ -10,6 +10,7 @@ export interface ServiceInstallOptions {
|
|
|
10
10
|
export interface ServiceManager {
|
|
11
11
|
install(relayServer: string, options?: ServiceInstallOptions): Promise<void>;
|
|
12
12
|
uninstall(): Promise<void>;
|
|
13
|
+
restart(): Promise<void>;
|
|
13
14
|
status(): Promise<ServiceStatus>;
|
|
14
15
|
}
|
|
15
16
|
export interface ServiceStatus {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { ServiceManager, ServiceStatus, ServiceInstallOptions } from './platform.js';
|
|
12
12
|
export declare class SystemdService implements ServiceManager {
|
|
13
13
|
install(relayServer: string, options?: ServiceInstallOptions): Promise<void>;
|
|
14
|
+
restart(): Promise<void>;
|
|
14
15
|
uninstall(): Promise<void>;
|
|
15
16
|
status(): Promise<ServiceStatus>;
|
|
16
17
|
}
|
package/dist/service/systemd.js
CHANGED
|
@@ -171,6 +171,13 @@ WantedBy=default.target
|
|
|
171
171
|
console.log(` ⚠ loginctl enable-linger failed (may require sudo). Service will start on login.`);
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
async restart() {
|
|
175
|
+
const userSessionOk = await isUserSessionAvailable();
|
|
176
|
+
if (!userSessionOk) {
|
|
177
|
+
throw new Error('systemd user session not available. Try: sudo loginctl enable-linger $(whoami)');
|
|
178
|
+
}
|
|
179
|
+
await execFileAsync('systemctl', ['--user', 'restart', UNIT_NAME]);
|
|
180
|
+
}
|
|
174
181
|
async uninstall() {
|
|
175
182
|
try {
|
|
176
183
|
await execFileAsync('systemctl', ['--user', 'disable', '--now', UNIT_NAME]);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { ServiceManager, ServiceStatus, ServiceInstallOptions } from './platform.js';
|
|
8
8
|
export declare class WindowsService implements ServiceManager {
|
|
9
9
|
install(relayServer: string, options?: ServiceInstallOptions): Promise<void>;
|
|
10
|
+
restart(): Promise<void>;
|
|
10
11
|
uninstall(): Promise<void>;
|
|
11
12
|
status(): Promise<ServiceStatus>;
|
|
12
13
|
}
|
package/dist/service/windows.js
CHANGED
|
@@ -128,6 +128,13 @@ export class WindowsService {
|
|
|
128
128
|
}
|
|
129
129
|
catch { /* may fail if already running */ }
|
|
130
130
|
}
|
|
131
|
+
async restart() {
|
|
132
|
+
try {
|
|
133
|
+
await execFileAsync('schtasks', ['/End', '/TN', TASK_NAME]);
|
|
134
|
+
}
|
|
135
|
+
catch { /* not running */ }
|
|
136
|
+
await execFileAsync('schtasks', ['/Run', '/TN', TASK_NAME]);
|
|
137
|
+
}
|
|
131
138
|
async uninstall() {
|
|
132
139
|
try {
|
|
133
140
|
await execFileAsync('schtasks', ['/End', '/TN', TASK_NAME]);
|
package/package.json
CHANGED