ragent-cli 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1237 -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.0",
34
+ version: "1.4.0",
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);
@@ -1275,9 +1030,19 @@ var SessionStreamer = class {
1275
1030
  const cleanEnv = { ...process.env };
1276
1031
  delete cleanEnv.TMUX;
1277
1032
  delete cleanEnv.TMUX_PANE;
1278
- const tmpDir = (0, import_node_fs.mkdtempSync)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "ragent-stream-"));
1033
+ const streamsBase = (0, import_node_path.join)((0, import_node_os.homedir)(), ".config", "ragent", "streams");
1034
+ try {
1035
+ (0, import_node_fs.mkdirSync)(streamsBase, { recursive: true });
1036
+ } catch {
1037
+ }
1038
+ let tmpDir;
1039
+ try {
1040
+ tmpDir = (0, import_node_fs.mkdtempSync)((0, import_node_path.join)(streamsBase, "s-"));
1041
+ } catch {
1042
+ tmpDir = (0, import_node_fs.mkdtempSync)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "ragent-stream-"));
1043
+ }
1279
1044
  const fifoPath = (0, import_node_path.join)(tmpDir, "pane.fifo");
1280
- (0, import_node_child_process3.execFileSync)("mkfifo", ["-m", "600", fifoPath]);
1045
+ (0, import_node_child_process2.execFileSync)("mkfifo", ["-m", "600", fifoPath]);
1281
1046
  const stream = {
1282
1047
  sessionId,
1283
1048
  streamType: "tmux-pipe",
@@ -1288,13 +1053,14 @@ var SessionStreamer = class {
1288
1053
  stopped: false,
1289
1054
  initializing: true,
1290
1055
  initBuffer: [],
1056
+ cleanEnv,
1291
1057
  ptyProc: null
1292
1058
  };
1293
1059
  this.active.set(sessionId, stream);
1294
1060
  try {
1295
- (0, import_node_child_process3.execFileSync)(
1061
+ (0, import_node_child_process2.execFileSync)(
1296
1062
  "tmux",
1297
- ["pipe-pane", "-O", "-t", paneTarget, `cat > ${fifoPath}`],
1063
+ ["pipe-pane", "-O", "-t", paneTarget, `cat > ${shellQuote(fifoPath)}`],
1298
1064
  { env: cleanEnv, timeout: 5e3 }
1299
1065
  );
1300
1066
  } catch (error) {
@@ -1303,7 +1069,7 @@ var SessionStreamer = class {
1303
1069
  console.warn(`[rAgent] Failed pipe-pane for ${sessionId}: ${message}`);
1304
1070
  return false;
1305
1071
  }
1306
- 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"] });
1307
1073
  stream.catProc = catProc;
1308
1074
  catProc.stdout.on("data", (chunk) => {
1309
1075
  if (stream.stopped) return;
@@ -1323,9 +1089,20 @@ var SessionStreamer = class {
1323
1089
  this.onStreamStopped?.(sessionId);
1324
1090
  }
1325
1091
  });
1092
+ try {
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
+ }
1326
1103
  this.sendFn(sessionId, "\x1B[2J\x1B[H");
1327
1104
  try {
1328
- const initial = (0, import_node_child_process3.execFileSync)(
1105
+ const initial = (0, import_node_child_process2.execFileSync)(
1329
1106
  "tmux",
1330
1107
  ["capture-pane", "-t", paneTarget, "-p", "-e"],
1331
1108
  { env: cleanEnv, timeout: 5e3, encoding: "utf-8" }
@@ -1336,7 +1113,7 @@ var SessionStreamer = class {
1336
1113
  } catch {
1337
1114
  }
1338
1115
  try {
1339
- const cursorInfo = (0, import_node_child_process3.execFileSync)(
1116
+ const cursorInfo = (0, import_node_child_process2.execFileSync)(
1340
1117
  "tmux",
1341
1118
  ["display-message", "-t", paneTarget, "-p", "#{cursor_x} #{cursor_y}"],
1342
1119
  { env: cleanEnv, timeout: 5e3, encoding: "utf-8" }
@@ -1352,12 +1129,7 @@ var SessionStreamer = class {
1352
1129
  } catch {
1353
1130
  }
1354
1131
  stream.initializing = false;
1355
- if (stream.initBuffer.length > 0) {
1356
- for (const buffered of stream.initBuffer) {
1357
- this.sendFn(sessionId, buffered);
1358
- }
1359
- stream.initBuffer = [];
1360
- }
1132
+ stream.initBuffer = [];
1361
1133
  console.log(`[rAgent] Started streaming: ${sessionId} (pane: ${paneTarget})`);
1362
1134
  return true;
1363
1135
  } catch (error) {
@@ -1373,7 +1145,7 @@ var SessionStreamer = class {
1373
1145
  const sessionName = parseScreenSession(sessionId);
1374
1146
  if (!sessionName) return false;
1375
1147
  try {
1376
- const proc = pty2.spawn("screen", ["-x", sessionName], {
1148
+ const proc = pty.spawn("screen", ["-x", sessionName], {
1377
1149
  name: "xterm-256color",
1378
1150
  cols: 80,
1379
1151
  rows: 30,
@@ -1422,7 +1194,7 @@ var SessionStreamer = class {
1422
1194
  const sessionName = parseZellijSession(sessionId);
1423
1195
  if (!sessionName) return false;
1424
1196
  try {
1425
- const proc = pty2.spawn("zellij", ["attach", sessionName], {
1197
+ const proc = pty.spawn("zellij", ["attach", sessionName], {
1426
1198
  name: "xterm-256color",
1427
1199
  cols: 80,
1428
1200
  rows: 30,
@@ -1471,7 +1243,10 @@ var SessionStreamer = class {
1471
1243
  stream.stopped = true;
1472
1244
  if (stream.streamType === "tmux-pipe") {
1473
1245
  try {
1474
- (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
+ });
1475
1250
  } catch {
1476
1251
  }
1477
1252
  if (stream.catProc && !stream.catProc.killed) {
@@ -1496,10 +1271,680 @@ var SessionStreamer = class {
1496
1271
  }
1497
1272
  }
1498
1273
  }
1499
- };
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
+ }
1500
1945
 
1501
1946
  // src/provisioner.ts
1502
- var import_child_process2 = require("child_process");
1947
+ var import_child_process3 = require("child_process");
1503
1948
  var DANGEROUS_PATTERN = /rm\s+-r[fe]*\s+\/|mkfs|dd\s+if=|:()\s*\{|>\s*\/dev\/sd/i;
1504
1949
  function shellQuote2(s) {
1505
1950
  return `'${s.replace(/'/g, "'\\''")}'`;
@@ -1507,7 +1952,7 @@ function shellQuote2(s) {
1507
1952
  var SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
1508
1953
  var MAX_SESSION_NAME = 128;
1509
1954
  function runCommand(cmd, timeout = 12e4) {
1510
- return (0, import_child_process2.execSync)(cmd, {
1955
+ return (0, import_child_process3.execSync)(cmd, {
1511
1956
  encoding: "utf8",
1512
1957
  timeout,
1513
1958
  maxBuffer: 10 * 1024 * 1024,
@@ -1518,7 +1963,7 @@ function runCommand(cmd, timeout = 12e4) {
1518
1963
  function commandExists2(cmd) {
1519
1964
  if (!/^[a-zA-Z0-9._+-]+$/.test(cmd)) return false;
1520
1965
  try {
1521
- (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" });
1522
1967
  return true;
1523
1968
  } catch {
1524
1969
  return false;
@@ -1668,7 +2113,7 @@ function startAgent(request, onProgress) {
1668
2113
  }
1669
2114
  tmuxArgs.push(fullCmd);
1670
2115
  try {
1671
- (0, import_child_process2.execFileSync)("tmux", tmuxArgs, { stdio: "ignore" });
2116
+ (0, import_child_process3.execFileSync)("tmux", tmuxArgs, { stdio: "ignore" });
1672
2117
  onProgress({
1673
2118
  type: "provision-progress",
1674
2119
  provisionId: request.provisionId,
@@ -1703,6 +2148,250 @@ async function executeProvision(request, onProgress) {
1703
2148
  return startAgent(request, emit);
1704
2149
  }
1705
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
+
1706
2395
  // src/agent.ts
1707
2396
  function pidFilePath(hostId) {
1708
2397
  return path2.join(CONFIG_DIR, `agent-${hostId}.pid`);
@@ -1751,7 +2440,7 @@ function resolveRunOptions(commandOptions) {
1751
2440
  const config = loadConfig();
1752
2441
  const portal = commandOptions.portal || config.portal || DEFAULT_PORTAL;
1753
2442
  const hostId = sanitizeHostId(commandOptions.id || config.hostId || inferHostId());
1754
- const hostName = commandOptions.name || config.hostName || os5.hostname();
2443
+ const hostName = commandOptions.name || config.hostName || os6.hostname();
1755
2444
  const command = commandOptions.command || config.command || "bash";
1756
2445
  const agentToken = commandOptions.agentToken || config.agentToken || "";
1757
2446
  return { portal, hostId, hostName, command, agentToken };
@@ -1772,400 +2461,93 @@ async function runAgent(rawOptions) {
1772
2461
  }
1773
2462
  } catch {
1774
2463
  }
1775
- let shouldRun = true;
1776
- let reconnectRequested = false;
1777
- let reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
1778
- let activeSocket = null;
1779
- let activeGroups = { privateGroup: "", registryGroup: "" };
1780
- let wsHeartbeatTimer = null;
1781
- let httpHeartbeatTimer = null;
1782
- let wsPingTimer = null;
1783
- let wsPongTimeout = null;
1784
- let suppressNextShellRespawn = false;
1785
- let lastSentFingerprint = "";
1786
- let lastHttpHeartbeatAt = 0;
1787
2464
  const outputBuffer = new OutputBuffer();
1788
- let ptyProcess = null;
1789
2465
  const ptySessionId = `pty:${options.hostId}`;
1790
- const sendOutput = (chunk) => {
1791
- const ws = activeSocket;
1792
- if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.privateGroup) {
1793
- outputBuffer.push(chunk);
1794
- return;
1795
- }
1796
- sendToGroup(ws, activeGroups.privateGroup, { type: "output", data: chunk, sessionId: ptySessionId });
1797
- };
1798
2466
  const sessionStreamer = new SessionStreamer(
1799
2467
  (sessionId, data) => {
1800
- const ws = activeSocket;
1801
- if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.privateGroup) return;
1802
- 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
+ }
1803
2475
  },
1804
2476
  (sessionId) => {
1805
- const ws = activeSocket;
1806
- if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.privateGroup) return;
1807
- sendToGroup(ws, activeGroups.privateGroup, { type: "stream-stopped", sessionId });
2477
+ if (!conn.isReady()) return;
2478
+ sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "stream-stopped", sessionId });
1808
2479
  }
1809
2480
  );
1810
- const killCurrentShell = () => {
1811
- if (!ptyProcess) return;
1812
- suppressNextShellRespawn = true;
1813
- try {
1814
- ptyProcess.kill();
1815
- } catch {
1816
- }
1817
- ptyProcess = null;
1818
- };
1819
- const spawnOrRespawnShell = () => {
1820
- ptyProcess = spawnConnectorShell(options.command, sendOutput, () => {
1821
- if (suppressNextShellRespawn) {
1822
- suppressNextShellRespawn = false;
1823
- return;
1824
- }
1825
- if (!shouldRun) return;
1826
- console.warn("[rAgent] Shell exited. Restarting shell process.");
1827
- setTimeout(() => {
1828
- if (shouldRun) spawnOrRespawnShell();
1829
- }, 200);
1830
- });
1831
- };
1832
- const restartLocalShell = () => {
1833
- killCurrentShell();
1834
- spawnOrRespawnShell();
1835
- };
1836
- spawnOrRespawnShell();
1837
- const cleanupSocket = (opts = {}) => {
1838
- if (opts.stopStreams) sessionStreamer.stopAll();
1839
- if (wsPingTimer) {
1840
- clearInterval(wsPingTimer);
1841
- wsPingTimer = null;
1842
- }
1843
- if (wsPongTimeout) {
1844
- clearTimeout(wsPongTimeout);
1845
- wsPongTimeout = null;
1846
- }
1847
- if (wsHeartbeatTimer) {
1848
- clearInterval(wsHeartbeatTimer);
1849
- wsHeartbeatTimer = null;
1850
- }
1851
- if (httpHeartbeatTimer) {
1852
- clearInterval(httpHeartbeatTimer);
1853
- httpHeartbeatTimer = null;
1854
- }
1855
- if (activeSocket) {
1856
- activeSocket.removeAllListeners();
1857
- try {
1858
- activeSocket.close();
1859
- } catch {
1860
- }
1861
- activeSocket = null;
1862
- }
1863
- activeGroups = { privateGroup: "", registryGroup: "" };
1864
- };
1865
- let prevCpuSnapshot = null;
1866
- function takeCpuSnapshot() {
1867
- const cpus2 = os5.cpus();
1868
- let idle = 0;
1869
- let total = 0;
1870
- for (const cpu of cpus2) {
1871
- for (const type of Object.keys(cpu.times)) {
1872
- total += cpu.times[type];
1873
- }
1874
- idle += cpu.times.idle;
1875
- }
1876
- return { idle, total };
1877
- }
1878
- const collectVitals = () => {
1879
- const currentSnapshot = takeCpuSnapshot();
1880
- let cpuUsage = 0;
1881
- if (prevCpuSnapshot) {
1882
- const idleDelta = currentSnapshot.idle - prevCpuSnapshot.idle;
1883
- const totalDelta = currentSnapshot.total - prevCpuSnapshot.total;
1884
- cpuUsage = totalDelta > 0 ? Math.round((totalDelta - idleDelta) / totalDelta * 100) : 0;
1885
- }
1886
- prevCpuSnapshot = currentSnapshot;
1887
- const totalMem = os5.totalmem();
1888
- const freeMem = os5.freemem();
1889
- const memUsedPct = totalMem > 0 ? Math.round((totalMem - freeMem) / totalMem * 100) : 0;
1890
- let diskUsedPct = 0;
1891
- try {
1892
- const { execSync: execSync2 } = require("child_process");
1893
- const dfOutput = execSync2("df -P / | tail -1", { encoding: "utf8", timeout: 5e3 });
1894
- const parts = dfOutput.trim().split(/\s+/);
1895
- if (parts.length >= 5) {
1896
- diskUsedPct = parseInt(parts[4].replace("%", ""), 10) || 0;
1897
- }
1898
- } catch {
1899
- }
1900
- return { cpu: cpuUsage, memUsedPct, diskUsedPct };
1901
- };
1902
- const announceToRegistry = async (type = "heartbeat") => {
1903
- const ws = activeSocket;
1904
- if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.registryGroup) return;
1905
- const sessions = await collectSessionInventory(options.hostId, options.command);
1906
- const vitals = collectVitals();
1907
- sendToGroup(ws, activeGroups.registryGroup, {
1908
- type,
1909
- hostId: options.hostId,
1910
- hostName: options.hostName,
1911
- environment: os5.hostname(),
1912
- sessions,
1913
- vitals,
1914
- agentVersion: CURRENT_VERSION,
1915
- lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
1916
- });
1917
- };
1918
- const syncInventory = async (force = false) => {
1919
- const sessions = await collectSessionInventory(options.hostId, options.command);
1920
- const fingerprint = sessionInventoryFingerprint(sessions);
1921
- const changed = fingerprint !== lastSentFingerprint;
1922
- const checkpointDue = Date.now() - lastHttpHeartbeatAt > HTTP_HEARTBEAT_MS;
1923
- if (changed || force) {
1924
- await announceToRegistry("inventory");
1925
- lastSentFingerprint = fingerprint;
1926
- }
1927
- if (changed || checkpointDue || force) {
1928
- await postHeartbeat({
1929
- portal: options.portal,
1930
- agentToken: options.agentToken,
1931
- hostId: options.hostId,
1932
- hostName: options.hostName,
1933
- command: options.command
1934
- });
1935
- lastHttpHeartbeatAt = Date.now();
2481
+ const conn = new ConnectionManager(sessionStreamer, outputBuffer);
2482
+ const sendOutput = (chunk) => {
2483
+ if (!conn.isReady()) {
2484
+ outputBuffer.push(chunk);
2485
+ return;
1936
2486
  }
1937
- };
1938
- const handleControlAction = async (payload) => {
1939
- const action = typeof payload?.action === "string" ? payload.action : "";
1940
- const sessionId = typeof payload?.sessionId === "string" && payload.sessionId.trim().length > 0 ? payload.sessionId.trim() : null;
1941
- switch (action) {
1942
- case "restart-shell":
1943
- restartLocalShell();
1944
- await syncInventory();
1945
- return;
1946
- case "restart-agent":
1947
- case "disconnect":
1948
- reconnectRequested = true;
1949
- sessionStreamer.stopAll();
1950
- if (activeSocket && activeSocket.readyState === import_ws2.default.OPEN) {
1951
- activeSocket.close();
1952
- }
1953
- return;
1954
- case "stop-agent":
1955
- shouldRun = false;
1956
- sessionStreamer.stopAll();
1957
- requestStopSelfService();
1958
- if (activeSocket && activeSocket.readyState === import_ws2.default.OPEN) {
1959
- activeSocket.close();
1960
- }
1961
- return;
1962
- case "stop-session":
1963
- if (!sessionId) return;
1964
- if (sessionId.startsWith("pty:")) {
1965
- restartLocalShell();
1966
- await syncInventory();
1967
- return;
1968
- }
1969
- if (sessionId.startsWith("tmux:")) {
1970
- try {
1971
- await stopTmuxPaneBySessionId(sessionId);
1972
- console.log(`[rAgent] Closed remote session ${sessionId}.`);
1973
- } catch (error) {
1974
- const message = error instanceof Error ? error.message : String(error);
1975
- console.warn(
1976
- `[rAgent] Failed to close ${sessionId}: ${message}`
1977
- );
1978
- }
1979
- await syncInventory();
1980
- }
1981
- return;
1982
- case "stop-detached": {
1983
- const killed = await stopAllDetachedTmuxSessions();
1984
- console.log(`[rAgent] Killed ${killed} detached tmux session(s).`);
1985
- await syncInventory();
1986
- return;
1987
- }
1988
- case "start-agent": {
1989
- const sessionName = typeof payload?.sessionName === "string" && payload.sessionName.trim().length > 0 ? payload.sessionName.trim() : `agent-${Date.now().toString(36)}`;
1990
- const cmd = typeof payload?.command === "string" && payload.command.trim().length > 0 ? payload.command.trim() : null;
1991
- if (!cmd) {
1992
- console.warn("[rAgent] start-agent: no command provided, ignoring.");
1993
- return;
1994
- }
1995
- if (sessionName.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(sessionName)) {
1996
- console.warn("[rAgent] start-agent: invalid session name, ignoring.");
1997
- return;
1998
- }
1999
- const dangerous = /rm\s+-r[fe]*\s+\/|mkfs|dd\s+if=|:()\s*\{|>\s*\/dev\/sd/i;
2000
- if (dangerous.test(cmd)) {
2001
- console.warn(`[rAgent] start-agent: rejected dangerous command: ${cmd}`);
2002
- return;
2003
- }
2004
- const workingDir = typeof payload?.workingDir === "string" && payload.workingDir.trim().length > 0 ? payload.workingDir.trim() : void 0;
2005
- const envVars = payload?.envVars && typeof payload.envVars === "object" ? payload.envVars : void 0;
2006
- const tmuxArgs = ["new-session", "-d", "-s", sessionName];
2007
- if (workingDir) {
2008
- tmuxArgs.push("-c", workingDir);
2009
- }
2010
- let fullCmd = cmd;
2011
- if (envVars) {
2012
- 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(" ");
2013
- if (entries) fullCmd = `${entries} ${cmd}`;
2014
- }
2015
- tmuxArgs.push(fullCmd);
2016
- try {
2017
- const { execFileSync: execFileSync3 } = await import("child_process");
2018
- execFileSync3("tmux", tmuxArgs, { stdio: "ignore" });
2019
- console.log(`[rAgent] Started agent session "${sessionName}": ${cmd}`);
2020
- } catch (error) {
2021
- const message = error instanceof Error ? error.message : String(error);
2022
- console.error(`[rAgent] Failed to start agent session "${sessionName}": ${message}`);
2023
- }
2024
- await syncInventory(true);
2025
- return;
2026
- }
2027
- case "stream-session": {
2028
- if (!sessionId) return;
2029
- if (sessionId.startsWith("process:")) {
2030
- const ws2 = activeSocket;
2031
- if (ws2 && ws2.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
2032
- sendToGroup(ws2, activeGroups.privateGroup, {
2033
- type: "stream-error",
2034
- sessionId,
2035
- error: "This agent is running outside a terminal multiplexer. Stop and relaunch via Start Agent to enable live streaming."
2036
- });
2037
- }
2038
- return;
2039
- }
2040
- if (sessionId.startsWith("tmux:") || sessionId.startsWith("screen:") || sessionId.startsWith("zellij:")) {
2041
- const started = sessionStreamer.startStream(sessionId);
2042
- const ws2 = activeSocket;
2043
- if (ws2 && ws2.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
2044
- if (started) {
2045
- sendToGroup(ws2, activeGroups.privateGroup, {
2046
- type: "stream-started",
2047
- sessionId
2048
- });
2049
- } else {
2050
- sendToGroup(ws2, activeGroups.privateGroup, {
2051
- type: "stream-error",
2052
- sessionId,
2053
- error: "Failed to attach to session. It may no longer exist."
2054
- });
2055
- }
2056
- }
2057
- return;
2058
- }
2059
- const ws = activeSocket;
2060
- if (ws && ws.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
2061
- sendToGroup(ws, activeGroups.privateGroup, {
2062
- type: "stream-error",
2063
- sessionId,
2064
- error: "Live streaming is not yet supported for this session type."
2065
- });
2066
- }
2067
- return;
2068
- }
2069
- case "stop-stream": {
2070
- if (!sessionId) return;
2071
- sessionStreamer.stopStream(sessionId);
2072
- return;
2073
- }
2074
- 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 });
2075
2492
  }
2076
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();
2077
2498
  const onSignal = () => {
2078
- shouldRun = false;
2079
- cleanupSocket({ stopStreams: true });
2080
- killCurrentShell();
2499
+ dispatcher.shouldRun = false;
2500
+ conn.cleanup({ stopStreams: true });
2501
+ shell.stop();
2081
2502
  releasePidLock(lockPath);
2082
2503
  };
2083
2504
  process.once("SIGTERM", onSignal);
2084
2505
  process.once("SIGINT", onSignal);
2085
2506
  try {
2086
- while (shouldRun) {
2087
- reconnectRequested = false;
2507
+ while (dispatcher.shouldRun) {
2508
+ dispatcher.reconnectRequested = false;
2088
2509
  try {
2089
2510
  options.agentToken = await refreshTokenIfNeeded({
2090
2511
  portal: options.portal,
2091
2512
  agentToken: options.agentToken
2092
2513
  });
2514
+ inventory.updateOptions(options);
2515
+ dispatcher.updateOptions(options);
2093
2516
  const negotiated = await negotiateAgent({
2094
2517
  portal: options.portal,
2095
2518
  agentToken: options.agentToken
2096
2519
  });
2097
- activeGroups = {
2520
+ const groups = {
2098
2521
  privateGroup: negotiated.groups.privateGroup,
2099
2522
  registryGroup: negotiated.groups.registryGroup
2100
2523
  };
2101
2524
  await new Promise((resolve) => {
2102
- const ws = new import_ws2.default(
2103
- negotiated.url,
2104
- "json.webpubsub.azure.v1"
2105
- );
2106
- activeSocket = ws;
2525
+ const ws = new import_ws5.default(negotiated.url, "json.webpubsub.azure.v1");
2526
+ conn.setConnection(ws, groups, negotiated.sessionKey ?? null);
2107
2527
  ws.on("open", async () => {
2108
2528
  console.log("[rAgent] Connector connected to relay.");
2109
- reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
2110
- ws.send(
2111
- JSON.stringify({
2112
- type: "joinGroup",
2113
- group: activeGroups.privateGroup
2114
- })
2115
- );
2116
- ws.send(
2117
- JSON.stringify({
2118
- type: "joinGroup",
2119
- group: activeGroups.registryGroup
2120
- })
2121
- );
2122
- 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, {
2123
2533
  type: "register",
2124
2534
  hostName: options.hostName,
2125
- environment: os5.platform()
2535
+ environment: os6.platform()
2126
2536
  });
2127
- const buffered = outputBuffer.drain();
2128
- if (buffered.length > 0) {
2129
- console.log(
2130
- `[rAgent] Replaying ${buffered.length} buffered output chunks (${buffered.reduce((sum, c) => sum + Buffer.byteLength(c, "utf8"), 0)} bytes)`
2131
- );
2132
- for (const chunk of buffered) {
2133
- sendToGroup(ws, activeGroups.privateGroup, {
2134
- type: "output",
2135
- data: chunk,
2136
- sessionId: ptySessionId
2137
- });
2138
- }
2139
- }
2140
- await syncInventory(true);
2141
- wsHeartbeatTimer = setInterval(async () => {
2142
- if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN)
2143
- return;
2144
- await announceToRegistry("heartbeat");
2145
- }, WS_HEARTBEAT_MS);
2146
- httpHeartbeatTimer = setInterval(async () => {
2147
- if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN)
2148
- return;
2149
- await syncInventory();
2150
- }, HTTP_HEARTBEAT_MS);
2151
- wsPingTimer = setInterval(() => {
2152
- if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN) return;
2153
- activeSocket.ping();
2154
- wsPongTimeout = setTimeout(() => {
2155
- console.warn("[rAgent] No pong received within 10s \u2014 closing stale connection.");
2156
- try {
2157
- activeSocket?.terminate();
2158
- } catch {
2159
- }
2160
- }, 1e4);
2161
- }, 2e4);
2162
- });
2163
- ws.on("pong", () => {
2164
- if (wsPongTimeout) {
2165
- clearTimeout(wsPongTimeout);
2166
- wsPongTimeout = null;
2167
- }
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
+ );
2168
2549
  });
2550
+ ws.on("pong", () => conn.onPong());
2169
2551
  ws.on("message", async (data) => {
2170
2552
  let msg;
2171
2553
  try {
@@ -2173,69 +2555,37 @@ async function runAgent(rawOptions) {
2173
2555
  } catch {
2174
2556
  return;
2175
2557
  }
2176
- if (msg.type === "message" && msg.group === activeGroups.privateGroup) {
2558
+ if (msg.type === "message" && msg.group === groups.privateGroup) {
2177
2559
  const payload = msg.data || {};
2178
- if (payload.type === "input" && typeof payload.data === "string") {
2179
- const sid = typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
2180
- if (!sid || sid.startsWith("pty:")) {
2181
- if (ptyProcess) ptyProcess.write(payload.data);
2182
- } else if (sid.startsWith("tmux:")) {
2183
- await sendInputToTmux(sid, payload.data);
2184
- } else if (sid.startsWith("screen:") || sid.startsWith("zellij:")) {
2185
- 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);
2186
2573
  }
2187
2574
  } else if (payload.type === "resize" && Number.isInteger(payload.cols) && Number.isInteger(payload.rows)) {
2188
2575
  const sid = typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
2189
- if (!sid || sid.startsWith("pty:")) {
2190
- try {
2191
- if (ptyProcess)
2192
- ptyProcess.resize(
2193
- payload.cols,
2194
- payload.rows
2195
- );
2196
- } catch (error) {
2197
- const message = error instanceof Error ? error.message : String(error);
2198
- if (!message.includes("EBADF")) {
2199
- console.warn(`[rAgent] Resize failed: ${message}`);
2200
- }
2201
- }
2202
- }
2576
+ dispatcher.handleResize(payload.cols, payload.rows, sid);
2203
2577
  } else if (payload.type === "control" && typeof payload.action === "string") {
2204
- await handleControlAction(payload);
2578
+ await dispatcher.handleControlAction(payload);
2205
2579
  } else if (payload.type === "start-agent") {
2206
- await handleControlAction({ ...payload, action: "start-agent" });
2580
+ await dispatcher.handleControlAction({ ...payload, action: "start-agent" });
2207
2581
  } else if (payload.type === "provision") {
2208
- const provReq = payload;
2209
- if (provReq.provisionId && provReq.manifest) {
2210
- console.log(`[rAgent] Provision request: ${provReq.manifest.name} (${provReq.provisionId})`);
2211
- const sendProgress = (progress) => {
2212
- const currentWs = activeSocket;
2213
- if (currentWs && currentWs.readyState === import_ws2.default.OPEN && activeGroups.registryGroup) {
2214
- sendToGroup(currentWs, activeGroups.registryGroup, {
2215
- ...progress,
2216
- hostId: options.hostId
2217
- });
2218
- }
2219
- };
2220
- try {
2221
- await executeProvision(provReq, sendProgress);
2222
- await syncInventory(true);
2223
- } catch (error) {
2224
- const errMsg = error instanceof Error ? error.message : String(error);
2225
- sendProgress({
2226
- type: "provision-progress",
2227
- provisionId: provReq.provisionId,
2228
- step: "error",
2229
- message: `Provision failed: ${errMsg}`
2230
- });
2231
- }
2232
- }
2582
+ await dispatcher.handleProvision(payload);
2233
2583
  }
2234
2584
  }
2235
- if (msg.type === "message" && msg.group === activeGroups.registryGroup) {
2585
+ if (msg.type === "message" && msg.group === groups.registryGroup) {
2236
2586
  const payload = msg.data || {};
2237
2587
  if (payload.type === "ping") {
2238
- await announceToRegistry("announce");
2588
+ await inventory.announceToRegistry(ws, groups.registryGroup, "announce");
2239
2589
  }
2240
2590
  }
2241
2591
  });
@@ -2243,10 +2593,8 @@ async function runAgent(rawOptions) {
2243
2593
  console.error("[rAgent] WebSocket error:", error.message);
2244
2594
  });
2245
2595
  ws.on("close", () => {
2246
- console.log(
2247
- "[rAgent] Relay disconnected. Output will be buffered until reconnect."
2248
- );
2249
- cleanupSocket();
2596
+ console.log("[rAgent] Relay disconnected. Output will be buffered until reconnect.");
2597
+ conn.cleanup();
2250
2598
  resolve();
2251
2599
  });
2252
2600
  });
@@ -2256,28 +2604,26 @@ async function runAgent(rawOptions) {
2256
2604
  console.error(
2257
2605
  "[rAgent] Connector token is invalid or revoked. Stopping. Re-connect with: ragent connect --token <token>"
2258
2606
  );
2259
- shouldRun = false;
2607
+ dispatcher.shouldRun = false;
2260
2608
  break;
2261
2609
  }
2262
2610
  const message = error instanceof Error ? error.message : String(error);
2263
2611
  console.error(`[rAgent] Relay connect failed: ${message}`);
2264
2612
  }
2265
- if (!shouldRun) break;
2266
- if (reconnectRequested) {
2613
+ if (!dispatcher.shouldRun) break;
2614
+ if (dispatcher.reconnectRequested) {
2267
2615
  console.log("[rAgent] Reconnecting to relay...");
2268
2616
  await wait(300);
2269
2617
  continue;
2270
2618
  }
2271
- const jitteredDelay = reconnectDelay * (0.5 + Math.random());
2272
- console.log(
2273
- `[rAgent] Disconnected. Reconnecting in ${Math.round(jitteredDelay / 1e3)}s...`
2274
- );
2619
+ const jitteredDelay = conn.reconnectDelay * (0.5 + Math.random());
2620
+ console.log(`[rAgent] Disconnected. Reconnecting in ${Math.round(jitteredDelay / 1e3)}s...`);
2275
2621
  await wait(jitteredDelay);
2276
- reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY_MS);
2622
+ conn.reconnectDelay = Math.min(conn.reconnectDelay * 1.5, MAX_RECONNECT_DELAY_MS);
2277
2623
  }
2278
2624
  } finally {
2279
- cleanupSocket({ stopStreams: true });
2280
- killCurrentShell();
2625
+ conn.cleanup({ stopStreams: true });
2626
+ shell.stop();
2281
2627
  releasePidLock(lockPath);
2282
2628
  process.removeListener("SIGTERM", onSignal);
2283
2629
  process.removeListener("SIGINT", onSignal);
@@ -2372,7 +2718,7 @@ function printCommandArt(title, subtitle = "remote agent control") {
2372
2718
  async function connectMachine(opts) {
2373
2719
  const portal = opts.portal || DEFAULT_PORTAL;
2374
2720
  const hostId = sanitizeHostId(opts.id || inferHostId());
2375
- const hostName = opts.name || os6.hostname();
2721
+ const hostName = opts.name || os7.hostname();
2376
2722
  const command = opts.command || "bash";
2377
2723
  if (!opts.token) {
2378
2724
  throw new Error("Connection token is required.");
@@ -2446,12 +2792,12 @@ function registerRunCommand(program2) {
2446
2792
  }
2447
2793
 
2448
2794
  // src/commands/doctor.ts
2449
- var os7 = __toESM(require("os"));
2795
+ var os8 = __toESM(require("os"));
2450
2796
  async function runDoctor(opts) {
2451
2797
  const options = resolveRunOptions(opts);
2452
2798
  const checks = [];
2453
- const platformOk = os7.platform() === "linux";
2454
- 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() });
2455
2801
  checks.push({
2456
2802
  name: "node",
2457
2803
  ok: Number(process.versions.node.split(".")[0]) >= 20,