pinggy 0.6.1 → 0.6.3

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.
@@ -1,29 +1,33 @@
1
1
  import {
2
- ErrorCode,
3
2
  TunnelClient,
3
+ TunnelConfigV1Schema,
4
4
  TunnelOperations,
5
5
  buildRemoteManagementWsUrl,
6
6
  daemonIpcVersion,
7
+ ensureDaemonRunning,
7
8
  getDaemonInfo,
8
9
  initiateRemoteManagement,
9
10
  ipcMismatchMessage,
10
11
  isDaemonRunning,
11
- isErrorResponse,
12
12
  isIpcCompatible,
13
13
  startDaemon,
14
14
  startRemoteManagement,
15
15
  stopDaemon
16
- } from "./chunk-QVV3MRKY.js";
16
+ } from "./chunk-GESMZQHI.js";
17
17
  import {
18
18
  readDaemonConfig
19
- } from "./chunk-MT44NAXX.js";
19
+ } from "./chunk-PD7HXVQ3.js";
20
20
  import {
21
21
  DaemonHost,
22
+ DeviceAgentState,
23
+ HTTP_NOT_FOUND,
24
+ IPCClient,
25
+ IPCHttpError,
22
26
  IPC_VERSION,
23
27
  Route,
24
28
  SessionMode,
25
29
  ShutdownStatus
26
- } from "./chunk-I4HXBP5G.js";
30
+ } from "./chunk-7EQKHVQX.js";
27
31
  import {
28
32
  ConfigVerb,
29
33
  SUBCOMMANDS,
@@ -39,36 +43,46 @@ import {
39
43
  saveConfig,
40
44
  updateConfigAutoStart,
41
45
  updateTunnelConfig,
42
- validateName
43
- } from "./chunk-DJ5WX37D.js";
46
+ upsertConfig,
47
+ validateName,
48
+ validateNameStrict
49
+ } from "./chunk-Y435RMHQ.js";
44
50
  import {
51
+ createQrCodes
52
+ } from "./chunk-XZ64ZAGO.js";
53
+ import {
54
+ ErrorCode,
45
55
  TunnelManager,
56
+ TunnelStateType,
46
57
  detachAllTunnelLoggers,
47
58
  errorMessage,
48
59
  getLocalAddress,
49
60
  getRandomId,
50
61
  getVersion,
62
+ isErrorResponse,
51
63
  isTunnelLoggingEnabled,
52
64
  isValidPort,
65
+ newErrorResponse,
53
66
  printer_default,
54
67
  setTunnelLoggingEnabled
55
- } from "./chunk-LV3J6TAV.js";
68
+ } from "./chunk-QNUVQ6KD.js";
56
69
  import {
57
70
  configureLogger,
58
71
  enablePackageLogging,
59
72
  getLogLevel,
60
73
  logger,
61
74
  setLogLevel
62
- } from "./chunk-JQAQRJPE.js";
75
+ } from "./chunk-MV56U3E6.js";
63
76
  import {
64
77
  ensurePinggyConfigDir,
65
78
  ensurePinggyLogDir,
66
79
  getDaemonInfoPath,
67
80
  getDaemonLogPath,
81
+ getDeviceConfigPath,
68
82
  getPinggyConfigDir,
69
83
  getTunnelLogDir,
70
84
  getTunnelLogPath
71
- } from "./chunk-GBYF2H4H.js";
85
+ } from "./chunk-AFMTIELL.js";
72
86
 
73
87
  // src/cli/options.ts
74
88
  var cliOptions = {
@@ -798,10 +812,10 @@ function parseArgs2(finalConfig, remainingPositionals) {
798
812
  }
799
813
  function storeJson(config, saveconf) {
800
814
  if (saveconf) {
801
- const path5 = saveconf;
815
+ const path6 = saveconf;
802
816
  try {
803
- fs.writeFileSync(path5, JSON.stringify(config, null, 2), { encoding: "utf-8", flag: "w" });
804
- logger.info(`Configuration saved to ${path5}`);
817
+ fs.writeFileSync(path6, JSON.stringify(config, null, 2), { encoding: "utf-8", flag: "w" });
818
+ logger.info(`Configuration saved to ${path6}`);
805
819
  } catch (err) {
806
820
  const msg = err instanceof Error ? err.message : String(err);
807
821
  logger.error("Error loading configuration:", msg);
@@ -1051,7 +1065,7 @@ async function printAlreadyRunning(client, configId, label) {
1051
1065
  async function startTunnel(client, config, opts) {
1052
1066
  const result = await client.handleStartV2(config, false, opts.mode);
1053
1067
  const prefix = opts.label ? `[${opts.label}] ` : "";
1054
- const fail = (reason) => {
1068
+ const fail2 = (reason) => {
1055
1069
  const msg = `${prefix}Failed to start tunnel: ${reason}`;
1056
1070
  if (opts.onError === "fatal") {
1057
1071
  printer_default.error(msg);
@@ -1066,11 +1080,11 @@ async function startTunnel(client, config, opts) {
1066
1080
  await printAlreadyRunning(client, config.configId, opts.label);
1067
1081
  return null;
1068
1082
  }
1069
- return fail(result.message);
1083
+ return fail2(result.message);
1070
1084
  }
1071
1085
  const lastError = result.status?.lastError;
1072
1086
  if (lastError?.isFatal) {
1073
- return fail(lastError.message);
1087
+ return fail2(lastError.message);
1074
1088
  }
1075
1089
  return result;
1076
1090
  }
@@ -1126,7 +1140,7 @@ async function connectTui(opts) {
1126
1140
  const exitMessage = opts.exitMessage || "Stopping tunnel...";
1127
1141
  if (!noTui && process.stdin.isTTY) {
1128
1142
  try {
1129
- const { TunnelTui } = await import("./TunnelTui-GBRJDOQD.js");
1143
+ const { TunnelTui } = await import("./TunnelTui-UCG2YSLX.js");
1130
1144
  const tui = new TunnelTui({
1131
1145
  urls,
1132
1146
  greet,
@@ -1349,7 +1363,7 @@ async function startBackgroundTunnels(configs, values, positionals) {
1349
1363
  client.close();
1350
1364
  }
1351
1365
  async function startAutoStartTunnels() {
1352
- const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-XRWOTTA4.js");
1366
+ const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-RR5ZU4E3.js");
1353
1367
  const configs = getAutoStartConfigs2();
1354
1368
  if (configs.length === 0) {
1355
1369
  printer_default.warn("No configs marked for auto-start. Use: pinggy config auto <name>");
@@ -1757,218 +1771,3062 @@ function printStartHelp() {
1757
1771
  console.log(" pinggy start tunnela tunnelb Start multiple tunnels");
1758
1772
  console.log(" pinggy start --all Start all auto-start tunnels\n");
1759
1773
  }
1774
+ function printDeviceHelp() {
1775
+ console.log("\nUsage: pinggy device <command> [options]\n");
1776
+ console.log("Run this machine as a Pinggy device.\n");
1777
+ console.log("Commands:");
1778
+ console.log(" login Sign this machine in with your email, then stay connected");
1779
+ console.log(" connect --token <TOKEN> Enrol, then stay connected until interrupted");
1780
+ console.log(" stop Stop the agent running in the background");
1781
+ console.log(" status Show the enrolment, and the agent running in the background");
1782
+ console.log(" remove Forget the local credential (does not delete the device)\n");
1783
+ console.log("Options:");
1784
+ console.log(" -b login and connect: run the agent in the background, in the daemon.");
1785
+ console.log(" It stays connected after this terminal closes");
1786
+ console.log(" --token <TOKEN> Device token from the dashboard");
1787
+ console.log(" --manage <HOST> Dashboard address. Default: dashboard.pinggy.io");
1788
+ console.log(" --name <NAME> login only: the device's name. Default: this machine's hostname\n");
1789
+ console.log("Environment:");
1790
+ console.log(" DEVICE_AGENT_TOKEN connect only: the device token, when --token is not given. With no");
1791
+ console.log(" --manage, it dials dashboard.pinggy.io. Removed once read\n");
1792
+ }
1760
1793
 
1761
- // src/cli/subcommand/handlers/daemonCommandsHandler.ts
1762
- async function handleDaemon(args) {
1763
- if (args.length === 0) {
1764
- printDaemonHelp();
1765
- return;
1794
+ // src/devices/deviceIdentity.ts
1795
+ import fs3 from "fs";
1796
+ var SECRET_FILE_MODE = 384;
1797
+ function readDeviceIdentity() {
1798
+ const filePath = getDeviceConfigPath();
1799
+ if (!fs3.existsSync(filePath)) {
1800
+ return null;
1766
1801
  }
1767
- const verb = args[0];
1768
- switch (verb) {
1769
- case "start":
1770
- await handleDaemonStart();
1771
- return;
1772
- case "stop":
1773
- await handleDaemonStop();
1774
- return;
1775
- case "status":
1776
- handleDaemonStatus();
1777
- return;
1778
- case "service-install":
1779
- installService();
1780
- return;
1781
- case "service-uninstall":
1782
- uninstallService();
1783
- return;
1784
- default:
1785
- printer_default.error(`Unknown daemon command: "${verb}"`);
1786
- printDaemonHelp();
1787
- return;
1802
+ try {
1803
+ return JSON.parse(fs3.readFileSync(filePath, "utf-8"));
1804
+ } catch (e) {
1805
+ logger.warn("Could not read device identity file", { error: String(e) });
1806
+ return null;
1788
1807
  }
1789
1808
  }
1790
- async function handleDaemonStart() {
1791
- const info = getDaemonInfo();
1792
- if (info) {
1793
- if (!isIpcCompatible(info)) {
1794
- printer_default.error(ipcMismatchMessage(info));
1795
- process.exit(1);
1796
- }
1797
- printer_default.print(pico2.yellow(`Daemon already running (PID ${info.pid}, port ${info.port}). Use: pinggy daemon status for details.`));
1798
- return;
1799
- }
1800
- const autoConfigs = getAutoStartConfigs();
1801
- if (autoConfigs.length > 0) {
1802
- printer_default.print(pico2.cyanBright(`Starting daemon with ${autoConfigs.length} auto-start tunnel(s):`));
1803
- for (const c of autoConfigs) {
1804
- printer_default.print(` ${pico2.bold(c.name)} (${c.configId.slice(0, 8)})`);
1805
- }
1806
- } else {
1807
- printer_default.print(pico2.cyanBright("Starting daemon..."));
1809
+ function writeDeviceIdentity(identity) {
1810
+ ensurePinggyConfigDir();
1811
+ const filePath = getDeviceConfigPath();
1812
+ fs3.writeFileSync(filePath, JSON.stringify(identity, null, 2), {
1813
+ encoding: "utf-8",
1814
+ mode: SECRET_FILE_MODE
1815
+ });
1816
+ fs3.chmodSync(filePath, SECRET_FILE_MODE);
1817
+ }
1818
+ function clearDeviceIdentity() {
1819
+ const filePath = getDeviceConfigPath();
1820
+ if (!fs3.existsSync(filePath)) {
1821
+ return false;
1808
1822
  }
1823
+ fs3.unlinkSync(filePath);
1824
+ return true;
1825
+ }
1826
+ function maskToken(token) {
1827
+ return token.length <= 8 ? "***" : `${token.slice(0, 8)}...${token.slice(-4)}`;
1828
+ }
1829
+
1830
+ // src/devices/deviceAgent.ts
1831
+ import WebSocket from "ws";
1832
+ import os8 from "os";
1833
+
1834
+ // src/devices/envelope.ts
1835
+ import { randomUUID } from "crypto";
1836
+ var PROTOCOL_VERSION = 1;
1837
+ var CHANNEL_SYSTEM = "system";
1838
+ var CHANNEL_DEVICE = "device";
1839
+ var OP_HELLO = "hello";
1840
+ var OP_WELCOME = "welcome";
1841
+ var OP_HEARTBEAT = "heartbeat";
1842
+ var OP_DISCONNECT = "disconnect";
1843
+ var OP_INFO = "info";
1844
+ var OP_METRICS = "metrics";
1845
+ var OP_TUNNELS = "tunnels";
1846
+ var CHANNEL_TUNNEL = "tunnel";
1847
+ var OP_START = "start";
1848
+ var OP_STOP = "stop";
1849
+ var OP_RESTART = "restart";
1850
+ var OP_UPDATE = "update";
1851
+ function nowSeconds() {
1852
+ return Math.floor(Date.now() / 1e3);
1853
+ }
1854
+ function request(ch, op, payload) {
1855
+ return { v: PROTOCOL_VERSION, kind: "req", ch, op, id: randomUUID(), seq: 0, ts: nowSeconds(), payload };
1856
+ }
1857
+ function response(to, op, payload) {
1858
+ return { v: PROTOCOL_VERSION, kind: "res", ch: to.ch, op, id: to.id, seq: 0, ts: nowSeconds(), payload };
1859
+ }
1860
+ function event(ch, op, payload) {
1861
+ return { v: PROTOCOL_VERSION, kind: "event", ch, op, id: "", seq: 0, ts: nowSeconds(), payload };
1862
+ }
1863
+ function parseEnvelope(raw) {
1809
1864
  try {
1810
- const info2 = await startDaemon();
1811
- printer_default.success(`Daemon started PID ${info2.pid}.`);
1812
- } catch (err) {
1813
- printer_default.error(`Failed to start daemon: ${errorMessage(err)}`);
1814
- process.exit(1);
1865
+ const parsed = JSON.parse(raw);
1866
+ if (typeof parsed !== "object" || parsed === null) return null;
1867
+ if (typeof parsed.ch !== "string" || typeof parsed.op !== "string") return null;
1868
+ return parsed;
1869
+ } catch {
1870
+ return null;
1815
1871
  }
1816
1872
  }
1817
- async function handleDaemonStop() {
1818
- if (!isDaemonRunning()) {
1819
- printer_default.print(pico2.yellow("No daemon is running."));
1820
- return;
1873
+
1874
+ // src/devices/device_schema.ts
1875
+ import { z } from "zod";
1876
+ var WelcomeSchema = z.object({
1877
+ device_agent_id: z.string(),
1878
+ accepted_proto: z.number(),
1879
+ heartbeat_interval_seconds: z.number(),
1880
+ stats_interval_seconds: z.number(),
1881
+ server_time: z.number(),
1882
+ max_frame_bytes: z.number(),
1883
+ // Optional: a dashboard that predates terminals sends none of these.
1884
+ terminal_enabled: z.boolean().optional(),
1885
+ max_terminals_per_device: z.number().optional(),
1886
+ // No default on the agent. Without it, terminals stay unavailable rather than unbraked.
1887
+ terminal_window_bytes: z.number().optional(),
1888
+ // No default either. Without them the agent runs no expiry timers, and the dashboard's sweep
1889
+ // still ends an expired shell, only later.
1890
+ terminal_idle_timeout_seconds: z.number().optional(),
1891
+ terminal_max_session_seconds: z.number().optional(),
1892
+ // Optional: a dashboard older than these sends neither, and the agent prints only the device id.
1893
+ email: z.string().optional(),
1894
+ device_url: z.string().optional()
1895
+ });
1896
+ var ErrorPayloadSchema = z.object({
1897
+ error: z.object({
1898
+ code: z.string(),
1899
+ message: z.string()
1900
+ })
1901
+ });
1902
+ var DisconnectSchema = z.object({
1903
+ reason: z.string()
1904
+ });
1905
+ var TunnelActionSchema = z.object({
1906
+ tunnel_id: z.string().min(1).max(128).optional(),
1907
+ source: z.enum(["device", "dashboard"]).optional(),
1908
+ config_id: z.string().min(1).max(128).optional(),
1909
+ config: z.record(z.string(), z.unknown()).optional()
1910
+ });
1911
+
1912
+ // src/devices/deviceAgentReporter.ts
1913
+ function printConnected(welcome) {
1914
+ printer_default.success(`Connected as device ${welcome.device_agent_id}`);
1915
+ if (welcome.email) {
1916
+ printer_default.print(`Signed in as ${welcome.email}`);
1821
1917
  }
1822
- const result = await stopDaemon();
1823
- if (result.ok) {
1824
- printer_default.success("Daemon stopped.");
1825
- } else if (result.reason === DaemonHost.APP) {
1826
- printer_default.print(pico2.yellow("The Pinggy daemon is running inside the Pinggy desktop app. Please quit the Pinggy app to stop it."));
1827
- } else {
1828
- printer_default.error(result.error);
1918
+ if (welcome.device_url) {
1919
+ printer_default.print("Open the device in a browser:");
1920
+ printer_default.print(` ${welcome.device_url}`);
1829
1921
  }
1830
1922
  }
1831
- function handleDaemonStatus() {
1832
- const info = getDaemonInfo();
1833
- if (!info) {
1834
- printer_default.print(pico2.yellow("No daemon is running. Start with: pinggy daemon start"));
1835
- return;
1836
- }
1837
- const uptimeSec = Math.floor((Date.now() - new Date(info.startedAt).getTime()) / 1e3);
1838
- const h = Math.floor(uptimeSec / 3600);
1839
- const m = Math.floor(uptimeSec % 3600 / 60);
1840
- const s = uptimeSec % 60;
1841
- const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`;
1842
- printer_default.print(pico2.cyanBright("Daemon Status"));
1843
- printer_default.print(` PID: ${info.pid}`);
1844
- printer_default.print(` Port: ${info.port}`);
1845
- printer_default.print(` Started: ${info.startedAt}`);
1846
- printer_default.print(` Uptime: ${uptimeStr}`);
1847
- printer_default.print(` Host: ${info.host === DaemonHost.APP ? "Pinggy app (in-process)" : "standalone (CLI)"}`);
1848
- printer_default.print(` IPC: v${daemonIpcVersion(info)}`);
1849
- if (!isIpcCompatible(info)) {
1850
- printer_default.print(pico2.yellow(` Warning: ${ipcMismatchMessage(info)}`));
1851
- }
1923
+ function formatSeconds(millis) {
1924
+ return (millis / 1e3).toFixed(1);
1852
1925
  }
1926
+ var printingReporter = {
1927
+ connecting: (server) => printer_default.print(`Connecting to ${server}`),
1928
+ connected: printConnected,
1929
+ reconnecting: (delayMillis, attempt) => printer_default.warn(
1930
+ `Disconnected. Reconnecting in ${formatSeconds(delayMillis)} seconds (attempt ${attempt}).`
1931
+ ),
1932
+ warn: (message) => printer_default.warn(message),
1933
+ failed: (error) => printer_default.error(error.message),
1934
+ replacingDevice: (oldDeviceAgentId) => printer_default.print(`This machine was device ${oldDeviceAgentId}. Connecting it with the token from the command.`)
1935
+ };
1853
1936
 
1854
- // src/cli/subcommand/handlers/psCommand.ts
1855
- import pico3 from "picocolors";
1856
- async function handlePs() {
1857
- const client = new TunnelClient();
1937
+ // src/devices/collectors/systemInfo.ts
1938
+ import os3 from "os";
1939
+ var UNKNOWN_CPU_MODEL = "unknown";
1940
+ function collectSystemInfo() {
1941
+ const cpus = os3.cpus();
1942
+ return {
1943
+ hostname: os3.hostname(),
1944
+ os: os3.platform(),
1945
+ os_version: os3.release(),
1946
+ // os.machine() reports what uname does (x86_64, arm64, aarch64). os.arch() reports the Node
1947
+ // build target (x64), which is not what an operator reads on the machine itself.
1948
+ arch: os3.machine(),
1949
+ kernel: os3.type(),
1950
+ // os.cpus() is empty on some Android and container builds.
1951
+ cpu_model: cpus[0]?.model?.trim() || UNKNOWN_CPU_MODEL,
1952
+ cpu_cores: cpus.length,
1953
+ total_memory_bytes: os3.totalmem()
1954
+ };
1955
+ }
1956
+
1957
+ // src/devices/collectors/metrics.ts
1958
+ import os4 from "os";
1959
+ var CPU_SAMPLE_WINDOW_MS = 200;
1960
+ function sumCpuTicks(cpus) {
1961
+ let idleTicks = 0;
1962
+ let totalTicks = 0;
1963
+ for (const cpu of cpus) {
1964
+ const { user, nice, sys, idle, irq } = cpu.times;
1965
+ idleTicks += idle;
1966
+ totalTicks += user + nice + sys + idle + irq;
1967
+ }
1968
+ return { idleTicks, totalTicks };
1969
+ }
1970
+ function cpuPercentBetween(earlier, later) {
1971
+ const totalDelta = later.totalTicks - earlier.totalTicks;
1972
+ if (totalDelta <= 0) {
1973
+ return 0;
1974
+ }
1975
+ const busyDelta = totalDelta - (later.idleTicks - earlier.idleTicks);
1976
+ const percent = Math.min(100, Math.max(0, 100 * busyDelta / totalDelta));
1977
+ return Math.round(percent * 10) / 10;
1978
+ }
1979
+ function buildMetrics(readings) {
1980
+ const [oneMinute = 0, fiveMinutes = 0, fifteenMinutes = 0] = readings.loadAverages;
1981
+ return {
1982
+ cpu_percent: readings.cpuPercent,
1983
+ load_avg_1m: oneMinute,
1984
+ load_avg_5m: fiveMinutes,
1985
+ load_avg_15m: fifteenMinutes,
1986
+ memory_used_bytes: readings.totalMemoryBytes - readings.freeMemoryBytes,
1987
+ memory_total_bytes: readings.totalMemoryBytes,
1988
+ uptime_seconds: Math.floor(readings.uptimeSeconds),
1989
+ collected_at: readings.collectedAtEpochSeconds
1990
+ };
1991
+ }
1992
+ async function collectMetrics() {
1993
+ const earlier = sumCpuTicks(os4.cpus());
1994
+ await new Promise((resolve) => setTimeout(resolve, CPU_SAMPLE_WINDOW_MS));
1995
+ const later = sumCpuTicks(os4.cpus());
1996
+ return buildMetrics({
1997
+ cpuPercent: cpuPercentBetween(earlier, later),
1998
+ loadAverages: os4.loadavg(),
1999
+ totalMemoryBytes: os4.totalmem(),
2000
+ freeMemoryBytes: os4.freemem(),
2001
+ uptimeSeconds: os4.uptime(),
2002
+ collectedAtEpochSeconds: Math.floor(Date.now() / 1e3)
2003
+ });
2004
+ }
2005
+
2006
+ // src/devices/tunnels/tunnelList.ts
2007
+ var TUNNEL_POLL_INTERVAL_MILLIS = 5e3;
2008
+ var MAX_TUNNELS = 50;
2009
+ var MAX_SAVED_CONFIGS = 100;
2010
+ var ERROR_MESSAGE_MAX_LENGTH = 256;
2011
+ var REASON_IPC_VERSION_MISMATCH = "ipc_version_mismatch";
2012
+ var REASON_DAEMON_UNREACHABLE = "daemon_unreachable";
2013
+ var FORWARDING_TYPES = /* @__PURE__ */ new Set(["http", "tcp", "udp", "tls", "tlstcp"]);
2014
+ var DEFAULT_FORWARDING_TYPE = "http";
2015
+ var MODES = /* @__PURE__ */ new Set(["foreground", "detached"]);
2016
+ var STATES = new Set(Object.values(TunnelStateType));
2017
+ var STOPPED_STATES = /* @__PURE__ */ new Set(["closed" /* Closed */, "exited" /* Exited */]);
2018
+ async function readDaemonOnPort(port) {
1858
2019
  try {
1859
- await client.ensureDaemon();
2020
+ const answer = await new IPCClient(port, "cli").listTunnels();
2021
+ return { running: true, tunnels: Array.isArray(answer) ? answer : [] };
1860
2022
  } catch (err) {
1861
- printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
1862
- return;
2023
+ logger.debug("Device agent could not list the daemon's tunnels", { error: String(err) });
2024
+ return { running: false, reason: REASON_DAEMON_UNREACHABLE };
1863
2025
  }
1864
- try {
1865
- const tunnels = await client.handleListV2();
1866
- if (isErrorResponse(tunnels)) {
1867
- printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
1868
- return;
2026
+ }
2027
+ var machineTunnelListSource = {
2028
+ async readDaemon() {
2029
+ const info = getDaemonInfo();
2030
+ if (!info) {
2031
+ return { running: false, reason: null };
1869
2032
  }
1870
- if (tunnels.length === 0) {
1871
- printer_default.print("No tunnels running.");
1872
- return;
2033
+ if (!isIpcCompatible(info)) {
2034
+ return { running: false, reason: REASON_IPC_VERSION_MISMATCH };
1873
2035
  }
1874
- const header = `${pad("ID", 14)} ${pad("NAME", 16)} ${pad("STATUS", 10)} ${pad("LOCAL", 20)} URL`;
1875
- printer_default.print(pico3.gray(header));
1876
- printer_default.print(pico3.gray("\u2500".repeat(90)));
1877
- for (const t of tunnels) {
1878
- const id = t.tunnelid.slice(0, 12);
1879
- const name = t.tunnelconfig?.name || "-";
1880
- const status = t.status.state;
1881
- const local = getLocalAddress(t.tunnelconfig);
1882
- const url = t.remoteurls?.[0] || "-";
1883
- const statusColor = status === "running" ? pico3.green : status === "starting" ? pico3.yellow : pico3.red;
1884
- printer_default.print(
1885
- `${pico3.cyan(pad(id, 14))} ${pad(name, 16)} ${statusColor(pad(status, 10))} ${pad(local, 20)} ${pico3.magentaBright(url)}`
1886
- );
2036
+ return readDaemonOnPort(info.port);
2037
+ },
2038
+ readSavedConfigs: listSavedConfigs
2039
+ };
2040
+ function selfTunnelListSource(port) {
2041
+ return {
2042
+ readDaemon: () => readDaemonOnPort(port),
2043
+ readSavedConfigs: listSavedConfigs
2044
+ };
2045
+ }
2046
+ function isObject(value) {
2047
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2048
+ }
2049
+ function stringOrNull(value) {
2050
+ return typeof value === "string" && value !== "" ? value : null;
2051
+ }
2052
+ function toEpochSeconds(value) {
2053
+ if (typeof value !== "string" || value === "") return null;
2054
+ const millis = Date.parse(value);
2055
+ return Number.isNaN(millis) ? null : Math.floor(millis / 1e3);
2056
+ }
2057
+ function sanitizeErrorMessage(value) {
2058
+ if (typeof value !== "string") return null;
2059
+ const printable = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
2060
+ return printable === "" ? null : printable.slice(0, ERROR_MESSAGE_MAX_LENGTH);
2061
+ }
2062
+ function forwardingType(value) {
2063
+ return typeof value === "string" && FORWARDING_TYPES.has(value) ? value : DEFAULT_FORWARDING_TYPE;
2064
+ }
2065
+ function toForwarding(forwarding) {
2066
+ if (typeof forwarding === "string") {
2067
+ if (forwarding.trim() === "") return [];
2068
+ const schemeMatch = /^([a-z]+):\/\/(.+)$/i.exec(forwarding.trim());
2069
+ if (!schemeMatch) {
2070
+ return [{ type: DEFAULT_FORWARDING_TYPE, local_address: forwarding.trim() }];
1887
2071
  }
1888
- } catch (err) {
1889
- printer_default.error(`Failed to list tunnels: ${errorMessage(err)}`);
1890
- } finally {
1891
- client.close();
1892
- }
2072
+ const scheme = schemeMatch[1].toLowerCase();
2073
+ return [{
2074
+ type: scheme === "https" ? DEFAULT_FORWARDING_TYPE : forwardingType(scheme),
2075
+ local_address: schemeMatch[2]
2076
+ }];
2077
+ }
2078
+ if (!Array.isArray(forwarding)) return [];
2079
+ return forwarding.filter(isObject).filter((entry) => typeof entry.address === "string").map((entry) => ({ type: forwardingType(entry.type), local_address: entry.address }));
1893
2080
  }
1894
- function pad(str, len) {
1895
- return str.length >= len ? str.slice(0, len) : str + " ".repeat(len - str.length);
2081
+ function toFullConfig(rawConfig) {
2082
+ const parsed = TunnelConfigV1Schema.safeParse(rawConfig);
2083
+ return parsed.success ? parsed.data : null;
1896
2084
  }
1897
-
1898
- // src/cli/subcommand/handlers/stopCommand.ts
1899
- async function handleStop(args) {
1900
- if (args.length === 0) {
1901
- printer_default.error("Usage: pinggy stop <name|id> [<name|id> ...]");
1902
- return;
1903
- }
1904
- const client = new TunnelClient();
2085
+ function toTunnelRow(daemonTunnel) {
2086
+ if (!isObject(daemonTunnel) || typeof daemonTunnel.tunnelid !== "string") return null;
2087
+ const config = isObject(daemonTunnel.tunnelconfig) ? daemonTunnel.tunnelconfig : {};
2088
+ const status = isObject(daemonTunnel.status) ? daemonTunnel.status : {};
2089
+ const remoteUrls = Array.isArray(daemonTunnel.remoteurls) ? daemonTunnel.remoteurls.filter((url) => typeof url === "string") : [];
2090
+ return {
2091
+ tunnel_id: daemonTunnel.tunnelid,
2092
+ config_id: stringOrNull(config.configId),
2093
+ name: stringOrNull(config.name),
2094
+ state: typeof status.state === "string" && STATES.has(status.state) ? status.state : "idle" /* New */,
2095
+ error_message: sanitizeErrorMessage(status.errormsg),
2096
+ remote_urls: remoteUrls,
2097
+ forwarding: toForwarding(config.forwarding),
2098
+ mode: typeof daemonTunnel.mode === "string" && MODES.has(daemonTunnel.mode) ? daemonTunnel.mode : null,
2099
+ created_at: toEpochSeconds(status.createdtimestamp),
2100
+ started_at: toEpochSeconds(status.starttimestamp),
2101
+ tunnel_config: toFullConfig(daemonTunnel.tunnelconfig)
2102
+ };
2103
+ }
2104
+ function newestFirst(rows, timeOf) {
2105
+ return [...rows].sort((a, b) => (timeOf(b) ?? 0) - (timeOf(a) ?? 0));
2106
+ }
2107
+ function buildTunnelList(reading, savedConfigs, collectedAtEpochSeconds) {
2108
+ const allTunnels = reading.running ? newestFirst(
2109
+ reading.tunnels.map(toTunnelRow).filter((row) => row !== null),
2110
+ (row) => row.created_at
2111
+ ) : [];
2112
+ const runningConfigIds = new Set(allTunnels.filter((row) => row.config_id !== null && !STOPPED_STATES.has(row.state)).map((row) => row.config_id));
2113
+ const allSaved = newestFirst(savedConfigs, (config) => toEpochSeconds(config.createdAt)).map((config) => ({
2114
+ config_id: config.configId,
2115
+ name: config.name,
2116
+ running: runningConfigIds.has(config.configId),
2117
+ // The same shape tunnelActions.ts sends the daemon to start this exact config.
2118
+ tunnel_config: { ...config.tunnelConfig, configId: config.configId, name: config.name }
2119
+ }));
2120
+ return {
2121
+ daemon_running: reading.running,
2122
+ daemon_unavailable_reason: reading.running ? null : reading.reason,
2123
+ tunnels: allTunnels.slice(0, MAX_TUNNELS),
2124
+ saved_configs: allSaved.slice(0, MAX_SAVED_CONFIGS),
2125
+ truncated: allTunnels.length > MAX_TUNNELS || allSaved.length > MAX_SAVED_CONFIGS,
2126
+ collected_at: collectedAtEpochSeconds
2127
+ };
2128
+ }
2129
+ async function collectTunnelList(source = machineTunnelListSource) {
2130
+ const reading = await source.readDaemon();
2131
+ let savedConfigs = [];
1905
2132
  try {
1906
- await client.ensureDaemon();
2133
+ savedConfigs = source.readSavedConfigs();
1907
2134
  } catch (err) {
1908
- printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
1909
- return;
2135
+ logger.debug("Device agent could not read saved tunnel configs", { error: String(err) });
1910
2136
  }
1911
- try {
1912
- const tunnels = await client.handleListV2();
1913
- if (isErrorResponse(tunnels)) {
1914
- printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
1915
- return;
1916
- }
1917
- const targets = resolveTargets(tunnels, args);
1918
- if (targets.matched.length === 0) {
1919
- printer_default.error(`No tunnel found matching: ${args.join(", ")}. Use: pinggy ps`);
2137
+ return buildTunnelList(reading, savedConfigs, Math.floor(Date.now() / 1e3));
2138
+ }
2139
+ function changeKey(list) {
2140
+ const { collected_at: _collectedAt, ...comparable } = list;
2141
+ return JSON.stringify(comparable);
2142
+ }
2143
+ function startTunnelReporting(collect, send, intervalMillis = TUNNEL_POLL_INTERVAL_MILLIS) {
2144
+ let stopped = false;
2145
+ let inFlight = false;
2146
+ let pollAgain = false;
2147
+ let lastSentKey = null;
2148
+ const poll = () => {
2149
+ if (stopped) return;
2150
+ if (inFlight) {
2151
+ pollAgain = true;
1920
2152
  return;
1921
2153
  }
1922
- for (const { input, tunnel } of targets.matched) {
1923
- const result = await client.handleStop(tunnel.tunnelid);
1924
- if (isErrorResponse(result)) {
1925
- printer_default.error(`Failed to stop "${input}": ${result.message}`);
1926
- continue;
2154
+ inFlight = true;
2155
+ collect().then((list) => {
2156
+ if (stopped) return;
2157
+ const key = changeKey(list);
2158
+ if (key === lastSentKey) return;
2159
+ lastSentKey = key;
2160
+ send(list);
2161
+ }).catch((err) => logger.warn("Device tunnel list collection failed", { error: String(err) })).finally(() => {
2162
+ inFlight = false;
2163
+ if (pollAgain) {
2164
+ pollAgain = false;
2165
+ poll();
1927
2166
  }
1928
- const name = tunnel.tunnelconfig.name || tunnel.tunnelid.slice(0, 12);
1929
- printer_default.success(`Tunnel "${name}" stopped.`);
2167
+ });
2168
+ };
2169
+ poll();
2170
+ const timer = setInterval(poll, intervalMillis);
2171
+ const stop = () => {
2172
+ stopped = true;
2173
+ clearInterval(timer);
2174
+ };
2175
+ return Object.assign(stop, { pollNow: poll });
2176
+ }
2177
+
2178
+ // src/devices/tunnels/tunnelActions.ts
2179
+ var ERROR_INVALID_PAYLOAD = "invalid_payload";
2180
+ var ERROR_UNSUPPORTED_OP = "unsupported_op";
2181
+ var ERROR_TUNNEL_NOT_FOUND = "tunnel_not_found";
2182
+ var ERROR_SAVED_CONFIG_NOT_FOUND = "saved_config_not_found";
2183
+ var ERROR_SAVED_CONFIG_NAME_TAKEN = "saved_config_name_taken";
2184
+ var ERROR_TUNNEL_ACTION_FAILED = "tunnel_action_failed";
2185
+ var STATE_STARTING = "starting";
2186
+ var DaemonUnusableError = class extends Error {
2187
+ };
2188
+ var machineTunnelActionDependencies = {
2189
+ async daemon(startIfMissing) {
2190
+ const info = getDaemonInfo();
2191
+ if (info) {
2192
+ if (!isIpcCompatible(info)) {
2193
+ throw new DaemonUnusableError(ipcMismatchMessage(info));
2194
+ }
2195
+ return new IPCClient(info.port, "device");
1930
2196
  }
1931
- for (const m of targets.missing) {
1932
- printer_default.warn(`No tunnel found matching "${m}".`);
2197
+ if (!startIfMissing) {
2198
+ return null;
1933
2199
  }
1934
- } catch (err) {
1935
- printer_default.error(`Failed to stop tunnel: ${errorMessage(err)}`);
1936
- } finally {
1937
- client.close();
2200
+ const started = await ensureDaemonRunning();
2201
+ return new IPCClient(started.port, "device");
2202
+ },
2203
+ readSavedConfigs: listSavedConfigs,
2204
+ writeSavedConfig: (saved) => {
2205
+ upsertConfig(saved);
1938
2206
  }
2207
+ };
2208
+ function selfTunnelActionDependencies(port) {
2209
+ return {
2210
+ daemon: () => Promise.resolve(new IPCClient(port, "device")),
2211
+ readSavedConfigs: machineTunnelActionDependencies.readSavedConfigs,
2212
+ writeSavedConfig: machineTunnelActionDependencies.writeSavedConfig
2213
+ };
1939
2214
  }
1940
- function resolveTargets(tunnels, args) {
1941
- if (args.length > 1) {
1942
- const input = args.join(" ");
1943
- const tunnel = findTunnel(tunnels, input);
1944
- if (tunnel) return { matched: [{ input, tunnel }], missing: [] };
2215
+ function failure(code, message) {
2216
+ return { error: { code, message } };
2217
+ }
2218
+ function actionFailed(message) {
2219
+ return failure(ERROR_TUNNEL_ACTION_FAILED, sanitizeErrorMessage(message) ?? "The tunnel action failed.");
2220
+ }
2221
+ var NO_DAEMON = failure(ERROR_TUNNEL_NOT_FOUND, "No tunnel daemon is running on this machine.");
2222
+ function toResult(daemonAnswer, fallbackTunnelId) {
2223
+ if (isErrorResponse(daemonAnswer)) {
2224
+ if (daemonAnswer.code === ErrorCode.TunnelNotFound) {
2225
+ return failure(ERROR_TUNNEL_NOT_FOUND, sanitizeErrorMessage(daemonAnswer.message) ?? "No such tunnel.");
2226
+ }
2227
+ return actionFailed(daemonAnswer.message);
1945
2228
  }
1946
- const matched = [];
1947
- const missing = [];
1948
- for (const input of args) {
1949
- const tunnel = findTunnel(tunnels, input);
1950
- if (tunnel) matched.push({ input, tunnel });
1951
- else missing.push(input);
2229
+ const answer = typeof daemonAnswer === "object" && daemonAnswer !== null ? daemonAnswer : {};
2230
+ const tunnelId = typeof answer.tunnelid === "string" && answer.tunnelid !== "" ? answer.tunnelid : fallbackTunnelId;
2231
+ if (!tunnelId) {
2232
+ return actionFailed("The daemon did not name the tunnel.");
1952
2233
  }
1953
- return { matched, missing };
2234
+ const state = typeof answer.status?.state === "string" ? answer.status.state : STATE_STARTING;
2235
+ return { tunnel_id: tunnelId, state };
1954
2236
  }
1955
-
1956
- // src/cli/subcommand/handlers/attachCommand.ts
1957
- import pico4 from "picocolors";
1958
- async function handleAttach(args) {
1959
- if (args.length === 0) {
1960
- printer_default.error("Usage: pinggy attach <name|id>");
1961
- return;
2237
+ function withoutNulls(config) {
2238
+ return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== null));
2239
+ }
2240
+ function targetOf(op, action) {
2241
+ const namesTunnel = action.tunnel_id !== void 0;
2242
+ if (op === OP_UPDATE) {
2243
+ if (namesTunnel || action.config === void 0) {
2244
+ return null;
2245
+ }
2246
+ if (action.source === "dashboard" && action.config_id === void 0) {
2247
+ return { kind: "dashboard", config: action.config };
2248
+ }
2249
+ if (action.source === "device" && action.config_id !== void 0) {
2250
+ return { kind: "savedEdit", configId: action.config_id, config: action.config };
2251
+ }
2252
+ return null;
1962
2253
  }
1963
- const nameOrId = args[0];
1964
- const client = new TunnelClient();
1965
- try {
1966
- await client.ensureDaemon();
1967
- } catch (err) {
1968
- printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
1969
- return;
2254
+ if (op !== OP_START) {
2255
+ return namesTunnel ? { kind: "tunnel", tunnelId: action.tunnel_id } : null;
1970
2256
  }
1971
- try {
2257
+ if (namesTunnel) {
2258
+ return action.source === void 0 && action.config_id === void 0 && action.config === void 0 ? { kind: "tunnel", tunnelId: action.tunnel_id } : null;
2259
+ }
2260
+ if (action.source === "device" && action.config_id !== void 0 && action.config === void 0) {
2261
+ return { kind: "saved", configId: action.config_id };
2262
+ }
2263
+ if (action.source === "dashboard" && action.config !== void 0 && action.config_id === void 0) {
2264
+ return { kind: "dashboard", config: action.config };
2265
+ }
2266
+ return null;
2267
+ }
2268
+ async function perform(op, action, dependencies) {
2269
+ const target = targetOf(op, action);
2270
+ if (!target) {
2271
+ return failure(ERROR_INVALID_PAYLOAD, "The tunnel request names no tunnel or config.");
2272
+ }
2273
+ if (target.kind === "tunnel") {
2274
+ const client2 = await dependencies.daemon(false);
2275
+ if (!client2) {
2276
+ return NO_DAEMON;
2277
+ }
2278
+ const answer = op === OP_STOP ? await client2.stopTunnel(target.tunnelId) : await client2.restartTunnel(target.tunnelId, true);
2279
+ return toResult(answer, target.tunnelId);
2280
+ }
2281
+ if (target.kind === "savedEdit") {
2282
+ return rewriteSavedConfig(target.configId, target.config, dependencies);
2283
+ }
2284
+ let config;
2285
+ if (target.kind === "saved") {
2286
+ const saved = dependencies.readSavedConfigs().find((candidate) => candidate.configId === target.configId);
2287
+ if (!saved) {
2288
+ return failure(ERROR_SAVED_CONFIG_NOT_FOUND, "No config with that id is saved on this machine.");
2289
+ }
2290
+ config = { ...saved.tunnelConfig, configId: saved.configId, name: saved.name };
2291
+ } else {
2292
+ const parsed = TunnelConfigV1Schema.safeParse(withoutNulls(target.config));
2293
+ if (!parsed.success) {
2294
+ return failure(ERROR_INVALID_PAYLOAD, "The dashboard config could not be read.");
2295
+ }
2296
+ config = parsed.data;
2297
+ }
2298
+ if (op === OP_UPDATE) {
2299
+ return updateTunnel(config, dependencies);
2300
+ }
2301
+ const client = await dependencies.daemon(true);
2302
+ if (!client) {
2303
+ return actionFailed("The tunnel daemon could not be started.");
2304
+ }
2305
+ return toResult(await client.startTunnelWithConfig(config, SessionMode.Detached, true), null);
2306
+ }
2307
+ function rewriteSavedConfig(configId, rawConfig, dependencies) {
2308
+ const parsed = TunnelConfigV1Schema.safeParse(withoutNulls(rawConfig));
2309
+ if (!parsed.success) {
2310
+ return failure(ERROR_INVALID_PAYLOAD, "The config could not be read.");
2311
+ }
2312
+ const savedConfigs = dependencies.readSavedConfigs();
2313
+ const saved = savedConfigs.find((candidate) => candidate.configId === configId);
2314
+ if (!saved) {
2315
+ return failure(ERROR_SAVED_CONFIG_NOT_FOUND, "No config with that id is saved on this machine.");
2316
+ }
2317
+ const name = parsed.data.name || saved.name;
2318
+ if (name !== saved.name) {
2319
+ const nameError = validateNameStrict(name);
2320
+ if (nameError) {
2321
+ return failure(ERROR_INVALID_PAYLOAD, nameError.message);
2322
+ }
2323
+ if (savedConfigs.some((candidate) => candidate.name === name)) {
2324
+ return failure(ERROR_SAVED_CONFIG_NAME_TAKEN, `A config named "${name}" is already saved on this machine.`);
2325
+ }
2326
+ }
2327
+ dependencies.writeSavedConfig({
2328
+ ...saved,
2329
+ name,
2330
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2331
+ tunnelConfig: { ...parsed.data, configId, name }
2332
+ });
2333
+ return { config_id: configId };
2334
+ }
2335
+ async function updateTunnel(config, dependencies) {
2336
+ const client = await dependencies.daemon(false);
2337
+ if (!client) {
2338
+ return NO_DAEMON;
2339
+ }
2340
+ const listed = await client.listTunnels();
2341
+ if (!Array.isArray(listed)) {
2342
+ return actionFailed(isErrorResponse(listed) ? listed.message : "The daemon did not list its tunnels.");
2343
+ }
2344
+ const running = listed.find((candidate) => candidate.tunnelconfig?.configId === config.configId);
2345
+ if (!running) {
2346
+ return failure(ERROR_TUNNEL_NOT_FOUND, "No tunnel on this machine runs that config.");
2347
+ }
2348
+ return toResult(await client.updateConfigV2(config, true), running.tunnelid);
2349
+ }
2350
+ async function handleTunnelAction(envelope, send, dependencies = machineTunnelActionDependencies) {
2351
+ if (envelope.kind !== "req") {
2352
+ return;
2353
+ }
2354
+ const op = envelope.op;
2355
+ if (op !== OP_START && op !== OP_STOP && op !== OP_RESTART && op !== OP_UPDATE) {
2356
+ send(response(envelope, op, failure(ERROR_UNSUPPORTED_OP, `Unsupported operation: tunnel/${op}`)));
2357
+ return;
2358
+ }
2359
+ const parsed = TunnelActionSchema.safeParse(envelope.payload);
2360
+ if (!parsed.success) {
2361
+ send(response(envelope, op, failure(ERROR_INVALID_PAYLOAD, "The tunnel request could not be read.")));
2362
+ return;
2363
+ }
2364
+ let result;
2365
+ try {
2366
+ result = await perform(op, parsed.data, dependencies);
2367
+ } catch (err) {
2368
+ result = actionFailed(err instanceof Error ? err.message : String(err));
2369
+ }
2370
+ logger.info("Device agent tunnel action", {
2371
+ op,
2372
+ tunnelId: parsed.data.tunnel_id ?? null,
2373
+ configId: parsed.data.config_id ?? null,
2374
+ source: parsed.data.source ?? null,
2375
+ outcome: "error" in result ? result.error.code : "accepted"
2376
+ });
2377
+ send(response(envelope, op, result));
2378
+ }
2379
+
2380
+ // src/devices/reconnect.ts
2381
+ var INITIAL_DELAY_MILLIS = 1e3;
2382
+ var MAX_DELAY_MILLIS = 6e4;
2383
+ var STABLE_CONNECTION_MILLIS = 6e4;
2384
+ var MAX_ATTEMPT = Math.ceil(Math.log2(MAX_DELAY_MILLIS / INITIAL_DELAY_MILLIS));
2385
+ function backoffCeilingMillis(attempt) {
2386
+ const uncapped = INITIAL_DELAY_MILLIS * 2 ** Math.max(0, attempt);
2387
+ return Math.min(uncapped, MAX_DELAY_MILLIS);
2388
+ }
2389
+ function fullJitterMillis(ceilingMillis, random) {
2390
+ return Math.floor(random() * ceilingMillis);
2391
+ }
2392
+ var ReconnectPolicy = class {
2393
+ constructor(options = {}) {
2394
+ this.attemptCount = 0;
2395
+ this.connectedAtMillis = null;
2396
+ this.now = options.now ?? Date.now;
2397
+ this.random = options.random ?? Math.random;
2398
+ }
2399
+ /** Attempts taken since the last reset. Exposed for logging and for tests. */
2400
+ get attempt() {
2401
+ return this.attemptCount;
2402
+ }
2403
+ /** The ceiling the next delay will be drawn from, before jitter. */
2404
+ get nextCeilingMillis() {
2405
+ return backoffCeilingMillis(this.attemptCount);
2406
+ }
2407
+ markConnected() {
2408
+ this.connectedAtMillis = this.now();
2409
+ }
2410
+ /**
2411
+ * Ends the current connection. Returns true when it had held long enough to reset the
2412
+ * schedule, which the caller logs.
2413
+ */
2414
+ markDisconnected() {
2415
+ const heldMillis = this.connectedAtMillis === null ? 0 : this.now() - this.connectedAtMillis;
2416
+ this.connectedAtMillis = null;
2417
+ const wasStable = heldMillis >= STABLE_CONNECTION_MILLIS;
2418
+ if (wasStable) {
2419
+ this.attemptCount = 0;
2420
+ }
2421
+ return wasStable;
2422
+ }
2423
+ /** The delay before the next dial, and advances the schedule. */
2424
+ nextDelayMillis() {
2425
+ const delay = fullJitterMillis(this.nextCeilingMillis, this.random);
2426
+ this.attemptCount = Math.min(this.attemptCount + 1, MAX_ATTEMPT);
2427
+ return delay;
2428
+ }
2429
+ };
2430
+
2431
+ // src/devices/terminal/terminal_schema.ts
2432
+ import { z as z2 } from "zod";
2433
+ var CHANNEL_TERMINAL = "terminal";
2434
+ var OP_OPEN = "open";
2435
+ var OP_OPENED = "opened";
2436
+ var OP_CLOSE = "close";
2437
+ var OP_DATA = "data";
2438
+ var OP_ACK = "ack";
2439
+ var OP_EXIT = "exit";
2440
+ var OP_RESIZE = "resize";
2441
+ var OP_SIGNAL = "signal";
2442
+ var OP_CONTEXT = "context";
2443
+ var OP_SNAPSHOT = "snapshot";
2444
+ var TERMINAL_SIGNALS = ["INT", "TERM", "QUIT", "HUP"];
2445
+ var MIN_GRID = 1;
2446
+ var MAX_GRID = 1e3;
2447
+ var CLOSE_REASON_USER_CLOSED = "user_closed";
2448
+ var CLOSE_REASON_IDLE_TIMEOUT = "idle_timeout";
2449
+ var CLOSE_REASON_MAX_SESSION = "max_session";
2450
+ var ERROR_INVALID_PAYLOAD2 = "invalid_payload";
2451
+ var ERROR_TERMINAL_LIMIT_REACHED = "terminal_limit_reached";
2452
+ var ERROR_TERMINAL_DISABLED = "terminal_disabled";
2453
+ var ERROR_SHELL_NOT_ALLOWED = "shell_not_allowed";
2454
+ var ERROR_SPAWN_FAILED = "spawn_failed";
2455
+ var TerminalOpenSchema = z2.object({
2456
+ terminal_id: z2.string().min(1),
2457
+ cols: z2.number().int().optional(),
2458
+ rows: z2.number().int().optional(),
2459
+ shell: z2.string().nullable().optional(),
2460
+ cwd: z2.string().nullable().optional()
2461
+ });
2462
+ var TerminalCloseSchema = z2.object({
2463
+ terminal_id: z2.string().min(1),
2464
+ reason: z2.string().optional()
2465
+ });
2466
+ var TerminalResizeSchema = z2.object({
2467
+ terminal_id: z2.string().min(1),
2468
+ cols: z2.number().int(),
2469
+ rows: z2.number().int()
2470
+ });
2471
+ var TerminalInputSchema = z2.object({
2472
+ terminal_id: z2.string().min(1),
2473
+ data: z2.string()
2474
+ });
2475
+ var TerminalSignalSchema = z2.object({
2476
+ terminal_id: z2.string().min(1),
2477
+ signal: z2.enum(TERMINAL_SIGNALS)
2478
+ });
2479
+ var TerminalAckSchema = z2.object({
2480
+ terminal_id: z2.string().min(1),
2481
+ ack_seq: z2.number().optional(),
2482
+ ack_bytes: z2.number()
2483
+ });
2484
+ var TerminalSnapshotRequestSchema = z2.object({
2485
+ terminal_id: z2.string().min(1),
2486
+ snapshot_id: z2.string().min(1)
2487
+ });
2488
+ function clampGrid(requested, fallback) {
2489
+ if (requested === void 0 || !Number.isFinite(requested)) return fallback;
2490
+ return Math.max(MIN_GRID, Math.min(MAX_GRID, Math.trunc(requested)));
2491
+ }
2492
+
2493
+ // src/devices/terminal/terminalHandler.ts
2494
+ import os6 from "os";
2495
+
2496
+ // src/devices/terminal/flowWindow.ts
2497
+ var FlowWindow = class {
2498
+ constructor(windowBytes) {
2499
+ this.sentBytes = 0;
2500
+ this.ackedBytes = 0;
2501
+ if (!Number.isFinite(windowBytes) || windowBytes <= 0) {
2502
+ throw new Error("A terminal flow window needs a positive size from welcome");
2503
+ }
2504
+ this.windowBytes = Math.trunc(windowBytes);
2505
+ }
2506
+ /** How many more raw bytes may go out before the browser has to catch up. */
2507
+ remaining() {
2508
+ return Math.max(0, this.windowBytes - (this.sentBytes - this.ackedBytes));
2509
+ }
2510
+ /** False means stop reading the pty. Nothing else is an acceptable response. */
2511
+ isOpen() {
2512
+ return this.remaining() > 0;
2513
+ }
2514
+ recordSent(byteCount) {
2515
+ if (!Number.isFinite(byteCount) || byteCount <= 0) return;
2516
+ this.sentBytes += Math.trunc(byteCount);
2517
+ }
2518
+ /**
2519
+ * Cumulative totals, never increments, so a dropped ack self-heals on the next one.
2520
+ *
2521
+ * A browser is not a trusted input. An ack beyond what was sent is clamped rather than believed,
2522
+ * and one that arrives out of order never moves the counter backwards.
2523
+ */
2524
+ recordAck(ackBytes) {
2525
+ if (!Number.isFinite(ackBytes)) return;
2526
+ const clamped = Math.min(Math.max(Math.trunc(ackBytes), 0), this.sentBytes);
2527
+ if (clamped > this.ackedBytes) {
2528
+ this.ackedBytes = clamped;
2529
+ }
2530
+ }
2531
+ get sent() {
2532
+ return this.sentBytes;
2533
+ }
2534
+ get acked() {
2535
+ return this.ackedBytes;
2536
+ }
2537
+ get size() {
2538
+ return this.windowBytes;
2539
+ }
2540
+ };
2541
+
2542
+ // src/devices/terminal/frameSplitter.ts
2543
+ var ENVELOPE_OVERHEAD_BYTES = 512;
2544
+ var BASE64_NUMERATOR = 3;
2545
+ var BASE64_DENOMINATOR = 4;
2546
+ var BUNDLE_MILLIS = 8;
2547
+ function rawBundleBytes(maxFrameBytes) {
2548
+ const available = maxFrameBytes - ENVELOPE_OVERHEAD_BYTES;
2549
+ if (available <= 0) {
2550
+ throw new Error("max_frame_bytes leaves no room for a terminal payload");
2551
+ }
2552
+ return Math.floor(available * BASE64_NUMERATOR / BASE64_DENOMINATOR);
2553
+ }
2554
+ var FrameSplitter = class {
2555
+ constructor(emit, bundleBytes, bundleMillis = BUNDLE_MILLIS) {
2556
+ this.emit = emit;
2557
+ this.bundleBytes = bundleBytes;
2558
+ this.bundleMillis = bundleMillis;
2559
+ this.pending = [];
2560
+ this.pendingBytes = 0;
2561
+ if (!Number.isFinite(bundleBytes) || bundleBytes <= 0) {
2562
+ throw new Error("A frame splitter needs a positive bundle size");
2563
+ }
2564
+ }
2565
+ /** A chunk over the cap is split across frames, never truncated. */
2566
+ push(chunk) {
2567
+ if (chunk.length === 0) return;
2568
+ this.pending.push(chunk);
2569
+ this.pendingBytes += chunk.length;
2570
+ while (this.pendingBytes >= this.bundleBytes) {
2571
+ this.emitBytes(this.bundleBytes);
2572
+ }
2573
+ if (this.pendingBytes > 0) {
2574
+ this.arm();
2575
+ } else {
2576
+ this.disarm();
2577
+ }
2578
+ }
2579
+ /** Sends whatever is held, if anything. Used when the terminal closes and by the timer. */
2580
+ flush() {
2581
+ this.disarm();
2582
+ if (this.pendingBytes > 0) {
2583
+ this.emitBytes(this.pendingBytes);
2584
+ }
2585
+ }
2586
+ dispose() {
2587
+ this.disarm();
2588
+ this.pending = [];
2589
+ this.pendingBytes = 0;
2590
+ }
2591
+ get buffered() {
2592
+ return this.pendingBytes;
2593
+ }
2594
+ emitBytes(byteCount) {
2595
+ const joined = Buffer.concat(this.pending, this.pendingBytes);
2596
+ const frame = joined.subarray(0, byteCount);
2597
+ const rest = joined.subarray(byteCount);
2598
+ this.pending = rest.length > 0 ? [rest] : [];
2599
+ this.pendingBytes = rest.length;
2600
+ this.emit(frame);
2601
+ }
2602
+ arm() {
2603
+ if (this.timer !== void 0) return;
2604
+ this.timer = setTimeout(() => {
2605
+ this.timer = void 0;
2606
+ this.flush();
2607
+ }, this.bundleMillis);
2608
+ this.timer.unref?.();
2609
+ }
2610
+ disarm() {
2611
+ if (this.timer === void 0) return;
2612
+ clearTimeout(this.timer);
2613
+ this.timer = void 0;
2614
+ }
2615
+ };
2616
+
2617
+ // src/devices/terminal/shellContext.ts
2618
+ import { execFile } from "child_process";
2619
+ import fs4 from "fs";
2620
+ import os5 from "os";
2621
+ var SHELL_CONTEXT_POLL_MILLIS = 5e3;
2622
+ var MAX_CWD_CHARACTERS = 1024;
2623
+ var MAX_COMMAND_CHARACTERS = 64;
2624
+ var PROCESS_READ_TIMEOUT_MILLIS = 2e3;
2625
+ var HOME_PREFIX = "~";
2626
+ function processTableReader(platform2 = process.platform) {
2627
+ if (platform2 === "linux") return readFromProc;
2628
+ if (platform2 === "darwin") return readFromPsAndLsof;
2629
+ return null;
2630
+ }
2631
+ function programName(rawName) {
2632
+ const text = cleanText(rawName, Number.MAX_SAFE_INTEGER);
2633
+ if (text === null) return null;
2634
+ const firstWord = text.split(/\s+/, 1)[0];
2635
+ const baseName = firstWord.slice(firstWord.lastIndexOf("/") + 1).replace(/:+$/, "");
2636
+ return baseName === "" ? null : truncate(baseName, MAX_COMMAND_CHARACTERS);
2637
+ }
2638
+ function displayCwd(rawCwd, resolvedHome) {
2639
+ const cwd = cleanText(rawCwd, Number.MAX_SAFE_INTEGER);
2640
+ if (cwd === null) return null;
2641
+ let shown = cwd;
2642
+ if (resolvedHome && cwd === resolvedHome) {
2643
+ shown = HOME_PREFIX;
2644
+ } else if (resolvedHome && cwd.startsWith(resolvedHome + "/")) {
2645
+ shown = HOME_PREFIX + cwd.slice(resolvedHome.length);
2646
+ }
2647
+ return truncate(shown, MAX_CWD_CHARACTERS);
2648
+ }
2649
+ function resolvedHomeDirectory() {
2650
+ try {
2651
+ return fs4.realpathSync(os5.homedir());
2652
+ } catch {
2653
+ return null;
2654
+ }
2655
+ }
2656
+ function cleanText(raw, maxCharacters) {
2657
+ if (raw === null || raw === void 0) return null;
2658
+ const text = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
2659
+ return text === "" ? null : truncate(text, maxCharacters);
2660
+ }
2661
+ function truncate(text, maxCharacters) {
2662
+ const codePoints = Array.from(text);
2663
+ return codePoints.length <= maxCharacters ? text : codePoints.slice(0, maxCharacters).join("");
2664
+ }
2665
+ var ShellContextTracker = class {
2666
+ constructor() {
2667
+ this.known = /* @__PURE__ */ new Map();
2668
+ }
2669
+ /** The event to send, or null when nothing changed since the last one. */
2670
+ observe(terminalId, raw, resolvedHome) {
2671
+ const previous = this.known.get(terminalId);
2672
+ const command = programName(raw.foregroundName);
2673
+ const previousCommand = previous?.command ?? null;
2674
+ const lastCommand = previousCommand !== null && previousCommand !== command ? previousCommand : previous?.last_command ?? null;
2675
+ const next = {
2676
+ terminal_id: terminalId,
2677
+ cwd: displayCwd(raw.cwd, resolvedHome),
2678
+ command,
2679
+ last_command: lastCommand
2680
+ };
2681
+ if (previous && previous.cwd === next.cwd && previous.command === next.command && previous.last_command === next.last_command) {
2682
+ return null;
2683
+ }
2684
+ this.known.set(terminalId, next);
2685
+ return next;
2686
+ }
2687
+ /** What was last sent for every shell, to send again after a reconnect. */
2688
+ current() {
2689
+ return [...this.known.values()];
2690
+ }
2691
+ forget(terminalId) {
2692
+ this.known.delete(terminalId);
2693
+ }
2694
+ clear() {
2695
+ this.known.clear();
2696
+ }
2697
+ };
2698
+ async function readFromProc(shellPids) {
2699
+ const contexts = /* @__PURE__ */ new Map();
2700
+ await Promise.all(shellPids.map(async (shellPid) => {
2701
+ const groups = await readProcGroups(shellPid);
2702
+ if (!groups) return;
2703
+ const cwd = await readOrNull(() => fs4.promises.readlink(`/proc/${shellPid}/cwd`));
2704
+ const foregroundName = groups.foregroundGroupId === groups.shellGroupId ? null : await readOrNull(() => fs4.promises.readFile(`/proc/${groups.foregroundGroupId}/comm`, "utf8"));
2705
+ contexts.set(shellPid, { cwd, foregroundName });
2706
+ }));
2707
+ return contexts;
2708
+ }
2709
+ async function readProcGroups(shellPid) {
2710
+ const stat = await readOrNull(() => fs4.promises.readFile(`/proc/${shellPid}/stat`, "utf8"));
2711
+ if (stat === null) return null;
2712
+ const afterName = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
2713
+ const shellGroupId = Number.parseInt(afterName[2], 10);
2714
+ const foregroundGroupId = Number.parseInt(afterName[5], 10);
2715
+ if (!(shellGroupId > 0) || !(foregroundGroupId > 0)) return null;
2716
+ return { shellGroupId, foregroundGroupId };
2717
+ }
2718
+ async function readFromPsAndLsof(shellPids) {
2719
+ const contexts = /* @__PURE__ */ new Map();
2720
+ if (shellPids.length === 0) return contexts;
2721
+ const groups = parseColumns(await run("ps", ["-o", "pid=,pgid=,tpgid=", "-p", shellPids.join(",")]));
2722
+ const foregroundGroupIds = [...groups.values()].filter(([shellGroupId, foregroundGroupId]) => foregroundGroupId > 0 && foregroundGroupId !== shellGroupId).map(([, foregroundGroupId]) => foregroundGroupId);
2723
+ const [names, cwds] = await Promise.all([
2724
+ foregroundGroupIds.length === 0 ? Promise.resolve(/* @__PURE__ */ new Map()) : run("ps", ["-o", "pid=,comm=", "-p", foregroundGroupIds.join(",")]).then(parseNames),
2725
+ run("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", shellPids.join(",")]).then(parseLsofCwds)
2726
+ ]);
2727
+ for (const shellPid of shellPids) {
2728
+ const group = groups.get(shellPid);
2729
+ if (!group) continue;
2730
+ const [shellGroupId, foregroundGroupId] = group;
2731
+ contexts.set(shellPid, {
2732
+ cwd: cwds.get(shellPid) ?? null,
2733
+ foregroundName: foregroundGroupId === shellGroupId ? null : names.get(foregroundGroupId) ?? null
2734
+ });
2735
+ }
2736
+ return contexts;
2737
+ }
2738
+ function parseColumns(stdout) {
2739
+ const columns = /* @__PURE__ */ new Map();
2740
+ for (const line of (stdout ?? "").split("\n")) {
2741
+ const [pid, groupId, foregroundGroupId] = line.trim().split(/\s+/).map((field) => Number.parseInt(field, 10));
2742
+ if (pid > 0 && groupId > 0) columns.set(pid, [groupId, foregroundGroupId]);
2743
+ }
2744
+ return columns;
2745
+ }
2746
+ function parseNames(stdout) {
2747
+ const names = /* @__PURE__ */ new Map();
2748
+ for (const line of (stdout ?? "").split("\n")) {
2749
+ const match = /^\s*(\d+)\s+(.*)$/.exec(line);
2750
+ if (match) names.set(Number.parseInt(match[1], 10), match[2]);
2751
+ }
2752
+ return names;
2753
+ }
2754
+ function parseLsofCwds(stdout) {
2755
+ const cwds = /* @__PURE__ */ new Map();
2756
+ let pid = 0;
2757
+ for (const line of (stdout ?? "").split("\n")) {
2758
+ if (line.startsWith("p")) pid = Number.parseInt(line.slice(1), 10);
2759
+ else if (line.startsWith("n") && pid > 0) cwds.set(pid, line.slice(1));
2760
+ }
2761
+ return cwds;
2762
+ }
2763
+ function run(program, args) {
2764
+ return new Promise((resolve) => {
2765
+ execFile(program, args, { timeout: PROCESS_READ_TIMEOUT_MILLIS }, (err, stdout) => {
2766
+ const output = String(stdout ?? "");
2767
+ resolve(err && output === "" ? null : output);
2768
+ });
2769
+ });
2770
+ }
2771
+ async function readOrNull(read) {
2772
+ try {
2773
+ return await read();
2774
+ } catch {
2775
+ return null;
2776
+ }
2777
+ }
2778
+
2779
+ // src/devices/terminal/screenMirror.ts
2780
+ import { createRequire } from "module";
2781
+ var SCROLLBACK_LINES = 1e3;
2782
+ var MAX_SNAPSHOT_BYTES = 1024 * 1024;
2783
+ var SCROLLBACK_LINES_TO_TRY = [SCROLLBACK_LINES, 200, 0];
2784
+ var require2 = createRequire(import.meta.url);
2785
+ var loadedModules;
2786
+ function loadModules() {
2787
+ if (loadedModules !== void 0) return loadedModules;
2788
+ try {
2789
+ const headless = require2("@xterm/headless");
2790
+ const serialize = require2("@xterm/addon-serialize");
2791
+ loadedModules = { Terminal: headless.Terminal, SerializeAddon: serialize.SerializeAddon };
2792
+ } catch (err) {
2793
+ logger.warn("Screen restore unavailable: the headless terminal did not load", { error: errorName(err) });
2794
+ loadedModules = null;
2795
+ }
2796
+ return loadedModules;
2797
+ }
2798
+ function isScreenMirrorSupported() {
2799
+ return loadModules() !== null;
2800
+ }
2801
+ var ScreenMirror = class _ScreenMirror {
2802
+ constructor(terminal, serializer) {
2803
+ this.terminal = terminal;
2804
+ this.serializer = serializer;
2805
+ this.disposed = false;
2806
+ }
2807
+ /** Null when the headless terminal cannot load. The shell then works as before, without restore. */
2808
+ static create(cols, rows) {
2809
+ const modules = loadModules();
2810
+ if (!modules) return null;
2811
+ const terminal = new modules.Terminal({ cols, rows, scrollback: SCROLLBACK_LINES, allowProposedApi: true });
2812
+ const serializer = new modules.SerializeAddon();
2813
+ terminal.loadAddon(serializer);
2814
+ return new _ScreenMirror(terminal, serializer);
2815
+ }
2816
+ /** Bytes exactly as they were sent. xterm parses them later, in order, and joins split characters. */
2817
+ feed(bytes) {
2818
+ if (!this.disposed) this.terminal.write(bytes);
2819
+ }
2820
+ resize(cols, rows) {
2821
+ if (!this.disposed) this.terminal.resize(cols, rows);
2822
+ }
2823
+ /**
2824
+ * The screen after everything fed before this call, and nothing fed after it. Null when the
2825
+ * mirror was disposed first.
2826
+ *
2827
+ * xterm parses writes in batches, on a timer. An empty write queued now has its callback run
2828
+ * after every earlier chunk is parsed and before the next one is, so serialising inside it sees
2829
+ * exactly what was fed before this call.
2830
+ */
2831
+ snapshot() {
2832
+ return new Promise((resolve) => {
2833
+ if (this.disposed) {
2834
+ resolve(null);
2835
+ return;
2836
+ }
2837
+ this.terminal.write("", () => resolve(this.disposed ? null : this.serialise()));
2838
+ });
2839
+ }
2840
+ dispose() {
2841
+ if (this.disposed) return;
2842
+ this.disposed = true;
2843
+ this.terminal.dispose();
2844
+ }
2845
+ /** Null when even the screen alone is over the cap. The tab then waits out its fallback. */
2846
+ serialise() {
2847
+ for (const scrollback of SCROLLBACK_LINES_TO_TRY) {
2848
+ const text = this.serializer.serialize({ scrollback });
2849
+ if (Buffer.byteLength(text, "utf-8") <= MAX_SNAPSHOT_BYTES) {
2850
+ return { text, cols: this.terminal.cols, rows: this.terminal.rows };
2851
+ }
2852
+ }
2853
+ return null;
2854
+ }
2855
+ };
2856
+ function errorName(err) {
2857
+ return err instanceof Error ? err.name : typeof err;
2858
+ }
2859
+
2860
+ // src/devices/terminal/terminalHandler.ts
2861
+ var DEFAULT_COLS = 80;
2862
+ var DEFAULT_ROWS = 24;
2863
+ var EXPIRY_CHECK_MILLIS = 5e3;
2864
+ var MILLIS_PER_SECOND = 1e3;
2865
+ var TerminalHandler = class {
2866
+ constructor(dependencies) {
2867
+ this.dependencies = dependencies;
2868
+ this.enabled = true;
2869
+ this.suspended = false;
2870
+ this.expiryTimer = null;
2871
+ this.contextTimer = null;
2872
+ this.contextPollInFlight = false;
2873
+ this.contextTracker = new ShellContextTracker();
2874
+ this.streams = /* @__PURE__ */ new Map();
2875
+ }
2876
+ /**
2877
+ * From welcome. An older dashboard sends no terminal fields, and `terminal_enabled` and the
2878
+ * per-device ceiling keep their defaults.
2879
+ *
2880
+ * The window has no default. A compiled-in one keeps working after the dashboard stops sending
2881
+ * the field, and nobody notices until a node falls over, so an agent without one refuses to open
2882
+ * a terminal at all.
2883
+ */
2884
+ configure(terminalEnabled, maxTerminalsPerDevice, terminalWindowBytes, maxFrameBytes, idleTimeoutSeconds, maxSessionSeconds) {
2885
+ if (terminalEnabled !== void 0) this.enabled = terminalEnabled;
2886
+ if (maxTerminalsPerDevice !== void 0) this.dependencies.registry.setMaxTerminals(maxTerminalsPerDevice);
2887
+ this.windowBytes = terminalWindowBytes;
2888
+ this.bundleBytes = rawBundleBytes(maxFrameBytes);
2889
+ if (terminalWindowBytes === void 0) {
2890
+ logger.info("Terminals unavailable: welcome carried no terminal_window_bytes");
2891
+ }
2892
+ this.idleTimeoutMillis = positiveMillis(idleTimeoutSeconds);
2893
+ this.maxSessionMillis = positiveMillis(maxSessionSeconds);
2894
+ this.startExpiryTimer();
2895
+ }
2896
+ /**
2897
+ * Ends every shell past its idle timeout or its max-session cap. Runs on a timer; public so a test
2898
+ * decides when. The cap goes first: a shell both idle and too old is closed for the rule typing
2899
+ * cannot reset.
2900
+ */
2901
+ expireShells() {
2902
+ if (this.suspended) return;
2903
+ const nowMillis = this.nowMillis();
2904
+ for (const [terminalId, stream] of [...this.streams.entries()]) {
2905
+ if (this.maxSessionMillis !== void 0 && nowMillis - stream.openedAtMillis >= this.maxSessionMillis) {
2906
+ this.expire(terminalId, CLOSE_REASON_MAX_SESSION);
2907
+ } else if (this.idleTimeoutMillis !== void 0 && nowMillis - stream.lastActivityMillis >= this.idleTimeoutMillis) {
2908
+ this.expire(terminalId, CLOSE_REASON_IDLE_TIMEOUT);
2909
+ }
2910
+ }
2911
+ }
2912
+ handle(envelope) {
2913
+ if (envelope.op === OP_OPEN) {
2914
+ this.open(envelope);
2915
+ } else if (envelope.op === OP_CLOSE) {
2916
+ this.closeFromDashboard(envelope);
2917
+ } else if (envelope.op === OP_ACK) {
2918
+ this.acknowledge(envelope);
2919
+ } else if (envelope.op === OP_RESIZE) {
2920
+ this.resize(envelope);
2921
+ } else if (envelope.op === OP_DATA) {
2922
+ this.input(envelope);
2923
+ } else if (envelope.op === OP_SIGNAL) {
2924
+ this.signal(envelope);
2925
+ } else if (envelope.op === OP_SNAPSHOT) {
2926
+ void this.snapshot(envelope);
2927
+ } else {
2928
+ logger.debug("Ignoring unhandled terminal op", { op: envelope.op });
2929
+ }
2930
+ }
2931
+ /**
2932
+ * The agent is stopping: interrupted, revoked, or refused for good. Every shell dies with it.
2933
+ * A dropped socket is not this; it is `suspend`.
2934
+ */
2935
+ closeAll() {
2936
+ this.stopExpiryTimer();
2937
+ this.stopContextTimer();
2938
+ this.contextTracker.clear();
2939
+ this.suspended = false;
2940
+ for (const stream of this.streams.values()) {
2941
+ stream.splitter.dispose();
2942
+ stream.mirror?.dispose();
2943
+ }
2944
+ this.streams.clear();
2945
+ this.dependencies.registry.killAll();
2946
+ }
2947
+ /**
2948
+ * The socket dropped and the agent will redial. Every shell stops being read and keeps running.
2949
+ * Whatever it prints waits in the kernel, and the shell blocks once that buffer is full.
2950
+ */
2951
+ suspend() {
2952
+ if (this.suspended) return;
2953
+ this.suspended = true;
2954
+ for (const [terminalId, session] of this.dependencies.registry.entries()) {
2955
+ try {
2956
+ session.pause();
2957
+ } catch (err) {
2958
+ logger.warn("Terminal pause failed", { terminal_id: terminalId, error: errorName2(err) });
2959
+ }
2960
+ }
2961
+ logger.info("Terminals suspended until the dashboard is back", { count: this.dependencies.registry.size() });
2962
+ }
2963
+ /**
2964
+ * `welcome` arrived. Every shell the dashboard did not close is read again, except one whose
2965
+ * window is still full. That one resumes on the `ack` that reopens it.
2966
+ */
2967
+ resumeAll() {
2968
+ if (!this.suspended) return;
2969
+ this.suspended = false;
2970
+ for (const [terminalId, session] of this.dependencies.registry.entries()) {
2971
+ const stream = this.streams.get(terminalId);
2972
+ if (stream) {
2973
+ for (const chunk of stream.heldBundles.splice(0)) {
2974
+ this.sendData(terminalId, chunk);
2975
+ }
2976
+ }
2977
+ if (stream?.paused) continue;
2978
+ try {
2979
+ session.resume();
2980
+ } catch (err) {
2981
+ logger.warn("Terminal resume failed", { terminal_id: terminalId, error: errorName2(err) });
2982
+ }
2983
+ }
2984
+ for (const context of this.contextTracker.current()) {
2985
+ if (this.streams.has(context.terminal_id)) {
2986
+ this.dependencies.send(event(CHANNEL_TERMINAL, OP_CONTEXT, context));
2987
+ }
2988
+ }
2989
+ }
2990
+ /**
2991
+ * Reads every open shell's directory and foreground program, and sends `context` for each that
2992
+ * changed. Runs on a timer while any shell is open; public so a test decides when. Never throws,
2993
+ * and never moves a shell's idle clock.
2994
+ */
2995
+ async pollShellContexts() {
2996
+ const read = this.dependencies.readShellContexts;
2997
+ if (!read || this.suspended || this.contextPollInFlight || this.streams.size === 0) return;
2998
+ this.contextPollInFlight = true;
2999
+ try {
3000
+ const terminalIdsByPid = /* @__PURE__ */ new Map();
3001
+ for (const [terminalId, stream] of this.streams) {
3002
+ terminalIdsByPid.set(stream.session.pid, terminalId);
3003
+ }
3004
+ const contexts = await read([...terminalIdsByPid.keys()]);
3005
+ if (this.suspended) return;
3006
+ for (const [shellPid, raw] of contexts) {
3007
+ const terminalId = terminalIdsByPid.get(shellPid);
3008
+ if (!terminalId || !this.streams.has(terminalId)) continue;
3009
+ const changed = this.contextTracker.observe(terminalId, raw, this.dependencies.resolvedHome ?? null);
3010
+ if (changed) this.dependencies.send(event(CHANNEL_TERMINAL, OP_CONTEXT, changed));
3011
+ }
3012
+ } catch (err) {
3013
+ logger.debug("Shell context poll failed", { error: errorName2(err) });
3014
+ } finally {
3015
+ this.contextPollInFlight = false;
3016
+ }
3017
+ }
3018
+ /** What `hello` lists: every shell still held, so the dashboard can keep it. */
3019
+ held() {
3020
+ return this.dependencies.registry.entries().map(([terminalId, session]) => ({
3021
+ terminal_id: terminalId,
3022
+ pid: session.pid,
3023
+ shell: session.shell,
3024
+ cols: session.cols,
3025
+ rows: session.rows
3026
+ }));
3027
+ }
3028
+ open(envelope) {
3029
+ const parsed = TerminalOpenSchema.safeParse(envelope.payload);
3030
+ if (!parsed.success) {
3031
+ this.refuse(envelope, null, ERROR_INVALID_PAYLOAD2, "open payload could not be read.");
3032
+ return;
3033
+ }
3034
+ const request2 = parsed.data;
3035
+ const { registry } = this.dependencies;
3036
+ if (!this.enabled || this.windowBytes === void 0 || this.bundleBytes === void 0) {
3037
+ this.refuse(envelope, request2.terminal_id, ERROR_TERMINAL_DISABLED, "Terminals are disabled.");
3038
+ return;
3039
+ }
3040
+ if (registry.has(request2.terminal_id) || registry.isFull()) {
3041
+ this.refuse(
3042
+ envelope,
3043
+ request2.terminal_id,
3044
+ ERROR_TERMINAL_LIMIT_REACHED,
3045
+ "Too many terminals are already open on this machine."
3046
+ );
3047
+ return;
3048
+ }
3049
+ const resolution = this.dependencies.resolveShell(request2.shell);
3050
+ if ("error" in resolution) {
3051
+ this.refuse(envelope, request2.terminal_id, ERROR_SHELL_NOT_ALLOWED, resolution.error);
3052
+ return;
3053
+ }
3054
+ const cols = clampGrid(request2.cols, DEFAULT_COLS);
3055
+ const rows = clampGrid(request2.rows, DEFAULT_ROWS);
3056
+ let session;
3057
+ try {
3058
+ session = this.dependencies.spawn({ shell: resolution.shell, cwd: request2.cwd, cols, rows });
3059
+ } catch (err) {
3060
+ logger.warn("Terminal spawn failed", { terminal_id: request2.terminal_id, error: errorName2(err) });
3061
+ this.refuse(envelope, request2.terminal_id, ERROR_SPAWN_FAILED, "The shell could not be started.");
3062
+ return;
3063
+ }
3064
+ if (!registry.add(request2.terminal_id, session)) {
3065
+ session.kill();
3066
+ this.refuse(
3067
+ envelope,
3068
+ request2.terminal_id,
3069
+ ERROR_TERMINAL_LIMIT_REACHED,
3070
+ "Too many terminals are already open on this machine."
3071
+ );
3072
+ return;
3073
+ }
3074
+ this.startStream(request2.terminal_id, session, this.windowBytes, this.bundleBytes);
3075
+ logger.info("Terminal opened", { terminal_id: request2.terminal_id, pid: session.pid });
3076
+ const opened = {
3077
+ terminal_id: request2.terminal_id,
3078
+ pid: session.pid,
3079
+ shell: session.shell,
3080
+ cols,
3081
+ rows
3082
+ };
3083
+ this.dependencies.send(response(envelope, OP_OPENED, opened));
3084
+ }
3085
+ startStream(terminalId, session, windowBytes, bundleBytes) {
3086
+ const openedAtMillis = this.nowMillis();
3087
+ const stream = {
3088
+ session,
3089
+ window: new FlowWindow(windowBytes),
3090
+ splitter: new FrameSplitter((chunk) => this.sendData(terminalId, chunk), bundleBytes),
3091
+ mirror: ScreenMirror.create(session.cols, session.rows),
3092
+ paused: false,
3093
+ sentSeq: 0,
3094
+ heldBundles: [],
3095
+ openedAtMillis,
3096
+ lastActivityMillis: openedAtMillis
3097
+ };
3098
+ this.streams.set(terminalId, stream);
3099
+ this.startContextTimer();
3100
+ session.onData((chunk) => {
3101
+ stream.lastActivityMillis = this.nowMillis();
3102
+ stream.splitter.push(chunk);
3103
+ });
3104
+ session.onExit((exitCode, signal) => {
3105
+ if (this.dependencies.registry.remove(terminalId) === void 0) return;
3106
+ stream.splitter.flush();
3107
+ stream.splitter.dispose();
3108
+ this.forgetStream(terminalId);
3109
+ logger.info("Terminal shell exited", { terminal_id: terminalId, exit_code: exitCode, signal });
3110
+ const exit = signal ? { terminal_id: terminalId, exit_code: null, signal: signalName(signal) } : { terminal_id: terminalId, exit_code: exitCode, signal: null };
3111
+ this.dependencies.send(event(CHANNEL_TERMINAL, OP_EXIT, exit));
3112
+ this.dependencies.send(event(
3113
+ CHANNEL_TERMINAL,
3114
+ OP_CLOSE,
3115
+ { terminal_id: terminalId, reason: CLOSE_REASON_USER_CLOSED }
3116
+ ));
3117
+ });
3118
+ }
3119
+ /**
3120
+ * 1 bundle leaves, and the window narrows by what it carried.
3121
+ *
3122
+ * The pause happens after the send rather than before. These bytes are already out of the pty,
3123
+ * and holding them is the buffering this whole mechanism exists to avoid.
3124
+ *
3125
+ * While the socket is down the bundle is held instead, and neither counts against the window nor
3126
+ * takes a seq. Sent into no socket, it would be a seq the dashboard never sees and bytes nobody
3127
+ * can ack, and the window would never reopen.
3128
+ *
3129
+ * The mirror is fed here, in the same tick as the seq, and nowhere else. A snapshot then holds
3130
+ * exactly the bundles up to its `after_seq`. Fed when the pty is read, a bundle still being cut
3131
+ * would be in the screen and drawn a second time when it is sent.
3132
+ */
3133
+ sendData(terminalId, chunk) {
3134
+ const stream = this.streams.get(terminalId);
3135
+ if (!stream) return;
3136
+ if (this.suspended) {
3137
+ stream.heldBundles.push(chunk);
3138
+ return;
3139
+ }
3140
+ stream.sentSeq += 1;
3141
+ stream.mirror?.feed(chunk);
3142
+ const data = { terminal_id: terminalId, data: chunk.toString("base64") };
3143
+ this.dependencies.send({ ...event(CHANNEL_TERMINAL, OP_DATA, data), seq: stream.sentSeq });
3144
+ stream.window.recordSent(chunk.length);
3145
+ if (!stream.window.isOpen() && !stream.paused) {
3146
+ stream.paused = true;
3147
+ stream.session.pause();
3148
+ logger.debug("Terminal window full, reads paused", { terminal_id: terminalId });
3149
+ }
3150
+ }
3151
+ acknowledge(envelope) {
3152
+ const parsed = TerminalAckSchema.safeParse(envelope.payload);
3153
+ if (!parsed.success) return;
3154
+ const stream = this.streams.get(parsed.data.terminal_id);
3155
+ if (!stream) return;
3156
+ stream.window.recordAck(parsed.data.ack_bytes);
3157
+ if (stream.paused && stream.window.isOpen()) {
3158
+ stream.paused = false;
3159
+ if (this.suspended) return;
3160
+ stream.session.resume();
3161
+ logger.debug("Terminal window reopened, reads resumed", { terminal_id: parsed.data.terminal_id });
3162
+ }
3163
+ }
3164
+ closeFromDashboard(envelope) {
3165
+ const parsed = TerminalCloseSchema.safeParse(envelope.payload);
3166
+ if (!parsed.success) return;
3167
+ const session = this.dependencies.registry.remove(parsed.data.terminal_id);
3168
+ if (!session) return;
3169
+ const stream = this.streams.get(parsed.data.terminal_id);
3170
+ this.forgetStream(parsed.data.terminal_id);
3171
+ stream?.splitter.dispose();
3172
+ logger.info("Terminal closed by the dashboard", { terminal_id: parsed.data.terminal_id, pid: session.pid });
3173
+ try {
3174
+ session.kill();
3175
+ } catch {
3176
+ }
3177
+ }
3178
+ /**
3179
+ * The tabs watching this shell changed size. Clamped like `open`, and a gone shell is ignored.
3180
+ * The pty is resized only on an actual change: every call is a `TIOCSWINSZ` and a SIGWINCH, and
3181
+ * a full-screen program redraws on each one.
3182
+ */
3183
+ resize(envelope) {
3184
+ const parsed = TerminalResizeSchema.safeParse(envelope.payload);
3185
+ if (!parsed.success) return;
3186
+ const session = this.dependencies.registry.get(parsed.data.terminal_id);
3187
+ if (!session) return;
3188
+ this.markActive(parsed.data.terminal_id);
3189
+ const cols = clampGrid(parsed.data.cols, session.cols);
3190
+ const rows = clampGrid(parsed.data.rows, session.rows);
3191
+ if (cols === session.cols && rows === session.rows) return;
3192
+ try {
3193
+ session.resize(cols, rows);
3194
+ this.streams.get(parsed.data.terminal_id)?.mirror?.resize(cols, rows);
3195
+ } catch (err) {
3196
+ logger.debug("Terminal resize failed", { terminal_id: parsed.data.terminal_id, error: errorName2(err) });
3197
+ }
3198
+ }
3199
+ /** Keystrokes, written as raw bytes. A gone shell is ignored. Never logged: this is what a person typed. */
3200
+ input(envelope) {
3201
+ const parsed = TerminalInputSchema.safeParse(envelope.payload);
3202
+ if (!parsed.success) return;
3203
+ const session = this.dependencies.registry.get(parsed.data.terminal_id);
3204
+ if (!session) return;
3205
+ this.markActive(parsed.data.terminal_id);
3206
+ try {
3207
+ session.write(Buffer.from(parsed.data.data, "base64"));
3208
+ } catch (err) {
3209
+ logger.debug("Terminal write failed", { terminal_id: parsed.data.terminal_id, error: errorName2(err) });
3210
+ }
3211
+ }
3212
+ /** 1 allowlisted signal to the foreground process group. Anything else fails to parse and is dropped. */
3213
+ signal(envelope) {
3214
+ const parsed = TerminalSignalSchema.safeParse(envelope.payload);
3215
+ if (!parsed.success) {
3216
+ logger.debug("Ignoring a terminal signal outside the allowlist");
3217
+ return;
3218
+ }
3219
+ const session = this.dependencies.registry.get(parsed.data.terminal_id);
3220
+ if (!session) return;
3221
+ this.markActive(parsed.data.terminal_id);
3222
+ session.signalForeground(parsed.data.signal);
3223
+ }
3224
+ markActive(terminalId) {
3225
+ const stream = this.streams.get(terminalId);
3226
+ if (stream) stream.lastActivityMillis = this.nowMillis();
3227
+ }
3228
+ /**
3229
+ * Removed from the registry before the kill, so its exit callback finds nothing and sends no
3230
+ * `user_closed`. The output already read goes out first, then `close` with the reason.
3231
+ */
3232
+ expire(terminalId, reason) {
3233
+ const session = this.dependencies.registry.remove(terminalId);
3234
+ const stream = this.streams.get(terminalId);
3235
+ this.forgetStream(terminalId);
3236
+ stream?.splitter.flush();
3237
+ stream?.splitter.dispose();
3238
+ if (!session) return;
3239
+ logger.info("Terminal expired", { terminal_id: terminalId, pid: session.pid, reason });
3240
+ try {
3241
+ session.kill();
3242
+ } catch {
3243
+ }
3244
+ this.dependencies.send(event(CHANNEL_TERMINAL, OP_CLOSE, { terminal_id: terminalId, reason }));
3245
+ }
3246
+ /**
3247
+ * A tab attached and asked for the screen it missed (slice T6). Answered in parts, each 1 frame,
3248
+ * which the dashboard routes to that tab alone.
3249
+ *
3250
+ * `after_seq` and `after_bytes` are read in the same tick as the request, before the mirror is
3251
+ * asked, so they name exactly the bundles the screen holds. The parts go out even with the window
3252
+ * closed: they are not pty output, so they take no seq and the window never counts them. A request
3253
+ * for a shell that is gone, or a screen too big to send, is not answered; the tab falls back.
3254
+ */
3255
+ async snapshot(envelope) {
3256
+ const parsed = TerminalSnapshotRequestSchema.safeParse(envelope.payload);
3257
+ if (!parsed.success || this.bundleBytes === void 0) return;
3258
+ const { terminal_id: terminalId, snapshot_id: snapshotId } = parsed.data;
3259
+ const stream = this.streams.get(terminalId);
3260
+ if (!stream?.mirror) return;
3261
+ const afterSeq = stream.sentSeq;
3262
+ const afterBytes = stream.window.sent;
3263
+ const screen = await stream.mirror.snapshot();
3264
+ if (!screen || this.suspended || this.streams.get(terminalId) !== stream) return;
3265
+ const parts = splitIntoParts(Buffer.from(screen.text, "utf-8"), this.bundleBytes);
3266
+ parts.forEach((part, index) => {
3267
+ const payload = {
3268
+ terminal_id: terminalId,
3269
+ snapshot_id: snapshotId,
3270
+ part: index,
3271
+ last: index === parts.length - 1,
3272
+ after_seq: afterSeq,
3273
+ after_bytes: afterBytes,
3274
+ cols: screen.cols,
3275
+ rows: screen.rows,
3276
+ data: part.toString("base64")
3277
+ };
3278
+ this.dependencies.send(event(CHANNEL_TERMINAL, OP_SNAPSHOT, payload));
3279
+ });
3280
+ logger.debug("Terminal screen sent", { terminal_id: terminalId, parts: parts.length, after_seq: afterSeq });
3281
+ }
3282
+ /** A shell is gone. The context poll stops with the last one, so an idle agent spawns nothing. */
3283
+ forgetStream(terminalId) {
3284
+ this.streams.get(terminalId)?.mirror?.dispose();
3285
+ this.streams.delete(terminalId);
3286
+ this.contextTracker.forget(terminalId);
3287
+ if (this.streams.size === 0) this.stopContextTimer();
3288
+ }
3289
+ startContextTimer() {
3290
+ if (this.contextTimer || !this.dependencies.readShellContexts) return;
3291
+ this.contextTimer = setInterval(() => void this.pollShellContexts(), SHELL_CONTEXT_POLL_MILLIS);
3292
+ this.contextTimer.unref();
3293
+ }
3294
+ stopContextTimer() {
3295
+ if (this.contextTimer) {
3296
+ clearInterval(this.contextTimer);
3297
+ this.contextTimer = null;
3298
+ }
3299
+ }
3300
+ startExpiryTimer() {
3301
+ if (this.expiryTimer || this.idleTimeoutMillis === void 0 && this.maxSessionMillis === void 0) {
3302
+ return;
3303
+ }
3304
+ this.expiryTimer = setInterval(() => this.expireShells(), EXPIRY_CHECK_MILLIS);
3305
+ this.expiryTimer.unref();
3306
+ }
3307
+ stopExpiryTimer() {
3308
+ if (this.expiryTimer) {
3309
+ clearInterval(this.expiryTimer);
3310
+ this.expiryTimer = null;
3311
+ }
3312
+ }
3313
+ nowMillis() {
3314
+ return this.dependencies.now ? this.dependencies.now() : Date.now();
3315
+ }
3316
+ refuse(envelope, terminalId, code, message) {
3317
+ logger.info("Terminal open refused", { terminal_id: terminalId, code });
3318
+ const refused = { terminal_id: terminalId, error: { code, message } };
3319
+ this.dependencies.send(response(envelope, OP_OPENED, refused));
3320
+ }
3321
+ };
3322
+ function positiveMillis(seconds) {
3323
+ return seconds !== void 0 && Number.isFinite(seconds) && seconds > 0 ? seconds * MILLIS_PER_SECOND : void 0;
3324
+ }
3325
+ function splitIntoParts(bytes, partBytes) {
3326
+ const parts = [];
3327
+ for (let offset = 0; offset < bytes.length; offset += partBytes) {
3328
+ parts.push(bytes.subarray(offset, offset + partBytes));
3329
+ }
3330
+ return parts.length > 0 ? parts : [Buffer.alloc(0)];
3331
+ }
3332
+ function errorName2(err) {
3333
+ return err instanceof Error ? err.name : typeof err;
3334
+ }
3335
+ function signalName(signal) {
3336
+ for (const [name, number] of Object.entries(os6.constants.signals)) {
3337
+ if (number === signal) return name.replace(/^SIG/, "");
3338
+ }
3339
+ return String(signal);
3340
+ }
3341
+
3342
+ // src/devices/terminal/terminalRegistry.ts
3343
+ var DEFAULT_MAX_TERMINALS = 3;
3344
+ var TerminalRegistry = class {
3345
+ constructor() {
3346
+ this.handlesById = /* @__PURE__ */ new Map();
3347
+ this.maxTerminals = DEFAULT_MAX_TERMINALS;
3348
+ }
3349
+ setMaxTerminals(maxTerminals) {
3350
+ if (Number.isInteger(maxTerminals) && maxTerminals > 0) {
3351
+ this.maxTerminals = maxTerminals;
3352
+ }
3353
+ }
3354
+ has(terminalId) {
3355
+ return this.handlesById.has(terminalId);
3356
+ }
3357
+ isFull() {
3358
+ return this.handlesById.size >= this.maxTerminals;
3359
+ }
3360
+ /** False when the id is already taken or the ceiling is reached. The caller then kills the handle. */
3361
+ add(terminalId, handle) {
3362
+ if (this.handlesById.has(terminalId) || this.isFull()) return false;
3363
+ this.handlesById.set(terminalId, handle);
3364
+ return true;
3365
+ }
3366
+ get(terminalId) {
3367
+ return this.handlesById.get(terminalId);
3368
+ }
3369
+ /** Removes and returns the handle. Undefined the second time, so a close runs at most once. */
3370
+ remove(terminalId) {
3371
+ const handle = this.handlesById.get(terminalId);
3372
+ this.handlesById.delete(terminalId);
3373
+ return handle;
3374
+ }
3375
+ /** Every held shell with its id, in the order it was added. */
3376
+ entries() {
3377
+ return [...this.handlesById.entries()];
3378
+ }
3379
+ size() {
3380
+ return this.handlesById.size;
3381
+ }
3382
+ /** Removes every handle before killing any, so no exit callback finds itself still registered. */
3383
+ killAll() {
3384
+ const handles = [...this.handlesById.values()];
3385
+ this.handlesById.clear();
3386
+ for (const handle of handles) {
3387
+ try {
3388
+ handle.kill();
3389
+ } catch {
3390
+ }
3391
+ }
3392
+ }
3393
+ };
3394
+
3395
+ // src/devices/terminal/ptySession.ts
3396
+ import { execFile as execFile2 } from "child_process";
3397
+ import fs6 from "fs";
3398
+ import os7 from "os";
3399
+
3400
+ // src/devices/terminal/nodePtyRuntime.ts
3401
+ import fs5 from "fs";
3402
+ import path3 from "path";
3403
+ import { createRequire as createRequire2 } from "module";
3404
+ var require3 = createRequire2(import.meta.url);
3405
+ var PACKAGE_NAME = "node-pty";
3406
+ var NATIVE_FILE = "pty.node";
3407
+ var SPAWN_HELPER_FILE = "spawn-helper";
3408
+ var EXECUTABLE_MODE = 493;
3409
+ var EXECUTE_BITS = 73;
3410
+ var RUNTIME_DIR_NAME = "runtime";
3411
+ var ASAR_ARCHIVE_SUFFIX = ".asar";
3412
+ var NATIVE_DIRECTORIES = [
3413
+ path3.join("build", "Release"),
3414
+ path3.join("prebuilds", `${process.platform}-${process.arch}`)
3415
+ ];
3416
+ function isPackagedBinary() {
3417
+ return Boolean(process.pkg);
3418
+ }
3419
+ function isInsideAsarArchive(packageRoot) {
3420
+ if (!process.versions.electron) return false;
3421
+ return packageRoot.split(path3.sep).some((segment) => segment.endsWith(ASAR_ARCHIVE_SUFFIX));
3422
+ }
3423
+ function resolveNodePtyRoot() {
3424
+ const resolvedRoot = path3.dirname(require3.resolve(`${PACKAGE_NAME}/package.json`));
3425
+ if (!isPackagedBinary() && !isInsideAsarArchive(resolvedRoot)) return resolvedRoot;
3426
+ return unpackNodePty(resolvedRoot);
3427
+ }
3428
+ function requireNodePty(packageRoot) {
3429
+ return require3(packageRoot);
3430
+ }
3431
+ function spawnHelperPaths(packageRoot) {
3432
+ if (process.platform === "win32") return [];
3433
+ return NATIVE_DIRECTORIES.map((directory) => path3.join(packageRoot, directory, SPAWN_HELPER_FILE));
3434
+ }
3435
+ function hasExecutableSpawnHelper(packageRoot) {
3436
+ const helpers = spawnHelperPaths(packageRoot);
3437
+ if (helpers.length === 0) return true;
3438
+ return helpers.some((helper) => {
3439
+ try {
3440
+ const stat = fs5.statSync(helper);
3441
+ return stat.isFile() && (stat.mode & EXECUTE_BITS) !== 0;
3442
+ } catch {
3443
+ return false;
3444
+ }
3445
+ });
3446
+ }
3447
+ function makeSpawnHelperExecutable(packageRoot) {
3448
+ for (const helper of spawnHelperPaths(packageRoot)) {
3449
+ try {
3450
+ const stat = fs5.statSync(helper);
3451
+ if ((stat.mode & EXECUTE_BITS) === 0) {
3452
+ fs5.chmodSync(helper, EXECUTABLE_MODE);
3453
+ }
3454
+ } catch {
3455
+ }
3456
+ }
3457
+ }
3458
+ function unpackNodePty(snapshotRoot) {
3459
+ const unpackedRoot = unpackedRootPath(snapshotRoot);
3460
+ if (isLoadable(unpackedRoot)) return unpackedRoot;
3461
+ const stagingRoot = `${unpackedRoot}.${process.pid}`;
3462
+ fs5.rmSync(stagingRoot, { recursive: true, force: true });
3463
+ try {
3464
+ copyRuntimeFiles(snapshotRoot, stagingRoot);
3465
+ makeSpawnHelperExecutable(stagingRoot);
3466
+ fs5.renameSync(stagingRoot, unpackedRoot);
3467
+ } catch (error) {
3468
+ fs5.rmSync(stagingRoot, { recursive: true, force: true });
3469
+ if (!isLoadable(unpackedRoot)) throw error;
3470
+ }
3471
+ return unpackedRoot;
3472
+ }
3473
+ function unpackedRootPath(snapshotRoot) {
3474
+ const version = readPackageVersion(snapshotRoot);
3475
+ const directoryName = `${PACKAGE_NAME}-${version}-${process.platform}-${process.arch}`;
3476
+ return path3.join(getPinggyConfigDir(), RUNTIME_DIR_NAME, directoryName);
3477
+ }
3478
+ function readPackageVersion(packageRoot) {
3479
+ const manifest = JSON.parse(fs5.readFileSync(path3.join(packageRoot, "package.json"), "utf8"));
3480
+ return String(manifest.version);
3481
+ }
3482
+ function isLoadable(packageRoot) {
3483
+ if (!fs5.existsSync(path3.join(packageRoot, "package.json"))) return false;
3484
+ return NATIVE_DIRECTORIES.some((directory) => fs5.existsSync(path3.join(packageRoot, directory, NATIVE_FILE)));
3485
+ }
3486
+ function copyRuntimeFiles(sourceRoot, targetRoot) {
3487
+ fs5.mkdirSync(targetRoot, { recursive: true });
3488
+ copyFile(path3.join(sourceRoot, "package.json"), path3.join(targetRoot, "package.json"));
3489
+ copyDirectory(path3.join(sourceRoot, "lib"), path3.join(targetRoot, "lib"));
3490
+ for (const directory of NATIVE_DIRECTORIES) {
3491
+ const source = path3.join(sourceRoot, directory);
3492
+ if (fs5.existsSync(source)) {
3493
+ copyDirectory(source, path3.join(targetRoot, directory));
3494
+ }
3495
+ }
3496
+ }
3497
+ function copyDirectory(sourceDirectory, targetDirectory) {
3498
+ fs5.mkdirSync(targetDirectory, { recursive: true });
3499
+ for (const entry of fs5.readdirSync(sourceDirectory, { withFileTypes: true })) {
3500
+ const source = path3.join(sourceDirectory, entry.name);
3501
+ const target = path3.join(targetDirectory, entry.name);
3502
+ if (entry.isDirectory()) {
3503
+ copyDirectory(source, target);
3504
+ continue;
3505
+ }
3506
+ copyFile(source, target);
3507
+ }
3508
+ }
3509
+ function copyFile(source, target) {
3510
+ fs5.writeFileSync(target, fs5.readFileSync(source));
3511
+ }
3512
+
3513
+ // src/devices/terminal/ptySession.ts
3514
+ var loadedPty;
3515
+ var TERM_NAME = "xterm-256color";
3516
+ function foregroundProcessGroup(shellPid) {
3517
+ return new Promise((resolve) => {
3518
+ execFile2("ps", ["-o", "tpgid=", "-p", String(shellPid)], (err, stdout) => {
3519
+ const processGroupId = err ? NaN : Number.parseInt(String(stdout).trim(), 10);
3520
+ resolve(Number.isInteger(processGroupId) && processGroupId > 0 ? processGroupId : null);
3521
+ });
3522
+ });
3523
+ }
3524
+ async function signalForegroundGroup(shellPid, signal) {
3525
+ if (process.platform === "win32") {
3526
+ logger.debug("Terminal signal ignored on Windows", { pid: shellPid, signal });
3527
+ return;
3528
+ }
3529
+ const processGroupId = await foregroundProcessGroup(shellPid) ?? shellPid;
3530
+ try {
3531
+ process.kill(-processGroupId, `SIG${signal}`);
3532
+ } catch (err) {
3533
+ logger.debug("Terminal signal not delivered", {
3534
+ pid: shellPid,
3535
+ signal,
3536
+ error: err instanceof Error ? err.name : typeof err
3537
+ });
3538
+ }
3539
+ }
3540
+ function loadNodePty() {
3541
+ if (loadedPty !== void 0) return loadedPty;
3542
+ try {
3543
+ const packageRoot = resolveNodePtyRoot();
3544
+ const pty = requireNodePty(packageRoot);
3545
+ ensureSpawnHelperExecutable(packageRoot);
3546
+ loadedPty = hasExecutableSpawnHelper(packageRoot) ? pty : null;
3547
+ } catch {
3548
+ loadedPty = null;
3549
+ }
3550
+ return loadedPty;
3551
+ }
3552
+ function isTerminalSupported() {
3553
+ return loadNodePty() !== null;
3554
+ }
3555
+ function ensureSpawnHelperExecutable(packageRoot) {
3556
+ makeSpawnHelperExecutable(packageRoot ?? resolveNodePtyRoot());
3557
+ }
3558
+ function resolveCwd(requested) {
3559
+ if (requested) {
3560
+ try {
3561
+ if (fs6.statSync(requested).isDirectory()) return requested;
3562
+ } catch {
3563
+ }
3564
+ }
3565
+ return os7.homedir();
3566
+ }
3567
+ function spawnPty(request2) {
3568
+ const pty = loadNodePty();
3569
+ if (!pty) {
3570
+ throw new Error("node-pty is not available on this machine");
3571
+ }
3572
+ const process_ = pty.spawn(request2.shell, [], {
3573
+ name: TERM_NAME,
3574
+ cols: request2.cols,
3575
+ rows: request2.rows,
3576
+ cwd: resolveCwd(request2.cwd),
3577
+ env: { ...process.env, TERM: TERM_NAME },
3578
+ // Buffers rather than strings. node-pty decodes per read otherwise, and a read that ends
3579
+ // mid-character yields a replacement character the browser can never recover.
3580
+ encoding: null
3581
+ });
3582
+ return {
3583
+ pid: process_.pid,
3584
+ shell: request2.shell,
3585
+ get cols() {
3586
+ return process_.cols;
3587
+ },
3588
+ get rows() {
3589
+ return process_.rows;
3590
+ },
3591
+ kill: () => process_.kill(),
3592
+ onExit: (listener) => {
3593
+ process_.onExit(({ exitCode, signal }) => listener(exitCode, signal));
3594
+ },
3595
+ onData: (listener) => {
3596
+ process_.onData((chunk) => {
3597
+ listener(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
3598
+ });
3599
+ },
3600
+ pause: () => process_.pause(),
3601
+ resume: () => process_.resume(),
3602
+ resize: (cols, rows) => process_.resize(cols, rows),
3603
+ write: (bytes) => process_.write(bytes),
3604
+ signalForeground: (signal) => {
3605
+ void signalForegroundGroup(process_.pid, signal);
3606
+ }
3607
+ };
3608
+ }
3609
+
3610
+ // src/devices/terminal/shellAllowlist.ts
3611
+ import fs7 from "fs";
3612
+ var FALLBACK_UNIX_SHELLS = ["/bin/bash", "/bin/zsh", "/bin/sh", "/usr/bin/bash", "/usr/bin/zsh"];
3613
+ var WINDOWS_SHELLS = ["powershell.exe", "cmd.exe"];
3614
+ var ETC_SHELLS_PATH = "/etc/shells";
3615
+ function readShellEnvironment() {
3616
+ let etcShells = null;
3617
+ try {
3618
+ etcShells = fs7.readFileSync(ETC_SHELLS_PATH, "utf8");
3619
+ } catch {
3620
+ etcShells = null;
3621
+ }
3622
+ return {
3623
+ platform: process.platform,
3624
+ loginShell: process.env.SHELL,
3625
+ etcShells,
3626
+ exists: (path6) => {
3627
+ try {
3628
+ return fs7.statSync(path6).isFile();
3629
+ } catch {
3630
+ return false;
3631
+ }
3632
+ }
3633
+ };
3634
+ }
3635
+ function parseEtcShells(contents) {
3636
+ return contents.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("/"));
3637
+ }
3638
+ function allowedShells(environment) {
3639
+ if (environment.platform === "win32") return WINDOWS_SHELLS;
3640
+ const listed = environment.etcShells === null ? [] : parseEtcShells(environment.etcShells);
3641
+ return listed.length > 0 ? listed : FALLBACK_UNIX_SHELLS;
3642
+ }
3643
+ function resolveShell(requested, environment) {
3644
+ const allowed = allowedShells(environment);
3645
+ const usable = (shell) => environment.platform === "win32" || environment.exists(shell);
3646
+ if (requested !== null && requested !== void 0) {
3647
+ if (allowed.includes(requested) && usable(requested)) {
3648
+ return { shell: requested };
3649
+ }
3650
+ return { error: "That shell is not allowed on this machine." };
3651
+ }
3652
+ const loginShell = environment.loginShell;
3653
+ if (loginShell && allowed.includes(loginShell) && usable(loginShell)) {
3654
+ return { shell: loginShell };
3655
+ }
3656
+ const firstUsable = allowed.find(usable);
3657
+ return firstUsable ? { shell: firstUsable } : { error: "No allowed shell exists on this machine." };
3658
+ }
3659
+
3660
+ // src/devices/deviceAgent.ts
3661
+ var CLOSE_CODE_REVOKED = 4001;
3662
+ var CLOSE_CODE_NORMAL = 1e3;
3663
+ var CLOSE_TIMEOUT_MILLIS = 3e3;
3664
+ var TOKEN_HEADER = "X-Pinggy-Device-Token";
3665
+ var DEFAULT_SERVER = "dashboard.pinggy.io";
3666
+ var ERROR_ALREADY_CONNECTED = "already_connected";
3667
+ var AGENT_ERROR_UNAUTHORIZED = "unauthorized";
3668
+ var AGENT_ERROR_HANDSHAKE_REFUSED = "handshake_refused";
3669
+ var AGENT_ERROR_DISCONNECTED = "disconnected";
3670
+ var AGENT_ERROR_NOT_CONNECTED = "not_connected";
3671
+ var HANDSHAKE_TIMEOUT_MILLIS = 3e4;
3672
+ var PONG_GRACE_INTERVALS = 2;
3673
+ var BASE_CAPABILITIES = ["tunnel", "stats", "tunnel_control", "tunnel_update", "saved_config_update"];
3674
+ var CAPABILITY_TERMINAL = "terminal";
3675
+ var CAPABILITY_TERMINAL_SNAPSHOT = "terminal_snapshot";
3676
+ function buildCapabilities(terminalSupported, screenRestoreSupported = false) {
3677
+ if (!terminalSupported) return BASE_CAPABILITIES;
3678
+ return screenRestoreSupported ? [...BASE_CAPABILITIES, CAPABILITY_TERMINAL, CAPABILITY_TERMINAL_SNAPSHOT] : [...BASE_CAPABILITIES, CAPABILITY_TERMINAL];
3679
+ }
3680
+ function buildDeviceAgentWsUrl(manage) {
3681
+ let baseUrl = (manage || DEFAULT_SERVER).trim();
3682
+ if (!(baseUrl.startsWith("ws://") || baseUrl.startsWith("wss://"))) {
3683
+ baseUrl = "wss://" + baseUrl;
3684
+ }
3685
+ return `${baseUrl.replace(/\/$/, "")}/backend/api/v1/device-agent/ws/connect`;
3686
+ }
3687
+ function sleep(millis, state) {
3688
+ return new Promise((resolve) => {
3689
+ const timer = setTimeout(() => {
3690
+ state.cancelPendingSleep = null;
3691
+ resolve();
3692
+ }, millis);
3693
+ state.cancelPendingSleep = () => {
3694
+ clearTimeout(timer);
3695
+ state.cancelPendingSleep = null;
3696
+ resolve();
3697
+ };
3698
+ });
3699
+ }
3700
+ function buildHello(terminalHandler) {
3701
+ return {
3702
+ agent_version: getVersion(),
3703
+ os: os8.platform(),
3704
+ hostname: os8.hostname(),
3705
+ capabilities: buildCapabilities(isTerminalSupported(), isScreenMirrorSupported()),
3706
+ terminals: terminalHandler.held()
3707
+ };
3708
+ }
3709
+ function startMetricsReporting(intervalSeconds, collect, send) {
3710
+ let stopped = false;
3711
+ const report = () => {
3712
+ collect().then((metrics) => {
3713
+ if (!stopped) send(metrics);
3714
+ }).catch((err) => logger.warn("Device metrics collection failed", { error: String(err) }));
3715
+ };
3716
+ report();
3717
+ const timer = setInterval(report, intervalSeconds * 1e3);
3718
+ return () => {
3719
+ stopped = true;
3720
+ clearInterval(timer);
3721
+ };
3722
+ }
3723
+ function identityForRun(token, server, stored) {
3724
+ if (stored?.token === token) {
3725
+ return { ...stored, server };
3726
+ }
3727
+ return { device_agent_id: null, token, server, enrolled_at: null };
3728
+ }
3729
+ function startDeviceAgent(token, manage, options = {}) {
3730
+ const server = manage || DEFAULT_SERVER;
3731
+ const reporter = options.reporter ?? printingReporter;
3732
+ const stored = readDeviceIdentity();
3733
+ if (stored?.device_agent_id && stored.token !== token) {
3734
+ reporter.replacingDevice(stored.device_agent_id);
3735
+ }
3736
+ const connection = { send: null, close: null };
3737
+ const context = {
3738
+ wsUrl: buildDeviceAgentWsUrl(manage),
3739
+ server,
3740
+ identity: identityForRun(token, server, stored),
3741
+ reconnectPolicy: options.reconnectPolicy ?? new ReconnectPolicy(),
3742
+ handshakeTimeoutMillis: options.handshakeTimeoutMillis ?? HANDSHAKE_TIMEOUT_MILLIS,
3743
+ terminalHandler: new TerminalHandler({
3744
+ send: (frame) => connection.send?.(frame),
3745
+ spawn: spawnPty,
3746
+ resolveShell: (requested) => resolveShell(requested, readShellEnvironment()),
3747
+ registry: new TerminalRegistry(),
3748
+ readShellContexts: processTableReader(),
3749
+ resolvedHome: resolvedHomeDirectory()
3750
+ }),
3751
+ connection,
3752
+ reporter,
3753
+ writeIdentity: options.writeIdentity ?? writeDeviceIdentity,
3754
+ options,
3755
+ state: { stopRequested: false, cancelPendingSleep: null, error: null, welcomed: false }
3756
+ };
3757
+ const done = runLoop(context);
3758
+ return {
3759
+ done,
3760
+ stop: () => {
3761
+ context.state.stopRequested = true;
3762
+ context.state.cancelPendingSleep?.();
3763
+ connection.close?.();
3764
+ return done;
3765
+ }
3766
+ };
3767
+ }
3768
+ async function runDeviceAgent(token, manage, options = {}) {
3769
+ const run2 = startDeviceAgent(token, manage, options);
3770
+ const sigintHandler = () => {
3771
+ void run2.stop();
3772
+ };
3773
+ process.once("SIGINT", sigintHandler);
3774
+ try {
3775
+ await run2.done;
3776
+ } finally {
3777
+ process.removeListener("SIGINT", sigintHandler);
3778
+ }
3779
+ }
3780
+ async function runLoop(context) {
3781
+ const { state, reporter, reconnectPolicy } = context;
3782
+ while (!state.stopRequested) {
3783
+ reporter.connecting(context.server);
3784
+ const outcome = await connectOnce(context);
3785
+ if (outcome === "terminal" || state.stopRequested) {
3786
+ break;
3787
+ }
3788
+ if (context.options.verifyOnly) {
3789
+ state.error = {
3790
+ code: AGENT_ERROR_NOT_CONNECTED,
3791
+ message: `Could not connect to ${context.server}. Check the address and the network, then try again.`
3792
+ };
3793
+ break;
3794
+ }
3795
+ const delayMillis = reconnectPolicy.nextDelayMillis();
3796
+ reporter.reconnecting(delayMillis, reconnectPolicy.attempt);
3797
+ await sleep(delayMillis, state);
3798
+ }
3799
+ context.terminalHandler.closeAll();
3800
+ return { error: state.error, welcomed: state.welcomed };
3801
+ }
3802
+ function fail(context, error) {
3803
+ context.state.error = error;
3804
+ context.reporter.failed(error);
3805
+ return "terminal";
3806
+ }
3807
+ function connectOnce(context) {
3808
+ const { identity, reconnectPolicy, terminalHandler, connection, reporter, state, options } = context;
3809
+ return new Promise((resolve) => {
3810
+ const ws = new WebSocket(context.wsUrl, { headers: { [TOKEN_HEADER]: identity.token } });
3811
+ let heartbeat = null;
3812
+ let stopMetrics = null;
3813
+ let stopTunnels = null;
3814
+ let pingTimer = null;
3815
+ let pongDeadline = null;
3816
+ let handshakeDeadline = null;
3817
+ let closeDeadline = null;
3818
+ let settled = false;
3819
+ const stopTimers = () => {
3820
+ if (heartbeat) clearInterval(heartbeat);
3821
+ heartbeat = null;
3822
+ stopMetrics?.();
3823
+ stopMetrics = null;
3824
+ stopTunnels?.();
3825
+ stopTunnels = null;
3826
+ if (pingTimer) clearInterval(pingTimer);
3827
+ pingTimer = null;
3828
+ if (pongDeadline) clearTimeout(pongDeadline);
3829
+ pongDeadline = null;
3830
+ if (handshakeDeadline) clearTimeout(handshakeDeadline);
3831
+ handshakeDeadline = null;
3832
+ };
3833
+ const finish = (outcome) => {
3834
+ if (settled) return;
3835
+ settled = true;
3836
+ stopTimers();
3837
+ if (closeDeadline) clearTimeout(closeDeadline);
3838
+ closeDeadline = null;
3839
+ if (reconnectPolicy.markDisconnected()) {
3840
+ logger.info("Connection held long enough to reset the reconnect schedule");
3841
+ }
3842
+ connection.send = null;
3843
+ connection.close = null;
3844
+ terminalHandler.suspend();
3845
+ resolve(outcome);
3846
+ };
3847
+ const sendFrame = (frame) => {
3848
+ if (ws.readyState === WebSocket.OPEN) {
3849
+ ws.send(JSON.stringify(frame));
3850
+ }
3851
+ };
3852
+ const dropDeadSocket = (message) => {
3853
+ logger.warn(message);
3854
+ reporter.warn(message);
3855
+ ws.terminate();
3856
+ };
3857
+ connection.close = () => {
3858
+ if (ws.readyState === WebSocket.CONNECTING) {
3859
+ ws.terminate();
3860
+ return;
3861
+ }
3862
+ ws.close(CLOSE_CODE_NORMAL);
3863
+ closeDeadline = setTimeout(() => ws.terminate(), CLOSE_TIMEOUT_MILLIS);
3864
+ };
3865
+ ws.once("open", () => {
3866
+ logger.info("Device agent socket open, sending hello");
3867
+ connection.send = sendFrame;
3868
+ sendFrame(request(CHANNEL_SYSTEM, OP_HELLO, buildHello(terminalHandler)));
3869
+ handshakeDeadline = setTimeout(() => {
3870
+ dropDeadSocket("No welcome from the dashboard. Reconnecting.");
3871
+ }, context.handshakeTimeoutMillis);
3872
+ });
3873
+ const startPongWatchdog = (intervalSeconds) => {
3874
+ const intervalMillis = intervalSeconds * 1e3;
3875
+ const graceMillis = intervalMillis * PONG_GRACE_INTERVALS;
3876
+ const armDeadline = () => {
3877
+ if (pongDeadline) clearTimeout(pongDeadline);
3878
+ pongDeadline = setTimeout(() => {
3879
+ dropDeadSocket("Connection stopped answering. Reconnecting.");
3880
+ }, graceMillis);
3881
+ };
3882
+ ws.on("pong", armDeadline);
3883
+ pingTimer = setInterval(() => {
3884
+ if (ws.readyState === WebSocket.OPEN) {
3885
+ ws.ping();
3886
+ }
3887
+ }, intervalMillis);
3888
+ armDeadline();
3889
+ };
3890
+ const startHeartbeat = (intervalSeconds) => {
3891
+ heartbeat = setInterval(
3892
+ () => sendFrame(
3893
+ event(CHANNEL_SYSTEM, OP_HEARTBEAT, { uptime_seconds: Math.floor(os8.uptime()) })
3894
+ ),
3895
+ intervalSeconds * 1e3
3896
+ );
3897
+ };
3898
+ const onWelcome = (welcome) => {
3899
+ stopTimers();
3900
+ if (options.verifyOnly) {
3901
+ state.stopRequested = true;
3902
+ connection.close?.();
3903
+ return;
3904
+ }
3905
+ reconnectPolicy.markConnected();
3906
+ terminalHandler.configure(
3907
+ welcome.terminal_enabled,
3908
+ welcome.max_terminals_per_device,
3909
+ welcome.terminal_window_bytes,
3910
+ welcome.max_frame_bytes,
3911
+ welcome.terminal_idle_timeout_seconds,
3912
+ welcome.terminal_max_session_seconds
3913
+ );
3914
+ terminalHandler.resumeAll();
3915
+ startHeartbeat(welcome.heartbeat_interval_seconds);
3916
+ startPongWatchdog(welcome.heartbeat_interval_seconds);
3917
+ sendFrame(event(CHANNEL_DEVICE, OP_INFO, collectSystemInfo()));
3918
+ stopMetrics = startMetricsReporting(
3919
+ welcome.stats_interval_seconds,
3920
+ collectMetrics,
3921
+ (metrics) => sendFrame(event(CHANNEL_DEVICE, OP_METRICS, metrics))
3922
+ );
3923
+ stopTunnels = startTunnelReporting(
3924
+ () => collectTunnelList(options.tunnelListSource),
3925
+ (tunnels) => sendFrame(event(CHANNEL_DEVICE, OP_TUNNELS, tunnels))
3926
+ );
3927
+ };
3928
+ ws.on("message", (data) => {
3929
+ const envelope = parseEnvelope(data.toString("utf8"));
3930
+ if (!envelope) {
3931
+ logger.debug("Ignoring unparseable frame");
3932
+ return;
3933
+ }
3934
+ if (envelope.ch === CHANNEL_TERMINAL) {
3935
+ terminalHandler.handle(envelope);
3936
+ return;
3937
+ }
3938
+ if (envelope.ch === CHANNEL_TUNNEL) {
3939
+ void handleTunnelAction(envelope, (frame) => connection.send?.(frame), options.tunnelActions).then(() => stopTunnels?.pollNow());
3940
+ return;
3941
+ }
3942
+ const outcome = handleFrame(envelope, context, onWelcome);
3943
+ if (outcome === "terminal") {
3944
+ finish("terminal");
3945
+ ws.close();
3946
+ }
3947
+ });
3948
+ ws.on("unexpected-response", (_req, res) => {
3949
+ if (res.statusCode === 401) {
3950
+ finish(fail(context, {
3951
+ code: AGENT_ERROR_UNAUTHORIZED,
3952
+ message: "Unauthorized. This device token is not valid. Re-enrol this machine with: pinggy device login"
3953
+ }));
3954
+ } else {
3955
+ reporter.warn(`Unexpected HTTP ${res.statusCode}.`);
3956
+ finish("retry");
3957
+ }
3958
+ ws.close();
3959
+ });
3960
+ ws.on("close", (code, reason) => {
3961
+ logger.info("Device agent socket closed", { code, reason: reason.toString() });
3962
+ finish(code === CLOSE_CODE_REVOKED ? "terminal" : "retry");
3963
+ });
3964
+ ws.on("error", (err) => {
3965
+ logger.warn("Device agent socket error", { error: err.message });
3966
+ if (!state.stopRequested) {
3967
+ reporter.warn(err.message);
3968
+ }
3969
+ finish("retry");
3970
+ });
3971
+ });
3972
+ }
3973
+ function handleFrame(envelope, context, onWelcome) {
3974
+ const { identity, reporter } = context;
3975
+ if (envelope.ch !== CHANNEL_SYSTEM) {
3976
+ logger.debug("Ignoring frame on unknown channel", { channel: envelope.ch });
3977
+ return "retry";
3978
+ }
3979
+ if (envelope.op === OP_WELCOME) {
3980
+ const error = ErrorPayloadSchema.safeParse(envelope.payload);
3981
+ if (error.success) {
3982
+ if (error.data.error.code === ERROR_ALREADY_CONNECTED) {
3983
+ reporter.warn("This device is connected elsewhere. Retrying.");
3984
+ return "retry";
3985
+ }
3986
+ return fail(context, {
3987
+ code: AGENT_ERROR_HANDSHAKE_REFUSED,
3988
+ message: `Handshake refused: ${error.data.error.message}`
3989
+ });
3990
+ }
3991
+ const welcome = WelcomeSchema.safeParse(envelope.payload);
3992
+ if (!welcome.success) {
3993
+ logger.warn("Welcome payload could not be read");
3994
+ return "retry";
3995
+ }
3996
+ identity.device_agent_id = welcome.data.device_agent_id;
3997
+ identity.enrolled_at = identity.enrolled_at ?? (/* @__PURE__ */ new Date()).toISOString();
3998
+ context.writeIdentity(identity);
3999
+ context.state.welcomed = true;
4000
+ reporter.connected(welcome.data);
4001
+ onWelcome(welcome.data);
4002
+ return "retry";
4003
+ }
4004
+ if (envelope.op === OP_DISCONNECT) {
4005
+ const disconnect = DisconnectSchema.safeParse(envelope.payload);
4006
+ const reason = disconnect.success ? disconnect.data.reason : "unknown";
4007
+ const { outcome, message } = describeDisconnect(reason);
4008
+ if (outcome === "retry") {
4009
+ reporter.warn(message);
4010
+ return "retry";
4011
+ }
4012
+ return fail(context, { code: disconnectErrorCode(reason), message });
4013
+ }
4014
+ logger.debug("Ignoring unhandled system op", { op: envelope.op });
4015
+ return "retry";
4016
+ }
4017
+ var DISCONNECT_REASON_SHUTDOWN = "shutdown";
4018
+ var DISCONNECT_MESSAGES = {
4019
+ revoked: "Credential revoked. Copy the new install command from the dashboard and run it to reconnect.",
4020
+ deleted: "Device deleted from the dashboard. Add it again to reconnect this machine.",
4021
+ replaced: "This device connected again from another session. Stopping this one."
4022
+ };
4023
+ function disconnectErrorCode(reason) {
4024
+ return reason in DISCONNECT_MESSAGES ? reason : AGENT_ERROR_DISCONNECTED;
4025
+ }
4026
+ function describeDisconnect(reason) {
4027
+ if (reason === DISCONNECT_REASON_SHUTDOWN) {
4028
+ return { outcome: "retry", message: "Server is shutting down. Reconnecting." };
4029
+ }
4030
+ return {
4031
+ outcome: "terminal",
4032
+ message: DISCONNECT_MESSAGES[reason] ?? `Disconnected by the dashboard: ${reason}. Re-enrol this device to continue.`
4033
+ };
4034
+ }
4035
+
4036
+ // src/devices/login/deviceLogin.ts
4037
+ import os9 from "os";
4038
+
4039
+ // src/devices/login/login_schema.ts
4040
+ import { z as z3 } from "zod";
4041
+ var LoginStartedSchema = z3.object({
4042
+ device_code: z3.string().min(1),
4043
+ match_code: z3.string().min(1).optional(),
4044
+ expires_in_seconds: z3.number().int().positive(),
4045
+ poll_interval_seconds: z3.number().int().positive()
4046
+ });
4047
+ var LoginStatus = {
4048
+ Pending: "pending",
4049
+ MfaRequired: "mfa_required",
4050
+ Approved: "approved"
4051
+ };
4052
+ var LoginAnswerSchema = z3.object({
4053
+ status: z3.enum([LoginStatus.Pending, LoginStatus.MfaRequired, LoginStatus.Approved]),
4054
+ token: z3.string().min(1).optional(),
4055
+ device_agent_id: z3.string().min(1).optional(),
4056
+ device_name: z3.string().optional(),
4057
+ open_url: z3.string().optional(),
4058
+ device_url: z3.string().optional()
4059
+ });
4060
+ var LoginErrorSchema = z3.object({
4061
+ error_type: z3.string(),
4062
+ errors: z3.array(z3.string()).default([])
4063
+ });
4064
+ var LoginErrorType = {
4065
+ /** A wrong code or authenticator code, with attempts left. Asked again. */
4066
+ InvalidCode: "device_login_invalid_code",
4067
+ /** Expired, ended after too many wrong codes, or already used. */
4068
+ Expired: "device_login_expired",
4069
+ RateLimited: "device_login_rate_limited",
4070
+ InvalidClient: "device_login_invalid_client"
4071
+ };
4072
+
4073
+ // src/devices/login/loginApi.ts
4074
+ var DEVICE_LOGIN_CLIENT_TOKEN = "pinggy-cli-1";
4075
+ var DEFAULT_DASHBOARD = "dashboard.pinggy.io";
4076
+ var LOGIN_PATH = "/backend/api/v1/device-agent/login";
4077
+ var SCHEME_PATTERN = /^(wss?|https?):\/\//i;
4078
+ var PLAIN_SCHEMES = /* @__PURE__ */ new Set(["ws", "http"]);
4079
+ function buildDashboardHttpUrl(manage) {
4080
+ const address = (manage || DEFAULT_DASHBOARD).trim().replace(/\/+$/, "");
4081
+ const match = SCHEME_PATTERN.exec(address);
4082
+ const host = match ? address.slice(match[0].length) : address;
4083
+ const scheme = match && PLAIN_SCHEMES.has(match[1].toLowerCase()) ? "http" : "https";
4084
+ return `${scheme}://${host}${LOGIN_PATH}`;
4085
+ }
4086
+ var DeviceLoginApiError = class extends Error {
4087
+ constructor(status, errorType, message) {
4088
+ super(message);
4089
+ this.status = status;
4090
+ this.errorType = errorType;
4091
+ this.name = "DeviceLoginApiError";
4092
+ }
4093
+ };
4094
+ var UNREADABLE_ANSWER = "invalid_answer";
4095
+ function createDeviceLoginApi(manage, fetchImpl = fetch) {
4096
+ const baseUrl = buildDashboardHttpUrl(manage);
4097
+ async function post(path6, body, schema) {
4098
+ const response2 = await fetchImpl(`${baseUrl}/${path6}`, {
4099
+ method: "POST",
4100
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
4101
+ body: JSON.stringify(body)
4102
+ });
4103
+ const json = await readJson(response2);
4104
+ if (!response2.ok) {
4105
+ const refusal = LoginErrorSchema.safeParse(json);
4106
+ if (refusal.success && refusal.data.errors.length > 0) {
4107
+ throw new DeviceLoginApiError(response2.status, refusal.data.error_type, refusal.data.errors[0]);
4108
+ }
4109
+ throw new DeviceLoginApiError(
4110
+ response2.status,
4111
+ `http_${response2.status}`,
4112
+ `The dashboard answered HTTP ${response2.status}.`
4113
+ );
4114
+ }
4115
+ const answer = schema.safeParse(json);
4116
+ if (!answer.success) {
4117
+ throw new DeviceLoginApiError(
4118
+ response2.status,
4119
+ UNREADABLE_ANSWER,
4120
+ "The dashboard sent an answer this pinggy cannot read. Update pinggy and try again."
4121
+ );
4122
+ }
4123
+ return answer.data;
4124
+ }
4125
+ return {
4126
+ start: (request2) => post("start", request2, LoginStartedSchema),
4127
+ verifyCode: (deviceCode, code) => post("verify-code", { device_code: deviceCode, code }, LoginAnswerSchema),
4128
+ verifyMfa: (deviceCode, totp) => post("verify-mfa", { device_code: deviceCode, totp }, LoginAnswerSchema),
4129
+ poll: (deviceCode) => post("poll", { device_code: deviceCode }, LoginAnswerSchema)
4130
+ };
4131
+ }
4132
+ async function readJson(response2) {
4133
+ const text = await response2.text();
4134
+ if (!text) {
4135
+ return void 0;
4136
+ }
4137
+ try {
4138
+ return JSON.parse(text);
4139
+ } catch {
4140
+ return void 0;
4141
+ }
4142
+ }
4143
+
4144
+ // src/devices/login/prompts.ts
4145
+ import { createInterface } from "readline/promises";
4146
+ import { Writable } from "stream";
4147
+ function createTerminalPrompter(input, output) {
4148
+ return {
4149
+ async ask(question, signal) {
4150
+ const readline = createInterface({ input, output, terminal: true });
4151
+ try {
4152
+ return await readline.question(question, signal ? { signal } : {});
4153
+ } finally {
4154
+ readline.close();
4155
+ }
4156
+ },
4157
+ async askHidden(question, signal) {
4158
+ output.write(question);
4159
+ const readline = createInterface({ input, output: discardingOutput(), terminal: true });
4160
+ try {
4161
+ const answer = await readline.question("", signal ? { signal } : {});
4162
+ output.write("\n");
4163
+ return answer;
4164
+ } finally {
4165
+ readline.close();
4166
+ }
4167
+ }
4168
+ };
4169
+ }
4170
+ var terminalPrompter = createTerminalPrompter(process.stdin, process.stdout);
4171
+ function isInteractiveTerminal() {
4172
+ return process.stdin.isTTY === true;
4173
+ }
4174
+ function discardingOutput() {
4175
+ return new Writable({
4176
+ write(_chunk, _encoding, callback) {
4177
+ callback();
4178
+ }
4179
+ });
4180
+ }
4181
+
4182
+ // src/devices/login/deviceLogin.ts
4183
+ var EXIT_OK = 0;
4184
+ var EXIT_FAILED = 1;
4185
+ var DEFAULT_SERVER2 = "dashboard.pinggy.io";
4186
+ var YES_ANSWER = /^y(es)?$/i;
4187
+ var SECONDS_PER_MINUTE = 60;
4188
+ var MILLIS_PER_SECOND2 = 1e3;
4189
+ var FIRST_SERVER_ERROR_STATUS = 500;
4190
+ var NO_TERMINAL_MESSAGE = "pinggy device login needs a terminal to type the email and the code. On a machine without one, add the device in the dashboard and run: pinggy device connect --token <TOKEN>";
4191
+ function machineDeviceLoginDependencies() {
4192
+ return {
4193
+ createApi: (manage) => createDeviceLoginApi(manage),
4194
+ prompter: terminalPrompter,
4195
+ isInteractive: isInteractiveTerminal,
4196
+ readIdentity: readDeviceIdentity,
4197
+ writeIdentity: writeDeviceIdentity,
4198
+ runAgent: (token, manage) => runDeviceAgent(token, manage),
4199
+ renderQr: async (url) => (await createQrCodes([url]))[0],
4200
+ machine: () => ({
4201
+ hostname: os9.hostname(),
4202
+ os: os9.platform(),
4203
+ arch: os9.arch(),
4204
+ agent_version: getVersion()
4205
+ }),
4206
+ print: (message) => printer_default.print(message),
4207
+ printError: (message) => printer_default.error(message),
4208
+ sleep: sleepUnlessAborted
4209
+ };
4210
+ }
4211
+ async function runDeviceLogin(options, dependencies = machineDeviceLoginDependencies()) {
4212
+ if (!dependencies.isInteractive()) {
4213
+ dependencies.printError(NO_TERMINAL_MESSAGE);
4214
+ return EXIT_FAILED;
4215
+ }
4216
+ const stored = dependencies.readIdentity();
4217
+ const manage = options.manage ?? stored?.server;
4218
+ if (stored) {
4219
+ const answer = await dependencies.prompter.ask(
4220
+ `This machine is already enrolled as ${stored.device_agent_id ?? "a device"} on ${stored.server}. Add it again as a new device? [y/N] `
4221
+ );
4222
+ if (!YES_ANSWER.test(answer.trim())) {
4223
+ return await dependencies.runAgent(stored.token, manage) ?? EXIT_OK;
4224
+ }
4225
+ }
4226
+ const email = (await dependencies.prompter.ask("Email: ")).trim();
4227
+ if (!email) {
4228
+ dependencies.printError("An email is required.");
4229
+ return EXIT_FAILED;
4230
+ }
4231
+ const api = dependencies.createApi(manage);
4232
+ let approved;
4233
+ let linkMinutes;
4234
+ try {
4235
+ const started = await api.start({
4236
+ email,
4237
+ client_token: DEVICE_LOGIN_CLIENT_TOKEN,
4238
+ ...dependencies.machine(),
4239
+ ...options.name ? { name: options.name } : {}
4240
+ });
4241
+ linkMinutes = Math.round(started.expires_in_seconds / SECONDS_PER_MINUTE);
4242
+ dependencies.print(`We sent a 6-digit code to ${email}. It works for ${linkMinutes} minutes.`);
4243
+ if (started.match_code) {
4244
+ dependencies.print("Type it below, or click Approve sign-in in the email.");
4245
+ dependencies.print(`If you use the email: approve only if the page shows ${started.match_code}. A different code is someone else's sign-in.`);
4246
+ }
4247
+ approved = await waitForApproval(api, started.device_code, started.poll_interval_seconds, dependencies);
4248
+ } catch (err) {
4249
+ dependencies.printError(messageOf(err, manage));
4250
+ return EXIT_FAILED;
4251
+ }
4252
+ if (!approved.token || !approved.device_agent_id) {
4253
+ dependencies.printError("The dashboard approved the sign-in without a device token. Update pinggy and try again.");
4254
+ return EXIT_FAILED;
4255
+ }
4256
+ dependencies.writeIdentity({
4257
+ device_agent_id: approved.device_agent_id,
4258
+ token: approved.token,
4259
+ server: manage || DEFAULT_SERVER2,
4260
+ enrolled_at: null
4261
+ });
4262
+ dependencies.print(`Signed in as ${email}. Added device ${approved.device_name ?? approved.device_agent_id}.`);
4263
+ await printDeviceLink(approved, linkMinutes, dependencies);
4264
+ return await dependencies.runAgent(approved.token, manage) ?? EXIT_OK;
4265
+ }
4266
+ async function waitForApproval(api, deviceCode, pollIntervalSeconds, dependencies) {
4267
+ const controller = new AbortController();
4268
+ const prompt = { showing: false };
4269
+ const typed = typeTheCode(api, deviceCode, controller.signal, prompt, dependencies);
4270
+ const polled = pollUntilApproved(
4271
+ api,
4272
+ deviceCode,
4273
+ pollIntervalSeconds * MILLIS_PER_SECOND2,
4274
+ controller.signal,
4275
+ dependencies
4276
+ );
4277
+ typed.catch(() => void 0);
4278
+ polled.catch(() => void 0);
4279
+ try {
4280
+ return await Promise.race([typed, polled]);
4281
+ } finally {
4282
+ controller.abort();
4283
+ if (prompt.showing) {
4284
+ dependencies.print("");
4285
+ }
4286
+ }
4287
+ }
4288
+ async function typeTheCode(api, deviceCode, signal, prompt, dependencies) {
4289
+ const ask = async (question, hidden = false) => {
4290
+ prompt.showing = true;
4291
+ try {
4292
+ const answer2 = hidden ? dependencies.prompter.askHidden(question, signal) : dependencies.prompter.ask(question, signal);
4293
+ return (await answer2).trim();
4294
+ } finally {
4295
+ prompt.showing = false;
4296
+ }
4297
+ };
4298
+ let answer = null;
4299
+ while (answer === null) {
4300
+ const code = await ask("Code from the email: ");
4301
+ if (code) {
4302
+ answer = await askAgainOnWrongAnswer(() => api.verifyCode(deviceCode, code), dependencies);
4303
+ }
4304
+ }
4305
+ while (answer.status === LoginStatus.MfaRequired) {
4306
+ const totp = await ask("Authenticator code: ", true);
4307
+ if (totp) {
4308
+ answer = await askAgainOnWrongAnswer(() => api.verifyMfa(deviceCode, totp), dependencies) ?? answer;
4309
+ }
4310
+ }
4311
+ if (answer.status === LoginStatus.Pending) {
4312
+ dependencies.print("Approved from the email link. Waiting for the dashboard to finish.");
4313
+ return waitUntilAborted(signal);
4314
+ }
4315
+ if (answer.status !== LoginStatus.Approved) {
4316
+ throw new DeviceLoginApiError(
4317
+ 0,
4318
+ "invalid_answer",
4319
+ "The dashboard answered the code with something this pinggy cannot use. Update pinggy and try again."
4320
+ );
4321
+ }
4322
+ return answer;
4323
+ }
4324
+ async function askAgainOnWrongAnswer(verify, dependencies) {
4325
+ try {
4326
+ return await verify();
4327
+ } catch (err) {
4328
+ if (err instanceof DeviceLoginApiError && err.errorType === LoginErrorType.InvalidCode) {
4329
+ dependencies.printError(err.message);
4330
+ return null;
4331
+ }
4332
+ throw err;
4333
+ }
4334
+ }
4335
+ async function pollUntilApproved(api, deviceCode, intervalMillis, signal, dependencies) {
4336
+ while (!signal.aborted) {
4337
+ await dependencies.sleep(intervalMillis, signal);
4338
+ if (signal.aborted) {
4339
+ break;
4340
+ }
4341
+ try {
4342
+ const answer = await api.poll(deviceCode);
4343
+ if (answer.status === LoginStatus.Approved) {
4344
+ return answer;
4345
+ }
4346
+ } catch (err) {
4347
+ if (err instanceof DeviceLoginApiError && err.status > 0 && err.status < FIRST_SERVER_ERROR_STATUS) {
4348
+ throw err;
4349
+ }
4350
+ }
4351
+ }
4352
+ throw signal.reason ?? new Error("The poll was stopped.");
4353
+ }
4354
+ async function printDeviceLink(approved, linkMinutes, dependencies) {
4355
+ let link;
4356
+ if (approved.open_url) {
4357
+ link = approved.open_url;
4358
+ dependencies.print(`Open the device in a browser. This link signs you in once, within ${linkMinutes} minutes:`);
4359
+ } else if (approved.device_url) {
4360
+ link = approved.device_url;
4361
+ dependencies.print("Open the device in a browser:");
4362
+ } else {
4363
+ return;
4364
+ }
4365
+ dependencies.print(` ${link}`);
4366
+ try {
4367
+ dependencies.print(await dependencies.renderQr(link));
4368
+ } catch {
4369
+ }
4370
+ }
4371
+ function messageOf(err, manage) {
4372
+ if (err instanceof DeviceLoginApiError) {
4373
+ return err.message;
4374
+ }
4375
+ const cause = err?.cause;
4376
+ const causeText = cause instanceof Error ? `: ${cause.message}` : "";
4377
+ const detail = err instanceof Error ? `${err.message}${causeText}` : String(err);
4378
+ return `Could not reach the dashboard at ${buildDashboardHttpUrl(manage)} (${detail}).`;
4379
+ }
4380
+ function waitUntilAborted(signal) {
4381
+ return new Promise((_resolve, reject) => {
4382
+ const stop = () => reject(signal.reason ?? new Error("The wait was stopped."));
4383
+ if (signal.aborted) {
4384
+ stop();
4385
+ return;
4386
+ }
4387
+ signal.addEventListener("abort", stop, { once: true });
4388
+ });
4389
+ }
4390
+ function sleepUnlessAborted(millis, signal) {
4391
+ return new Promise((resolve) => {
4392
+ if (signal.aborted) {
4393
+ resolve();
4394
+ return;
4395
+ }
4396
+ const onAbort = () => {
4397
+ clearTimeout(timer);
4398
+ resolve();
4399
+ };
4400
+ const timer = setTimeout(() => {
4401
+ signal.removeEventListener("abort", onAbort);
4402
+ resolve();
4403
+ }, millis);
4404
+ signal.addEventListener("abort", onAbort, { once: true });
4405
+ });
4406
+ }
4407
+
4408
+ // src/devices/backgroundAgent.ts
4409
+ var POLL_INTERVAL_MILLIS = 500;
4410
+ var CONNECT_WAIT_MILLIS = 15e3;
4411
+ var verifyingReporter = {
4412
+ connecting: () => void 0,
4413
+ connected: () => void 0,
4414
+ reconnecting: () => void 0,
4415
+ warn: (message) => printer_default.warn(message),
4416
+ // The caller prints the error the run ends with.
4417
+ failed: () => void 0,
4418
+ replacingDevice: printingReporter.replacingDevice
4419
+ };
4420
+ function machineBackgroundAgentDependencies() {
4421
+ return {
4422
+ readIdentity: readDeviceIdentity,
4423
+ findDaemon: getDaemonInfo,
4424
+ ensureDaemon: ensureDaemonRunning,
4425
+ client: (port) => new IPCClient(port, "cli"),
4426
+ verify: (token, manage) => startDeviceAgent(token, manage, {
4427
+ verifyOnly: true,
4428
+ reporter: verifyingReporter
4429
+ }).done,
4430
+ sleep: (millis) => new Promise((resolve) => setTimeout(resolve, millis)),
4431
+ now: () => Date.now(),
4432
+ print: (message) => printer_default.print(message),
4433
+ printSuccess: (message) => printer_default.success(message),
4434
+ printWarning: (message) => printer_default.warn(message),
4435
+ printError: (message) => printer_default.error(message),
4436
+ daemonLogPath: getDaemonLogPath
4437
+ };
4438
+ }
4439
+ function isAgentRunning(status) {
4440
+ return status.state !== DeviceAgentState.Stopped;
4441
+ }
4442
+ function differsFromStored(target, stored) {
4443
+ if (!stored?.token) return true;
4444
+ return stored.token !== target.token || stored.server !== (target.manage || DEFAULT_SERVER);
4445
+ }
4446
+ function olderDaemonMessage(daemon) {
4447
+ if (daemon.host === DaemonHost.APP) {
4448
+ return "The Pinggy app runs an older daemon. Update the Pinggy app, or quit it and run this again.";
4449
+ }
4450
+ return "The running daemon predates background devices. Run pinggy daemon stop, then retry. This stops all running tunnels.";
4451
+ }
4452
+ async function readBackgroundAgent(dependencies) {
4453
+ const daemon = dependencies.findDaemon();
4454
+ if (!daemon || !isIpcCompatible(daemon)) {
4455
+ return null;
4456
+ }
4457
+ try {
4458
+ return { daemon, status: await dependencies.client(daemon.port).getDeviceAgentStatus() };
4459
+ } catch (err) {
4460
+ logger.debug("No background device agent to read", { error: errorMessage(err) });
4461
+ return null;
4462
+ }
4463
+ }
4464
+ async function startAgentInBackground(target, dependencies) {
4465
+ if (differsFromStored(target, dependencies.readIdentity())) {
4466
+ const verified = await dependencies.verify(target.token, target.manage);
4467
+ if (verified.error || !verified.welcomed) {
4468
+ dependencies.printError(verified.error?.message ?? "The device did not connect. Nothing changed.");
4469
+ return EXIT_FAILED;
4470
+ }
4471
+ }
4472
+ dependencies.print("Starting the device agent in the background.");
4473
+ let daemon;
4474
+ try {
4475
+ daemon = await dependencies.ensureDaemon();
4476
+ } catch (err) {
4477
+ dependencies.printError(errorMessage(err));
4478
+ return EXIT_FAILED;
4479
+ }
4480
+ const client = dependencies.client(daemon.port);
4481
+ try {
4482
+ const started = await client.startDeviceAgent();
4483
+ if (isErrorResponse(started)) {
4484
+ dependencies.printError(started.message);
4485
+ return EXIT_FAILED;
4486
+ }
4487
+ } catch (err) {
4488
+ dependencies.printError(daemonFailureMessage(err, daemon));
4489
+ return EXIT_FAILED;
4490
+ }
4491
+ return waitUntilConnected(client, daemon, dependencies);
4492
+ }
4493
+ async function stopAgentInBackground(dependencies) {
4494
+ const reading = await readBackgroundAgent(dependencies);
4495
+ if (!reading || !isAgentRunning(reading.status)) {
4496
+ dependencies.print("No device agent is running in the background.");
4497
+ return EXIT_OK;
4498
+ }
4499
+ if (!await stopReadAgent(reading, dependencies)) {
4500
+ return EXIT_FAILED;
4501
+ }
4502
+ dependencies.printSuccess("Stopped. The device shows offline in the dashboard.");
4503
+ return EXIT_OK;
4504
+ }
4505
+ async function stopAgentBeforeRemove(dependencies) {
4506
+ const reading = await readBackgroundAgent(dependencies);
4507
+ if (!reading || !isAgentRunning(reading.status)) {
4508
+ return true;
4509
+ }
4510
+ return stopReadAgent(reading, dependencies);
4511
+ }
4512
+ async function foregroundRefusal(dependencies) {
4513
+ const reading = await readBackgroundAgent(dependencies);
4514
+ if (!reading || !isAgentRunning(reading.status)) {
4515
+ return null;
4516
+ }
4517
+ const device = reading.status.deviceAgentId ? `device ${reading.status.deviceAgentId}` : "a device";
4518
+ return `This machine already runs as ${device} in the background. Stop it first: pinggy device stop`;
4519
+ }
4520
+ async function printBackgroundStatus(dependencies) {
4521
+ const reading = await readBackgroundAgent(dependencies);
4522
+ if (!reading) {
4523
+ return;
4524
+ }
4525
+ dependencies.print(` Background ${describeAgent(reading)}`);
4526
+ if (reading.status.lastError) {
4527
+ dependencies.print(` Last error ${reading.status.lastError.message}`);
4528
+ }
4529
+ }
4530
+ function describeAgentForDaemonStatus(status) {
4531
+ switch (status.state) {
4532
+ case DeviceAgentState.Connected:
4533
+ return `connected as ${status.deviceAgentId}`;
4534
+ case DeviceAgentState.Connecting:
4535
+ return "connecting";
4536
+ case DeviceAgentState.Reconnecting:
4537
+ return "reconnecting";
4538
+ default:
4539
+ return "not running";
4540
+ }
4541
+ }
4542
+ function describeAgent({ daemon, status }) {
4543
+ const host = daemon.host === DaemonHost.APP ? "inside the Pinggy app" : `in the daemon, PID ${daemon.pid}`;
4544
+ switch (status.state) {
4545
+ case DeviceAgentState.Connected:
4546
+ return `connected since ${status.connectedAt}, ${host}`;
4547
+ case DeviceAgentState.Connecting:
4548
+ return `connecting, ${host}`;
4549
+ case DeviceAgentState.Reconnecting:
4550
+ return `reconnecting, ${host}`;
4551
+ default:
4552
+ return "not running";
4553
+ }
4554
+ }
4555
+ async function stopReadAgent(reading, dependencies) {
4556
+ try {
4557
+ await dependencies.client(reading.daemon.port).stopDeviceAgent();
4558
+ return true;
4559
+ } catch (err) {
4560
+ dependencies.printError(`Could not stop the background device agent: ${errorMessage(err)}`);
4561
+ return false;
4562
+ }
4563
+ }
4564
+ function daemonFailureMessage(err, daemon) {
4565
+ if (err instanceof IPCHttpError && err.statusCode === HTTP_NOT_FOUND) {
4566
+ return olderDaemonMessage(daemon);
4567
+ }
4568
+ return `Could not reach the daemon: ${errorMessage(err)}`;
4569
+ }
4570
+ async function waitUntilConnected(client, daemon, dependencies) {
4571
+ const deadline = dependencies.now() + CONNECT_WAIT_MILLIS;
4572
+ for (; ; ) {
4573
+ let status;
4574
+ try {
4575
+ status = await client.getDeviceAgentStatus();
4576
+ } catch (err) {
4577
+ dependencies.printError(daemonFailureMessage(err, daemon));
4578
+ return EXIT_FAILED;
4579
+ }
4580
+ if (status.state === DeviceAgentState.Connected) {
4581
+ printRunning(status, daemon, dependencies);
4582
+ return EXIT_OK;
4583
+ }
4584
+ if (status.state === DeviceAgentState.Stopped) {
4585
+ dependencies.printError(status.lastError?.message ?? "The device agent stopped before it connected.");
4586
+ return EXIT_FAILED;
4587
+ }
4588
+ if (dependencies.now() >= deadline) {
4589
+ dependencies.printWarning("Still connecting in the background. Check with: pinggy device status");
4590
+ return EXIT_OK;
4591
+ }
4592
+ await dependencies.sleep(POLL_INTERVAL_MILLIS);
4593
+ }
4594
+ }
4595
+ function printRunning(status, daemon, dependencies) {
4596
+ dependencies.printSuccess(`Running in the background as device ${status.deviceAgentId}`);
4597
+ if (status.email) {
4598
+ dependencies.print(`Signed in as ${status.email}`);
4599
+ }
4600
+ if (status.deviceUrl) {
4601
+ dependencies.print("Open the device in a browser:");
4602
+ dependencies.print(` ${status.deviceUrl}`);
4603
+ }
4604
+ if (daemon.host === DaemonHost.APP) {
4605
+ dependencies.print("It runs inside the Pinggy app, and stops when the app quits.");
4606
+ }
4607
+ dependencies.print(`Logs: ${dependencies.daemonLogPath()}`);
4608
+ dependencies.print("Stop it with: pinggy device stop");
4609
+ }
4610
+
4611
+ // src/cli/subcommand/handlers/daemonCommandsHandler.ts
4612
+ async function handleDaemon(args) {
4613
+ if (args.length === 0) {
4614
+ printDaemonHelp();
4615
+ return;
4616
+ }
4617
+ const verb = args[0];
4618
+ switch (verb) {
4619
+ case "start":
4620
+ await handleDaemonStart();
4621
+ return;
4622
+ case "stop":
4623
+ await handleDaemonStop();
4624
+ return;
4625
+ case "status":
4626
+ await handleDaemonStatus();
4627
+ return;
4628
+ case "service-install":
4629
+ installService();
4630
+ return;
4631
+ case "service-uninstall":
4632
+ uninstallService();
4633
+ return;
4634
+ default:
4635
+ printer_default.error(`Unknown daemon command: "${verb}"`);
4636
+ printDaemonHelp();
4637
+ return;
4638
+ }
4639
+ }
4640
+ async function handleDaemonStart() {
4641
+ const info = getDaemonInfo();
4642
+ if (info) {
4643
+ if (!isIpcCompatible(info)) {
4644
+ printer_default.error(ipcMismatchMessage(info));
4645
+ process.exit(1);
4646
+ }
4647
+ printer_default.print(pico2.yellow(`Daemon already running (PID ${info.pid}, port ${info.port}). Use: pinggy daemon status for details.`));
4648
+ return;
4649
+ }
4650
+ const autoConfigs = getAutoStartConfigs();
4651
+ if (autoConfigs.length > 0) {
4652
+ printer_default.print(pico2.cyanBright(`Starting daemon with ${autoConfigs.length} auto-start tunnel(s):`));
4653
+ for (const c of autoConfigs) {
4654
+ printer_default.print(` ${pico2.bold(c.name)} (${c.configId.slice(0, 8)})`);
4655
+ }
4656
+ } else {
4657
+ printer_default.print(pico2.cyanBright("Starting daemon..."));
4658
+ }
4659
+ try {
4660
+ const info2 = await startDaemon();
4661
+ printer_default.success(`Daemon started PID ${info2.pid}.`);
4662
+ } catch (err) {
4663
+ printer_default.error(`Failed to start daemon: ${errorMessage(err)}`);
4664
+ process.exit(1);
4665
+ }
4666
+ }
4667
+ async function handleDaemonStop() {
4668
+ if (!isDaemonRunning()) {
4669
+ printer_default.print(pico2.yellow("No daemon is running."));
4670
+ return;
4671
+ }
4672
+ const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
4673
+ const result = await stopDaemon();
4674
+ if (result.ok) {
4675
+ printer_default.success("Daemon stopped.");
4676
+ if (agent && isAgentRunning(agent.status)) {
4677
+ printer_default.print("The background device agent stopped too. The device shows offline in the dashboard.");
4678
+ }
4679
+ } else if (result.reason === DaemonHost.APP) {
4680
+ printer_default.print(pico2.yellow("The Pinggy daemon is running inside the Pinggy desktop app. Please quit the Pinggy app to stop it."));
4681
+ } else {
4682
+ printer_default.error(result.error);
4683
+ }
4684
+ }
4685
+ async function handleDaemonStatus() {
4686
+ const info = getDaemonInfo();
4687
+ if (!info) {
4688
+ printer_default.print(pico2.yellow("No daemon is running. Start with: pinggy daemon start"));
4689
+ return;
4690
+ }
4691
+ const uptimeSec = Math.floor((Date.now() - new Date(info.startedAt).getTime()) / 1e3);
4692
+ const h = Math.floor(uptimeSec / 3600);
4693
+ const m = Math.floor(uptimeSec % 3600 / 60);
4694
+ const s = uptimeSec % 60;
4695
+ const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`;
4696
+ printer_default.print(pico2.cyanBright("Daemon Status"));
4697
+ printer_default.print(` PID: ${info.pid}`);
4698
+ printer_default.print(` Port: ${info.port}`);
4699
+ printer_default.print(` Started: ${info.startedAt}`);
4700
+ printer_default.print(` Uptime: ${uptimeStr}`);
4701
+ printer_default.print(` Host: ${info.host === DaemonHost.APP ? "Pinggy app (in-process)" : "standalone (CLI)"}`);
4702
+ printer_default.print(` IPC: v${daemonIpcVersion(info)}`);
4703
+ if (!isIpcCompatible(info)) {
4704
+ printer_default.print(pico2.yellow(` Warning: ${ipcMismatchMessage(info)}`));
4705
+ }
4706
+ const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
4707
+ if (agent) {
4708
+ printer_default.print(` Device: ${describeAgentForDaemonStatus(agent.status)}`);
4709
+ }
4710
+ }
4711
+
4712
+ // src/cli/subcommand/handlers/psCommand.ts
4713
+ import pico3 from "picocolors";
4714
+ async function handlePs() {
4715
+ const client = new TunnelClient();
4716
+ try {
4717
+ await client.ensureDaemon();
4718
+ } catch (err) {
4719
+ printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
4720
+ return;
4721
+ }
4722
+ try {
4723
+ const tunnels = await client.handleListV2();
4724
+ if (isErrorResponse(tunnels)) {
4725
+ printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
4726
+ return;
4727
+ }
4728
+ if (tunnels.length === 0) {
4729
+ printer_default.print("No tunnels running.");
4730
+ return;
4731
+ }
4732
+ const header = `${pad("ID", 14)} ${pad("NAME", 16)} ${pad("STATUS", 10)} ${pad("LOCAL", 20)} URL`;
4733
+ printer_default.print(pico3.gray(header));
4734
+ printer_default.print(pico3.gray("\u2500".repeat(90)));
4735
+ for (const t of tunnels) {
4736
+ const id = t.tunnelid.slice(0, 12);
4737
+ const name = t.tunnelconfig?.name || "-";
4738
+ const status = t.status.state;
4739
+ const local = getLocalAddress(t.tunnelconfig);
4740
+ const url = t.remoteurls?.[0] || "-";
4741
+ const statusColor = status === "running" ? pico3.green : status === "starting" ? pico3.yellow : pico3.red;
4742
+ printer_default.print(
4743
+ `${pico3.cyan(pad(id, 14))} ${pad(name, 16)} ${statusColor(pad(status, 10))} ${pad(local, 20)} ${pico3.magentaBright(url)}`
4744
+ );
4745
+ }
4746
+ } catch (err) {
4747
+ printer_default.error(`Failed to list tunnels: ${errorMessage(err)}`);
4748
+ } finally {
4749
+ client.close();
4750
+ }
4751
+ }
4752
+ function pad(str, len) {
4753
+ return str.length >= len ? str.slice(0, len) : str + " ".repeat(len - str.length);
4754
+ }
4755
+
4756
+ // src/cli/subcommand/handlers/stopCommand.ts
4757
+ async function handleStop(args) {
4758
+ if (args.length === 0) {
4759
+ printer_default.error("Usage: pinggy stop <name|id> [<name|id> ...]");
4760
+ return;
4761
+ }
4762
+ const client = new TunnelClient();
4763
+ try {
4764
+ await client.ensureDaemon();
4765
+ } catch (err) {
4766
+ printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
4767
+ return;
4768
+ }
4769
+ try {
4770
+ const tunnels = await client.handleListV2();
4771
+ if (isErrorResponse(tunnels)) {
4772
+ printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
4773
+ return;
4774
+ }
4775
+ const targets = resolveTargets(tunnels, args);
4776
+ if (targets.matched.length === 0) {
4777
+ printer_default.error(`No tunnel found matching: ${args.join(", ")}. Use: pinggy ps`);
4778
+ return;
4779
+ }
4780
+ for (const { input, tunnel } of targets.matched) {
4781
+ const result = await client.handleStop(tunnel.tunnelid);
4782
+ if (isErrorResponse(result)) {
4783
+ printer_default.error(`Failed to stop "${input}": ${result.message}`);
4784
+ continue;
4785
+ }
4786
+ const name = tunnel.tunnelconfig.name || tunnel.tunnelid.slice(0, 12);
4787
+ printer_default.success(`Tunnel "${name}" stopped.`);
4788
+ }
4789
+ for (const m of targets.missing) {
4790
+ printer_default.warn(`No tunnel found matching "${m}".`);
4791
+ }
4792
+ } catch (err) {
4793
+ printer_default.error(`Failed to stop tunnel: ${errorMessage(err)}`);
4794
+ } finally {
4795
+ client.close();
4796
+ }
4797
+ }
4798
+ function resolveTargets(tunnels, args) {
4799
+ if (args.length > 1) {
4800
+ const input = args.join(" ");
4801
+ const tunnel = findTunnel(tunnels, input);
4802
+ if (tunnel) return { matched: [{ input, tunnel }], missing: [] };
4803
+ }
4804
+ const matched = [];
4805
+ const missing = [];
4806
+ for (const input of args) {
4807
+ const tunnel = findTunnel(tunnels, input);
4808
+ if (tunnel) matched.push({ input, tunnel });
4809
+ else missing.push(input);
4810
+ }
4811
+ return { matched, missing };
4812
+ }
4813
+
4814
+ // src/cli/subcommand/handlers/attachCommand.ts
4815
+ import pico4 from "picocolors";
4816
+ async function handleAttach(args) {
4817
+ if (args.length === 0) {
4818
+ printer_default.error("Usage: pinggy attach <name|id>");
4819
+ return;
4820
+ }
4821
+ const nameOrId = args[0];
4822
+ const client = new TunnelClient();
4823
+ try {
4824
+ await client.ensureDaemon();
4825
+ } catch (err) {
4826
+ printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
4827
+ return;
4828
+ }
4829
+ try {
1972
4830
  const tunnels = await client.handleListV2();
1973
4831
  if (isErrorResponse(tunnels)) {
1974
4832
  printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
@@ -2010,7 +4868,7 @@ async function handleAttach(args) {
2010
4868
  }
2011
4869
 
2012
4870
  // src/cli/subcommand/handlers/logsCommand.ts
2013
- import fs3 from "fs";
4871
+ import fs8 from "fs";
2014
4872
  async function handleLogs(args, follow) {
2015
4873
  const arg = args.find((a) => !a.startsWith("-"));
2016
4874
  const client = new TunnelClient();
@@ -2050,7 +4908,7 @@ async function handleLogs(args, follow) {
2050
4908
  return;
2051
4909
  }
2052
4910
  client.close();
2053
- if (!filePath || !fs3.existsSync(filePath)) {
4911
+ if (!filePath || !fs8.existsSync(filePath)) {
2054
4912
  printer_default.error(`Log file not found: ${filePath}`);
2055
4913
  process.exit(1);
2056
4914
  }
@@ -2061,18 +4919,18 @@ async function handleLogs(args, follow) {
2061
4919
  }
2062
4920
  }
2063
4921
  function printTail(filePath, lines) {
2064
- const content = fs3.readFileSync(filePath, "utf-8");
4922
+ const content = fs8.readFileSync(filePath, "utf-8");
2065
4923
  const allLines = content.split("\n");
2066
4924
  const tail = allLines.slice(-lines).join("\n");
2067
4925
  process.stdout.write(tail);
2068
4926
  if (!tail.endsWith("\n")) process.stdout.write("\n");
2069
4927
  }
2070
4928
  async function followFile(filePath) {
2071
- if (fs3.existsSync(filePath)) printTail(filePath, 20);
2072
- let fileSize = fs3.existsSync(filePath) ? fs3.statSync(filePath).size : 0;
4929
+ if (fs8.existsSync(filePath)) printTail(filePath, 20);
4930
+ let fileSize = fs8.existsSync(filePath) ? fs8.statSync(filePath).size : 0;
2073
4931
  let watcher = null;
2074
4932
  const openStream = () => {
2075
- const stream = fs3.createReadStream(filePath, { start: fileSize, encoding: "utf-8" });
4933
+ const stream = fs8.createReadStream(filePath, { start: fileSize, encoding: "utf-8" });
2076
4934
  stream.on("data", (chunk) => {
2077
4935
  process.stdout.write(chunk);
2078
4936
  fileSize += Buffer.byteLength(chunk, "utf-8");
@@ -2080,9 +4938,9 @@ async function followFile(filePath) {
2080
4938
  stream.on("error", () => {
2081
4939
  });
2082
4940
  };
2083
- watcher = fs3.watch(filePath, { persistent: true }, (event) => {
2084
- if (event === "change") {
2085
- const stat = fs3.existsSync(filePath) ? fs3.statSync(filePath) : null;
4941
+ watcher = fs8.watch(filePath, { persistent: true }, (event2) => {
4942
+ if (event2 === "change") {
4943
+ const stat = fs8.existsSync(filePath) ? fs8.statSync(filePath) : null;
2086
4944
  if (!stat || stat.size < fileSize) {
2087
4945
  fileSize = 0;
2088
4946
  }
@@ -2224,6 +5082,145 @@ async function handleRestart(args) {
2224
5082
  }
2225
5083
  }
2226
5084
 
5085
+ // src/cli/subcommand/handlers/deviceCommand.ts
5086
+ import pico5 from "picocolors";
5087
+ var DeviceVerb = {
5088
+ Login: "login",
5089
+ Connect: "connect",
5090
+ Stop: "stop",
5091
+ Status: "status",
5092
+ Remove: "remove"
5093
+ };
5094
+ var DEVICE_AGENT_TOKEN_VARIABLE = "DEVICE_AGENT_TOKEN";
5095
+ function takeEnvironmentToken() {
5096
+ const value = process.env[DEVICE_AGENT_TOKEN_VARIABLE]?.trim();
5097
+ delete process.env[DEVICE_AGENT_TOKEN_VARIABLE];
5098
+ return value || void 0;
5099
+ }
5100
+ function resolveConnectTarget(flagToken, environmentToken, manage, stored) {
5101
+ if (flagToken) {
5102
+ return { token: flagToken, manage: manage ?? stored?.server };
5103
+ }
5104
+ if (environmentToken) {
5105
+ return { token: environmentToken, manage };
5106
+ }
5107
+ if (stored?.token) {
5108
+ return { token: stored.token, manage: manage ?? stored.server };
5109
+ }
5110
+ return null;
5111
+ }
5112
+ async function handleDevice(args, background = machineBackgroundAgentDependencies()) {
5113
+ const environmentToken = takeEnvironmentToken();
5114
+ if (args.length === 0) {
5115
+ printDeviceHelp();
5116
+ return;
5117
+ }
5118
+ const verb = args[0];
5119
+ const flagArgs = args.slice(1);
5120
+ const { values } = parseCliArgs(cliOptions, flagArgs);
5121
+ configureLogger(values);
5122
+ const inBackground = Boolean(values.b);
5123
+ switch (verb) {
5124
+ case DeviceVerb.Login:
5125
+ exitUnlessOk(await handleDeviceLogin(values.manage, values.name, inBackground, background));
5126
+ return;
5127
+ case DeviceVerb.Connect:
5128
+ exitUnlessOk(await handleDeviceConnect(
5129
+ values.token,
5130
+ environmentToken,
5131
+ values.manage,
5132
+ inBackground,
5133
+ background
5134
+ ));
5135
+ return;
5136
+ case DeviceVerb.Stop:
5137
+ exitUnlessOk(await stopAgentInBackground(background));
5138
+ return;
5139
+ case DeviceVerb.Status:
5140
+ await handleDeviceStatus(background);
5141
+ return;
5142
+ case DeviceVerb.Remove:
5143
+ exitUnlessOk(await handleDeviceRemove(background));
5144
+ return;
5145
+ default:
5146
+ if (verb === "help" || verb === "--help" || verb === "-h") {
5147
+ printDeviceHelp();
5148
+ return;
5149
+ }
5150
+ printer_default.error(`Unknown command "${verb}". Valid commands: login, connect, stop, status, remove.`);
5151
+ process.exit(1);
5152
+ }
5153
+ }
5154
+ function exitUnlessOk(exitCode) {
5155
+ if (exitCode !== EXIT_OK) {
5156
+ process.exit(exitCode);
5157
+ }
5158
+ }
5159
+ function refuseDevicesSpelling(args) {
5160
+ printer_default.error(devicesSpellingMessage(args));
5161
+ process.exit(1);
5162
+ }
5163
+ function devicesSpellingMessage(args) {
5164
+ const verbs = Object.values(DeviceVerb);
5165
+ const suggestion = verbs.includes(args[0]) ? args[0] : "--help";
5166
+ return `"pinggy devices" is now "pinggy device". Run: pinggy device ${suggestion}`;
5167
+ }
5168
+ async function mayRunInForeground(background) {
5169
+ const refusal = await foregroundRefusal(background);
5170
+ if (refusal) {
5171
+ printer_default.error(refusal);
5172
+ return false;
5173
+ }
5174
+ return true;
5175
+ }
5176
+ async function handleDeviceLogin(manage, name, inBackground, background) {
5177
+ if (!inBackground) {
5178
+ if (!await mayRunInForeground(background)) return EXIT_FAILED;
5179
+ return runDeviceLogin({ manage, name });
5180
+ }
5181
+ return runDeviceLogin({ manage, name }, {
5182
+ ...machineDeviceLoginDependencies(),
5183
+ runAgent: (token, agentManage) => startAgentInBackground({ token, manage: agentManage }, background)
5184
+ });
5185
+ }
5186
+ async function handleDeviceConnect(flagToken, environmentToken, manage, inBackground, background) {
5187
+ const target = resolveConnectTarget(flagToken, environmentToken, manage, background.readIdentity());
5188
+ if (!target) {
5189
+ printer_default.error("This machine is not enrolled. Sign it in with your email:\n pinggy device login\nOr add a device in the dashboard, then run:\n pinggy device connect --token <TOKEN>");
5190
+ return EXIT_FAILED;
5191
+ }
5192
+ if (inBackground) {
5193
+ return startAgentInBackground(target, background);
5194
+ }
5195
+ if (!await mayRunInForeground(background)) return EXIT_FAILED;
5196
+ await runDeviceAgent(target.token, target.manage);
5197
+ return EXIT_OK;
5198
+ }
5199
+ async function handleDeviceStatus(background) {
5200
+ const identity = background.readIdentity();
5201
+ if (!identity) {
5202
+ printer_default.print("This machine is not enrolled. Run: pinggy device login");
5203
+ return;
5204
+ }
5205
+ printer_default.print(` Device id ${identity.device_agent_id ?? pico5.gray("not yet assigned")}`);
5206
+ printer_default.print(` Server ${identity.server}`);
5207
+ printer_default.print(` Token ${maskToken(identity.token)}`);
5208
+ printer_default.print(` Enrolled ${identity.enrolled_at ?? pico5.gray("never")}`);
5209
+ await printBackgroundStatus(background);
5210
+ }
5211
+ async function handleDeviceRemove(background) {
5212
+ if (!await stopAgentBeforeRemove(background)) {
5213
+ return EXIT_FAILED;
5214
+ }
5215
+ if (clearDeviceIdentity()) {
5216
+ printer_default.success("Local device credential removed.");
5217
+ printer_default.print(pico5.gray("The device still exists in the dashboard. Delete it there to revoke it."));
5218
+ } else {
5219
+ printer_default.print("This machine is not enrolled.");
5220
+ }
5221
+ return EXIT_OK;
5222
+ }
5223
+
2227
5224
  // src/cli/subcommand/subcommands.ts
2228
5225
  var SUBCOMMAND_SET = new Set(SUBCOMMANDS);
2229
5226
  function isSubcommand(rawArgs) {
@@ -2264,6 +5261,12 @@ async function handleSubcommand(rawArgs) {
2264
5261
  case Subcommand.Restart:
2265
5262
  await handleRestart(rest);
2266
5263
  return;
5264
+ case Subcommand.Device:
5265
+ await handleDevice(rest);
5266
+ return;
5267
+ case Subcommand.Devices:
5268
+ refuseDevicesSpelling(rest);
5269
+ return;
2267
5270
  }
2268
5271
  }
2269
5272
  function handleConfig(args) {
@@ -2486,17 +5489,17 @@ async function initRemoteManagementBackground(values) {
2486
5489
  }
2487
5490
 
2488
5491
  // src/daemon/lifecycle/daemonChild.ts
2489
- import fs6 from "fs";
5492
+ import fs11 from "fs";
2490
5493
 
2491
5494
  // src/daemon/ipc/ipcServer.ts
2492
5495
  import http from "http";
2493
- import fs5 from "fs";
2494
- import path4 from "path";
2495
- import { WebSocketServer, WebSocket } from "ws";
5496
+ import fs10 from "fs";
5497
+ import path5 from "path";
5498
+ import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
2496
5499
 
2497
5500
  // src/daemon/ws/wsProtocol.ts
2498
- function createTunnelEvent(tunnelId, event, payload) {
2499
- return { type: "tunnel_event", tunnelId, event, payload };
5501
+ function createTunnelEvent(tunnelId, event2, payload) {
5502
+ return { type: "tunnel_event", tunnelId, event: event2, payload };
2500
5503
  }
2501
5504
  function parseClientMessage(raw) {
2502
5505
  try {
@@ -2518,17 +5521,17 @@ function parseClientMessage(raw) {
2518
5521
  }
2519
5522
 
2520
5523
  // src/daemon/lifecycle/stateStore.ts
2521
- import fs4 from "fs";
2522
- import path3 from "path";
5524
+ import fs9 from "fs";
5525
+ import path4 from "path";
2523
5526
  var STATE_FILENAME = "daemon-state.json";
2524
5527
  function getStatePath() {
2525
- return path3.join(getPinggyConfigDir(), STATE_FILENAME);
5528
+ return path4.join(getPinggyConfigDir(), STATE_FILENAME);
2526
5529
  }
2527
5530
  function loadDaemonState() {
2528
5531
  const statePath = getStatePath();
2529
5532
  try {
2530
- if (!fs4.existsSync(statePath)) return null;
2531
- const raw = fs4.readFileSync(statePath, "utf-8");
5533
+ if (!fs9.existsSync(statePath)) return null;
5534
+ const raw = fs9.readFileSync(statePath, "utf-8");
2532
5535
  const state = JSON.parse(raw);
2533
5536
  if (!Array.isArray(state.tunnels)) return null;
2534
5537
  return state;
@@ -2542,8 +5545,8 @@ function persistDaemonState(state) {
2542
5545
  const tmpPath = statePath + ".tmp";
2543
5546
  try {
2544
5547
  state.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
2545
- fs4.writeFileSync(tmpPath, JSON.stringify(state, null, 2), "utf-8");
2546
- fs4.renameSync(tmpPath, statePath);
5548
+ fs9.writeFileSync(tmpPath, JSON.stringify(state, null, 2), "utf-8");
5549
+ fs9.renameSync(tmpPath, statePath);
2547
5550
  } catch (err) {
2548
5551
  logger.error("Failed to persist daemon state", { error: errorMessage(err) });
2549
5552
  }
@@ -2551,8 +5554,8 @@ function persistDaemonState(state) {
2551
5554
  function clearDaemonState() {
2552
5555
  const statePath = getStatePath();
2553
5556
  try {
2554
- if (fs4.existsSync(statePath)) {
2555
- fs4.unlinkSync(statePath);
5557
+ if (fs9.existsSync(statePath)) {
5558
+ fs9.unlinkSync(statePath);
2556
5559
  }
2557
5560
  } catch {
2558
5561
  }
@@ -2566,7 +5569,7 @@ function removeTunnelFromState(state, tunnelId) {
2566
5569
  }
2567
5570
 
2568
5571
  // src/daemon/ipc/ipcServer.ts
2569
- var VALID_ORIGINS = ["app", "cli", "remote"];
5572
+ var VALID_ORIGINS = ["app", "cli", "remote", "device"];
2570
5573
  function parseOrigin(req) {
2571
5574
  const raw = req.headers["x-pinggy-origin"];
2572
5575
  const v = Array.isArray(raw) ? raw[0] : raw;
@@ -2599,6 +5602,7 @@ var IPCServer = class {
2599
5602
  this.sessionCounter = 0;
2600
5603
  this.onSessionDisconnect = null;
2601
5604
  this.sessionTracker = null;
5605
+ this.deviceAgentHost = null;
2602
5606
  this.ops = new TunnelOperations();
2603
5607
  this.startedAt = Date.now();
2604
5608
  this.server = http.createServer(this.handleRequest.bind(this));
@@ -2616,6 +5620,13 @@ var IPCServer = class {
2616
5620
  setSessionTracker(st) {
2617
5621
  this.sessionTracker = st;
2618
5622
  }
5623
+ setDeviceAgentHost(host) {
5624
+ this.deviceAgentHost = host;
5625
+ }
5626
+ requireDeviceAgentHost() {
5627
+ if (!this.deviceAgentHost) throw new Error("The device agent is not available in this daemon yet");
5628
+ return this.deviceAgentHost;
5629
+ }
2619
5630
  /**
2620
5631
  * Get all active sessions (for SessionTracker inspection).
2621
5632
  */
@@ -2720,8 +5731,8 @@ var IPCServer = class {
2720
5731
  const logDir = getTunnelLogDir();
2721
5732
  const daemonPath = getDaemonLogPath();
2722
5733
  const tunnels = [];
2723
- if (fs5.existsSync(logDir)) {
2724
- const files = fs5.readdirSync(logDir).filter((f) => f.endsWith(".log") && !/\.log\.\d+$/.test(f));
5734
+ if (fs10.existsSync(logDir)) {
5735
+ const files = fs10.readdirSync(logDir).filter((f) => f.endsWith(".log") && !/\.log\.\d+$/.test(f));
2725
5736
  const manager = TunnelManager.getInstance();
2726
5737
  const activeIds = manager.getActiveTunnelIds();
2727
5738
  const activeNames = /* @__PURE__ */ new Set();
@@ -2731,8 +5742,8 @@ var IPCServer = class {
2731
5742
  if (n) activeNames.add(n);
2732
5743
  }
2733
5744
  for (const file of files) {
2734
- const filePath = path4.join(logDir, file);
2735
- const stat = fs5.statSync(filePath);
5745
+ const filePath = path5.join(logDir, file);
5746
+ const stat = fs10.statSync(filePath);
2736
5747
  const parsed = parseTunnelLogFilename(file);
2737
5748
  if (!parsed) continue;
2738
5749
  const running = parsed.tunnelId ? activeIds.has(parsed.tunnelId) : parsed.name ? activeNames.has(parsed.name) : false;
@@ -2748,6 +5759,10 @@ var IPCServer = class {
2748
5759
  }
2749
5760
  return { daemon: daemonPath, tunnels };
2750
5761
  },
5762
+ // The request body is never read: a token or a server in it must not reach the agent.
5763
+ [Route.GetDeviceAgent]: () => this.requireDeviceAgentHost().status(),
5764
+ [Route.StartDeviceAgent]: (_req, ctx) => this.requireDeviceAgentHost().start(ctx.origin),
5765
+ [Route.StopDeviceAgent]: () => this.requireDeviceAgentHost().stop(),
2751
5766
  [Route.Shutdown]: () => {
2752
5767
  logger.info("Daemon shutdown requested via IPC");
2753
5768
  if (getDaemonHost() === DaemonHost.APP) {
@@ -2764,6 +5779,9 @@ var IPCServer = class {
2764
5779
  logger.error(`Shutdown step "${label}" failed`, { error: msg });
2765
5780
  }
2766
5781
  };
5782
+ step("stopDeviceAgent", () => {
5783
+ void this.deviceAgentHost?.close();
5784
+ });
2767
5785
  step("removeDaemonInfo", removeDaemonInfo);
2768
5786
  step("clearDaemonState", clearDaemonState);
2769
5787
  step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels());
@@ -2796,8 +5814,8 @@ var IPCServer = class {
2796
5814
  if (paramMatch) {
2797
5815
  try {
2798
5816
  await this.readBody(req);
2799
- const response = await paramMatch.invoke();
2800
- this.sendJson(res, 200, response);
5817
+ const response2 = await paramMatch.invoke();
5818
+ this.sendJson(res, 200, response2);
2801
5819
  } catch (err) {
2802
5820
  const msg = errorMessage(err);
2803
5821
  logger.error("IPC handler error", { route: `${method} ${url}`, error: msg });
@@ -2844,8 +5862,8 @@ var IPCServer = class {
2844
5862
  return { status: "running", path: logPath, tunnelId: t.tunnelid, name, origin, running: true };
2845
5863
  }
2846
5864
  }
2847
- if (fs5.existsSync(logDir)) {
2848
- const files = fs5.readdirSync(logDir).filter((f) => f.endsWith(".log") && !/\.log\.\d+$/.test(f));
5865
+ if (fs10.existsSync(logDir)) {
5866
+ const files = fs10.readdirSync(logDir).filter((f) => f.endsWith(".log") && !/\.log\.\d+$/.test(f));
2849
5867
  const matches = [];
2850
5868
  for (const file of files) {
2851
5869
  const parsed = parseTunnelLogFilename(file);
@@ -2853,8 +5871,8 @@ var IPCServer = class {
2853
5871
  const nameMatch = parsed.name !== void 0 && parsed.name === q;
2854
5872
  const idMatch = parsed.tunnelId !== void 0 && (parsed.tunnelId === q || parsed.tunnelId.startsWith(q));
2855
5873
  if (nameMatch || idMatch) {
2856
- const filePath = path4.join(logDir, file);
2857
- const stat = fs5.statSync(filePath);
5874
+ const filePath = path5.join(logDir, file);
5875
+ const stat = fs10.statSync(filePath);
2858
5876
  matches.push({ path: filePath, mtime: stat.mtimeMs, tunnelId: parsed.tunnelId, name: parsed.name, origin: parsed.origin });
2859
5877
  }
2860
5878
  }
@@ -3030,9 +6048,9 @@ var IPCServer = class {
3030
6048
  session.subscriptions.clear();
3031
6049
  this.sessions.delete(session.id);
3032
6050
  }
3033
- sendEvent(session, tunnelId, event, payload) {
3034
- if (session.ws.readyState === WebSocket.OPEN) {
3035
- const msg = createTunnelEvent(tunnelId, event, payload);
6051
+ sendEvent(session, tunnelId, event2, payload) {
6052
+ if (session.ws.readyState === WebSocket2.OPEN) {
6053
+ const msg = createTunnelEvent(tunnelId, event2, payload);
3036
6054
  session.ws.send(JSON.stringify(msg));
3037
6055
  }
3038
6056
  }
@@ -3209,6 +6227,196 @@ var SessionTracker = class {
3209
6227
  }
3210
6228
  };
3211
6229
 
6230
+ // src/daemon/deviceAgentHost.ts
6231
+ var NOT_ENROLLED_MESSAGE = "This machine is not enrolled. Sign it in first: pinggy device login";
6232
+ function stoppedStatus() {
6233
+ return {
6234
+ state: DeviceAgentState.Stopped,
6235
+ deviceAgentId: null,
6236
+ server: null,
6237
+ email: null,
6238
+ deviceUrl: null,
6239
+ startedAt: null,
6240
+ connectedAt: null,
6241
+ startedBy: null,
6242
+ lastError: null
6243
+ };
6244
+ }
6245
+ var DeviceAgentHost = class {
6246
+ constructor(dependencies) {
6247
+ this.dependencies = dependencies;
6248
+ this.current = null;
6249
+ this.statusValue = stoppedStatus();
6250
+ /** Bumped on every start and stop. A run's reporter whose number is old belongs to a run that ended. */
6251
+ this.generation = 0;
6252
+ /** Set once the daemon shuts down. Nothing starts and nothing more reaches daemon-state.json. */
6253
+ this.closed = false;
6254
+ /** Start and stop chain on this, so 1 finishes before the next begins. */
6255
+ this.queue = Promise.resolve();
6256
+ }
6257
+ status() {
6258
+ return { ...this.statusValue };
6259
+ }
6260
+ /**
6261
+ * Starts the agent on what device.json holds now. A run on the same token and server is left alone.
6262
+ * A run on another is stopped first.
6263
+ */
6264
+ start(startedBy) {
6265
+ return this.exclusive(async () => {
6266
+ if (this.closed) {
6267
+ return this.status();
6268
+ }
6269
+ const identity = this.dependencies.readIdentity();
6270
+ if (!identity?.token) {
6271
+ return newErrorResponse(ErrorCode.DeviceNotEnrolled, NOT_ENROLLED_MESSAGE);
6272
+ }
6273
+ const current = this.current;
6274
+ if (current && current.token === identity.token && current.server === identity.server) {
6275
+ return this.status();
6276
+ }
6277
+ await this.stopCurrent();
6278
+ this.launch(identity, startedBy);
6279
+ return this.status();
6280
+ });
6281
+ }
6282
+ /** Stops the run: the socket closes with 1000 and every shell ends. */
6283
+ stop() {
6284
+ return this.exclusive(async () => {
6285
+ await this.stopCurrent();
6286
+ return this.status();
6287
+ });
6288
+ }
6289
+ /**
6290
+ * The daemon is shutting down. Stops the run at once, outside the queue, and writes nothing to
6291
+ * daemon-state.json: the shutdown deletes that file, and a write after it would bring the file back.
6292
+ */
6293
+ close() {
6294
+ this.closed = true;
6295
+ const current = this.current;
6296
+ this.current = null;
6297
+ this.generation += 1;
6298
+ this.statusValue = { ...this.statusValue, state: DeviceAgentState.Stopped, connectedAt: null };
6299
+ return current ? current.run.stop().then(() => void 0) : Promise.resolve();
6300
+ }
6301
+ exclusive(operation) {
6302
+ const result = this.queue.then(operation);
6303
+ this.queue = result.catch(() => void 0);
6304
+ return result;
6305
+ }
6306
+ launch(identity, startedBy) {
6307
+ this.generation += 1;
6308
+ const generation = this.generation;
6309
+ this.statusValue = {
6310
+ state: DeviceAgentState.Connecting,
6311
+ deviceAgentId: identity.device_agent_id,
6312
+ server: identity.server,
6313
+ email: null,
6314
+ deviceUrl: null,
6315
+ startedAt: this.dependencies.now().toISOString(),
6316
+ connectedAt: null,
6317
+ startedBy,
6318
+ lastError: null
6319
+ };
6320
+ const run2 = this.dependencies.startRun(identity, this.reporterFor(generation));
6321
+ this.current = { run: run2, token: identity.token, server: identity.server };
6322
+ this.dependencies.recordRunning(startedBy);
6323
+ logger.info("Device agent started in the daemon", { startedBy });
6324
+ void run2.done.then((end) => this.onRunEnded(generation, end));
6325
+ }
6326
+ async stopCurrent() {
6327
+ const current = this.current;
6328
+ if (!current) {
6329
+ return;
6330
+ }
6331
+ this.current = null;
6332
+ this.generation += 1;
6333
+ this.statusValue = { ...this.statusValue, state: DeviceAgentState.Stopped, connectedAt: null };
6334
+ if (!this.closed) {
6335
+ this.dependencies.recordStopped();
6336
+ }
6337
+ await current.run.stop();
6338
+ logger.info("Device agent stopped in the daemon");
6339
+ }
6340
+ /** The run ended on its own: the dashboard refused the credential or ended the device. */
6341
+ onRunEnded(generation, end) {
6342
+ if (generation !== this.generation) {
6343
+ return;
6344
+ }
6345
+ this.current = null;
6346
+ this.statusValue = {
6347
+ ...this.statusValue,
6348
+ state: DeviceAgentState.Stopped,
6349
+ connectedAt: null,
6350
+ lastError: end.error
6351
+ };
6352
+ if (!this.closed) {
6353
+ this.dependencies.recordStopped();
6354
+ }
6355
+ }
6356
+ /** Logs every event, and keeps the latest for `GET /device`. Never logs the email or a payload. */
6357
+ reporterFor(generation) {
6358
+ const isCurrent = () => generation === this.generation;
6359
+ let welcomedOnce = false;
6360
+ const notConnected = () => welcomedOnce ? DeviceAgentState.Reconnecting : DeviceAgentState.Connecting;
6361
+ return {
6362
+ connecting: (server) => {
6363
+ if (!isCurrent()) return;
6364
+ this.statusValue = { ...this.statusValue, state: notConnected(), connectedAt: null };
6365
+ logger.info("Device agent connecting", { server });
6366
+ },
6367
+ connected: (welcome) => {
6368
+ if (!isCurrent()) return;
6369
+ welcomedOnce = true;
6370
+ this.statusValue = {
6371
+ ...this.statusValue,
6372
+ state: DeviceAgentState.Connected,
6373
+ deviceAgentId: welcome.device_agent_id,
6374
+ email: welcome.email ?? null,
6375
+ deviceUrl: welcome.device_url ?? null,
6376
+ connectedAt: this.dependencies.now().toISOString()
6377
+ };
6378
+ logger.info("Device agent connected", { deviceAgentId: welcome.device_agent_id });
6379
+ },
6380
+ reconnecting: (delayMillis, attempt) => {
6381
+ if (!isCurrent()) return;
6382
+ this.statusValue = { ...this.statusValue, state: notConnected(), connectedAt: null };
6383
+ logger.warn("Device agent disconnected", { delayMillis, attempt });
6384
+ },
6385
+ warn: (message) => {
6386
+ logger.warn("Device agent warning", { message });
6387
+ },
6388
+ failed: (error) => {
6389
+ logger.error("Device agent ended", { code: error.code });
6390
+ },
6391
+ replacingDevice: (oldDeviceAgentId) => {
6392
+ logger.info("Device agent connects a new device in place of a stored one", { oldDeviceAgentId });
6393
+ }
6394
+ };
6395
+ }
6396
+ };
6397
+ function writeIdentityWhileOwned(identity) {
6398
+ const stored = readDeviceIdentity();
6399
+ if (stored && stored.token !== identity.token) {
6400
+ logger.info("Device agent left a newer device.json in place");
6401
+ return;
6402
+ }
6403
+ writeDeviceIdentity(identity);
6404
+ }
6405
+ function machineDeviceAgentHostDependencies(port, record) {
6406
+ return {
6407
+ readIdentity: readDeviceIdentity,
6408
+ startRun: (identity, reporter) => startDeviceAgent(identity.token, identity.server, {
6409
+ reporter,
6410
+ tunnelListSource: selfTunnelListSource(port),
6411
+ tunnelActions: selfTunnelActionDependencies(port),
6412
+ writeIdentity: writeIdentityWhileOwned
6413
+ }),
6414
+ recordRunning: record.recordRunning,
6415
+ recordStopped: record.recordStopped,
6416
+ now: () => /* @__PURE__ */ new Date()
6417
+ };
6418
+ }
6419
+
3212
6420
  // src/daemon/lifecycle/daemonChild.ts
3213
6421
  var daemonState = { tunnels: [], lastUpdated: "" };
3214
6422
  var daemonHost = DaemonHost.CLI;
@@ -3223,13 +6431,13 @@ function writeDaemonInfo(info) {
3223
6431
  ensurePinggyConfigDir();
3224
6432
  const infoPath = getDaemonInfoPath();
3225
6433
  const tmpPath = infoPath + ".tmp";
3226
- fs6.writeFileSync(tmpPath, JSON.stringify(info, null, 2), "utf-8");
3227
- fs6.renameSync(tmpPath, infoPath);
6434
+ fs11.writeFileSync(tmpPath, JSON.stringify(info, null, 2), "utf-8");
6435
+ fs11.renameSync(tmpPath, infoPath);
3228
6436
  }
3229
6437
  function removeDaemonInfo() {
3230
6438
  try {
3231
6439
  const infoPath = getDaemonInfoPath();
3232
- if (fs6.existsSync(infoPath)) fs6.unlinkSync(infoPath);
6440
+ if (fs11.existsSync(infoPath)) fs11.unlinkSync(infoPath);
3233
6441
  } catch {
3234
6442
  }
3235
6443
  }
@@ -3254,6 +6462,29 @@ function trackTunnelStop(tunnelId) {
3254
6462
  persistDaemonState(daemonState);
3255
6463
  sessionTrackerRef?.removeTunnel(tunnelId);
3256
6464
  }
6465
+ function trackDeviceAgentStart(startedBy) {
6466
+ daemonState.deviceAgent = { startedAt: (/* @__PURE__ */ new Date()).toISOString(), startedBy };
6467
+ persistDaemonState(daemonState);
6468
+ }
6469
+ function trackDeviceAgentStop() {
6470
+ if (!daemonState.deviceAgent) return;
6471
+ delete daemonState.deviceAgent;
6472
+ persistDaemonState(daemonState);
6473
+ }
6474
+ async function restoreCrashedDeviceAgent(host, saved) {
6475
+ try {
6476
+ const result = await host.start(saved.startedBy);
6477
+ if (isErrorResponse(result)) {
6478
+ logger.warn("Crash recovery: device agent not restored", { reason: result.message });
6479
+ trackDeviceAgentStop();
6480
+ return;
6481
+ }
6482
+ logger.info("Crash recovery: device agent restored");
6483
+ } catch (err) {
6484
+ logger.error("Crash recovery: device agent failed to start", { error: errorMessage(err) });
6485
+ trackDeviceAgentStop();
6486
+ }
6487
+ }
3257
6488
  function trackIPCTunnelStart(tunnelId, origin, mode = SessionMode.Detached) {
3258
6489
  if (mode === SessionMode.Foreground) return;
3259
6490
  const manager = TunnelManager.getInstance();
@@ -3367,11 +6598,16 @@ async function runDaemonChild(opts = {}) {
3367
6598
  });
3368
6599
  ipcServer.setSessionTracker(sessionTracker);
3369
6600
  setDaemonSessionTracker(sessionTracker);
6601
+ let deviceAgentHost = null;
3370
6602
  let cleanedUp = false;
3371
6603
  const cleanup = () => {
3372
6604
  if (cleanedUp) return;
3373
6605
  cleanedUp = true;
3374
6606
  logger.info("Daemon shutting down");
6607
+ try {
6608
+ void deviceAgentHost?.close();
6609
+ } catch {
6610
+ }
3375
6611
  try {
3376
6612
  removeDaemonInfo();
3377
6613
  } catch {
@@ -3417,6 +6653,15 @@ async function runDaemonChild(opts = {}) {
3417
6653
  }
3418
6654
  try {
3419
6655
  const port = await ipcServer.listen();
6656
+ deviceAgentHost = new DeviceAgentHost(machineDeviceAgentHostDependencies(port, {
6657
+ recordRunning: trackDeviceAgentStart,
6658
+ recordStopped: trackDeviceAgentStop
6659
+ }));
6660
+ ipcServer.setDeviceAgentHost(deviceAgentHost);
6661
+ const crashedDeviceAgent = loadDaemonState()?.deviceAgent;
6662
+ if (crashedDeviceAgent) {
6663
+ daemonState.deviceAgent = crashedDeviceAgent;
6664
+ }
3420
6665
  const info = {
3421
6666
  pid: process.pid,
3422
6667
  port,
@@ -3440,6 +6685,9 @@ async function runDaemonChild(opts = {}) {
3440
6685
  } else {
3441
6686
  logger.info("No auto-start tunnels configured");
3442
6687
  }
6688
+ if (crashedDeviceAgent) {
6689
+ await restoreCrashedDeviceAgent(deviceAgentHost, crashedDeviceAgent);
6690
+ }
3443
6691
  logger.info("Daemon ready", { pid: process.pid, port });
3444
6692
  return {
3445
6693
  pid: process.pid,
@@ -3463,7 +6711,7 @@ async function main() {
3463
6711
  const { values, positionals, hasAnyArgs } = parseCliArgs(cliOptions);
3464
6712
  configureLogger(values);
3465
6713
  if (values["_daemon-child"]) {
3466
- const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-VX7SCGVS.js");
6714
+ const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-E73NFWKE.js");
3467
6715
  await runDaemonChild2();
3468
6716
  return;
3469
6717
  }
@@ -3502,6 +6750,8 @@ export {
3502
6750
  removeDaemonInfo,
3503
6751
  trackTunnelStart,
3504
6752
  trackTunnelStop,
6753
+ trackDeviceAgentStart,
6754
+ trackDeviceAgentStop,
3505
6755
  trackIPCTunnelStart,
3506
6756
  runDaemonChild
3507
6757
  };