echoclaw-relay-agent 0.1.2 → 0.2.0

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.
Files changed (2) hide show
  1. package/dist/main.js +567 -9
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -203,6 +203,80 @@ async function forwardToBridge(bridgeUrl, request) {
203
203
  }
204
204
  }
205
205
 
206
+ // src/service/session.ts
207
+ var import_fs = __toESM(require("fs"));
208
+ var import_path = __toESM(require("path"));
209
+ var import_crypto = require("crypto");
210
+ var CONFIG_DIR = import_path.default.join(
211
+ process.env.HOME || process.env.USERPROFILE || ".",
212
+ ".echoclaw-relay"
213
+ );
214
+ var SESSION_FILE = import_path.default.join(CONFIG_DIR, "session.json");
215
+ function ensureDir() {
216
+ if (!import_fs.default.existsSync(CONFIG_DIR)) {
217
+ import_fs.default.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
218
+ }
219
+ }
220
+ async function saveSession(sessionId2, sessionKey2, relayUrl, bridgeUrl, pairingCode2) {
221
+ ensureDir();
222
+ const rawKey = await import_crypto.webcrypto.subtle.exportKey("raw", sessionKey2);
223
+ const keyBase64 = Buffer.from(rawKey).toString("base64");
224
+ const data = {
225
+ sessionId: sessionId2,
226
+ sessionKeyBase64: keyBase64,
227
+ relayUrl,
228
+ bridgeUrl,
229
+ pairingCode: pairingCode2,
230
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
231
+ };
232
+ import_fs.default.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), {
233
+ mode: 384
234
+ // Only owner can read/write
235
+ });
236
+ console.log(`[session] Saved to ${SESSION_FILE}`);
237
+ }
238
+ async function loadSession() {
239
+ if (!import_fs.default.existsSync(SESSION_FILE)) return null;
240
+ try {
241
+ const raw = import_fs.default.readFileSync(SESSION_FILE, "utf-8");
242
+ const data = JSON.parse(raw);
243
+ if (!data.sessionId || !data.sessionKeyBase64) return null;
244
+ const keyBytes = Buffer.from(data.sessionKeyBase64, "base64");
245
+ const sessionKey2 = await import_crypto.webcrypto.subtle.importKey(
246
+ "raw",
247
+ keyBytes,
248
+ "AES-GCM",
249
+ true,
250
+ ["encrypt", "decrypt"]
251
+ );
252
+ console.log(`[session] Loaded saved session (ID: ${data.sessionId})`);
253
+ return {
254
+ sessionId: data.sessionId,
255
+ sessionKey: sessionKey2,
256
+ relayUrl: data.relayUrl,
257
+ bridgeUrl: data.bridgeUrl,
258
+ pairingCode: data.pairingCode
259
+ };
260
+ } catch (err) {
261
+ console.warn(`[session] Failed to load session: ${err.message}`);
262
+ return null;
263
+ }
264
+ }
265
+ function clearSession() {
266
+ if (import_fs.default.existsSync(SESSION_FILE)) {
267
+ import_fs.default.unlinkSync(SESSION_FILE);
268
+ console.log("[session] Cleared saved session");
269
+ return true;
270
+ }
271
+ return false;
272
+ }
273
+ function hasSession() {
274
+ return import_fs.default.existsSync(SESSION_FILE);
275
+ }
276
+ function getConfigDir() {
277
+ return CONFIG_DIR;
278
+ }
279
+
206
280
  // src/relay/client.ts
207
281
  var ws = null;
208
282
  var keyPair = null;
@@ -212,16 +286,31 @@ var pairingCode = null;
212
286
  var reconnect;
213
287
  var config;
214
288
  var _stopped = false;
289
+ var _heartbeatTimer = null;
290
+ var HEARTBEAT_INTERVAL_MS = 25e3;
291
+ var _onPaired = null;
292
+ function onPaired(cb) {
293
+ _onPaired = cb;
294
+ }
215
295
  async function startRelayClient(cfg) {
216
296
  config = cfg;
217
297
  _stopped = false;
218
298
  reconnect = createReconnectController();
299
+ if (cfg.resumeSessionKey && cfg.sessionId) {
300
+ sessionKey = cfg.resumeSessionKey;
301
+ sessionId = cfg.sessionId;
302
+ console.log(`[relay-agent] Resuming session ${sessionId}`);
303
+ }
219
304
  keyPair = await generateKeyPair();
220
305
  console.log("[relay-agent] X25519 key pair generated");
221
306
  connect();
222
307
  }
223
308
  function stopRelayClient() {
224
309
  _stopped = true;
310
+ if (_heartbeatTimer) {
311
+ clearInterval(_heartbeatTimer);
312
+ _heartbeatTimer = null;
313
+ }
225
314
  if (ws) {
226
315
  ws.close(1e3, "agent shutdown");
227
316
  ws = null;
@@ -230,14 +319,30 @@ function stopRelayClient() {
230
319
  keyPair = null;
231
320
  reconnect?.reset();
232
321
  }
322
+ function startHeartbeat() {
323
+ if (_heartbeatTimer) clearInterval(_heartbeatTimer);
324
+ _heartbeatTimer = setInterval(() => {
325
+ if (ws?.readyState === import_ws.default.OPEN) {
326
+ sendRaw({ version: 1, session_id: sessionId ?? "", msg_id: makeId(), type: "PING", sender_role: "agent" });
327
+ }
328
+ }, HEARTBEAT_INTERVAL_MS);
329
+ }
330
+ function stopHeartbeat() {
331
+ if (_heartbeatTimer) {
332
+ clearInterval(_heartbeatTimer);
333
+ _heartbeatTimer = null;
334
+ }
335
+ }
233
336
  function connect() {
234
337
  if (_stopped) return;
235
338
  reconnect.setState("connecting");
236
- const url = config.sessionId ? `${config.relayUrl}?resume=${config.sessionId}` : config.relayUrl;
339
+ const resumeId = sessionId || config.sessionId;
340
+ const url = resumeId ? `${config.relayUrl}${config.relayUrl.includes("?") ? "&" : "?"}resume=${resumeId}` : config.relayUrl;
237
341
  console.log(`[relay-agent] Connecting to ${url}...`);
238
342
  ws = new import_ws.default(url);
239
343
  ws.on("open", () => {
240
344
  reconnect.setState("connected");
345
+ startHeartbeat();
241
346
  console.log("[relay-agent] Connected to relay server");
242
347
  const registerMsg = {
243
348
  version: 1,
@@ -255,7 +360,7 @@ function connect() {
255
360
  });
256
361
  ws.on("close", (code, reason) => {
257
362
  console.log(`[relay-agent] Disconnected (code=${code}, reason=${reason.toString()})`);
258
- sessionKey = null;
363
+ stopHeartbeat();
259
364
  scheduleReconnect();
260
365
  });
261
366
  ws.on("error", (err) => {
@@ -327,6 +432,24 @@ async function handleHello(msg) {
327
432
  });
328
433
  reconnect.setState("paired");
329
434
  console.log("[relay-agent] \u2705 E2E encryption established \u2014 session paired");
435
+ try {
436
+ await saveSession(
437
+ sessionId,
438
+ sessionKey,
439
+ config.relayUrl,
440
+ config.bridgeUrl,
441
+ pairingCode ?? void 0
442
+ );
443
+ } catch (err) {
444
+ console.warn("[relay-agent] Failed to persist session:", err.message);
445
+ }
446
+ if (_onPaired) {
447
+ try {
448
+ _onPaired();
449
+ } catch {
450
+ }
451
+ _onPaired = null;
452
+ }
330
453
  }
331
454
  }
332
455
  async function handleData(msg) {
@@ -375,7 +498,357 @@ function sendRaw(msg) {
375
498
  }
376
499
  }
377
500
 
501
+ // src/service/installer.ts
502
+ var import_fs2 = __toESM(require("fs"));
503
+ var import_path2 = __toESM(require("path"));
504
+ var import_child_process = require("child_process");
505
+ var SERVICE_NAME = "me.echoclaw.relay-agent";
506
+ var LABEL = "EchoClaw Relay Agent";
507
+ function resolveNodeBin() {
508
+ const scriptPath = process.argv[1];
509
+ try {
510
+ const globalBin = (0, import_child_process.execSync)("npm root -g", { encoding: "utf-8" }).trim();
511
+ const globalAgent = import_path2.default.join(import_path2.default.dirname(globalBin), "bin", "echoclaw-relay-agent");
512
+ if (import_fs2.default.existsSync(globalAgent)) {
513
+ return globalAgent;
514
+ }
515
+ } catch {
516
+ }
517
+ if (scriptPath && import_fs2.default.existsSync(scriptPath)) {
518
+ return scriptPath;
519
+ }
520
+ throw new Error(
521
+ "Cannot determine the installed path of echoclaw-relay-agent.\nPlease install globally first:\n\n npm install -g echoclaw-relay-agent\n\nThen run: echoclaw-relay-agent --install"
522
+ );
523
+ }
524
+ function resolveNodePath() {
525
+ return process.execPath;
526
+ }
527
+ function macosInstall() {
528
+ const agentBin = resolveNodeBin();
529
+ const nodePath = resolveNodePath();
530
+ const logDir = getConfigDir();
531
+ const plistDir = import_path2.default.join(process.env.HOME, "Library", "LaunchAgents");
532
+ const plistPath = import_path2.default.join(plistDir, `${SERVICE_NAME}.plist`);
533
+ if (!import_fs2.default.existsSync(plistDir)) {
534
+ import_fs2.default.mkdirSync(plistDir, { recursive: true });
535
+ }
536
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
537
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
538
+ <plist version="1.0">
539
+ <dict>
540
+ <key>Label</key>
541
+ <string>${SERVICE_NAME}</string>
542
+
543
+ <key>ProgramArguments</key>
544
+ <array>
545
+ <string>${nodePath}</string>
546
+ <string>${agentBin}</string>
547
+ </array>
548
+
549
+ <key>RunAtLoad</key>
550
+ <true/>
551
+
552
+ <key>KeepAlive</key>
553
+ <true/>
554
+
555
+ <key>ThrottleInterval</key>
556
+ <integer>10</integer>
557
+
558
+ <key>StandardOutPath</key>
559
+ <string>${logDir}/stdout.log</string>
560
+
561
+ <key>StandardErrorPath</key>
562
+ <string>${logDir}/stderr.log</string>
563
+
564
+ <key>EnvironmentVariables</key>
565
+ <dict>
566
+ <key>PATH</key>
567
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
568
+ </dict>
569
+ </dict>
570
+ </plist>`;
571
+ import_fs2.default.writeFileSync(plistPath, plist, { mode: 420 });
572
+ try {
573
+ (0, import_child_process.execSync)(`launchctl unload "${plistPath}" 2>/dev/null || true`);
574
+ (0, import_child_process.execSync)(`launchctl load "${plistPath}"`);
575
+ } catch (err) {
576
+ console.error(`[install] Failed to load LaunchAgent: ${err.message}`);
577
+ console.log(`[install] Plist written to ${plistPath} \u2014 you can load it manually:`);
578
+ console.log(` launchctl load "${plistPath}"`);
579
+ return;
580
+ }
581
+ console.log("");
582
+ console.log(" \u2705 Installed as macOS LaunchAgent");
583
+ console.log(` Plist: ${plistPath}`);
584
+ console.log(` Logs: ${logDir}/stdout.log`);
585
+ console.log("");
586
+ console.log(" The relay agent will now start automatically on login.");
587
+ console.log(" To uninstall: echoclaw-relay-agent --uninstall");
588
+ console.log("");
589
+ }
590
+ function macosUninstall() {
591
+ const plistPath = import_path2.default.join(
592
+ process.env.HOME,
593
+ "Library",
594
+ "LaunchAgents",
595
+ `${SERVICE_NAME}.plist`
596
+ );
597
+ if (!import_fs2.default.existsSync(plistPath)) {
598
+ console.log("[uninstall] No LaunchAgent found \u2014 nothing to remove.");
599
+ return;
600
+ }
601
+ try {
602
+ (0, import_child_process.execSync)(`launchctl unload "${plistPath}" 2>/dev/null || true`);
603
+ } catch {
604
+ }
605
+ import_fs2.default.unlinkSync(plistPath);
606
+ console.log("");
607
+ console.log(" \u2705 Uninstalled macOS LaunchAgent");
608
+ console.log(` Removed: ${plistPath}`);
609
+ console.log("");
610
+ }
611
+ function macosStatus() {
612
+ const plistPath = import_path2.default.join(
613
+ process.env.HOME,
614
+ "Library",
615
+ "LaunchAgents",
616
+ `${SERVICE_NAME}.plist`
617
+ );
618
+ if (!import_fs2.default.existsSync(plistPath)) {
619
+ console.log(" Service: not installed");
620
+ return;
621
+ }
622
+ try {
623
+ const output = (0, import_child_process.execSync)(`launchctl list "${SERVICE_NAME}" 2>&1`, { encoding: "utf-8" });
624
+ const pidMatch = output.match(/"PID"\s*=\s*(\d+)/);
625
+ const pid = pidMatch ? pidMatch[1] : null;
626
+ console.log(` Service: installed (${pid ? `running, PID ${pid}` : "not running"})`);
627
+ console.log(` Plist: ${plistPath}`);
628
+ } catch {
629
+ console.log(" Service: installed (not currently loaded)");
630
+ console.log(` Plist: ${plistPath}`);
631
+ }
632
+ }
633
+ function linuxInstall() {
634
+ const agentBin = resolveNodeBin();
635
+ const nodePath = resolveNodePath();
636
+ const logDir = getConfigDir();
637
+ const serviceDir = import_path2.default.join(
638
+ process.env.HOME,
639
+ ".config",
640
+ "systemd",
641
+ "user"
642
+ );
643
+ const servicePath = import_path2.default.join(serviceDir, `${SERVICE_NAME}.service`);
644
+ if (!import_fs2.default.existsSync(serviceDir)) {
645
+ import_fs2.default.mkdirSync(serviceDir, { recursive: true });
646
+ }
647
+ const unit = `[Unit]
648
+ Description=${LABEL}
649
+ After=network-online.target
650
+ Wants=network-online.target
651
+
652
+ [Service]
653
+ Type=simple
654
+ ExecStart=${nodePath} ${agentBin}
655
+ Restart=always
656
+ RestartSec=10
657
+ StandardOutput=append:${logDir}/stdout.log
658
+ StandardError=append:${logDir}/stderr.log
659
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin
660
+
661
+ [Install]
662
+ WantedBy=default.target
663
+ `;
664
+ import_fs2.default.writeFileSync(servicePath, unit, { mode: 420 });
665
+ try {
666
+ (0, import_child_process.execSync)("systemctl --user daemon-reload");
667
+ (0, import_child_process.execSync)(`systemctl --user enable ${SERVICE_NAME}.service`);
668
+ (0, import_child_process.execSync)(`systemctl --user start ${SERVICE_NAME}.service`);
669
+ } catch (err) {
670
+ console.error(`[install] Failed to start service: ${err.message}`);
671
+ console.log(`[install] Service file written to ${servicePath}`);
672
+ console.log(" Enable manually:");
673
+ console.log(` systemctl --user enable --now ${SERVICE_NAME}.service`);
674
+ return;
675
+ }
676
+ try {
677
+ (0, import_child_process.execSync)(`loginctl enable-linger ${process.env.USER}`);
678
+ console.log(" Linger enabled \u2014 service will run even without login.");
679
+ } catch {
680
+ console.log(" \u26A0\uFE0F Could not enable linger \u2014 service may stop on logout.");
681
+ console.log(` Run: sudo loginctl enable-linger ${process.env.USER}`);
682
+ }
683
+ console.log("");
684
+ console.log(" \u2705 Installed as systemd user service");
685
+ console.log(` Unit: ${servicePath}`);
686
+ console.log(` Logs: ${logDir}/stdout.log`);
687
+ console.log("");
688
+ console.log(" The relay agent will now start automatically on boot.");
689
+ console.log(" To uninstall: echoclaw-relay-agent --uninstall");
690
+ console.log("");
691
+ }
692
+ function linuxUninstall() {
693
+ const servicePath = import_path2.default.join(
694
+ process.env.HOME,
695
+ ".config",
696
+ "systemd",
697
+ "user",
698
+ `${SERVICE_NAME}.service`
699
+ );
700
+ if (!import_fs2.default.existsSync(servicePath)) {
701
+ console.log("[uninstall] No systemd service found \u2014 nothing to remove.");
702
+ return;
703
+ }
704
+ try {
705
+ (0, import_child_process.execSync)(`systemctl --user stop ${SERVICE_NAME}.service 2>/dev/null || true`);
706
+ (0, import_child_process.execSync)(`systemctl --user disable ${SERVICE_NAME}.service 2>/dev/null || true`);
707
+ } catch {
708
+ }
709
+ import_fs2.default.unlinkSync(servicePath);
710
+ try {
711
+ (0, import_child_process.execSync)("systemctl --user daemon-reload");
712
+ } catch {
713
+ }
714
+ console.log("");
715
+ console.log(" \u2705 Uninstalled systemd user service");
716
+ console.log(` Removed: ${servicePath}`);
717
+ console.log("");
718
+ }
719
+ function linuxStatus() {
720
+ const servicePath = import_path2.default.join(
721
+ process.env.HOME,
722
+ ".config",
723
+ "systemd",
724
+ "user",
725
+ `${SERVICE_NAME}.service`
726
+ );
727
+ if (!import_fs2.default.existsSync(servicePath)) {
728
+ console.log(" Service: not installed");
729
+ return;
730
+ }
731
+ try {
732
+ const output = (0, import_child_process.execSync)(
733
+ `systemctl --user is-active ${SERVICE_NAME}.service 2>&1`,
734
+ { encoding: "utf-8" }
735
+ ).trim();
736
+ console.log(` Service: installed (${output})`);
737
+ console.log(` Unit: ${servicePath}`);
738
+ } catch {
739
+ console.log(" Service: installed (inactive)");
740
+ console.log(` Unit: ${servicePath}`);
741
+ }
742
+ }
743
+ function windowsInstall() {
744
+ const agentBin = resolveNodeBin();
745
+ const nodePath = resolveNodePath();
746
+ try {
747
+ (0, import_child_process.execSync)(
748
+ `schtasks /Delete /TN "${SERVICE_NAME}" /F 2>nul`,
749
+ { encoding: "utf-8", stdio: "pipe" }
750
+ );
751
+ } catch {
752
+ }
753
+ try {
754
+ (0, import_child_process.execSync)(
755
+ `schtasks /Create /TN "${SERVICE_NAME}" /TR "\\"${nodePath}\\" \\"${agentBin}\\"" /SC ONLOGON /RL HIGHEST /F`,
756
+ { encoding: "utf-8" }
757
+ );
758
+ (0, import_child_process.execSync)(`schtasks /Run /TN "${SERVICE_NAME}"`, { encoding: "utf-8" });
759
+ } catch (err) {
760
+ console.error(`[install] Failed to create scheduled task: ${err.message}`);
761
+ console.log(" You may need to run this command as Administrator.");
762
+ return;
763
+ }
764
+ console.log("");
765
+ console.log(" \u2705 Installed as Windows Scheduled Task");
766
+ console.log(` Task: ${SERVICE_NAME}`);
767
+ console.log("");
768
+ console.log(" The relay agent will now start automatically on login.");
769
+ console.log(" To uninstall: echoclaw-relay-agent --uninstall");
770
+ console.log("");
771
+ }
772
+ function windowsUninstall() {
773
+ try {
774
+ (0, import_child_process.execSync)(`schtasks /Delete /TN "${SERVICE_NAME}" /F`, { encoding: "utf-8" });
775
+ console.log("");
776
+ console.log(" \u2705 Uninstalled Windows Scheduled Task");
777
+ console.log("");
778
+ } catch {
779
+ console.log("[uninstall] No scheduled task found \u2014 nothing to remove.");
780
+ }
781
+ }
782
+ function windowsStatus() {
783
+ try {
784
+ const output = (0, import_child_process.execSync)(
785
+ `schtasks /Query /TN "${SERVICE_NAME}" /FO LIST 2>nul`,
786
+ { encoding: "utf-8" }
787
+ );
788
+ const statusMatch = output.match(/Status:\s*(.+)/);
789
+ console.log(` Service: installed (${statusMatch ? statusMatch[1].trim() : "unknown"})`);
790
+ } catch {
791
+ console.log(" Service: not installed");
792
+ }
793
+ }
794
+ function getPlatform() {
795
+ switch (process.platform) {
796
+ case "darwin":
797
+ return "macos";
798
+ case "linux":
799
+ return "linux";
800
+ case "win32":
801
+ return "windows";
802
+ default:
803
+ throw new Error(`Unsupported platform: ${process.platform}`);
804
+ }
805
+ }
806
+ function installService() {
807
+ const platform = getPlatform();
808
+ console.log(`[install] Platform: ${platform}`);
809
+ switch (platform) {
810
+ case "macos":
811
+ macosInstall();
812
+ break;
813
+ case "linux":
814
+ linuxInstall();
815
+ break;
816
+ case "windows":
817
+ windowsInstall();
818
+ break;
819
+ }
820
+ }
821
+ function uninstallService() {
822
+ const platform = getPlatform();
823
+ switch (platform) {
824
+ case "macos":
825
+ macosUninstall();
826
+ break;
827
+ case "linux":
828
+ linuxUninstall();
829
+ break;
830
+ case "windows":
831
+ windowsUninstall();
832
+ break;
833
+ }
834
+ }
835
+ function serviceStatus() {
836
+ const platform = getPlatform();
837
+ switch (platform) {
838
+ case "macos":
839
+ macosStatus();
840
+ break;
841
+ case "linux":
842
+ linuxStatus();
843
+ break;
844
+ case "windows":
845
+ windowsStatus();
846
+ break;
847
+ }
848
+ }
849
+
378
850
  // src/main.ts
851
+ var VERSION = "0.2.0";
379
852
  function parseArgs() {
380
853
  const args = process.argv.slice(2);
381
854
  const opts = {
@@ -401,13 +874,22 @@ function parseArgs() {
401
874
  case "-b":
402
875
  opts.bridgeUrl = args[++i] || opts.bridgeUrl;
403
876
  break;
877
+ case "--install":
878
+ opts.command = "install";
879
+ break;
880
+ case "--uninstall":
881
+ opts.command = "uninstall";
882
+ break;
883
+ case "--status":
884
+ opts.command = "status";
885
+ break;
404
886
  case "--help":
405
887
  case "-h":
406
888
  printHelp();
407
889
  process.exit(0);
408
890
  case "--version":
409
891
  case "-v":
410
- console.log("echoclaw-relay-agent 0.1.2");
892
+ console.log(`echoclaw-relay-agent ${VERSION}`);
411
893
  process.exit(0);
412
894
  default:
413
895
  if (arg.startsWith("-")) {
@@ -428,7 +910,7 @@ function printHelp() {
428
910
 
429
911
  OPTIONS
430
912
  --code, -c <code> Join an existing session by pairing code (e.g. ABC-1234).
431
- If omitted, a new session is created and a code is displayed.
913
+ If omitted, resumes a saved session or creates a new one.
432
914
 
433
915
  --relay, -r <url> Relay server WebSocket URL
434
916
  (default: wss://relay.echoclaw.me/agent/connect)
@@ -436,24 +918,74 @@ function printHelp() {
436
918
  --bridge, -b <url> Local OpenClaw bridge HTTP URL
437
919
  (default: http://localhost:8013)
438
920
 
921
+ --install Install as system service (auto-start on boot)
922
+ --uninstall Remove system service and saved session
923
+ --status Show service and connection status
924
+
439
925
  --help, -h Show this help message
440
926
  --version, -v Show version
441
927
 
442
928
  EXAMPLES
443
- echoclaw-relay-agent
444
- echoclaw-relay-agent --code ABC-1234
445
- echoclaw-relay-agent --bridge http://localhost:9000
929
+ echoclaw-relay-agent --code ABC-1234 # Pair and connect
930
+ echoclaw-relay-agent --code ABC-1234 --install # Pair, connect, and install as service
931
+ echoclaw-relay-agent --status # Check status
932
+ echoclaw-relay-agent --uninstall # Remove service
446
933
 
447
934
  SECURITY
448
935
  All communication is end-to-end encrypted (X25519 + AES-256-GCM).
449
936
  The relay server cannot read your messages \u2014 it only forwards ciphertext.
450
937
  `);
451
938
  }
939
+ function handleInstall() {
940
+ console.log("");
941
+ console.log(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
942
+ console.log(` \u2502 EchoClaw Relay Agent v${VERSION} \u2502`);
943
+ console.log(" \u2502 Installing system service... \u2502");
944
+ console.log(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518");
945
+ console.log("");
946
+ if (!hasSession()) {
947
+ console.error(" \u274C No saved session found.");
948
+ console.error(" You must pair first before installing as a service:");
949
+ console.error("");
950
+ console.error(" echoclaw-relay-agent --code ABC-1234 --install");
951
+ console.error("");
952
+ process.exit(1);
953
+ }
954
+ installService();
955
+ }
956
+ function handleUninstall() {
957
+ uninstallService();
958
+ clearSession();
959
+ console.log(" Session data cleared.");
960
+ }
961
+ function handleStatus() {
962
+ console.log("");
963
+ console.log(` EchoClaw Relay Agent v${VERSION}`);
964
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
965
+ serviceStatus();
966
+ console.log(` Session: ${hasSession() ? "saved (will resume on start)" : "none"}`);
967
+ console.log("");
968
+ }
452
969
  async function main() {
453
970
  const opts = parseArgs();
971
+ if (opts.command === "install" && !opts.code) {
972
+ handleInstall();
973
+ return;
974
+ }
975
+ if (opts.command === "uninstall") {
976
+ handleUninstall();
977
+ return;
978
+ }
979
+ if (opts.command === "status") {
980
+ handleStatus();
981
+ return;
982
+ }
983
+ if (opts.command === "install" && opts.code) {
984
+ opts.installAfterPairing = true;
985
+ }
454
986
  console.log("");
455
987
  console.log(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
456
- console.log(" \u2502 EchoClaw Relay Agent v0.1.2 \u2502");
988
+ console.log(` \u2502 EchoClaw Relay Agent v${VERSION} \u2502`);
457
989
  console.log(" \u2502 Open Source \xB7 Apache License 2.0 \u2502");
458
990
  console.log(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518");
459
991
  console.log("");
@@ -481,9 +1013,35 @@ async function main() {
481
1013
  const separator = relayUrl.includes("?") ? "&" : "?";
482
1014
  relayUrl = `${relayUrl}${separator}code=${opts.code}`;
483
1015
  }
1016
+ let resumeSessionKey;
1017
+ let resumeSessionId;
1018
+ if (!opts.code) {
1019
+ const saved = await loadSession();
1020
+ if (saved) {
1021
+ console.log(` \u{1F4C2} Found saved session: ${saved.sessionId}`);
1022
+ console.log(" Attempting to resume...");
1023
+ console.log("");
1024
+ resumeSessionKey = saved.sessionKey;
1025
+ resumeSessionId = saved.sessionId;
1026
+ relayUrl = opts.relayUrl;
1027
+ }
1028
+ }
1029
+ if (opts.installAfterPairing) {
1030
+ onPaired(() => {
1031
+ console.log("");
1032
+ console.log("[relay-agent] Pairing successful \u2014 installing system service...");
1033
+ try {
1034
+ installService();
1035
+ } catch (err) {
1036
+ console.error("[relay-agent] Service install failed:", err.message);
1037
+ }
1038
+ });
1039
+ }
484
1040
  await startRelayClient({
485
1041
  relayUrl,
486
- bridgeUrl: opts.bridgeUrl
1042
+ bridgeUrl: opts.bridgeUrl,
1043
+ sessionId: resumeSessionId,
1044
+ resumeSessionKey
487
1045
  });
488
1046
  }
489
1047
  process.on("SIGINT", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "EchoClaw Relay Agent — connects OpenClaw bridge to the EchoClaw Relay Server with E2E encryption",
5
5
  "main": "./dist/main.js",
6
6
  "bin": {