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.
@@ -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;
@@ -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.emit('unpaired');
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
  }
@@ -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
  }
@@ -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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.22.3",
3
+ "version": "0.22.5",
4
4
  "description": "EchoClaw Relay Connection — E2E encrypted relay transport, pairing, and session management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",