ragent-cli 1.3.1 → 1.4.1

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/index.js +1227 -891
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "ragent-cli",
34
- version: "1.3.1",
34
+ version: "1.4.1",
35
35
  description: "CLI agent for rAgent Live \u2014 browser-first terminal control plane for AI coding agents",
36
36
  main: "dist/index.js",
37
37
  bin: {
@@ -127,6 +127,7 @@ var MAX_RECONNECT_DELAY_MS = 3e4;
127
127
  var OUTPUT_BUFFER_MAX_BYTES = 100 * 1024;
128
128
 
129
129
  // src/config.ts
130
+ var crypto = __toESM(require("crypto"));
130
131
  var fs = __toESM(require("fs"));
131
132
  var os2 = __toESM(require("os"));
132
133
  function ensureConfigDir() {
@@ -162,8 +163,35 @@ function saveConfigPatch(patch) {
162
163
  function sanitizeHostId(value) {
163
164
  return String(value || "").toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+/, "").slice(0, 64);
164
165
  }
166
+ function readMachineId() {
167
+ try {
168
+ const mid = fs.readFileSync("/etc/machine-id", "utf8").trim();
169
+ if (mid.length >= 16) return mid;
170
+ } catch {
171
+ }
172
+ return null;
173
+ }
165
174
  function inferHostId() {
166
- return sanitizeHostId(os2.hostname().split(".")[0] || "linux-host");
175
+ const hostname5 = sanitizeHostId(os2.hostname().split(".")[0] || "linux-host");
176
+ const machineId = readMachineId();
177
+ if (machineId) {
178
+ return sanitizeHostId(`${hostname5}-${machineId.slice(0, 8)}`);
179
+ }
180
+ const idFile = `${CONFIG_DIR}/machine-id`;
181
+ try {
182
+ const stored = fs.readFileSync(idFile, "utf8").trim();
183
+ if (stored.length >= 8) {
184
+ return sanitizeHostId(`${hostname5}-${stored.slice(0, 8)}`);
185
+ }
186
+ } catch {
187
+ }
188
+ const generated = crypto.randomUUID().replace(/-/g, "");
189
+ try {
190
+ ensureConfigDir();
191
+ fs.writeFileSync(idFile, generated, { encoding: "utf8", mode: 384 });
192
+ } catch {
193
+ }
194
+ return sanitizeHostId(`${hostname5}-${generated.slice(0, 8)}`);
167
195
  }
168
196
 
169
197
  // src/version.ts
@@ -229,13 +257,13 @@ async function maybeWarnUpdate() {
229
257
  }
230
258
 
231
259
  // src/commands/connect.ts
232
- var os6 = __toESM(require("os"));
260
+ var os7 = __toESM(require("os"));
233
261
 
234
262
  // src/agent.ts
235
263
  var fs3 = __toESM(require("fs"));
236
- var os5 = __toESM(require("os"));
264
+ var os6 = __toESM(require("os"));
237
265
  var path2 = __toESM(require("path"));
238
- var import_ws2 = __toESM(require("ws"));
266
+ var import_ws5 = __toESM(require("ws"));
239
267
 
240
268
  // src/auth.ts
241
269
  var os3 = __toESM(require("os"));
@@ -249,9 +277,14 @@ function wait(ms) {
249
277
  function execAsync(command, options = {}) {
250
278
  const timeout = options.timeout ?? 5e3;
251
279
  const maxBuffer = options.maxBuffer ?? 1024 * 1024;
280
+ const allowNonZeroExit = options.allowNonZeroExit ?? false;
252
281
  return new Promise((resolve, reject) => {
253
282
  (0, import_node_child_process.exec)(command, { timeout, maxBuffer }, (error, stdout, stderr) => {
254
283
  if (error) {
284
+ if (allowNonZeroExit) {
285
+ resolve(stdout || "");
286
+ return;
287
+ }
255
288
  reject(new Error((stderr || error.message || "").trim()));
256
289
  return;
257
290
  }
@@ -332,27 +365,55 @@ async function collectTmuxSessions() {
332
365
  }
333
366
  try {
334
367
  const raw = await execAsync(
335
- "tmux list-panes -a -F '#{session_name}|#{window_index}|#{pane_index}|#{pane_current_command}|#{pane_active}|#{pane_last}|#{pane_pid}'"
368
+ "tmux list-panes -a -F '#{session_name}|#{window_index}|#{pane_index}|#{pane_current_command}|#{pane_active}|#{pane_last}|#{pane_pid}|#{window_layout}|#{pane_current_path}|#{window_name}|#{pane_title}|#{window_flags}|#{session_group}|#{session_grouped}'"
336
369
  );
337
370
  const rows = raw.split("\n").map((line) => line.trim()).filter(Boolean);
338
- return rows.map((row) => {
339
- const [sessionName, windowIndex, paneIndex, command, activeFlag, lastEpoch, panePid] = row.split("|");
371
+ const results = [];
372
+ for (const row of rows) {
373
+ const parts = row.split("|");
374
+ const [
375
+ sessionName,
376
+ windowIndex,
377
+ paneIndex,
378
+ command,
379
+ activeFlag,
380
+ lastEpoch,
381
+ panePid,
382
+ windowLayout,
383
+ paneCurrentPath,
384
+ windowName,
385
+ paneTitle,
386
+ windowFlags,
387
+ sessionGroup,
388
+ sessionGrouped
389
+ ] = parts;
390
+ if (sessionGrouped === "1" && sessionGroup && sessionGroup !== sessionName) {
391
+ continue;
392
+ }
340
393
  const id = `tmux:${sessionName}:${windowIndex}.${paneIndex}`;
341
394
  const lastActivityAt = Number(lastEpoch) > 0 ? new Date(Number(lastEpoch) * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
342
395
  const pids = [];
343
396
  const pid = Number(panePid);
344
397
  if (pid > 0) pids.push(pid);
345
- return {
398
+ results.push({
346
399
  id,
347
400
  type: "tmux",
348
401
  name: `${sessionName}:${windowIndex}.${paneIndex}`,
349
402
  status: activeFlag === "1" ? "active" : "detached",
350
403
  command,
351
404
  agentType: detectAgentType(command),
405
+ runningCommand: command || void 0,
406
+ windowLayout: windowLayout || void 0,
407
+ workingDir: paneCurrentPath || void 0,
408
+ windowName: windowName || void 0,
409
+ paneTitle: paneTitle || void 0,
410
+ isZoomed: windowFlags?.includes("Z") ?? false,
352
411
  lastActivityAt,
353
- pids
354
- };
355
- });
412
+ pids,
413
+ sessionGroup: sessionGroup || void 0
414
+ });
415
+ }
416
+ return results;
356
417
  } catch {
357
418
  return [];
358
419
  }
@@ -364,12 +425,7 @@ async function collectScreenSessions() {
364
425
  return [];
365
426
  }
366
427
  try {
367
- let raw;
368
- try {
369
- raw = await execAsync("screen -ls");
370
- } catch (e) {
371
- raw = e instanceof Error ? e.message : "";
372
- }
428
+ const raw = await execAsync("screen -ls", { allowNonZeroExit: true });
373
429
  const sessionPattern = /^\s*(\d+)\.(\S+)\s+\((Detached|Attached)\)/;
374
430
  const sessions = [];
375
431
  for (const line of raw.split("\n")) {
@@ -403,7 +459,7 @@ async function collectZellijSessions() {
403
459
  return [];
404
460
  }
405
461
  try {
406
- const raw = await execAsync("zellij list-sessions");
462
+ const raw = await execAsync("zellij list-sessions --short --no-formatting");
407
463
  const sessions = [];
408
464
  for (const line of raw.split("\n")) {
409
465
  const sessionName = line.trim();
@@ -454,16 +510,18 @@ async function getProcessWorkingDir(pid) {
454
510
  }
455
511
  async function collectBareAgentProcesses(excludePids) {
456
512
  try {
457
- const raw = await execAsync("ps axo pid,ppid,comm,args --no-headers");
513
+ const raw = await execAsync("ps axo pid,ppid,stat,comm,args --no-headers");
458
514
  const sessions = [];
459
515
  const seen = /* @__PURE__ */ new Set();
460
516
  for (const line of raw.split("\n")) {
461
517
  const trimmed = line.trim();
462
518
  if (!trimmed) continue;
463
519
  const parts = trimmed.split(/\s+/);
464
- if (parts.length < 4) continue;
520
+ if (parts.length < 5) continue;
465
521
  const pid = Number(parts[0]);
466
- const args = parts.slice(3).join(" ");
522
+ const stat = parts[2];
523
+ const args = parts.slice(4).join(" ");
524
+ if (stat.startsWith("Z")) continue;
467
525
  const agentType = detectAgentType(args);
468
526
  if (!agentType) continue;
469
527
  if (excludePids?.has(pid)) continue;
@@ -497,9 +555,20 @@ async function collectSessionInventory(hostId, command) {
497
555
  collectZellijSessions()
498
556
  ]);
499
557
  const multiplexerPids = /* @__PURE__ */ new Set();
500
- for (const s of [...tmux, ...screen, ...zellij]) {
558
+ for (const s of [...screen, ...zellij]) {
501
559
  if (s.pids) s.pids.forEach((p) => multiplexerPids.add(p));
502
560
  }
561
+ for (const s of tmux) {
562
+ if (s.pids) {
563
+ for (const pid of s.pids) {
564
+ multiplexerPids.add(pid);
565
+ const childInfo = await getChildAgentInfo(pid);
566
+ if (childInfo) {
567
+ childInfo.childPids.forEach((p) => multiplexerPids.add(p));
568
+ }
569
+ }
570
+ }
571
+ }
503
572
  const bare = await collectBareAgentProcesses(multiplexerPids);
504
573
  const ptySession = {
505
574
  id: `pty:${hostId}`,
@@ -533,7 +602,10 @@ function detectAgentType(command) {
533
602
  }
534
603
  function sessionInventoryFingerprint(sessions) {
535
604
  const sorted = [...sessions].sort((a, b) => a.id.localeCompare(b.id));
536
- return sorted.map((s) => `${s.id}|${s.type}|${s.name}|${s.status}|${s.command || ""}`).join("\n");
605
+ return sorted.map((s) => {
606
+ const pidStr = s.pids?.join(",") ?? "";
607
+ return `${s.id}|${s.type}|${s.name}|${s.status}|${s.command || ""}|${s.runningCommand || ""}|${pidStr}`;
608
+ }).join("\n");
537
609
  }
538
610
  async function getChildAgentInfo(parentPid) {
539
611
  try {
@@ -755,431 +827,99 @@ var OutputBuffer = class {
755
827
  }
756
828
  };
757
829
 
758
- // src/pty.ts
759
- var import_node_child_process2 = require("child_process");
760
- var pty = __toESM(require("node-pty"));
761
- function isInteractiveShell(command) {
762
- const trimmed = String(command).trim();
763
- return ["bash", "sh", "zsh", "fish"].includes(trimmed);
764
- }
765
- function spawnConnectorShell(command, onData, onExit) {
766
- const shell = "bash";
767
- const args = isInteractiveShell(command) ? [] : ["-lc", command];
768
- const processName = isInteractiveShell(command) ? command : shell;
769
- const ptyProcess = pty.spawn(processName, args, {
770
- name: "xterm-color",
771
- cols: 80,
772
- rows: 30,
773
- cwd: process.cwd(),
774
- env: process.env
775
- });
776
- ptyProcess.onData(onData);
777
- ptyProcess.onExit(onExit);
778
- return ptyProcess;
779
- }
780
- async function stopTmuxPaneBySessionId(sessionId) {
781
- if (!sessionId.startsWith("tmux:")) return false;
782
- const paneTarget = sessionId.slice("tmux:".length).trim();
783
- if (!paneTarget) return false;
784
- await execAsync(`tmux kill-pane -t ${shellQuote(paneTarget)}`, { timeout: 5e3 });
785
- return true;
830
+ // src/websocket.ts
831
+ var import_ws = __toESM(require("ws"));
832
+ var BACKPRESSURE_HIGH_WATER = 256 * 1024;
833
+ function sanitizeForJson(str) {
834
+ return str.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
786
835
  }
787
- async function sendInputToTmux(sessionId, data) {
788
- if (!sessionId.startsWith("tmux:")) return;
789
- const target = sessionId.slice("tmux:".length).trim();
790
- if (!target) return;
791
- const sessionName = target.split(":")[0].split(".")[0];
792
- if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(sessionName)) {
793
- console.warn(`[rAgent] Invalid tmux session name: ${sessionName}`);
836
+ function sendToGroup(ws, group, data) {
837
+ if (!group || ws.readyState !== import_ws.default.OPEN) return;
838
+ if (ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
794
839
  return;
795
840
  }
796
- try {
797
- await new Promise((resolve, reject) => {
798
- (0, import_node_child_process2.execFile)("tmux", ["send-keys", "-t", target, "-l", "--", data], { timeout: 5e3 }, (err) => {
799
- if (err) reject(err);
800
- else resolve();
801
- });
802
- });
803
- } catch (error) {
804
- const message = error instanceof Error ? error.message : String(error);
805
- console.warn(`[rAgent] Failed to send input to ${sessionId}: ${message}`);
806
- }
841
+ const sanitized = sanitizePayload(data);
842
+ ws.send(
843
+ JSON.stringify({
844
+ type: "sendToGroup",
845
+ group,
846
+ dataType: "json",
847
+ data: sanitized,
848
+ noEcho: true
849
+ })
850
+ );
807
851
  }
808
- async function stopAllDetachedTmuxSessions() {
809
- try {
810
- const raw = await execAsync(
811
- "tmux list-sessions -F '#{session_name}|#{session_attached}'",
812
- { timeout: 5e3 }
813
- );
814
- const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
815
- let killed = 0;
816
- for (const line of lines) {
817
- const [name, attached] = line.split("|");
818
- if (attached === "0" && name) {
819
- try {
820
- await execAsync(`tmux kill-session -t ${shellQuote(name)}`, { timeout: 5e3 });
821
- killed++;
822
- } catch {
823
- }
824
- }
852
+ function sanitizePayload(obj) {
853
+ const result = {};
854
+ for (const [key, value] of Object.entries(obj)) {
855
+ if (typeof value === "string") {
856
+ result[key] = sanitizeForJson(value);
857
+ } else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
858
+ result[key] = sanitizePayload(value);
859
+ } else {
860
+ result[key] = value;
825
861
  }
826
- return killed;
827
- } catch {
828
- return 0;
829
- }
830
- }
831
-
832
- // src/service.ts
833
- var import_child_process = require("child_process");
834
- var fs2 = __toESM(require("fs"));
835
- var os4 = __toESM(require("os"));
836
- function assertConfiguredAgentToken() {
837
- const config = loadConfig();
838
- if (!config.agentToken) {
839
- throw new Error("No saved connector token. Run `ragent connect --token <token>` first.");
840
- }
841
- }
842
- function getConfiguredServiceBackend() {
843
- const config = loadConfig();
844
- if (config.serviceBackend === "systemd" || config.serviceBackend === "pidfile") {
845
- return config.serviceBackend;
846
- }
847
- if (fs2.existsSync(SERVICE_FILE)) return "systemd";
848
- if (fs2.existsSync(FALLBACK_PID_FILE)) return "pidfile";
849
- return null;
850
- }
851
- async function canUseSystemdUser() {
852
- if (os4.platform() !== "linux") return false;
853
- try {
854
- await execAsync("systemctl --user --version", { timeout: 4e3 });
855
- await execAsync("systemctl --user show-environment", { timeout: 4e3 });
856
- return true;
857
- } catch {
858
- return false;
859
862
  }
863
+ return result;
860
864
  }
861
- async function runSystemctlUser(args, options = {}) {
862
- const command = `systemctl --user ${args.map(shellQuote).join(" ")}`;
863
- return execAsync(command, { timeout: options.timeout ?? 1e4 });
864
- }
865
- function buildSystemdUnit() {
866
- return `[Unit]
867
- Description=rAgent Live connector
868
- After=network-online.target
869
- Wants=network-online.target
870
-
871
- [Service]
872
- Type=simple
873
- ExecStart=${process.execPath} ${__filename} run
874
- Restart=always
875
- RestartSec=3
876
- Environment=NODE_ENV=production
877
- NoNewPrivileges=true
878
- PrivateTmp=true
879
- ProtectSystem=strict
880
- ProtectHome=read-only
881
- ReadWritePaths=%h/.config/ragent
882
865
 
883
- [Install]
884
- WantedBy=default.target
885
- `;
866
+ // src/session-streamer.ts
867
+ var import_node_child_process2 = require("child_process");
868
+ var import_node_fs = require("fs");
869
+ var import_node_path = require("path");
870
+ var import_node_os = require("os");
871
+ var pty = __toESM(require("node-pty"));
872
+ var STOP_DEBOUNCE_MS = 2e3;
873
+ function parsePaneTarget(sessionId) {
874
+ if (!sessionId.startsWith("tmux:")) return null;
875
+ const rest = sessionId.slice("tmux:".length);
876
+ if (!rest) return null;
877
+ return rest;
886
878
  }
887
- async function installSystemdService(opts = {}) {
888
- assertConfiguredAgentToken();
889
- fs2.mkdirSync(SERVICE_DIR, { recursive: true });
890
- fs2.writeFileSync(SERVICE_FILE, buildSystemdUnit(), "utf8");
891
- await runSystemctlUser(["daemon-reload"]);
892
- if (opts.enable !== false) {
893
- await runSystemctlUser(["enable", SERVICE_NAME]);
894
- }
895
- if (opts.start) {
896
- await runSystemctlUser(["restart", SERVICE_NAME]);
897
- }
898
- saveConfigPatch({ serviceBackend: "systemd" });
899
- console.log(`[rAgent] Installed systemd user service at ${SERVICE_FILE}`);
879
+ function parseScreenSession(sessionId) {
880
+ if (!sessionId.startsWith("screen:")) return null;
881
+ const rest = sessionId.slice("screen:".length);
882
+ if (!rest) return null;
883
+ const name = rest.split(":")[0];
884
+ return name || null;
900
885
  }
901
- function readFallbackPid() {
902
- try {
903
- const raw = fs2.readFileSync(FALLBACK_PID_FILE, "utf8").trim();
904
- const pid = Number.parseInt(raw, 10);
905
- return Number.isInteger(pid) ? pid : null;
906
- } catch {
907
- return null;
908
- }
886
+ function parseZellijSession(sessionId) {
887
+ if (!sessionId.startsWith("zellij:")) return null;
888
+ const rest = sessionId.slice("zellij:".length);
889
+ if (!rest) return null;
890
+ return rest.split(":")[0] || null;
909
891
  }
910
- function isProcessRunning(pid) {
911
- if (!pid || !Number.isInteger(pid)) return false;
912
- try {
913
- process.kill(pid, 0);
914
- return true;
915
- } catch {
916
- return false;
892
+ var SessionStreamer = class {
893
+ active = /* @__PURE__ */ new Map();
894
+ pendingStops = /* @__PURE__ */ new Map();
895
+ sendFn;
896
+ onStreamStopped;
897
+ constructor(sendFn, onStreamStopped) {
898
+ this.sendFn = sendFn;
899
+ this.onStreamStopped = onStreamStopped;
900
+ this.cleanupStaleStreams();
917
901
  }
918
- }
919
- async function startPidfileService() {
920
- assertConfiguredAgentToken();
921
- const existingPid = readFallbackPid();
922
- if (existingPid && isProcessRunning(existingPid)) {
923
- console.log(`[rAgent] Service already running (pid ${existingPid})`);
924
- return;
925
- }
926
- ensureConfigDir();
927
- const logFd = fs2.openSync(FALLBACK_LOG_FILE, "a");
928
- const child = (0, import_child_process.spawn)(process.execPath, [__filename, "run"], {
929
- detached: true,
930
- stdio: ["ignore", logFd, logFd],
931
- cwd: os4.homedir(),
932
- env: process.env
933
- });
934
- child.unref();
935
- fs2.closeSync(logFd);
936
- fs2.writeFileSync(FALLBACK_PID_FILE, `${child.pid}
937
- `, "utf8");
938
- saveConfigPatch({ serviceBackend: "pidfile" });
939
- console.log(`[rAgent] Started fallback background service (pid ${child.pid})`);
940
- console.log(`[rAgent] Logs: ${FALLBACK_LOG_FILE}`);
941
- }
942
- async function stopPidfileService() {
943
- const pid = readFallbackPid();
944
- if (!pid || !isProcessRunning(pid)) {
945
- try {
946
- fs2.unlinkSync(FALLBACK_PID_FILE);
947
- } catch {
948
- }
949
- console.log("[rAgent] Service is not running.");
950
- return;
951
- }
952
- process.kill(pid, "SIGTERM");
953
- for (let i = 0; i < 20; i += 1) {
954
- if (!isProcessRunning(pid)) break;
955
- await wait(150);
956
- }
957
- if (isProcessRunning(pid)) {
958
- process.kill(pid, "SIGKILL");
959
- }
960
- try {
961
- fs2.unlinkSync(FALLBACK_PID_FILE);
962
- } catch {
963
- }
964
- console.log(`[rAgent] Stopped fallback background service (pid ${pid})`);
965
- }
966
- async function ensureServiceInstalled(opts = {}) {
967
- const wantsSystemd = await canUseSystemdUser();
968
- if (wantsSystemd) {
969
- await installSystemdService(opts);
970
- return "systemd";
971
- }
972
- saveConfigPatch({ serviceBackend: "pidfile" });
973
- if (opts.start) {
974
- await startPidfileService();
975
- } else {
976
- console.log(
977
- "[rAgent] systemd user manager unavailable; using fallback pidfile backend."
978
- );
979
- }
980
- return "pidfile";
981
- }
982
- async function startService() {
983
- const backend = getConfiguredServiceBackend();
984
- if (backend === "systemd") {
985
- await runSystemctlUser(["start", SERVICE_NAME]);
986
- console.log("[rAgent] Started service via systemd.");
987
- return;
988
- }
989
- if (backend === "pidfile") {
990
- await startPidfileService();
991
- return;
992
- }
993
- await ensureServiceInstalled({ start: true, enable: true });
994
- }
995
- async function stopService() {
996
- const backend = getConfiguredServiceBackend();
997
- if (backend === "systemd") {
998
- await runSystemctlUser(["stop", SERVICE_NAME]);
999
- console.log("[rAgent] Stopped service via systemd.");
1000
- return;
1001
- }
1002
- await stopPidfileService();
1003
- }
1004
- async function restartService() {
1005
- const backend = getConfiguredServiceBackend();
1006
- if (backend === "systemd") {
1007
- await runSystemctlUser(["restart", SERVICE_NAME]);
1008
- console.log("[rAgent] Restarted service via systemd.");
1009
- return;
1010
- }
1011
- await stopPidfileService();
1012
- await startPidfileService();
1013
- }
1014
- async function printServiceStatus() {
1015
- const backend = getConfiguredServiceBackend();
1016
- if (backend === "systemd") {
1017
- const status = await execAsync(
1018
- `systemctl --user status ${shellQuote(SERVICE_NAME)} --no-pager --lines=20`,
1019
- { timeout: 1e4 }
1020
- ).catch((error) => {
1021
- console.log(error.message);
1022
- return "";
1023
- });
1024
- if (status) {
1025
- process.stdout.write(status);
1026
- }
1027
- return;
1028
- }
1029
- const pid = readFallbackPid();
1030
- if (pid && isProcessRunning(pid)) {
1031
- console.log(`[rAgent] fallback service running (pid ${pid})`);
1032
- console.log(`[rAgent] logs: ${FALLBACK_LOG_FILE}`);
1033
- return;
1034
- }
1035
- console.log("[rAgent] service is not running.");
1036
- }
1037
- async function printServiceLogs(opts) {
1038
- const lines = Number.parseInt(String(opts.lines || 100), 10) || 100;
1039
- const follow = Boolean(opts.follow);
1040
- const backend = getConfiguredServiceBackend();
1041
- if (backend === "systemd") {
1042
- if (follow) {
1043
- await new Promise((resolve) => {
1044
- const child = (0, import_child_process.spawn)(
1045
- "journalctl",
1046
- ["--user", "-u", SERVICE_NAME, "-f", "-n", String(lines)],
1047
- { stdio: "inherit" }
1048
- );
1049
- child.on("exit", () => resolve());
1050
- });
1051
- return;
1052
- }
1053
- const output = await execAsync(
1054
- `journalctl --user -u ${shellQuote(SERVICE_NAME)} -n ${lines} --no-pager`,
1055
- { timeout: 12e3 }
1056
- );
1057
- process.stdout.write(output);
1058
- return;
1059
- }
1060
- if (!fs2.existsSync(FALLBACK_LOG_FILE)) {
1061
- console.log(`[rAgent] No log file found at ${FALLBACK_LOG_FILE}`);
1062
- return;
1063
- }
1064
- if (follow) {
1065
- await new Promise((resolve) => {
1066
- const child = (0, import_child_process.spawn)("tail", ["-n", String(lines), "-f", FALLBACK_LOG_FILE], {
1067
- stdio: "inherit"
1068
- });
1069
- child.on("exit", () => resolve());
1070
- });
1071
- return;
1072
- }
1073
- const content = fs2.readFileSync(FALLBACK_LOG_FILE, "utf8");
1074
- const tail = content.split("\n").slice(-lines).join("\n");
1075
- process.stdout.write(`${tail}${tail.endsWith("\n") ? "" : "\n"}`);
1076
- }
1077
- async function uninstallService() {
1078
- const backend = getConfiguredServiceBackend();
1079
- if (backend === "systemd") {
1080
- await runSystemctlUser(["stop", SERVICE_NAME]).catch(() => void 0);
1081
- await runSystemctlUser(["disable", SERVICE_NAME]).catch(() => void 0);
1082
- try {
1083
- fs2.unlinkSync(SERVICE_FILE);
1084
- } catch {
1085
- }
1086
- await runSystemctlUser(["daemon-reload"]).catch(() => void 0);
1087
- } else {
1088
- await stopPidfileService();
1089
- }
1090
- const config = loadConfig();
1091
- delete config.serviceBackend;
1092
- saveConfig(config);
1093
- console.log("[rAgent] Service uninstalled.");
1094
- }
1095
- function requestStopSelfService() {
1096
- const backend = getConfiguredServiceBackend();
1097
- if (backend === "systemd") {
1098
- const child = (0, import_child_process.spawn)("systemctl", ["--user", "stop", SERVICE_NAME], {
1099
- detached: true,
1100
- stdio: "ignore"
1101
- });
1102
- child.unref();
1103
- return;
1104
- }
1105
- if (backend === "pidfile") {
1106
- try {
1107
- fs2.unlinkSync(FALLBACK_PID_FILE);
1108
- } catch {
1109
- }
1110
- }
1111
- }
1112
-
1113
- // src/websocket.ts
1114
- var import_ws = __toESM(require("ws"));
1115
- var BACKPRESSURE_HIGH_WATER = 256 * 1024;
1116
- function sanitizeForJson(str) {
1117
- return str.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
1118
- }
1119
- function sendToGroup(ws, group, data) {
1120
- if (!group || ws.readyState !== import_ws.default.OPEN) return;
1121
- if (ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
1122
- return;
1123
- }
1124
- const sanitized = sanitizePayload(data);
1125
- ws.send(
1126
- JSON.stringify({
1127
- type: "sendToGroup",
1128
- group,
1129
- dataType: "json",
1130
- data: sanitized,
1131
- noEcho: true
1132
- })
1133
- );
1134
- }
1135
- function sanitizePayload(obj) {
1136
- const result = {};
1137
- for (const [key, value] of Object.entries(obj)) {
1138
- if (typeof value === "string") {
1139
- result[key] = sanitizeForJson(value);
1140
- } else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1141
- result[key] = sanitizePayload(value);
1142
- } else {
1143
- result[key] = value;
1144
- }
1145
- }
1146
- return result;
1147
- }
1148
-
1149
- // src/session-streamer.ts
1150
- var import_node_child_process3 = require("child_process");
1151
- var import_node_fs = require("fs");
1152
- var import_node_path = require("path");
1153
- var import_node_os = require("os");
1154
- var pty2 = __toESM(require("node-pty"));
1155
- var STOP_DEBOUNCE_MS = 2e3;
1156
- function parsePaneTarget(sessionId) {
1157
- if (!sessionId.startsWith("tmux:")) return null;
1158
- const rest = sessionId.slice("tmux:".length);
1159
- if (!rest) return null;
1160
- return rest;
1161
- }
1162
- function parseScreenSession(sessionId) {
1163
- if (!sessionId.startsWith("screen:")) return null;
1164
- const rest = sessionId.slice("screen:".length);
1165
- if (!rest) return null;
1166
- const name = rest.split(":")[0];
1167
- return name || null;
1168
- }
1169
- function parseZellijSession(sessionId) {
1170
- if (!sessionId.startsWith("zellij:")) return null;
1171
- const rest = sessionId.slice("zellij:".length);
1172
- if (!rest) return null;
1173
- return rest.split(":")[0] || null;
1174
- }
1175
- var SessionStreamer = class {
1176
- active = /* @__PURE__ */ new Map();
1177
- pendingStops = /* @__PURE__ */ new Map();
1178
- sendFn;
1179
- onStreamStopped;
1180
- constructor(sendFn, onStreamStopped) {
1181
- this.sendFn = sendFn;
1182
- this.onStreamStopped = onStreamStopped;
902
+ /**
903
+ * Remove orphaned FIFO directories left behind by a previous crash.
904
+ * These stale FIFOs can cause tmux pipe-pane to block if the reader is dead.
905
+ */
906
+ cleanupStaleStreams() {
907
+ const streamsBase = (0, import_node_path.join)((0, import_node_os.homedir)(), ".config", "ragent", "streams");
908
+ try {
909
+ const entries = (0, import_node_fs.readdirSync)(streamsBase);
910
+ for (const entry of entries) {
911
+ if (entry.startsWith("s-")) {
912
+ try {
913
+ (0, import_node_fs.rmSync)((0, import_node_path.join)(streamsBase, entry), { recursive: true, force: true });
914
+ } catch {
915
+ }
916
+ }
917
+ }
918
+ if (entries.some((e) => e.startsWith("s-"))) {
919
+ console.log("[rAgent] Cleaned up stale stream directories from previous run.");
920
+ }
921
+ } catch {
922
+ }
1183
923
  }
1184
924
  /**
1185
925
  * Start streaming a session. Dispatches based on session type prefix.
@@ -1223,12 +963,27 @@ var SessionStreamer = class {
1223
963
  }
1224
964
  }
1225
965
  /**
1226
- * Stop streaming a session (debounced to absorb React remount cycles).
966
+ * Resize a PTY-attached stream (screen/zellij).
1227
967
  */
1228
- stopStream(sessionId) {
968
+ resize(sessionId, cols, rows) {
1229
969
  const stream = this.active.get(sessionId);
1230
- if (!stream) return;
1231
- if (this.pendingStops.has(sessionId)) return;
970
+ if (!stream || stream.stopped || stream.streamType !== "pty-attach" || !stream.ptyProc) return;
971
+ try {
972
+ stream.ptyProc.resize(cols, rows);
973
+ } catch (error) {
974
+ const message = error instanceof Error ? error.message : String(error);
975
+ if (!message.includes("EBADF")) {
976
+ console.warn(`[rAgent] Resize failed for ${sessionId}: ${message}`);
977
+ }
978
+ }
979
+ }
980
+ /**
981
+ * Stop streaming a session (debounced to absorb React remount cycles).
982
+ */
983
+ stopStream(sessionId) {
984
+ const stream = this.active.get(sessionId);
985
+ if (!stream) return;
986
+ if (this.pendingStops.has(sessionId)) return;
1232
987
  const timer = setTimeout(() => {
1233
988
  this.pendingStops.delete(sessionId);
1234
989
  const s = this.active.get(sessionId);
@@ -1287,7 +1042,7 @@ var SessionStreamer = class {
1287
1042
  tmpDir = (0, import_node_fs.mkdtempSync)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "ragent-stream-"));
1288
1043
  }
1289
1044
  const fifoPath = (0, import_node_path.join)(tmpDir, "pane.fifo");
1290
- (0, import_node_child_process3.execFileSync)("mkfifo", ["-m", "600", fifoPath]);
1045
+ (0, import_node_child_process2.execFileSync)("mkfifo", ["-m", "600", fifoPath]);
1291
1046
  const stream = {
1292
1047
  sessionId,
1293
1048
  streamType: "tmux-pipe",
@@ -1298,13 +1053,14 @@ var SessionStreamer = class {
1298
1053
  stopped: false,
1299
1054
  initializing: true,
1300
1055
  initBuffer: [],
1056
+ cleanEnv,
1301
1057
  ptyProc: null
1302
1058
  };
1303
1059
  this.active.set(sessionId, stream);
1304
1060
  try {
1305
- (0, import_node_child_process3.execFileSync)(
1061
+ (0, import_node_child_process2.execFileSync)(
1306
1062
  "tmux",
1307
- ["pipe-pane", "-O", "-t", paneTarget, `cat > ${fifoPath}`],
1063
+ ["pipe-pane", "-O", "-t", paneTarget, `cat > ${shellQuote(fifoPath)}`],
1308
1064
  { env: cleanEnv, timeout: 5e3 }
1309
1065
  );
1310
1066
  } catch (error) {
@@ -1313,7 +1069,7 @@ var SessionStreamer = class {
1313
1069
  console.warn(`[rAgent] Failed pipe-pane for ${sessionId}: ${message}`);
1314
1070
  return false;
1315
1071
  }
1316
- const catProc = (0, import_node_child_process3.spawn)("cat", [fifoPath], { env: cleanEnv, stdio: ["ignore", "pipe", "ignore"] });
1072
+ const catProc = (0, import_node_child_process2.spawn)("cat", [fifoPath], { env: cleanEnv, stdio: ["ignore", "pipe", "ignore"] });
1317
1073
  stream.catProc = catProc;
1318
1074
  catProc.stdout.on("data", (chunk) => {
1319
1075
  if (stream.stopped) return;
@@ -1333,9 +1089,20 @@ var SessionStreamer = class {
1333
1089
  this.onStreamStopped?.(sessionId);
1334
1090
  }
1335
1091
  });
1336
- this.sendFn(sessionId, "\x1B[2J\x1B[H");
1337
1092
  try {
1338
- const initial = (0, import_node_child_process3.execFileSync)(
1093
+ const scrollback = (0, import_node_child_process2.execFileSync)(
1094
+ "tmux",
1095
+ ["capture-pane", "-t", paneTarget, "-p", "-e", "-S", "-5000", "-E", "-1"],
1096
+ { env: cleanEnv, timeout: 1e4, encoding: "utf-8" }
1097
+ );
1098
+ if (scrollback && scrollback.length > 0) {
1099
+ this.sendFn(sessionId, scrollback);
1100
+ }
1101
+ } catch {
1102
+ }
1103
+ this.sendFn(sessionId, "\x1B[0m\x1B[2J\x1B[H");
1104
+ try {
1105
+ const initial = (0, import_node_child_process2.execFileSync)(
1339
1106
  "tmux",
1340
1107
  ["capture-pane", "-t", paneTarget, "-p", "-e"],
1341
1108
  { env: cleanEnv, timeout: 5e3, encoding: "utf-8" }
@@ -1346,7 +1113,7 @@ var SessionStreamer = class {
1346
1113
  } catch {
1347
1114
  }
1348
1115
  try {
1349
- const cursorInfo = (0, import_node_child_process3.execFileSync)(
1116
+ const cursorInfo = (0, import_node_child_process2.execFileSync)(
1350
1117
  "tmux",
1351
1118
  ["display-message", "-t", paneTarget, "-p", "#{cursor_x} #{cursor_y}"],
1352
1119
  { env: cleanEnv, timeout: 5e3, encoding: "utf-8" }
@@ -1362,12 +1129,7 @@ var SessionStreamer = class {
1362
1129
  } catch {
1363
1130
  }
1364
1131
  stream.initializing = false;
1365
- if (stream.initBuffer.length > 0) {
1366
- for (const buffered of stream.initBuffer) {
1367
- this.sendFn(sessionId, buffered);
1368
- }
1369
- stream.initBuffer = [];
1370
- }
1132
+ stream.initBuffer = [];
1371
1133
  console.log(`[rAgent] Started streaming: ${sessionId} (pane: ${paneTarget})`);
1372
1134
  return true;
1373
1135
  } catch (error) {
@@ -1383,7 +1145,7 @@ var SessionStreamer = class {
1383
1145
  const sessionName = parseScreenSession(sessionId);
1384
1146
  if (!sessionName) return false;
1385
1147
  try {
1386
- const proc = pty2.spawn("screen", ["-x", sessionName], {
1148
+ const proc = pty.spawn("screen", ["-x", sessionName], {
1387
1149
  name: "xterm-256color",
1388
1150
  cols: 80,
1389
1151
  rows: 30,
@@ -1432,7 +1194,7 @@ var SessionStreamer = class {
1432
1194
  const sessionName = parseZellijSession(sessionId);
1433
1195
  if (!sessionName) return false;
1434
1196
  try {
1435
- const proc = pty2.spawn("zellij", ["attach", sessionName], {
1197
+ const proc = pty.spawn("zellij", ["attach", sessionName], {
1436
1198
  name: "xterm-256color",
1437
1199
  cols: 80,
1438
1200
  rows: 30,
@@ -1481,7 +1243,10 @@ var SessionStreamer = class {
1481
1243
  stream.stopped = true;
1482
1244
  if (stream.streamType === "tmux-pipe") {
1483
1245
  try {
1484
- (0, import_node_child_process3.execFileSync)("tmux", ["pipe-pane", "-t", stream.paneTarget], { timeout: 5e3 });
1246
+ (0, import_node_child_process2.execFileSync)("tmux", ["pipe-pane", "-t", stream.paneTarget], {
1247
+ timeout: 5e3,
1248
+ env: stream.cleanEnv
1249
+ });
1485
1250
  } catch {
1486
1251
  }
1487
1252
  if (stream.catProc && !stream.catProc.killed) {
@@ -1506,10 +1271,680 @@ var SessionStreamer = class {
1506
1271
  }
1507
1272
  }
1508
1273
  }
1509
- };
1274
+ };
1275
+
1276
+ // src/crypto-channel.ts
1277
+ var import_node_crypto = require("crypto");
1278
+ function deriveAesKey(sessionKey) {
1279
+ return Buffer.from(sessionKey.slice(0, 64), "hex");
1280
+ }
1281
+ function encryptPayload(data, sessionKey) {
1282
+ const key = deriveAesKey(sessionKey);
1283
+ const iv = (0, import_node_crypto.randomBytes)(12);
1284
+ const cipher = (0, import_node_crypto.createCipheriv)("aes-256-gcm", key, iv);
1285
+ const encrypted = Buffer.concat([
1286
+ cipher.update(data, "utf-8"),
1287
+ cipher.final()
1288
+ ]);
1289
+ const authTag = cipher.getAuthTag();
1290
+ const combined = Buffer.concat([encrypted, authTag]);
1291
+ return {
1292
+ enc: combined.toString("base64"),
1293
+ iv: iv.toString("base64")
1294
+ };
1295
+ }
1296
+ function decryptPayload(enc, iv, sessionKey) {
1297
+ try {
1298
+ const key = deriveAesKey(sessionKey);
1299
+ const ivBuf = Buffer.from(iv, "base64");
1300
+ const combined = Buffer.from(enc, "base64");
1301
+ if (combined.length < 16) return null;
1302
+ const ciphertext = combined.subarray(0, combined.length - 16);
1303
+ const authTag = combined.subarray(combined.length - 16);
1304
+ const decipher = (0, import_node_crypto.createDecipheriv)("aes-256-gcm", key, ivBuf);
1305
+ decipher.setAuthTag(authTag);
1306
+ const decrypted = Buffer.concat([
1307
+ decipher.update(ciphertext),
1308
+ decipher.final()
1309
+ ]);
1310
+ return decrypted.toString("utf-8");
1311
+ } catch {
1312
+ return null;
1313
+ }
1314
+ }
1315
+
1316
+ // src/pty.ts
1317
+ var import_node_child_process3 = require("child_process");
1318
+ var pty2 = __toESM(require("node-pty"));
1319
+ function isInteractiveShell(command) {
1320
+ const trimmed = String(command).trim();
1321
+ return ["bash", "sh", "zsh", "fish"].includes(trimmed);
1322
+ }
1323
+ function spawnConnectorShell(command, onData, onExit) {
1324
+ const shell = "bash";
1325
+ const args = isInteractiveShell(command) ? [] : ["-lc", command];
1326
+ const processName = isInteractiveShell(command) ? command : shell;
1327
+ const ptyProcess = pty2.spawn(processName, args, {
1328
+ name: "xterm-color",
1329
+ cols: 80,
1330
+ rows: 30,
1331
+ cwd: process.cwd(),
1332
+ env: process.env
1333
+ });
1334
+ ptyProcess.onData(onData);
1335
+ ptyProcess.onExit(onExit);
1336
+ return ptyProcess;
1337
+ }
1338
+ async function stopTmuxPaneBySessionId(sessionId) {
1339
+ if (!sessionId.startsWith("tmux:")) return false;
1340
+ const paneTarget = sessionId.slice("tmux:".length).trim();
1341
+ if (!paneTarget) return false;
1342
+ await execAsync(`tmux kill-pane -t ${shellQuote(paneTarget)}`, { timeout: 5e3 });
1343
+ return true;
1344
+ }
1345
+ async function sendInputToTmux(sessionId, data) {
1346
+ if (!sessionId.startsWith("tmux:")) return;
1347
+ const target = sessionId.slice("tmux:".length).trim();
1348
+ if (!target) return;
1349
+ const sessionName = target.split(":")[0].split(".")[0];
1350
+ if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(sessionName)) {
1351
+ console.warn(`[rAgent] Invalid tmux session name: ${sessionName}`);
1352
+ return;
1353
+ }
1354
+ try {
1355
+ await new Promise((resolve, reject) => {
1356
+ (0, import_node_child_process3.execFile)("tmux", ["send-keys", "-t", target, "-l", "--", data], { timeout: 5e3 }, (err) => {
1357
+ if (err) reject(err);
1358
+ else resolve();
1359
+ });
1360
+ });
1361
+ } catch (error) {
1362
+ const message = error instanceof Error ? error.message : String(error);
1363
+ console.warn(`[rAgent] Failed to send input to ${sessionId}: ${message}`);
1364
+ }
1365
+ }
1366
+ async function stopAllDetachedTmuxSessions() {
1367
+ try {
1368
+ const raw = await execAsync(
1369
+ "tmux list-sessions -F '#{session_name}|#{session_attached}'",
1370
+ { timeout: 5e3 }
1371
+ );
1372
+ const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
1373
+ let killed = 0;
1374
+ for (const line of lines) {
1375
+ const [name, attached] = line.split("|");
1376
+ if (attached === "0" && name) {
1377
+ try {
1378
+ await execAsync(`tmux kill-session -t ${shellQuote(name)}`, { timeout: 5e3 });
1379
+ killed++;
1380
+ } catch {
1381
+ }
1382
+ }
1383
+ }
1384
+ return killed;
1385
+ } catch {
1386
+ return 0;
1387
+ }
1388
+ }
1389
+
1390
+ // src/shell-manager.ts
1391
+ var ShellManager = class {
1392
+ ptyProcess = null;
1393
+ suppressNextRespawn = false;
1394
+ shouldRun = true;
1395
+ sendOutput;
1396
+ command;
1397
+ constructor(command, sendOutput) {
1398
+ this.command = command;
1399
+ this.sendOutput = sendOutput;
1400
+ }
1401
+ /** Start the initial shell process. */
1402
+ spawn() {
1403
+ this.spawnOrRespawn();
1404
+ }
1405
+ /** Kill current shell and spawn a new one. */
1406
+ restart() {
1407
+ this.kill();
1408
+ this.spawnOrRespawn();
1409
+ }
1410
+ /** Kill the current shell process (suppresses auto-respawn). */
1411
+ kill() {
1412
+ if (!this.ptyProcess) return;
1413
+ this.suppressNextRespawn = true;
1414
+ try {
1415
+ this.ptyProcess.kill();
1416
+ } catch {
1417
+ }
1418
+ this.ptyProcess = null;
1419
+ }
1420
+ /** Write input data to the PTY process. */
1421
+ write(data) {
1422
+ if (this.ptyProcess) this.ptyProcess.write(data);
1423
+ }
1424
+ /** Resize the PTY process. */
1425
+ resize(cols, rows) {
1426
+ try {
1427
+ if (this.ptyProcess) this.ptyProcess.resize(cols, rows);
1428
+ } catch (error) {
1429
+ const message = error instanceof Error ? error.message : String(error);
1430
+ if (!message.includes("EBADF")) {
1431
+ console.warn(`[rAgent] Resize failed: ${message}`);
1432
+ }
1433
+ }
1434
+ }
1435
+ /** Signal that the agent is shutting down (prevents respawn). */
1436
+ stop() {
1437
+ this.shouldRun = false;
1438
+ this.kill();
1439
+ }
1440
+ spawnOrRespawn() {
1441
+ this.ptyProcess = spawnConnectorShell(this.command, this.sendOutput, () => {
1442
+ if (this.suppressNextRespawn) {
1443
+ this.suppressNextRespawn = false;
1444
+ return;
1445
+ }
1446
+ if (!this.shouldRun) return;
1447
+ console.warn("[rAgent] Shell exited. Restarting shell process.");
1448
+ setTimeout(() => {
1449
+ if (this.shouldRun) this.spawnOrRespawn();
1450
+ }, 200);
1451
+ });
1452
+ }
1453
+ };
1454
+
1455
+ // src/inventory-manager.ts
1456
+ var os4 = __toESM(require("os"));
1457
+ var import_child_process = require("child_process");
1458
+ var import_ws2 = __toESM(require("ws"));
1459
+ var InventoryManager = class {
1460
+ lastSentFingerprint = "";
1461
+ lastHttpHeartbeatAt = 0;
1462
+ prevCpuSnapshot = null;
1463
+ options;
1464
+ constructor(options) {
1465
+ this.options = options;
1466
+ }
1467
+ /** Update options (e.g., after token refresh). */
1468
+ updateOptions(options) {
1469
+ this.options = options;
1470
+ }
1471
+ /** Announce to the registry group via WebSocket. */
1472
+ async announceToRegistry(ws, registryGroup, type = "heartbeat") {
1473
+ if (ws.readyState !== import_ws2.default.OPEN || !registryGroup) return;
1474
+ const sessions = await collectSessionInventory(this.options.hostId, this.options.command);
1475
+ const vitals = this.collectVitals();
1476
+ sendToGroup(ws, registryGroup, {
1477
+ type,
1478
+ hostId: this.options.hostId,
1479
+ hostName: this.options.hostName,
1480
+ environment: os4.hostname(),
1481
+ sessions,
1482
+ vitals,
1483
+ agentVersion: CURRENT_VERSION,
1484
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
1485
+ });
1486
+ }
1487
+ /**
1488
+ * Sync inventory: detect changes via fingerprint, send WS announcement
1489
+ * and HTTP heartbeat as needed.
1490
+ */
1491
+ async syncInventory(ws, groups, force = false) {
1492
+ const sessions = await collectSessionInventory(this.options.hostId, this.options.command);
1493
+ const fingerprint = sessionInventoryFingerprint(sessions);
1494
+ const changed = fingerprint !== this.lastSentFingerprint;
1495
+ const checkpointDue = Date.now() - this.lastHttpHeartbeatAt > HTTP_HEARTBEAT_MS;
1496
+ if (changed || force) {
1497
+ if (ws && ws.readyState === import_ws2.default.OPEN) {
1498
+ await this.announceToRegistry(ws, groups.registryGroup, "inventory");
1499
+ }
1500
+ this.lastSentFingerprint = fingerprint;
1501
+ }
1502
+ if (changed || checkpointDue || force) {
1503
+ await postHeartbeat({
1504
+ portal: this.options.portal,
1505
+ agentToken: this.options.agentToken,
1506
+ hostId: this.options.hostId,
1507
+ hostName: this.options.hostName,
1508
+ command: this.options.command
1509
+ });
1510
+ this.lastHttpHeartbeatAt = Date.now();
1511
+ }
1512
+ }
1513
+ /** Collect system vitals (CPU, memory, disk). */
1514
+ collectVitals() {
1515
+ const currentSnapshot = this.takeCpuSnapshot();
1516
+ let cpuUsage = 0;
1517
+ if (this.prevCpuSnapshot) {
1518
+ const idleDelta = currentSnapshot.idle - this.prevCpuSnapshot.idle;
1519
+ const totalDelta = currentSnapshot.total - this.prevCpuSnapshot.total;
1520
+ cpuUsage = totalDelta > 0 ? Math.round((totalDelta - idleDelta) / totalDelta * 100) : 0;
1521
+ }
1522
+ this.prevCpuSnapshot = currentSnapshot;
1523
+ const totalMem = os4.totalmem();
1524
+ const freeMem = os4.freemem();
1525
+ const memUsedPct = totalMem > 0 ? Math.round((totalMem - freeMem) / totalMem * 100) : 0;
1526
+ let diskUsedPct = 0;
1527
+ try {
1528
+ const dfOutput = (0, import_child_process.execSync)("df -P / | tail -1", { encoding: "utf8", timeout: 5e3 });
1529
+ const parts = dfOutput.trim().split(/\s+/);
1530
+ if (parts.length >= 5) {
1531
+ diskUsedPct = parseInt(parts[4].replace("%", ""), 10) || 0;
1532
+ }
1533
+ } catch {
1534
+ }
1535
+ return { cpu: cpuUsage, memUsedPct, diskUsedPct };
1536
+ }
1537
+ takeCpuSnapshot() {
1538
+ const cpus2 = os4.cpus();
1539
+ let idle = 0;
1540
+ let total = 0;
1541
+ for (const cpu of cpus2) {
1542
+ for (const type of Object.keys(cpu.times)) {
1543
+ total += cpu.times[type];
1544
+ }
1545
+ idle += cpu.times.idle;
1546
+ }
1547
+ return { idle, total };
1548
+ }
1549
+ };
1550
+
1551
+ // src/connection-manager.ts
1552
+ var import_ws3 = __toESM(require("ws"));
1553
+ var ConnectionManager = class {
1554
+ activeSocket = null;
1555
+ activeGroups = { privateGroup: "", registryGroup: "" };
1556
+ sessionKey = null;
1557
+ reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
1558
+ wsHeartbeatTimer = null;
1559
+ httpHeartbeatTimer = null;
1560
+ wsPingTimer = null;
1561
+ wsPongTimeout = null;
1562
+ sessionStreamer;
1563
+ outputBuffer;
1564
+ constructor(sessionStreamer, outputBuffer) {
1565
+ this.sessionStreamer = sessionStreamer;
1566
+ this.outputBuffer = outputBuffer;
1567
+ }
1568
+ /** Check if the WebSocket is open and ready. */
1569
+ isReady() {
1570
+ return this.activeSocket !== null && this.activeSocket.readyState === import_ws3.default.OPEN;
1571
+ }
1572
+ /** Set the active socket and groups from a negotiate result. */
1573
+ setConnection(ws, groups, sessionKey) {
1574
+ this.activeSocket = ws;
1575
+ this.activeGroups = groups;
1576
+ this.sessionKey = sessionKey;
1577
+ }
1578
+ /** Reset reconnect delay on successful connection. */
1579
+ resetReconnectDelay() {
1580
+ this.reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
1581
+ }
1582
+ /**
1583
+ * Start periodic timers (heartbeat, inventory sync, ping/pong).
1584
+ */
1585
+ startTimers(onWsHeartbeat, onHttpHeartbeat) {
1586
+ this.wsHeartbeatTimer = setInterval(async () => {
1587
+ if (!this.isReady()) return;
1588
+ await onWsHeartbeat();
1589
+ }, WS_HEARTBEAT_MS);
1590
+ this.httpHeartbeatTimer = setInterval(async () => {
1591
+ if (!this.isReady()) return;
1592
+ await onHttpHeartbeat();
1593
+ }, HTTP_HEARTBEAT_MS);
1594
+ this.wsPingTimer = setInterval(() => {
1595
+ if (!this.activeSocket || this.activeSocket.readyState !== import_ws3.default.OPEN) return;
1596
+ this.activeSocket.ping();
1597
+ this.wsPongTimeout = setTimeout(() => {
1598
+ console.warn("[rAgent] No pong received within 10s \u2014 closing stale connection.");
1599
+ try {
1600
+ this.activeSocket?.terminate();
1601
+ } catch {
1602
+ }
1603
+ }, 1e4);
1604
+ }, 2e4);
1605
+ }
1606
+ /** Handle pong response — clear timeout. */
1607
+ onPong() {
1608
+ if (this.wsPongTimeout) {
1609
+ clearTimeout(this.wsPongTimeout);
1610
+ this.wsPongTimeout = null;
1611
+ }
1612
+ }
1613
+ /**
1614
+ * Cleanup socket and timers.
1615
+ * @param opts.stopStreams - Also stop all session streams (on intentional shutdown)
1616
+ */
1617
+ cleanup(opts = {}) {
1618
+ if (opts.stopStreams) this.sessionStreamer.stopAll();
1619
+ if (this.wsPingTimer) {
1620
+ clearInterval(this.wsPingTimer);
1621
+ this.wsPingTimer = null;
1622
+ }
1623
+ if (this.wsPongTimeout) {
1624
+ clearTimeout(this.wsPongTimeout);
1625
+ this.wsPongTimeout = null;
1626
+ }
1627
+ if (this.wsHeartbeatTimer) {
1628
+ clearInterval(this.wsHeartbeatTimer);
1629
+ this.wsHeartbeatTimer = null;
1630
+ }
1631
+ if (this.httpHeartbeatTimer) {
1632
+ clearInterval(this.httpHeartbeatTimer);
1633
+ this.httpHeartbeatTimer = null;
1634
+ }
1635
+ if (this.activeSocket) {
1636
+ this.activeSocket.removeAllListeners();
1637
+ try {
1638
+ this.activeSocket.close();
1639
+ } catch {
1640
+ }
1641
+ this.activeSocket = null;
1642
+ }
1643
+ this.activeGroups = { privateGroup: "", registryGroup: "" };
1644
+ }
1645
+ /**
1646
+ * Drain buffered output and replay through the WebSocket.
1647
+ */
1648
+ replayBufferedOutput(sendChunk) {
1649
+ const buffered = this.outputBuffer.drain();
1650
+ if (buffered.length > 0) {
1651
+ console.log(
1652
+ `[rAgent] Replaying ${buffered.length} buffered output chunks (${buffered.reduce((sum, c) => sum + Buffer.byteLength(c, "utf8"), 0)} bytes)`
1653
+ );
1654
+ for (const chunk of buffered) {
1655
+ sendChunk(chunk);
1656
+ }
1657
+ }
1658
+ }
1659
+ };
1660
+
1661
+ // src/control-dispatcher.ts
1662
+ var crypto2 = __toESM(require("crypto"));
1663
+ var import_ws4 = __toESM(require("ws"));
1664
+
1665
+ // src/service.ts
1666
+ var import_child_process2 = require("child_process");
1667
+ var fs2 = __toESM(require("fs"));
1668
+ var os5 = __toESM(require("os"));
1669
+ function assertConfiguredAgentToken() {
1670
+ const config = loadConfig();
1671
+ if (!config.agentToken) {
1672
+ throw new Error("No saved connector token. Run `ragent connect --token <token>` first.");
1673
+ }
1674
+ }
1675
+ function getConfiguredServiceBackend() {
1676
+ const config = loadConfig();
1677
+ if (config.serviceBackend === "systemd" || config.serviceBackend === "pidfile") {
1678
+ return config.serviceBackend;
1679
+ }
1680
+ if (fs2.existsSync(SERVICE_FILE)) return "systemd";
1681
+ if (fs2.existsSync(FALLBACK_PID_FILE)) return "pidfile";
1682
+ return null;
1683
+ }
1684
+ async function canUseSystemdUser() {
1685
+ if (os5.platform() !== "linux") return false;
1686
+ try {
1687
+ await execAsync("systemctl --user --version", { timeout: 4e3 });
1688
+ await execAsync("systemctl --user show-environment", { timeout: 4e3 });
1689
+ return true;
1690
+ } catch {
1691
+ return false;
1692
+ }
1693
+ }
1694
+ async function runSystemctlUser(args, options = {}) {
1695
+ const command = `systemctl --user ${args.map(shellQuote).join(" ")}`;
1696
+ return execAsync(command, { timeout: options.timeout ?? 1e4 });
1697
+ }
1698
+ function buildSystemdUnit() {
1699
+ return `[Unit]
1700
+ Description=rAgent Live connector
1701
+ After=network-online.target
1702
+ Wants=network-online.target
1703
+
1704
+ [Service]
1705
+ Type=simple
1706
+ ExecStart=${process.execPath} ${__filename} run
1707
+ Restart=always
1708
+ RestartSec=3
1709
+ Environment=NODE_ENV=production
1710
+ NoNewPrivileges=true
1711
+ PrivateTmp=true
1712
+ ProtectSystem=strict
1713
+ ProtectHome=read-only
1714
+ ReadWritePaths=%h/.config/ragent
1715
+
1716
+ [Install]
1717
+ WantedBy=default.target
1718
+ `;
1719
+ }
1720
+ async function installSystemdService(opts = {}) {
1721
+ assertConfiguredAgentToken();
1722
+ fs2.mkdirSync(SERVICE_DIR, { recursive: true });
1723
+ fs2.writeFileSync(SERVICE_FILE, buildSystemdUnit(), "utf8");
1724
+ await runSystemctlUser(["daemon-reload"]);
1725
+ if (opts.enable !== false) {
1726
+ await runSystemctlUser(["enable", SERVICE_NAME]);
1727
+ }
1728
+ if (opts.start) {
1729
+ await runSystemctlUser(["restart", SERVICE_NAME]);
1730
+ }
1731
+ saveConfigPatch({ serviceBackend: "systemd" });
1732
+ console.log(`[rAgent] Installed systemd user service at ${SERVICE_FILE}`);
1733
+ }
1734
+ function readFallbackPid() {
1735
+ try {
1736
+ const raw = fs2.readFileSync(FALLBACK_PID_FILE, "utf8").trim();
1737
+ const pid = Number.parseInt(raw, 10);
1738
+ return Number.isInteger(pid) ? pid : null;
1739
+ } catch {
1740
+ return null;
1741
+ }
1742
+ }
1743
+ function isProcessRunning(pid) {
1744
+ if (!pid || !Number.isInteger(pid)) return false;
1745
+ try {
1746
+ process.kill(pid, 0);
1747
+ return true;
1748
+ } catch {
1749
+ return false;
1750
+ }
1751
+ }
1752
+ async function startPidfileService() {
1753
+ assertConfiguredAgentToken();
1754
+ const existingPid = readFallbackPid();
1755
+ if (existingPid && isProcessRunning(existingPid)) {
1756
+ console.log(`[rAgent] Service already running (pid ${existingPid})`);
1757
+ return;
1758
+ }
1759
+ ensureConfigDir();
1760
+ const logFd = fs2.openSync(FALLBACK_LOG_FILE, "a");
1761
+ const child = (0, import_child_process2.spawn)(process.execPath, [__filename, "run"], {
1762
+ detached: true,
1763
+ stdio: ["ignore", logFd, logFd],
1764
+ cwd: os5.homedir(),
1765
+ env: process.env
1766
+ });
1767
+ child.unref();
1768
+ fs2.closeSync(logFd);
1769
+ fs2.writeFileSync(FALLBACK_PID_FILE, `${child.pid}
1770
+ `, "utf8");
1771
+ saveConfigPatch({ serviceBackend: "pidfile" });
1772
+ console.log(`[rAgent] Started fallback background service (pid ${child.pid})`);
1773
+ console.log(`[rAgent] Logs: ${FALLBACK_LOG_FILE}`);
1774
+ }
1775
+ async function stopPidfileService() {
1776
+ const pid = readFallbackPid();
1777
+ if (!pid || !isProcessRunning(pid)) {
1778
+ try {
1779
+ fs2.unlinkSync(FALLBACK_PID_FILE);
1780
+ } catch {
1781
+ }
1782
+ console.log("[rAgent] Service is not running.");
1783
+ return;
1784
+ }
1785
+ process.kill(pid, "SIGTERM");
1786
+ for (let i = 0; i < 20; i += 1) {
1787
+ if (!isProcessRunning(pid)) break;
1788
+ await wait(150);
1789
+ }
1790
+ if (isProcessRunning(pid)) {
1791
+ process.kill(pid, "SIGKILL");
1792
+ }
1793
+ try {
1794
+ fs2.unlinkSync(FALLBACK_PID_FILE);
1795
+ } catch {
1796
+ }
1797
+ console.log(`[rAgent] Stopped fallback background service (pid ${pid})`);
1798
+ }
1799
+ async function ensureServiceInstalled(opts = {}) {
1800
+ const wantsSystemd = await canUseSystemdUser();
1801
+ if (wantsSystemd) {
1802
+ await installSystemdService(opts);
1803
+ return "systemd";
1804
+ }
1805
+ saveConfigPatch({ serviceBackend: "pidfile" });
1806
+ if (opts.start) {
1807
+ await startPidfileService();
1808
+ } else {
1809
+ console.log(
1810
+ "[rAgent] systemd user manager unavailable; using fallback pidfile backend."
1811
+ );
1812
+ }
1813
+ return "pidfile";
1814
+ }
1815
+ async function startService() {
1816
+ const backend = getConfiguredServiceBackend();
1817
+ if (backend === "systemd") {
1818
+ await runSystemctlUser(["start", SERVICE_NAME]);
1819
+ console.log("[rAgent] Started service via systemd.");
1820
+ return;
1821
+ }
1822
+ if (backend === "pidfile") {
1823
+ await startPidfileService();
1824
+ return;
1825
+ }
1826
+ await ensureServiceInstalled({ start: true, enable: true });
1827
+ }
1828
+ async function stopService() {
1829
+ const backend = getConfiguredServiceBackend();
1830
+ if (backend === "systemd") {
1831
+ await runSystemctlUser(["stop", SERVICE_NAME]);
1832
+ console.log("[rAgent] Stopped service via systemd.");
1833
+ return;
1834
+ }
1835
+ await stopPidfileService();
1836
+ }
1837
+ async function restartService() {
1838
+ const backend = getConfiguredServiceBackend();
1839
+ if (backend === "systemd") {
1840
+ await runSystemctlUser(["restart", SERVICE_NAME]);
1841
+ console.log("[rAgent] Restarted service via systemd.");
1842
+ return;
1843
+ }
1844
+ await stopPidfileService();
1845
+ await startPidfileService();
1846
+ }
1847
+ async function printServiceStatus() {
1848
+ const backend = getConfiguredServiceBackend();
1849
+ if (backend === "systemd") {
1850
+ const status = await execAsync(
1851
+ `systemctl --user status ${shellQuote(SERVICE_NAME)} --no-pager --lines=20`,
1852
+ { timeout: 1e4 }
1853
+ ).catch((error) => {
1854
+ console.log(error.message);
1855
+ return "";
1856
+ });
1857
+ if (status) {
1858
+ process.stdout.write(status);
1859
+ }
1860
+ return;
1861
+ }
1862
+ const pid = readFallbackPid();
1863
+ if (pid && isProcessRunning(pid)) {
1864
+ console.log(`[rAgent] fallback service running (pid ${pid})`);
1865
+ console.log(`[rAgent] logs: ${FALLBACK_LOG_FILE}`);
1866
+ return;
1867
+ }
1868
+ console.log("[rAgent] service is not running.");
1869
+ }
1870
+ async function printServiceLogs(opts) {
1871
+ const lines = Number.parseInt(String(opts.lines || 100), 10) || 100;
1872
+ const follow = Boolean(opts.follow);
1873
+ const backend = getConfiguredServiceBackend();
1874
+ if (backend === "systemd") {
1875
+ if (follow) {
1876
+ await new Promise((resolve) => {
1877
+ const child = (0, import_child_process2.spawn)(
1878
+ "journalctl",
1879
+ ["--user", "-u", SERVICE_NAME, "-f", "-n", String(lines)],
1880
+ { stdio: "inherit" }
1881
+ );
1882
+ child.on("exit", () => resolve());
1883
+ });
1884
+ return;
1885
+ }
1886
+ const output = await execAsync(
1887
+ `journalctl --user -u ${shellQuote(SERVICE_NAME)} -n ${lines} --no-pager`,
1888
+ { timeout: 12e3 }
1889
+ );
1890
+ process.stdout.write(output);
1891
+ return;
1892
+ }
1893
+ if (!fs2.existsSync(FALLBACK_LOG_FILE)) {
1894
+ console.log(`[rAgent] No log file found at ${FALLBACK_LOG_FILE}`);
1895
+ return;
1896
+ }
1897
+ if (follow) {
1898
+ await new Promise((resolve) => {
1899
+ const child = (0, import_child_process2.spawn)("tail", ["-n", String(lines), "-f", FALLBACK_LOG_FILE], {
1900
+ stdio: "inherit"
1901
+ });
1902
+ child.on("exit", () => resolve());
1903
+ });
1904
+ return;
1905
+ }
1906
+ const content = fs2.readFileSync(FALLBACK_LOG_FILE, "utf8");
1907
+ const tail = content.split("\n").slice(-lines).join("\n");
1908
+ process.stdout.write(`${tail}${tail.endsWith("\n") ? "" : "\n"}`);
1909
+ }
1910
+ async function uninstallService() {
1911
+ const backend = getConfiguredServiceBackend();
1912
+ if (backend === "systemd") {
1913
+ await runSystemctlUser(["stop", SERVICE_NAME]).catch(() => void 0);
1914
+ await runSystemctlUser(["disable", SERVICE_NAME]).catch(() => void 0);
1915
+ try {
1916
+ fs2.unlinkSync(SERVICE_FILE);
1917
+ } catch {
1918
+ }
1919
+ await runSystemctlUser(["daemon-reload"]).catch(() => void 0);
1920
+ } else {
1921
+ await stopPidfileService();
1922
+ }
1923
+ const config = loadConfig();
1924
+ delete config.serviceBackend;
1925
+ saveConfig(config);
1926
+ console.log("[rAgent] Service uninstalled.");
1927
+ }
1928
+ function requestStopSelfService() {
1929
+ const backend = getConfiguredServiceBackend();
1930
+ if (backend === "systemd") {
1931
+ const child = (0, import_child_process2.spawn)("systemctl", ["--user", "stop", SERVICE_NAME], {
1932
+ detached: true,
1933
+ stdio: "ignore"
1934
+ });
1935
+ child.unref();
1936
+ return;
1937
+ }
1938
+ if (backend === "pidfile") {
1939
+ try {
1940
+ fs2.unlinkSync(FALLBACK_PID_FILE);
1941
+ } catch {
1942
+ }
1943
+ }
1944
+ }
1510
1945
 
1511
1946
  // src/provisioner.ts
1512
- var import_child_process2 = require("child_process");
1947
+ var import_child_process3 = require("child_process");
1513
1948
  var DANGEROUS_PATTERN = /rm\s+-r[fe]*\s+\/|mkfs|dd\s+if=|:()\s*\{|>\s*\/dev\/sd/i;
1514
1949
  function shellQuote2(s) {
1515
1950
  return `'${s.replace(/'/g, "'\\''")}'`;
@@ -1517,7 +1952,7 @@ function shellQuote2(s) {
1517
1952
  var SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
1518
1953
  var MAX_SESSION_NAME = 128;
1519
1954
  function runCommand(cmd, timeout = 12e4) {
1520
- return (0, import_child_process2.execSync)(cmd, {
1955
+ return (0, import_child_process3.execSync)(cmd, {
1521
1956
  encoding: "utf8",
1522
1957
  timeout,
1523
1958
  maxBuffer: 10 * 1024 * 1024,
@@ -1528,7 +1963,7 @@ function runCommand(cmd, timeout = 12e4) {
1528
1963
  function commandExists2(cmd) {
1529
1964
  if (!/^[a-zA-Z0-9._+-]+$/.test(cmd)) return false;
1530
1965
  try {
1531
- (0, import_child_process2.execFileSync)("sh", ["-c", `command -v -- ${cmd} >/dev/null 2>&1`], { stdio: "ignore" });
1966
+ (0, import_child_process3.execFileSync)("sh", ["-c", `command -v -- ${cmd} >/dev/null 2>&1`], { stdio: "ignore" });
1532
1967
  return true;
1533
1968
  } catch {
1534
1969
  return false;
@@ -1678,7 +2113,7 @@ function startAgent(request, onProgress) {
1678
2113
  }
1679
2114
  tmuxArgs.push(fullCmd);
1680
2115
  try {
1681
- (0, import_child_process2.execFileSync)("tmux", tmuxArgs, { stdio: "ignore" });
2116
+ (0, import_child_process3.execFileSync)("tmux", tmuxArgs, { stdio: "ignore" });
1682
2117
  onProgress({
1683
2118
  type: "provision-progress",
1684
2119
  provisionId: request.provisionId,
@@ -1713,6 +2148,250 @@ async function executeProvision(request, onProgress) {
1713
2148
  return startAgent(request, emit);
1714
2149
  }
1715
2150
 
2151
+ // src/control-dispatcher.ts
2152
+ var ControlDispatcher = class {
2153
+ shell;
2154
+ streamer;
2155
+ inventory;
2156
+ connection;
2157
+ options;
2158
+ /** Set to true when a reconnect was requested (restart-agent, disconnect). */
2159
+ reconnectRequested = false;
2160
+ /** Set to false to stop the agent. */
2161
+ shouldRun = true;
2162
+ constructor(shell, streamer, inventory, connection, options) {
2163
+ this.shell = shell;
2164
+ this.streamer = streamer;
2165
+ this.inventory = inventory;
2166
+ this.connection = connection;
2167
+ this.options = options;
2168
+ }
2169
+ /** Update options (e.g., after token refresh). */
2170
+ updateOptions(options) {
2171
+ this.options = options;
2172
+ }
2173
+ /**
2174
+ * Verify HMAC-SHA256 signature on a control message.
2175
+ * Returns true if valid or no session key is available (backward compat).
2176
+ */
2177
+ verifyMessageHmac(payload) {
2178
+ const sessionKey = this.connection.sessionKey;
2179
+ if (!sessionKey) return true;
2180
+ const receivedHmac = payload.hmac;
2181
+ if (typeof receivedHmac !== "string") {
2182
+ console.warn("[rAgent] Control message missing HMAC signature.");
2183
+ return false;
2184
+ }
2185
+ const { hmac: _, ...payloadWithoutHmac } = payload;
2186
+ const canonical = JSON.stringify(payloadWithoutHmac, Object.keys(payloadWithoutHmac).sort());
2187
+ const expected = crypto2.createHmac("sha256", sessionKey).update(canonical).digest("hex");
2188
+ return crypto2.timingSafeEqual(Buffer.from(receivedHmac, "hex"), Buffer.from(expected, "hex"));
2189
+ }
2190
+ /** Dispatch a control action message. */
2191
+ async handleControlAction(payload) {
2192
+ const action = typeof payload?.action === "string" ? payload.action : "";
2193
+ const sessionId = typeof payload?.sessionId === "string" && payload.sessionId.trim().length > 0 ? payload.sessionId.trim() : null;
2194
+ const dangerousActions = /* @__PURE__ */ new Set([
2195
+ "stop-agent",
2196
+ "restart-agent",
2197
+ "restart-shell",
2198
+ "stop-session",
2199
+ "stop-detached",
2200
+ "disconnect"
2201
+ ]);
2202
+ if (dangerousActions.has(action) && this.connection.sessionKey) {
2203
+ if (!this.verifyMessageHmac(payload)) {
2204
+ console.warn(`[rAgent] Rejecting control action "${action}" \u2014 HMAC verification failed.`);
2205
+ return;
2206
+ }
2207
+ }
2208
+ switch (action) {
2209
+ case "restart-shell":
2210
+ this.shell.restart();
2211
+ await this.syncInventory();
2212
+ return;
2213
+ case "restart-agent":
2214
+ case "disconnect":
2215
+ this.reconnectRequested = true;
2216
+ this.streamer.stopAll();
2217
+ if (this.connection.activeSocket?.readyState === import_ws4.default.OPEN) {
2218
+ this.connection.activeSocket.close();
2219
+ }
2220
+ return;
2221
+ case "stop-agent":
2222
+ this.shouldRun = false;
2223
+ this.streamer.stopAll();
2224
+ requestStopSelfService();
2225
+ if (this.connection.activeSocket?.readyState === import_ws4.default.OPEN) {
2226
+ this.connection.activeSocket.close();
2227
+ }
2228
+ return;
2229
+ case "stop-session":
2230
+ if (!sessionId) return;
2231
+ if (sessionId.startsWith("pty:")) {
2232
+ this.shell.restart();
2233
+ await this.syncInventory();
2234
+ return;
2235
+ }
2236
+ if (sessionId.startsWith("tmux:")) {
2237
+ try {
2238
+ await stopTmuxPaneBySessionId(sessionId);
2239
+ console.log(`[rAgent] Closed remote session ${sessionId}.`);
2240
+ } catch (error) {
2241
+ const message = error instanceof Error ? error.message : String(error);
2242
+ console.warn(`[rAgent] Failed to close ${sessionId}: ${message}`);
2243
+ }
2244
+ await this.syncInventory();
2245
+ }
2246
+ return;
2247
+ case "stop-detached": {
2248
+ const killed = await stopAllDetachedTmuxSessions();
2249
+ console.log(`[rAgent] Killed ${killed} detached tmux session(s).`);
2250
+ await this.syncInventory();
2251
+ return;
2252
+ }
2253
+ case "start-agent":
2254
+ await this.handleStartAgent(payload);
2255
+ return;
2256
+ case "stream-session":
2257
+ this.handleStreamSession(sessionId);
2258
+ return;
2259
+ case "stop-stream":
2260
+ if (sessionId) this.streamer.stopStream(sessionId);
2261
+ return;
2262
+ default:
2263
+ }
2264
+ }
2265
+ /** Handle input routing to the correct target. */
2266
+ handleInput(data, sessionId) {
2267
+ if (!sessionId || sessionId.startsWith("pty:")) {
2268
+ this.shell.write(data);
2269
+ } else if (sessionId.startsWith("tmux:")) {
2270
+ sendInputToTmux(sessionId, data).catch(() => {
2271
+ });
2272
+ } else if (sessionId.startsWith("screen:") || sessionId.startsWith("zellij:")) {
2273
+ this.streamer.writeInput(sessionId, data);
2274
+ }
2275
+ }
2276
+ /** Handle resize routing. */
2277
+ handleResize(cols, rows, sessionId) {
2278
+ if (!sessionId || sessionId.startsWith("pty:")) {
2279
+ this.shell.resize(cols, rows);
2280
+ } else if (sessionId.startsWith("screen:") || sessionId.startsWith("zellij:")) {
2281
+ this.streamer.resize(sessionId, cols, rows);
2282
+ }
2283
+ }
2284
+ /** Handle provision request from dashboard. */
2285
+ async handleProvision(payload) {
2286
+ const provReq = payload;
2287
+ if (!provReq.provisionId || !provReq.manifest) return;
2288
+ console.log(`[rAgent] Provision request: ${provReq.manifest.name} (${provReq.provisionId})`);
2289
+ const sendProgress = (progress) => {
2290
+ const ws = this.connection.activeSocket;
2291
+ if (ws && ws.readyState === import_ws4.default.OPEN && this.connection.activeGroups.registryGroup) {
2292
+ sendToGroup(ws, this.connection.activeGroups.registryGroup, {
2293
+ ...progress,
2294
+ hostId: this.options.hostId
2295
+ });
2296
+ }
2297
+ };
2298
+ try {
2299
+ await executeProvision(provReq, sendProgress);
2300
+ await this.syncInventory(true);
2301
+ } catch (error) {
2302
+ const errMsg = error instanceof Error ? error.message : String(error);
2303
+ sendProgress({
2304
+ type: "provision-progress",
2305
+ provisionId: provReq.provisionId,
2306
+ step: "error",
2307
+ message: `Provision failed: ${errMsg}`
2308
+ });
2309
+ }
2310
+ }
2311
+ async syncInventory(force = false) {
2312
+ await this.inventory.syncInventory(
2313
+ this.connection.activeSocket,
2314
+ this.connection.activeGroups,
2315
+ force
2316
+ );
2317
+ }
2318
+ async handleStartAgent(payload) {
2319
+ const sessionName = typeof payload?.sessionName === "string" && payload.sessionName.trim().length > 0 ? payload.sessionName.trim() : `agent-${Date.now().toString(36)}`;
2320
+ const cmd = typeof payload?.command === "string" && payload.command.trim().length > 0 ? payload.command.trim() : null;
2321
+ if (!cmd) {
2322
+ console.warn("[rAgent] start-agent: no command provided, ignoring.");
2323
+ return;
2324
+ }
2325
+ if (sessionName.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(sessionName)) {
2326
+ console.warn("[rAgent] start-agent: invalid session name, ignoring.");
2327
+ return;
2328
+ }
2329
+ const dangerous = /rm\s+-r[fe]*\s+\/|mkfs|dd\s+if=|:()\s*\{|>\s*\/dev\/sd/i;
2330
+ if (dangerous.test(cmd)) {
2331
+ console.warn(`[rAgent] start-agent: rejected dangerous command: ${cmd}`);
2332
+ return;
2333
+ }
2334
+ const workingDir = typeof payload?.workingDir === "string" && payload.workingDir.trim().length > 0 ? payload.workingDir.trim() : void 0;
2335
+ const envVars = payload?.envVars && typeof payload.envVars === "object" ? payload.envVars : void 0;
2336
+ const tmuxArgs = ["new-session", "-d", "-s", sessionName];
2337
+ if (workingDir) {
2338
+ tmuxArgs.push("-c", workingDir);
2339
+ }
2340
+ let fullCmd = cmd;
2341
+ if (envVars) {
2342
+ const entries = Object.entries(envVars).filter(([k, v]) => /^[A-Z_][A-Z0-9_]*$/i.test(k) && typeof v === "string").map(([k, v]) => `${k}='${v.replace(/'/g, "'\\''")}'`).join(" ");
2343
+ if (entries) fullCmd = `${entries} ${cmd}`;
2344
+ }
2345
+ tmuxArgs.push(fullCmd);
2346
+ try {
2347
+ const { execFileSync: execFileSync3 } = await import("child_process");
2348
+ execFileSync3("tmux", tmuxArgs, { stdio: "ignore" });
2349
+ console.log(`[rAgent] Started agent session "${sessionName}": ${cmd}`);
2350
+ } catch (error) {
2351
+ const message = error instanceof Error ? error.message : String(error);
2352
+ console.error(`[rAgent] Failed to start agent session "${sessionName}": ${message}`);
2353
+ }
2354
+ await this.syncInventory(true);
2355
+ }
2356
+ handleStreamSession(sessionId) {
2357
+ if (!sessionId) return;
2358
+ const ws = this.connection.activeSocket;
2359
+ const group = this.connection.activeGroups.privateGroup;
2360
+ if (sessionId.startsWith("process:")) {
2361
+ if (ws && ws.readyState === import_ws4.default.OPEN && group) {
2362
+ sendToGroup(ws, group, {
2363
+ type: "stream-error",
2364
+ sessionId,
2365
+ error: "This agent is running outside a terminal multiplexer. Stop and relaunch via Start Agent to enable live streaming."
2366
+ });
2367
+ }
2368
+ return;
2369
+ }
2370
+ if (sessionId.startsWith("tmux:") || sessionId.startsWith("screen:") || sessionId.startsWith("zellij:")) {
2371
+ const started = this.streamer.startStream(sessionId);
2372
+ if (ws && ws.readyState === import_ws4.default.OPEN && group) {
2373
+ if (started) {
2374
+ sendToGroup(ws, group, { type: "stream-started", sessionId });
2375
+ } else {
2376
+ sendToGroup(ws, group, {
2377
+ type: "stream-error",
2378
+ sessionId,
2379
+ error: "Failed to attach to session. It may no longer exist."
2380
+ });
2381
+ }
2382
+ }
2383
+ return;
2384
+ }
2385
+ if (ws && ws.readyState === import_ws4.default.OPEN && group) {
2386
+ sendToGroup(ws, group, {
2387
+ type: "stream-error",
2388
+ sessionId,
2389
+ error: "Live streaming is not yet supported for this session type."
2390
+ });
2391
+ }
2392
+ }
2393
+ };
2394
+
1716
2395
  // src/agent.ts
1717
2396
  function pidFilePath(hostId) {
1718
2397
  return path2.join(CONFIG_DIR, `agent-${hostId}.pid`);
@@ -1761,7 +2440,7 @@ function resolveRunOptions(commandOptions) {
1761
2440
  const config = loadConfig();
1762
2441
  const portal = commandOptions.portal || config.portal || DEFAULT_PORTAL;
1763
2442
  const hostId = sanitizeHostId(commandOptions.id || config.hostId || inferHostId());
1764
- const hostName = commandOptions.name || config.hostName || os5.hostname();
2443
+ const hostName = commandOptions.name || config.hostName || os6.hostname();
1765
2444
  const command = commandOptions.command || config.command || "bash";
1766
2445
  const agentToken = commandOptions.agentToken || config.agentToken || "";
1767
2446
  return { portal, hostId, hostName, command, agentToken };
@@ -1782,400 +2461,93 @@ async function runAgent(rawOptions) {
1782
2461
  }
1783
2462
  } catch {
1784
2463
  }
1785
- let shouldRun = true;
1786
- let reconnectRequested = false;
1787
- let reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
1788
- let activeSocket = null;
1789
- let activeGroups = { privateGroup: "", registryGroup: "" };
1790
- let wsHeartbeatTimer = null;
1791
- let httpHeartbeatTimer = null;
1792
- let wsPingTimer = null;
1793
- let wsPongTimeout = null;
1794
- let suppressNextShellRespawn = false;
1795
- let lastSentFingerprint = "";
1796
- let lastHttpHeartbeatAt = 0;
1797
2464
  const outputBuffer = new OutputBuffer();
1798
- let ptyProcess = null;
1799
2465
  const ptySessionId = `pty:${options.hostId}`;
1800
- const sendOutput = (chunk) => {
1801
- const ws = activeSocket;
1802
- if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.privateGroup) {
1803
- outputBuffer.push(chunk);
1804
- return;
1805
- }
1806
- sendToGroup(ws, activeGroups.privateGroup, { type: "output", data: chunk, sessionId: ptySessionId });
1807
- };
1808
2466
  const sessionStreamer = new SessionStreamer(
1809
2467
  (sessionId, data) => {
1810
- const ws = activeSocket;
1811
- if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.privateGroup) return;
1812
- sendToGroup(ws, activeGroups.privateGroup, { type: "output", data, sessionId });
2468
+ if (!conn.isReady()) return;
2469
+ if (conn.sessionKey) {
2470
+ const { enc, iv } = encryptPayload(data, conn.sessionKey);
2471
+ sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "output", enc, iv, sessionId });
2472
+ } else {
2473
+ sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "output", data, sessionId });
2474
+ }
1813
2475
  },
1814
2476
  (sessionId) => {
1815
- const ws = activeSocket;
1816
- if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.privateGroup) return;
1817
- sendToGroup(ws, activeGroups.privateGroup, { type: "stream-stopped", sessionId });
2477
+ if (!conn.isReady()) return;
2478
+ sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "stream-stopped", sessionId });
1818
2479
  }
1819
2480
  );
1820
- const killCurrentShell = () => {
1821
- if (!ptyProcess) return;
1822
- suppressNextShellRespawn = true;
1823
- try {
1824
- ptyProcess.kill();
1825
- } catch {
1826
- }
1827
- ptyProcess = null;
1828
- };
1829
- const spawnOrRespawnShell = () => {
1830
- ptyProcess = spawnConnectorShell(options.command, sendOutput, () => {
1831
- if (suppressNextShellRespawn) {
1832
- suppressNextShellRespawn = false;
1833
- return;
1834
- }
1835
- if (!shouldRun) return;
1836
- console.warn("[rAgent] Shell exited. Restarting shell process.");
1837
- setTimeout(() => {
1838
- if (shouldRun) spawnOrRespawnShell();
1839
- }, 200);
1840
- });
1841
- };
1842
- const restartLocalShell = () => {
1843
- killCurrentShell();
1844
- spawnOrRespawnShell();
1845
- };
1846
- spawnOrRespawnShell();
1847
- const cleanupSocket = (opts = {}) => {
1848
- if (opts.stopStreams) sessionStreamer.stopAll();
1849
- if (wsPingTimer) {
1850
- clearInterval(wsPingTimer);
1851
- wsPingTimer = null;
1852
- }
1853
- if (wsPongTimeout) {
1854
- clearTimeout(wsPongTimeout);
1855
- wsPongTimeout = null;
1856
- }
1857
- if (wsHeartbeatTimer) {
1858
- clearInterval(wsHeartbeatTimer);
1859
- wsHeartbeatTimer = null;
1860
- }
1861
- if (httpHeartbeatTimer) {
1862
- clearInterval(httpHeartbeatTimer);
1863
- httpHeartbeatTimer = null;
1864
- }
1865
- if (activeSocket) {
1866
- activeSocket.removeAllListeners();
1867
- try {
1868
- activeSocket.close();
1869
- } catch {
1870
- }
1871
- activeSocket = null;
1872
- }
1873
- activeGroups = { privateGroup: "", registryGroup: "" };
1874
- };
1875
- let prevCpuSnapshot = null;
1876
- function takeCpuSnapshot() {
1877
- const cpus2 = os5.cpus();
1878
- let idle = 0;
1879
- let total = 0;
1880
- for (const cpu of cpus2) {
1881
- for (const type of Object.keys(cpu.times)) {
1882
- total += cpu.times[type];
1883
- }
1884
- idle += cpu.times.idle;
1885
- }
1886
- return { idle, total };
1887
- }
1888
- const collectVitals = () => {
1889
- const currentSnapshot = takeCpuSnapshot();
1890
- let cpuUsage = 0;
1891
- if (prevCpuSnapshot) {
1892
- const idleDelta = currentSnapshot.idle - prevCpuSnapshot.idle;
1893
- const totalDelta = currentSnapshot.total - prevCpuSnapshot.total;
1894
- cpuUsage = totalDelta > 0 ? Math.round((totalDelta - idleDelta) / totalDelta * 100) : 0;
1895
- }
1896
- prevCpuSnapshot = currentSnapshot;
1897
- const totalMem = os5.totalmem();
1898
- const freeMem = os5.freemem();
1899
- const memUsedPct = totalMem > 0 ? Math.round((totalMem - freeMem) / totalMem * 100) : 0;
1900
- let diskUsedPct = 0;
1901
- try {
1902
- const { execSync: execSync2 } = require("child_process");
1903
- const dfOutput = execSync2("df -P / | tail -1", { encoding: "utf8", timeout: 5e3 });
1904
- const parts = dfOutput.trim().split(/\s+/);
1905
- if (parts.length >= 5) {
1906
- diskUsedPct = parseInt(parts[4].replace("%", ""), 10) || 0;
1907
- }
1908
- } catch {
1909
- }
1910
- return { cpu: cpuUsage, memUsedPct, diskUsedPct };
1911
- };
1912
- const announceToRegistry = async (type = "heartbeat") => {
1913
- const ws = activeSocket;
1914
- if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.registryGroup) return;
1915
- const sessions = await collectSessionInventory(options.hostId, options.command);
1916
- const vitals = collectVitals();
1917
- sendToGroup(ws, activeGroups.registryGroup, {
1918
- type,
1919
- hostId: options.hostId,
1920
- hostName: options.hostName,
1921
- environment: os5.hostname(),
1922
- sessions,
1923
- vitals,
1924
- agentVersion: CURRENT_VERSION,
1925
- lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
1926
- });
1927
- };
1928
- const syncInventory = async (force = false) => {
1929
- const sessions = await collectSessionInventory(options.hostId, options.command);
1930
- const fingerprint = sessionInventoryFingerprint(sessions);
1931
- const changed = fingerprint !== lastSentFingerprint;
1932
- const checkpointDue = Date.now() - lastHttpHeartbeatAt > HTTP_HEARTBEAT_MS;
1933
- if (changed || force) {
1934
- await announceToRegistry("inventory");
1935
- lastSentFingerprint = fingerprint;
1936
- }
1937
- if (changed || checkpointDue || force) {
1938
- await postHeartbeat({
1939
- portal: options.portal,
1940
- agentToken: options.agentToken,
1941
- hostId: options.hostId,
1942
- hostName: options.hostName,
1943
- command: options.command
1944
- });
1945
- lastHttpHeartbeatAt = Date.now();
2481
+ const conn = new ConnectionManager(sessionStreamer, outputBuffer);
2482
+ const sendOutput = (chunk) => {
2483
+ if (!conn.isReady()) {
2484
+ outputBuffer.push(chunk);
2485
+ return;
1946
2486
  }
1947
- };
1948
- const handleControlAction = async (payload) => {
1949
- const action = typeof payload?.action === "string" ? payload.action : "";
1950
- const sessionId = typeof payload?.sessionId === "string" && payload.sessionId.trim().length > 0 ? payload.sessionId.trim() : null;
1951
- switch (action) {
1952
- case "restart-shell":
1953
- restartLocalShell();
1954
- await syncInventory();
1955
- return;
1956
- case "restart-agent":
1957
- case "disconnect":
1958
- reconnectRequested = true;
1959
- sessionStreamer.stopAll();
1960
- if (activeSocket && activeSocket.readyState === import_ws2.default.OPEN) {
1961
- activeSocket.close();
1962
- }
1963
- return;
1964
- case "stop-agent":
1965
- shouldRun = false;
1966
- sessionStreamer.stopAll();
1967
- requestStopSelfService();
1968
- if (activeSocket && activeSocket.readyState === import_ws2.default.OPEN) {
1969
- activeSocket.close();
1970
- }
1971
- return;
1972
- case "stop-session":
1973
- if (!sessionId) return;
1974
- if (sessionId.startsWith("pty:")) {
1975
- restartLocalShell();
1976
- await syncInventory();
1977
- return;
1978
- }
1979
- if (sessionId.startsWith("tmux:")) {
1980
- try {
1981
- await stopTmuxPaneBySessionId(sessionId);
1982
- console.log(`[rAgent] Closed remote session ${sessionId}.`);
1983
- } catch (error) {
1984
- const message = error instanceof Error ? error.message : String(error);
1985
- console.warn(
1986
- `[rAgent] Failed to close ${sessionId}: ${message}`
1987
- );
1988
- }
1989
- await syncInventory();
1990
- }
1991
- return;
1992
- case "stop-detached": {
1993
- const killed = await stopAllDetachedTmuxSessions();
1994
- console.log(`[rAgent] Killed ${killed} detached tmux session(s).`);
1995
- await syncInventory();
1996
- return;
1997
- }
1998
- case "start-agent": {
1999
- const sessionName = typeof payload?.sessionName === "string" && payload.sessionName.trim().length > 0 ? payload.sessionName.trim() : `agent-${Date.now().toString(36)}`;
2000
- const cmd = typeof payload?.command === "string" && payload.command.trim().length > 0 ? payload.command.trim() : null;
2001
- if (!cmd) {
2002
- console.warn("[rAgent] start-agent: no command provided, ignoring.");
2003
- return;
2004
- }
2005
- if (sessionName.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(sessionName)) {
2006
- console.warn("[rAgent] start-agent: invalid session name, ignoring.");
2007
- return;
2008
- }
2009
- const dangerous = /rm\s+-r[fe]*\s+\/|mkfs|dd\s+if=|:()\s*\{|>\s*\/dev\/sd/i;
2010
- if (dangerous.test(cmd)) {
2011
- console.warn(`[rAgent] start-agent: rejected dangerous command: ${cmd}`);
2012
- return;
2013
- }
2014
- const workingDir = typeof payload?.workingDir === "string" && payload.workingDir.trim().length > 0 ? payload.workingDir.trim() : void 0;
2015
- const envVars = payload?.envVars && typeof payload.envVars === "object" ? payload.envVars : void 0;
2016
- const tmuxArgs = ["new-session", "-d", "-s", sessionName];
2017
- if (workingDir) {
2018
- tmuxArgs.push("-c", workingDir);
2019
- }
2020
- let fullCmd = cmd;
2021
- if (envVars) {
2022
- const entries = Object.entries(envVars).filter(([k, v]) => /^[A-Z_][A-Z0-9_]*$/i.test(k) && typeof v === "string").map(([k, v]) => `${k}='${v.replace(/'/g, "'\\''")}'`).join(" ");
2023
- if (entries) fullCmd = `${entries} ${cmd}`;
2024
- }
2025
- tmuxArgs.push(fullCmd);
2026
- try {
2027
- const { execFileSync: execFileSync3 } = await import("child_process");
2028
- execFileSync3("tmux", tmuxArgs, { stdio: "ignore" });
2029
- console.log(`[rAgent] Started agent session "${sessionName}": ${cmd}`);
2030
- } catch (error) {
2031
- const message = error instanceof Error ? error.message : String(error);
2032
- console.error(`[rAgent] Failed to start agent session "${sessionName}": ${message}`);
2033
- }
2034
- await syncInventory(true);
2035
- return;
2036
- }
2037
- case "stream-session": {
2038
- if (!sessionId) return;
2039
- if (sessionId.startsWith("process:")) {
2040
- const ws2 = activeSocket;
2041
- if (ws2 && ws2.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
2042
- sendToGroup(ws2, activeGroups.privateGroup, {
2043
- type: "stream-error",
2044
- sessionId,
2045
- error: "This agent is running outside a terminal multiplexer. Stop and relaunch via Start Agent to enable live streaming."
2046
- });
2047
- }
2048
- return;
2049
- }
2050
- if (sessionId.startsWith("tmux:") || sessionId.startsWith("screen:") || sessionId.startsWith("zellij:")) {
2051
- const started = sessionStreamer.startStream(sessionId);
2052
- const ws2 = activeSocket;
2053
- if (ws2 && ws2.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
2054
- if (started) {
2055
- sendToGroup(ws2, activeGroups.privateGroup, {
2056
- type: "stream-started",
2057
- sessionId
2058
- });
2059
- } else {
2060
- sendToGroup(ws2, activeGroups.privateGroup, {
2061
- type: "stream-error",
2062
- sessionId,
2063
- error: "Failed to attach to session. It may no longer exist."
2064
- });
2065
- }
2066
- }
2067
- return;
2068
- }
2069
- const ws = activeSocket;
2070
- if (ws && ws.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
2071
- sendToGroup(ws, activeGroups.privateGroup, {
2072
- type: "stream-error",
2073
- sessionId,
2074
- error: "Live streaming is not yet supported for this session type."
2075
- });
2076
- }
2077
- return;
2078
- }
2079
- case "stop-stream": {
2080
- if (!sessionId) return;
2081
- sessionStreamer.stopStream(sessionId);
2082
- return;
2083
- }
2084
- default:
2487
+ if (conn.sessionKey) {
2488
+ const { enc, iv } = encryptPayload(chunk, conn.sessionKey);
2489
+ sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "output", enc, iv, sessionId: ptySessionId });
2490
+ } else {
2491
+ sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "output", data: chunk, sessionId: ptySessionId });
2085
2492
  }
2086
2493
  };
2494
+ const shell = new ShellManager(options.command, sendOutput);
2495
+ const inventory = new InventoryManager(options);
2496
+ const dispatcher = new ControlDispatcher(shell, sessionStreamer, inventory, conn, options);
2497
+ shell.spawn();
2087
2498
  const onSignal = () => {
2088
- shouldRun = false;
2089
- cleanupSocket({ stopStreams: true });
2090
- killCurrentShell();
2499
+ dispatcher.shouldRun = false;
2500
+ conn.cleanup({ stopStreams: true });
2501
+ shell.stop();
2091
2502
  releasePidLock(lockPath);
2092
2503
  };
2093
2504
  process.once("SIGTERM", onSignal);
2094
2505
  process.once("SIGINT", onSignal);
2095
2506
  try {
2096
- while (shouldRun) {
2097
- reconnectRequested = false;
2507
+ while (dispatcher.shouldRun) {
2508
+ dispatcher.reconnectRequested = false;
2098
2509
  try {
2099
2510
  options.agentToken = await refreshTokenIfNeeded({
2100
2511
  portal: options.portal,
2101
2512
  agentToken: options.agentToken
2102
2513
  });
2514
+ inventory.updateOptions(options);
2515
+ dispatcher.updateOptions(options);
2103
2516
  const negotiated = await negotiateAgent({
2104
2517
  portal: options.portal,
2105
2518
  agentToken: options.agentToken
2106
2519
  });
2107
- activeGroups = {
2520
+ const groups = {
2108
2521
  privateGroup: negotiated.groups.privateGroup,
2109
2522
  registryGroup: negotiated.groups.registryGroup
2110
2523
  };
2111
2524
  await new Promise((resolve) => {
2112
- const ws = new import_ws2.default(
2113
- negotiated.url,
2114
- "json.webpubsub.azure.v1"
2115
- );
2116
- activeSocket = ws;
2525
+ const ws = new import_ws5.default(negotiated.url, "json.webpubsub.azure.v1");
2526
+ conn.setConnection(ws, groups, negotiated.sessionKey ?? null);
2117
2527
  ws.on("open", async () => {
2118
2528
  console.log("[rAgent] Connector connected to relay.");
2119
- reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
2120
- ws.send(
2121
- JSON.stringify({
2122
- type: "joinGroup",
2123
- group: activeGroups.privateGroup
2124
- })
2125
- );
2126
- ws.send(
2127
- JSON.stringify({
2128
- type: "joinGroup",
2129
- group: activeGroups.registryGroup
2130
- })
2131
- );
2132
- sendToGroup(ws, activeGroups.privateGroup, {
2529
+ conn.resetReconnectDelay();
2530
+ ws.send(JSON.stringify({ type: "joinGroup", group: groups.privateGroup }));
2531
+ ws.send(JSON.stringify({ type: "joinGroup", group: groups.registryGroup }));
2532
+ sendToGroup(ws, groups.privateGroup, {
2133
2533
  type: "register",
2134
2534
  hostName: options.hostName,
2135
- environment: os5.platform()
2535
+ environment: os6.platform()
2136
2536
  });
2137
- const buffered = outputBuffer.drain();
2138
- if (buffered.length > 0) {
2139
- console.log(
2140
- `[rAgent] Replaying ${buffered.length} buffered output chunks (${buffered.reduce((sum, c) => sum + Buffer.byteLength(c, "utf8"), 0)} bytes)`
2141
- );
2142
- for (const chunk of buffered) {
2143
- sendToGroup(ws, activeGroups.privateGroup, {
2144
- type: "output",
2145
- data: chunk,
2146
- sessionId: ptySessionId
2147
- });
2148
- }
2149
- }
2150
- await syncInventory(true);
2151
- wsHeartbeatTimer = setInterval(async () => {
2152
- if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN)
2153
- return;
2154
- await announceToRegistry("heartbeat");
2155
- }, WS_HEARTBEAT_MS);
2156
- httpHeartbeatTimer = setInterval(async () => {
2157
- if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN)
2158
- return;
2159
- await syncInventory();
2160
- }, HTTP_HEARTBEAT_MS);
2161
- wsPingTimer = setInterval(() => {
2162
- if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN) return;
2163
- activeSocket.ping();
2164
- wsPongTimeout = setTimeout(() => {
2165
- console.warn("[rAgent] No pong received within 10s \u2014 closing stale connection.");
2166
- try {
2167
- activeSocket?.terminate();
2168
- } catch {
2169
- }
2170
- }, 1e4);
2171
- }, 2e4);
2172
- });
2173
- ws.on("pong", () => {
2174
- if (wsPongTimeout) {
2175
- clearTimeout(wsPongTimeout);
2176
- wsPongTimeout = null;
2177
- }
2537
+ conn.replayBufferedOutput((chunk) => {
2538
+ sendToGroup(ws, groups.privateGroup, {
2539
+ type: "output",
2540
+ data: chunk,
2541
+ sessionId: ptySessionId
2542
+ });
2543
+ });
2544
+ await inventory.syncInventory(ws, groups, true);
2545
+ conn.startTimers(
2546
+ () => inventory.announceToRegistry(ws, groups.registryGroup, "heartbeat"),
2547
+ () => inventory.syncInventory(ws, groups)
2548
+ );
2178
2549
  });
2550
+ ws.on("pong", () => conn.onPong());
2179
2551
  ws.on("message", async (data) => {
2180
2552
  let msg;
2181
2553
  try {
@@ -2183,69 +2555,37 @@ async function runAgent(rawOptions) {
2183
2555
  } catch {
2184
2556
  return;
2185
2557
  }
2186
- if (msg.type === "message" && msg.group === activeGroups.privateGroup) {
2558
+ if (msg.type === "message" && msg.group === groups.privateGroup) {
2187
2559
  const payload = msg.data || {};
2188
- if (payload.type === "input" && typeof payload.data === "string") {
2189
- const sid = typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
2190
- if (!sid || sid.startsWith("pty:")) {
2191
- if (ptyProcess) ptyProcess.write(payload.data);
2192
- } else if (sid.startsWith("tmux:")) {
2193
- await sendInputToTmux(sid, payload.data);
2194
- } else if (sid.startsWith("screen:") || sid.startsWith("zellij:")) {
2195
- sessionStreamer.writeInput(sid, payload.data);
2560
+ if (payload.type === "input") {
2561
+ let inputData = null;
2562
+ if (typeof payload.enc === "string" && typeof payload.iv === "string" && conn.sessionKey) {
2563
+ inputData = decryptPayload(payload.enc, payload.iv, conn.sessionKey);
2564
+ if (inputData === null) {
2565
+ console.warn("[rAgent] Failed to decrypt input \u2014 ignoring.");
2566
+ }
2567
+ } else if (typeof payload.data === "string") {
2568
+ inputData = payload.data;
2569
+ }
2570
+ if (inputData !== null) {
2571
+ const sid = typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
2572
+ dispatcher.handleInput(inputData, sid);
2196
2573
  }
2197
2574
  } else if (payload.type === "resize" && Number.isInteger(payload.cols) && Number.isInteger(payload.rows)) {
2198
2575
  const sid = typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
2199
- if (!sid || sid.startsWith("pty:")) {
2200
- try {
2201
- if (ptyProcess)
2202
- ptyProcess.resize(
2203
- payload.cols,
2204
- payload.rows
2205
- );
2206
- } catch (error) {
2207
- const message = error instanceof Error ? error.message : String(error);
2208
- if (!message.includes("EBADF")) {
2209
- console.warn(`[rAgent] Resize failed: ${message}`);
2210
- }
2211
- }
2212
- }
2576
+ dispatcher.handleResize(payload.cols, payload.rows, sid);
2213
2577
  } else if (payload.type === "control" && typeof payload.action === "string") {
2214
- await handleControlAction(payload);
2578
+ await dispatcher.handleControlAction(payload);
2215
2579
  } else if (payload.type === "start-agent") {
2216
- await handleControlAction({ ...payload, action: "start-agent" });
2580
+ await dispatcher.handleControlAction({ ...payload, action: "start-agent" });
2217
2581
  } else if (payload.type === "provision") {
2218
- const provReq = payload;
2219
- if (provReq.provisionId && provReq.manifest) {
2220
- console.log(`[rAgent] Provision request: ${provReq.manifest.name} (${provReq.provisionId})`);
2221
- const sendProgress = (progress) => {
2222
- const currentWs = activeSocket;
2223
- if (currentWs && currentWs.readyState === import_ws2.default.OPEN && activeGroups.registryGroup) {
2224
- sendToGroup(currentWs, activeGroups.registryGroup, {
2225
- ...progress,
2226
- hostId: options.hostId
2227
- });
2228
- }
2229
- };
2230
- try {
2231
- await executeProvision(provReq, sendProgress);
2232
- await syncInventory(true);
2233
- } catch (error) {
2234
- const errMsg = error instanceof Error ? error.message : String(error);
2235
- sendProgress({
2236
- type: "provision-progress",
2237
- provisionId: provReq.provisionId,
2238
- step: "error",
2239
- message: `Provision failed: ${errMsg}`
2240
- });
2241
- }
2242
- }
2582
+ await dispatcher.handleProvision(payload);
2243
2583
  }
2244
2584
  }
2245
- if (msg.type === "message" && msg.group === activeGroups.registryGroup) {
2585
+ if (msg.type === "message" && msg.group === groups.registryGroup) {
2246
2586
  const payload = msg.data || {};
2247
2587
  if (payload.type === "ping") {
2248
- await announceToRegistry("announce");
2588
+ await inventory.announceToRegistry(ws, groups.registryGroup, "announce");
2249
2589
  }
2250
2590
  }
2251
2591
  });
@@ -2253,10 +2593,8 @@ async function runAgent(rawOptions) {
2253
2593
  console.error("[rAgent] WebSocket error:", error.message);
2254
2594
  });
2255
2595
  ws.on("close", () => {
2256
- console.log(
2257
- "[rAgent] Relay disconnected. Output will be buffered until reconnect."
2258
- );
2259
- cleanupSocket();
2596
+ console.log("[rAgent] Relay disconnected. Output will be buffered until reconnect.");
2597
+ conn.cleanup();
2260
2598
  resolve();
2261
2599
  });
2262
2600
  });
@@ -2266,28 +2604,26 @@ async function runAgent(rawOptions) {
2266
2604
  console.error(
2267
2605
  "[rAgent] Connector token is invalid or revoked. Stopping. Re-connect with: ragent connect --token <token>"
2268
2606
  );
2269
- shouldRun = false;
2607
+ dispatcher.shouldRun = false;
2270
2608
  break;
2271
2609
  }
2272
2610
  const message = error instanceof Error ? error.message : String(error);
2273
2611
  console.error(`[rAgent] Relay connect failed: ${message}`);
2274
2612
  }
2275
- if (!shouldRun) break;
2276
- if (reconnectRequested) {
2613
+ if (!dispatcher.shouldRun) break;
2614
+ if (dispatcher.reconnectRequested) {
2277
2615
  console.log("[rAgent] Reconnecting to relay...");
2278
2616
  await wait(300);
2279
2617
  continue;
2280
2618
  }
2281
- const jitteredDelay = reconnectDelay * (0.5 + Math.random());
2282
- console.log(
2283
- `[rAgent] Disconnected. Reconnecting in ${Math.round(jitteredDelay / 1e3)}s...`
2284
- );
2619
+ const jitteredDelay = conn.reconnectDelay * (0.5 + Math.random());
2620
+ console.log(`[rAgent] Disconnected. Reconnecting in ${Math.round(jitteredDelay / 1e3)}s...`);
2285
2621
  await wait(jitteredDelay);
2286
- reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY_MS);
2622
+ conn.reconnectDelay = Math.min(conn.reconnectDelay * 1.5, MAX_RECONNECT_DELAY_MS);
2287
2623
  }
2288
2624
  } finally {
2289
- cleanupSocket({ stopStreams: true });
2290
- killCurrentShell();
2625
+ conn.cleanup({ stopStreams: true });
2626
+ shell.stop();
2291
2627
  releasePidLock(lockPath);
2292
2628
  process.removeListener("SIGTERM", onSignal);
2293
2629
  process.removeListener("SIGINT", onSignal);
@@ -2382,7 +2718,7 @@ function printCommandArt(title, subtitle = "remote agent control") {
2382
2718
  async function connectMachine(opts) {
2383
2719
  const portal = opts.portal || DEFAULT_PORTAL;
2384
2720
  const hostId = sanitizeHostId(opts.id || inferHostId());
2385
- const hostName = opts.name || os6.hostname();
2721
+ const hostName = opts.name || os7.hostname();
2386
2722
  const command = opts.command || "bash";
2387
2723
  if (!opts.token) {
2388
2724
  throw new Error("Connection token is required.");
@@ -2456,12 +2792,12 @@ function registerRunCommand(program2) {
2456
2792
  }
2457
2793
 
2458
2794
  // src/commands/doctor.ts
2459
- var os7 = __toESM(require("os"));
2795
+ var os8 = __toESM(require("os"));
2460
2796
  async function runDoctor(opts) {
2461
2797
  const options = resolveRunOptions(opts);
2462
2798
  const checks = [];
2463
- const platformOk = os7.platform() === "linux";
2464
- checks.push({ name: "platform", ok: platformOk, detail: os7.platform() });
2799
+ const platformOk = os8.platform() === "linux";
2800
+ checks.push({ name: "platform", ok: platformOk, detail: os8.platform() });
2465
2801
  checks.push({
2466
2802
  name: "node",
2467
2803
  ok: Number(process.versions.node.split(".")[0]) >= 20,