coderemote 0.3.5 → 0.3.7

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,25 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/coderemote.js
4
- import { execFile as execFile8, spawn as spawn3 } from "node:child_process";
4
+ import { execFile as execFile8, execFileSync, spawn as spawn3 } from "node:child_process";
5
5
  import { randomBytes as randomBytes3 } from "node:crypto";
6
- import fs13 from "node:fs";
6
+ import fs14 from "node:fs";
7
7
  import net2 from "node:net";
8
8
  import os6 from "node:os";
9
- import path11 from "node:path";
9
+ import path12 from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
- import { promisify as promisify5 } from "node:util";
11
+ import { promisify as promisify4 } from "node:util";
12
12
  import qrcode from "qrcode-terminal";
13
13
 
14
14
  // ../../packages/agent-core/src/server.js
15
15
  import { createReadStream, createWriteStream as createWriteStream2 } from "node:fs";
16
- import fs7 from "node:fs/promises";
16
+ import fs8 from "node:fs/promises";
17
17
  import http2 from "node:http";
18
18
  import os5 from "node:os";
19
- import path5 from "node:path";
19
+ import path6 from "node:path";
20
20
  import { pipeline as pipeline2 } from "node:stream/promises";
21
21
  import { randomUUID as randomUUID4, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
22
- import { execFile as execFile5 } from "node:child_process";
23
22
 
24
23
  // ../../packages/protocol/index.js
25
24
  var PROTOCOL_VERSION = 4;
@@ -118,7 +117,7 @@ var HttpError = class extends Error {
118
117
  var Sandbox = class {
119
118
  constructor(root2) {
120
119
  this.root = path.resolve(root2);
121
- this.realRoot = fsSync.realpathSync(this.root);
120
+ this.realRoot = fsSync.realpathSync.native(this.root);
122
121
  }
123
122
  #containsReal(p) {
124
123
  return p === this.realRoot || p.startsWith(this.realRoot + path.sep);
@@ -1052,12 +1051,100 @@ var LANDING_HTML = `<!doctype html>
1052
1051
  `;
1053
1052
 
1054
1053
  // ../../packages/agent-core/src/ports.js
1055
- import { execFile as execFile2 } from "node:child_process";
1056
- import fs5 from "node:fs/promises";
1054
+ import { execFile as execFile3 } from "node:child_process";
1055
+ import fs6 from "node:fs/promises";
1057
1056
  import os from "node:os";
1057
+
1058
+ // ../../packages/agent-core/src/platform.js
1059
+ import { execFile as execFile2, spawn } from "node:child_process";
1060
+ import fs5 from "node:fs";
1061
+ import path4 from "node:path";
1062
+ var isWindows = process.platform === "win32";
1063
+ function shellQuote(arg) {
1064
+ return `'${String(arg).replace(/'/g, `'\\''`)}'`;
1065
+ }
1066
+ function runnable(file) {
1067
+ try {
1068
+ if (!isWindows) fs5.accessSync(file, fs5.constants.X_OK);
1069
+ return fs5.statSync(file).isFile();
1070
+ } catch {
1071
+ return false;
1072
+ }
1073
+ }
1074
+ function findExecutable(name, env = process.env) {
1075
+ const exts = isWindows && !path4.extname(name) ? (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
1076
+ const match = (base2) => exts.map((e) => base2 + e).find(runnable) ?? null;
1077
+ if (name.includes("/") || name.includes(path4.sep)) return match(path4.resolve(name));
1078
+ for (const dir of (env.PATH ?? env.Path ?? "").split(path4.delimiter)) {
1079
+ const found = dir && match(path4.join(dir.replace(/^"(.*)"$/, "$1"), name));
1080
+ if (found) return found;
1081
+ }
1082
+ return null;
1083
+ }
1084
+ function interactiveShell() {
1085
+ if (!isWindows) return { file: process.env.SHELL || "/bin/sh", args: ["-l"] };
1086
+ return { file: findExecutable("pwsh") ?? "powershell.exe", args: ["-NoLogo"] };
1087
+ }
1088
+ function cmdQuote(arg) {
1089
+ const s = String(arg);
1090
+ if (/["%\r\n]/.test(s)) throw new Error(`${JSON.stringify(s)} can't be passed through cmd.exe`);
1091
+ return `"${s.replace(/(\\+)$/, "$1$1")}"`;
1092
+ }
1093
+ function resolveCommand(argv2) {
1094
+ const [name, ...rest2] = argv2;
1095
+ if (!isWindows) return { file: name, args: rest2, verbatim: false };
1096
+ const file = findExecutable(name) ?? name;
1097
+ if (!/\.(bat|cmd)$/i.test(file)) return { file, args: rest2, verbatim: false };
1098
+ const line = [file, ...rest2].map(cmdQuote).join(" ");
1099
+ return { file: process.env.ComSpec || "cmd.exe", args: ["/d", "/v:off", "/s", "/c", `"${line}"`], verbatim: true };
1100
+ }
1101
+ function terminalCommand(argv2) {
1102
+ if (!isWindows) return { file: interactiveShell().file, args: ["-l", "-c", `exec ${argv2.map(shellQuote).join(" ")}`] };
1103
+ const { file, args, verbatim } = resolveCommand(argv2);
1104
+ return { file, args: verbatim ? args.join(" ") : args };
1105
+ }
1106
+ function spawnCommand(argv2, options = {}) {
1107
+ const { file, args, verbatim } = resolveCommand(argv2);
1108
+ return spawn(file, args, { ...options, windowsVerbatimArguments: verbatim });
1109
+ }
1110
+ function execCommand(argv2, options = {}) {
1111
+ return new Promise((resolve, reject) => {
1112
+ const { file, args, verbatim } = resolveCommand(argv2);
1113
+ execFile2(
1114
+ file,
1115
+ args,
1116
+ { ...options, windowsVerbatimArguments: verbatim },
1117
+ (err, stdout, stderr) => err ? reject(Object.assign(err, { stdout, stderr })) : resolve({ stdout, stderr })
1118
+ );
1119
+ });
1120
+ }
1121
+ function loginShell(script, timeout) {
1122
+ return new Promise((resolve) => {
1123
+ execFile2(
1124
+ interactiveShell().file,
1125
+ ["-l", "-c", script],
1126
+ { timeout, maxBuffer: 16 * 1024 * 1024 },
1127
+ (err, out) => resolve(err ? null : out)
1128
+ );
1129
+ });
1130
+ }
1131
+ async function capture(argv2, { timeout = 2e4 } = {}) {
1132
+ if (!isWindows) return loginShell(argv2.map(shellQuote).join(" "), timeout);
1133
+ try {
1134
+ return (await execCommand(argv2, { timeout, maxBuffer: 16 * 1024 * 1024 })).stdout;
1135
+ } catch {
1136
+ return null;
1137
+ }
1138
+ }
1139
+ async function commandExists(command) {
1140
+ if (isWindows) return findExecutable(command) !== null;
1141
+ return Boolean((await loginShell(`command -v ${shellQuote(command)}`, 2e4))?.trim());
1142
+ }
1143
+
1144
+ // ../../packages/agent-core/src/ports.js
1058
1145
  function run(cmd2, args) {
1059
1146
  return new Promise((resolve, reject) => {
1060
- execFile2(cmd2, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
1147
+ execFile3(cmd2, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
1061
1148
  if (err?.code === "ENOENT") reject(err);
1062
1149
  else resolve(stdout || "");
1063
1150
  });
@@ -1109,9 +1196,9 @@ async function processTable() {
1109
1196
  }
1110
1197
  async function workingDirs(pids) {
1111
1198
  const dirs = /* @__PURE__ */ new Map();
1112
- if (!pids.length) return dirs;
1199
+ if (!pids.length || isWindows) return dirs;
1113
1200
  if (os.platform() === "linux") {
1114
- await Promise.all(pids.map(async (pid2) => dirs.set(pid2, await fs5.readlink(`/proc/${pid2}/cwd`).catch(() => null))));
1201
+ await Promise.all(pids.map(async (pid2) => dirs.set(pid2, await fs6.readlink(`/proc/${pid2}/cwd`).catch(() => null))));
1115
1202
  return dirs;
1116
1203
  }
1117
1204
  const out = await run("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-F", "pn"]).catch(() => "");
@@ -1122,9 +1209,39 @@ async function workingDirs(pids) {
1122
1209
  }
1123
1210
  return dirs;
1124
1211
  }
1125
- var AGENT_COMMAND = /(^|[\/\s])coderemote(\.js)?\s+up(\s|$)/;
1212
+ var WINDOWS_SHELLS = /* @__PURE__ */ new Set(["cmd.exe", "powershell.exe", "pwsh.exe", "bash.exe", "wsl.exe", "windowsterminal.exe", "openconsole.exe"]);
1213
+ async function windowsListeners() {
1214
+ const query = "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress";
1215
+ const [v4, v6, json] = await Promise.all([
1216
+ run("netstat", ["-ano", "-p", "TCP"]),
1217
+ run("netstat", ["-ano", "-p", "TCPv6"]),
1218
+ run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", query])
1219
+ ]);
1220
+ const table = /* @__PURE__ */ new Map();
1221
+ for (const p of [JSON.parse(json || "[]")].flat()) {
1222
+ table.set(p.ProcessId, { ppid: p.ParentProcessId, name: p.Name, tty: null, command: p.CommandLine });
1223
+ }
1224
+ for (const proc of table.values()) {
1225
+ for (let p = proc.ppid, depth = 0; p && depth < 64; p = table.get(p)?.ppid, depth++) {
1226
+ if (WINDOWS_SHELLS.has(table.get(p)?.name?.toLowerCase())) {
1227
+ proc.tty = "console";
1228
+ break;
1229
+ }
1230
+ }
1231
+ }
1232
+ const sockets = [];
1233
+ for (const line of `${v4}
1234
+ ${v6}`.split(/\r?\n/)) {
1235
+ const m = line.match(/^\s*TCP\s+(\S+)\s+(\S+)\s+\S+\s+(\d+)\s*$/);
1236
+ const hp = m && m[2].endsWith(":0") && splitHostPort(m[1]);
1237
+ const pid = hp && parseInt(m[3], 10);
1238
+ if (hp && table.get(pid)?.command) sockets.push({ pid, name: table.get(pid).name, ...hp });
1239
+ }
1240
+ return [sockets, table];
1241
+ }
1242
+ var AGENT_COMMAND = /(^|[\/\\\s])coderemote(\.js)?"?\s+up(\s|$)/;
1126
1243
  async function listPorts(shells = /* @__PURE__ */ new Map()) {
1127
- const [sockets, table] = await Promise.all([listeningSockets(), processTable()]);
1244
+ const [sockets, table] = isWindows ? await windowsListeners() : await Promise.all([listeningSockets(), processTable()]);
1128
1245
  const byKey = /* @__PURE__ */ new Map();
1129
1246
  for (const s of sockets) {
1130
1247
  const key = `${s.pid}:${s.port}`;
@@ -1172,11 +1289,11 @@ async function killListener(pid, { force = false, shells } = {}) {
1172
1289
  }
1173
1290
 
1174
1291
  // ../../packages/agent-core/src/push.js
1175
- import { execFile as execFile3 } from "node:child_process";
1292
+ import { execFile as execFile4 } from "node:child_process";
1176
1293
  import { createCipheriv, randomBytes } from "node:crypto";
1177
1294
  import os2 from "node:os";
1178
1295
  import { promisify } from "node:util";
1179
- var run2 = promisify(execFile3);
1296
+ var run2 = promisify(execFile4);
1180
1297
  var FINISHED_AFTER = 60 * 1e3;
1181
1298
  var ACTIVE_WITHIN = 2 * 60;
1182
1299
  async function macIdleSeconds() {
@@ -1568,17 +1685,25 @@ function detail(e) {
1568
1685
  }
1569
1686
 
1570
1687
  // ../../packages/agent-core/src/sessions.js
1688
+ import { execFile as execFile5 } from "node:child_process";
1571
1689
  import { randomUUID as randomUUID2 } from "node:crypto";
1572
1690
  import os3 from "node:os";
1573
1691
  import pty from "node-pty";
1574
1692
  var SCROLLBACK_LIMIT = 256 * 1024;
1575
- function shellQuote(arg) {
1576
- return `'${String(arg).replace(/'/g, `'\\''`)}'`;
1693
+ function guardConpty(term) {
1694
+ term._agent?._inSocket?.on?.("error", () => {
1695
+ });
1696
+ term._agent?._conoutSocketWorker?._worker?.on?.("error", () => {
1697
+ });
1698
+ }
1699
+ function releaseConpty(term) {
1700
+ term._agent?._conoutSocketWorker?.dispose?.();
1701
+ term._agent?._inSocket?.destroy?.();
1577
1702
  }
1578
1703
  var TerminalSession = class {
1579
1704
  /**
1580
- * `command` (an argv array) runs that program through a login shell (for the user's PATH)
1581
- * instead of an interactive shell; the session ends when it exits.
1705
+ * `command` (an argv array) runs that program instead of an interactive shell, started as
1706
+ * terminalCommand says; the session ends when it exits.
1582
1707
  */
1583
1708
  constructor({ cols, rows, cwd, env = {}, command = null }) {
1584
1709
  this.id = randomUUID2();
@@ -1587,11 +1712,10 @@ var TerminalSession = class {
1587
1712
  this.bufferedBytes = 0;
1588
1713
  this.clients = /* @__PURE__ */ new Set();
1589
1714
  this.exitCode = null;
1590
- const shell = process.env.SHELL || (os3.platform() === "win32" ? "powershell.exe" : "/bin/sh");
1591
- this.shell = shell;
1715
+ const { file, args } = command ? terminalCommand(command) : interactiveShell();
1716
+ this.shell = command ? interactiveShell().file : file;
1592
1717
  this.command = command;
1593
- const args = command ? ["-l", "-c", `exec ${command.map(shellQuote).join(" ")}`] : ["-l"];
1594
- this.pty = pty.spawn(shell, args, {
1718
+ this.pty = pty.spawn(file, args, {
1595
1719
  name: "xterm-256color",
1596
1720
  cols,
1597
1721
  rows,
@@ -1599,9 +1723,11 @@ var TerminalSession = class {
1599
1723
  // CODEREMOTE_TERM_ID lets `coderemote hook` tell which terminal a coding agent runs in.
1600
1724
  env: { ...process.env, ...env, CODEREMOTE_TERM_ID: this.id, TERM: "xterm-256color", COLORTERM: "truecolor" }
1601
1725
  });
1726
+ if (isWindows) guardConpty(this.pty);
1602
1727
  this.pty.onData((data) => this.#output(Buffer.from(data, "utf8")));
1603
1728
  this.pty.onExit(({ exitCode }) => {
1604
1729
  this.exitCode = exitCode;
1730
+ if (isWindows) releaseConpty(this.pty);
1605
1731
  for (const ws of this.clients) {
1606
1732
  ws.send(JSON.stringify({ type: "exit", code: exitCode }));
1607
1733
  ws.close(1e3, "session exited");
@@ -1624,7 +1750,7 @@ var TerminalSession = class {
1624
1750
  if (replay.length) ws.send(replay, { binary: true });
1625
1751
  ws.on("message", (data, isBinary) => {
1626
1752
  if (isBinary) {
1627
- this.pty.write(data.toString("utf8"));
1753
+ this.write(data.toString("utf8"));
1628
1754
  return;
1629
1755
  }
1630
1756
  let msg;
@@ -1633,22 +1759,25 @@ var TerminalSession = class {
1633
1759
  } catch {
1634
1760
  return;
1635
1761
  }
1636
- if (msg.type === "input" && typeof msg.data === "string") this.pty.write(msg.data);
1762
+ if (msg.type === "input" && typeof msg.data === "string") this.write(msg.data);
1637
1763
  else if (msg.type === "resize") this.resize(msg.cols, msg.rows);
1638
1764
  });
1639
1765
  ws.on("close", () => this.clients.delete(ws));
1640
1766
  }
1641
1767
  resize(cols, rows) {
1642
- if (Number.isInteger(cols) && Number.isInteger(rows) && cols > 0 && rows > 0) {
1768
+ if (this.exitCode === null && Number.isInteger(cols) && Number.isInteger(rows) && cols > 0 && rows > 0) {
1643
1769
  this.pty.resize(cols, rows);
1644
1770
  }
1645
1771
  }
1772
+ /** Ends the session; on Windows, which has no signals, always forcibly (see releaseConpty). */
1646
1773
  kill(signal) {
1647
- this.pty.kill(signal);
1774
+ if (!isWindows) this.pty.kill(signal);
1775
+ else if (this.exitCode === null) execFile5("taskkill", ["/T", "/F", "/PID", String(this.pty.pid)], () => {
1776
+ });
1648
1777
  }
1649
- /** Types into the session, as a client's keyboard would. */
1778
+ /** Types into the session, as a client's keyboard would; nothing once it has exited. */
1650
1779
  write(data) {
1651
- this.pty.write(data);
1780
+ if (this.exitCode === null) this.pty.write(data);
1652
1781
  }
1653
1782
  toJSON() {
1654
1783
  return {
@@ -1682,28 +1811,42 @@ var SessionManager = class {
1682
1811
  list() {
1683
1812
  return [...this.#sessions.values()].map((s) => s.toJSON());
1684
1813
  }
1685
- killAll() {
1686
- for (const s of this.#sessions.values()) s.kill();
1814
+ /**
1815
+ * Ends every session; resolves once they have all exited, or after `timeout` ms. Wait for it
1816
+ * before exiting: on Windows terminals end asynchronously (see releaseConpty), and exiting
1817
+ * while node-pty still holds a console open can crash the process.
1818
+ */
1819
+ killAll(timeout = 3e3) {
1820
+ const exited = [...this.#sessions.values()].map((s) => {
1821
+ const done = new Promise((resolve) => s.pty.onExit(resolve));
1822
+ s.kill();
1823
+ return done;
1824
+ });
1825
+ let timer;
1826
+ const late = new Promise((resolve) => {
1827
+ timer = setTimeout(resolve, timeout);
1828
+ });
1829
+ return Promise.race([Promise.all(exited), late]).finally(() => clearTimeout(timer));
1687
1830
  }
1688
1831
  };
1689
1832
 
1690
1833
  // ../../packages/agent-core/src/uploads.js
1691
- import { execFile as execFile4 } from "node:child_process";
1834
+ import { execFile as execFile6 } from "node:child_process";
1692
1835
  import { randomUUID as randomUUID3 } from "node:crypto";
1693
1836
  import { createWriteStream } from "node:fs";
1694
- import fs6 from "node:fs/promises";
1837
+ import fs7 from "node:fs/promises";
1695
1838
  import os4 from "node:os";
1696
- import path4 from "node:path";
1839
+ import path5 from "node:path";
1697
1840
  import { Transform } from "node:stream";
1698
1841
  import { pipeline } from "node:stream/promises";
1699
1842
  import { promisify as promisify2 } from "node:util";
1700
- var run3 = promisify2(execFile4);
1843
+ var run3 = promisify2(execFile6);
1701
1844
  var UPLOAD_LIMIT = 100 * 1024 * 1024;
1702
1845
  var KEEP = 7 * 24 * 60 * 60 * 1e3;
1703
1846
  var ID = /^[0-9a-f-]{36}$/;
1704
1847
  function safeName(name) {
1705
- const base = path4.basename(String(name ?? "").replace(/\\/g, "/"));
1706
- const clean = base.replace(/[^\p{L}\p{N}._ -]/gu, "_").replace(/^[.\s]+/, "").slice(-80).trim();
1848
+ const base2 = path5.basename(String(name ?? "").replace(/\\/g, "/"));
1849
+ const clean = base2.replace(/[^\p{L}\p{N}._ -]/gu, "_").replace(/^[.\s]+/, "").slice(-80).trim();
1707
1850
  return clean || "file";
1708
1851
  }
1709
1852
  var UploadStore = class {
@@ -1716,8 +1859,8 @@ var UploadStore = class {
1716
1859
  if (Number(length) > limit) throw new HttpError(413, `files are limited to ${limit / 1024 / 1024} MB`);
1717
1860
  const id = randomUUID3();
1718
1861
  const day = new Date(this.now()).toISOString().slice(0, 10);
1719
- await fs6.mkdir(path4.join(this.dir, day), { recursive: true, mode: 448 });
1720
- let file = path4.join(this.dir, day, `${id}-${safeName(name)}`);
1862
+ await fs7.mkdir(path5.join(this.dir, day), { recursive: true, mode: 448 });
1863
+ let file = path5.join(this.dir, day, `${id}-${safeName(name)}`);
1721
1864
  let size = 0;
1722
1865
  const counter = new Transform({
1723
1866
  transform(chunk, _enc, done) {
@@ -1728,7 +1871,7 @@ var UploadStore = class {
1728
1871
  try {
1729
1872
  await pipeline(body, counter, createWriteStream(file, { mode: 384 }));
1730
1873
  } catch (e) {
1731
- await fs6.rm(file, { force: true });
1874
+ await fs7.rm(file, { force: true });
1732
1875
  throw e;
1733
1876
  }
1734
1877
  let kind = type || "application/octet-stream";
@@ -1736,31 +1879,31 @@ var UploadStore = class {
1736
1879
  const jpeg = file.replace(/(\.heic)?$/i, ".jpg");
1737
1880
  try {
1738
1881
  await run3("sips", ["-s", "format", "jpeg", file, "--out", jpeg], { timeout: 3e4 });
1739
- await fs6.rm(file);
1882
+ await fs7.rm(file);
1740
1883
  file = jpeg;
1741
1884
  kind = "image/jpeg";
1742
- size = (await fs6.stat(file)).size;
1885
+ size = (await fs7.stat(file)).size;
1743
1886
  } catch {
1744
1887
  }
1745
1888
  }
1746
- return { id, name: path4.basename(file).slice(id.length + 1), type: kind, size, path: file };
1889
+ return { id, name: path5.basename(file).slice(id.length + 1), type: kind, size, path: file };
1747
1890
  }
1748
1891
  /** The file of an upload; 404 once it's gone (swept, or never existed). */
1749
1892
  async path(id) {
1750
1893
  if (typeof id !== "string" || !ID.test(id)) throw new HttpError(400, "invalid upload id");
1751
1894
  let days = [];
1752
1895
  try {
1753
- days = (await fs6.readdir(this.dir)).sort().reverse();
1896
+ days = (await fs7.readdir(this.dir)).sort().reverse();
1754
1897
  } catch {
1755
1898
  }
1756
1899
  for (const day of days) {
1757
1900
  let names = [];
1758
1901
  try {
1759
- names = await fs6.readdir(path4.join(this.dir, day));
1902
+ names = await fs7.readdir(path5.join(this.dir, day));
1760
1903
  } catch {
1761
1904
  }
1762
1905
  const hit = names.find((n) => n.startsWith(`${id}-`));
1763
- if (hit) return path4.join(this.dir, day, hit);
1906
+ if (hit) return path5.join(this.dir, day, hit);
1764
1907
  }
1765
1908
  throw new HttpError(404, "no such upload (they are kept for 7 days)");
1766
1909
  }
@@ -1768,25 +1911,25 @@ var UploadStore = class {
1768
1911
  async sweep() {
1769
1912
  let days = [];
1770
1913
  try {
1771
- days = await fs6.readdir(this.dir);
1914
+ days = await fs7.readdir(this.dir);
1772
1915
  } catch {
1773
1916
  return;
1774
1917
  }
1775
1918
  const cutoff = this.now() - KEEP;
1776
1919
  for (const day of days) {
1777
- const dir = path4.join(this.dir, day);
1920
+ const dir = path5.join(this.dir, day);
1778
1921
  let names = [];
1779
1922
  try {
1780
- names = await fs6.readdir(dir);
1923
+ names = await fs7.readdir(dir);
1781
1924
  } catch {
1782
1925
  continue;
1783
1926
  }
1784
1927
  for (const n of names) {
1785
- const file = path4.join(dir, n);
1786
- const stat = await fs6.stat(file).catch(() => null);
1787
- if (stat && stat.mtimeMs < cutoff) await fs6.rm(file, { force: true });
1928
+ const file = path5.join(dir, n);
1929
+ const stat = await fs7.stat(file).catch(() => null);
1930
+ if (stat && stat.mtimeMs < cutoff) await fs7.rm(file, { force: true });
1788
1931
  }
1789
- if (!(await fs6.readdir(dir).catch(() => [])).length) await fs6.rm(dir, { recursive: true, force: true });
1932
+ if (!(await fs7.readdir(dir).catch(() => [])).length) await fs7.rm(dir, { recursive: true, force: true });
1790
1933
  }
1791
1934
  }
1792
1935
  };
@@ -1799,11 +1942,11 @@ function kindOf(stat) {
1799
1942
  return "other";
1800
1943
  }
1801
1944
  async function describe(fullPath, name) {
1802
- const lst = await fs7.lstat(fullPath);
1945
+ const lst = await fs8.lstat(fullPath);
1803
1946
  const entry2 = { name, type: kindOf(lst), size: lst.size, mtime: lst.mtimeMs };
1804
1947
  if (lst.isSymbolicLink()) {
1805
1948
  try {
1806
- entry2.target = kindOf(await fs7.stat(fullPath));
1949
+ entry2.target = kindOf(await fs8.stat(fullPath));
1807
1950
  } catch {
1808
1951
  entry2.target = "broken";
1809
1952
  }
@@ -1843,7 +1986,7 @@ function createAgentServer({
1843
1986
  uploadsDir = null,
1844
1987
  claudeCommand = "claude",
1845
1988
  codexCommand = "codex",
1846
- codexHome = process.env.CODEX_HOME || path5.join(os5.homedir(), ".codex"),
1989
+ codexHome = process.env.CODEX_HOME || path6.join(os5.homedir(), ".codex"),
1847
1990
  pluginInstalled = null,
1848
1991
  agents = new AgentHub(),
1849
1992
  pushStore: pushStore2 = null,
@@ -1898,17 +2041,6 @@ function createAgentServer({
1898
2041
  if (!Array.isArray(ids) || ids.some((a) => typeof a !== "string")) throw new HttpError(400, "attachments must be upload ids");
1899
2042
  return Promise.all(ids.map((a) => uploadPath(a)));
1900
2043
  }
1901
- function loginShell(script) {
1902
- const shell = process.env.SHELL || "/bin/sh";
1903
- return new Promise((resolve) => {
1904
- execFile5(
1905
- shell,
1906
- ["-l", "-c", script],
1907
- { timeout: 2e4, maxBuffer: 16 * 1024 * 1024 },
1908
- (err, out) => resolve(err ? null : out)
1909
- );
1910
- });
1911
- }
1912
2044
  const launchers = {
1913
2045
  claude: {
1914
2046
  command: claudeCommand,
@@ -1923,7 +2055,7 @@ function createAgentServer({
1923
2055
  // terminal's queries; the classic renderer doesn't need them.
1924
2056
  env: { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" },
1925
2057
  async plugin() {
1926
- const list = JSON.parse(await loginShell(`${shellQuote(claudeCommand)} plugin list --json`) ?? "[]");
2058
+ const list = JSON.parse(await capture([claudeCommand, "plugin", "list", "--json"]) ?? "[]");
1927
2059
  return list.some((p) => p.id === "coderemote@coderemote" && p.enabled !== false);
1928
2060
  },
1929
2061
  missingPlugin: "Claude Code needs the Code Remote plugin first: run `coderemote setup claude` once on the computer."
@@ -1941,14 +2073,14 @@ function createAgentServer({
1941
2073
  ],
1942
2074
  env: {},
1943
2075
  async plugin() {
1944
- const list = JSON.parse(await loginShell(`${shellQuote(codexCommand)} plugin list --marketplace coderemote --json`) ?? "{}");
2076
+ const list = JSON.parse(await capture([codexCommand, "plugin", "list", "--marketplace", "coderemote", "--json"]) ?? "{}");
1945
2077
  return (list.installed ?? []).some((p) => p.pluginId === "coderemote@coderemote" && p.enabled !== false);
1946
2078
  },
1947
2079
  missingPlugin: "Codex needs the Code Remote plugin first: run `coderemote setup codex` once on the computer, then trust its hooks with `/hooks` in Codex."
1948
2080
  }
1949
2081
  };
1950
- async function installed(agent) {
1951
- return Boolean((await loginShell(`command -v ${shellQuote(launchers[agent].command)}`))?.trim());
2082
+ function installed(agent) {
2083
+ return commandExists(launchers[agent].command);
1952
2084
  }
1953
2085
  const plugins = {};
1954
2086
  async function hasPlugin(agent) {
@@ -1964,7 +2096,7 @@ function createAgentServer({
1964
2096
  }
1965
2097
  async function codexModels() {
1966
2098
  try {
1967
- const { models } = JSON.parse(await fs7.readFile(path5.join(codexHome, "models_cache.json"), "utf8"));
2099
+ const { models } = JSON.parse(await fs8.readFile(path6.join(codexHome, "models_cache.json"), "utf8"));
1968
2100
  return models.filter((m) => m.visibility === "list" && typeof m.slug === "string").sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)).map((m) => ({ id: m.slug, name: m.display_name || m.slug, description: m.description ?? "" }));
1969
2101
  } catch {
1970
2102
  return [];
@@ -2014,20 +2146,20 @@ function createAgentServer({
2014
2146
  async function repoOf(p) {
2015
2147
  const repo = await sandbox.resolve(p);
2016
2148
  try {
2017
- await fs7.access(path5.join(repo, ".git"));
2149
+ await fs8.access(path6.join(repo, ".git"));
2018
2150
  } catch {
2019
2151
  throw new HttpError(400, "not a git repository");
2020
2152
  }
2021
2153
  return repo;
2022
2154
  }
2023
2155
  async function filesIn(repo, files) {
2024
- if (!Array.isArray(files) || !files.length || files.some((f) => typeof f !== "string" || !f || path5.isAbsolute(f))) {
2156
+ if (!Array.isArray(files) || !files.length || files.some((f) => typeof f !== "string" || !f || path6.isAbsolute(f))) {
2025
2157
  throw new HttpError(400, "files must be a non-empty list of paths relative to the repository");
2026
2158
  }
2027
2159
  return Promise.all(
2028
2160
  files.map(async (f) => {
2029
- const abs = await sandbox.resolve(path5.join(repo, f), { allowMissing: true });
2030
- const rel = path5.relative(repo, abs);
2161
+ const abs = await sandbox.resolve(path6.join(repo, f), { allowMissing: true });
2162
+ const rel = path6.relative(repo, abs);
2031
2163
  if (!rel || rel.startsWith("..")) throw new HttpError(403, "path is outside the repository");
2032
2164
  return rel;
2033
2165
  })
@@ -2040,21 +2172,22 @@ function createAgentServer({
2040
2172
  platform: os5.platform(),
2041
2173
  root: sandbox.root,
2042
2174
  user: os5.userInfo().username,
2043
- shell: process.env.SHELL || null
2175
+ // On Windows, terminals run PowerShell whatever $SHELL (Git Bash sets one) says.
2176
+ shell: isWindows ? interactiveShell().file : process.env.SHELL || null
2044
2177
  }),
2045
2178
  [Routes.fsList]: async (url) => {
2046
2179
  const dir = await sandbox.resolve(url.searchParams.get("path"));
2047
- const names = await fs7.readdir(dir);
2048
- const entries = (await Promise.all(names.map((n) => describe(path5.join(dir, n), n).catch(() => null)))).filter(Boolean);
2180
+ const names = await fs8.readdir(dir);
2181
+ const entries = (await Promise.all(names.map((n) => describe(path6.join(dir, n), n).catch(() => null)))).filter(Boolean);
2049
2182
  return { path: dir, entries };
2050
2183
  },
2051
2184
  [Routes.fsStat]: async (url) => {
2052
2185
  const p = await sandbox.resolve(url.searchParams.get("path"));
2053
- return { path: p, ...await describe(p, path5.basename(p)) };
2186
+ return { path: p, ...await describe(p, path6.basename(p)) };
2054
2187
  },
2055
2188
  [Routes.fsRead]: async (url, req, res) => {
2056
2189
  const p = await sandbox.resolve(url.searchParams.get("path"));
2057
- const stat = await fs7.stat(p);
2190
+ const stat = await fs8.stat(p);
2058
2191
  if (!stat.isFile()) throw new HttpError(400, "not a file");
2059
2192
  res.writeHead(200, { "content-type": "application/octet-stream", "content-length": stat.size });
2060
2193
  await pipeline2(createReadStream(p), res);
@@ -2064,24 +2197,24 @@ function createAgentServer({
2064
2197
  const tmp = `${p}.vsremote-${process.pid}-${Date.now()}.tmp`;
2065
2198
  try {
2066
2199
  await pipeline2(req, createWriteStream2(tmp));
2067
- await fs7.rename(tmp, p);
2200
+ await fs8.rename(tmp, p);
2068
2201
  } catch (e) {
2069
- await fs7.rm(tmp, { force: true });
2202
+ await fs8.rm(tmp, { force: true });
2070
2203
  throw e;
2071
2204
  }
2072
- return describe(p, path5.basename(p));
2205
+ return describe(p, path6.basename(p));
2073
2206
  },
2074
2207
  [Routes.fsMkdir]: async (url) => {
2075
- await fs7.mkdir(await sandbox.resolve(url.searchParams.get("path"), { allowMissing: true }), { recursive: true });
2208
+ await fs8.mkdir(await sandbox.resolve(url.searchParams.get("path"), { allowMissing: true }), { recursive: true });
2076
2209
  return {};
2077
2210
  },
2078
2211
  [Routes.fsRename]: async (url, req) => {
2079
2212
  const { from, to } = await readJson(req);
2080
- await fs7.rename(await sandbox.resolveChild(from), await sandbox.resolveChild(to, { allowMissing: true }));
2213
+ await fs8.rename(await sandbox.resolveChild(from), await sandbox.resolveChild(to, { allowMissing: true }));
2081
2214
  return {};
2082
2215
  },
2083
2216
  [Routes.fsDelete]: async (url) => {
2084
- await fs7.rm(await sandbox.resolveChild(url.searchParams.get("path")), { recursive: true });
2217
+ await fs8.rm(await sandbox.resolveChild(url.searchParams.get("path")), { recursive: true });
2085
2218
  return {};
2086
2219
  },
2087
2220
  [Routes.gitRepos]: async () => ({ repos: await findRepos(sandbox.root) }),
@@ -2216,7 +2349,7 @@ function createAgentServer({
2216
2349
  } catch (e) {
2217
2350
  throw new HttpError(400, e.message);
2218
2351
  }
2219
- if (!(await fs7.stat(dir)).isDirectory()) throw new HttpError(400, "cwd must be a folder");
2352
+ if (!(await fs8.stat(dir)).isDirectory()) throw new HttpError(400, "cwd must be a folder");
2220
2353
  if (model !== void 0 && (typeof model !== "string" || !/^[A-Za-z0-9._[\]-]{1,80}$/.test(model))) {
2221
2354
  throw new HttpError(400, "invalid model");
2222
2355
  }
@@ -2238,7 +2371,7 @@ function createAgentServer({
2238
2371
  throw Object.assign(new HttpError(400, launcher.missingPlugin), { code: "ENOPLUGIN" });
2239
2372
  }
2240
2373
  const id = resume ?? randomUUID4();
2241
- if (uploadsDir) await fs7.mkdir(uploadsDir, { recursive: true, mode: 448 });
2374
+ if (uploadsDir) await fs8.mkdir(uploadsDir, { recursive: true, mode: 448 });
2242
2375
  const argv2 = [
2243
2376
  launcher.command,
2244
2377
  ...launcher.args({
@@ -2475,42 +2608,33 @@ function createAgentServer({
2475
2608
  }
2476
2609
 
2477
2610
  // src/devtunnel.js
2478
- import { execFile as execFile6 } from "node:child_process";
2611
+ import { execFile as execFile7 } from "node:child_process";
2479
2612
  import { createHash } from "node:crypto";
2480
- import fs8 from "node:fs";
2481
- import path6 from "node:path";
2613
+ import fs9 from "node:fs";
2614
+ import path7 from "node:path";
2482
2615
  import { promisify as promisify3 } from "node:util";
2483
- var run4 = promisify3(execFile6);
2616
+ var run4 = promisify3(execFile7);
2484
2617
  var DOWNLOAD_BASE = "https://tunnelsassetsprod.blob.core.windows.net/cli";
2485
2618
  var BUILDS = {
2486
2619
  "darwin-arm64": "osx-arm64-devtunnel",
2487
2620
  "darwin-x64": "osx-x64-devtunnel",
2488
2621
  "linux-arm64": "linux-arm64-devtunnel",
2489
- "linux-x64": "linux-x64-devtunnel"
2622
+ "linux-x64": "linux-x64-devtunnel",
2623
+ "win32-x64": "devtunnel.exe",
2624
+ // There's no Arm build for Windows; the x64 one runs there under emulation.
2625
+ "win32-arm64": "devtunnel.exe"
2490
2626
  };
2491
2627
  var MICROSOFT_TEAM_ID = "UBF8T346G9";
2628
+ var MICROSOFT_SIGNER = /^CN=Microsoft Corporation, O=Microsoft Corporation,/;
2492
2629
  var REFRESH_INTERVAL_MS = 24 * 36e5;
2493
- function executable(file) {
2494
- try {
2495
- fs8.accessSync(file, fs8.constants.X_OK);
2496
- return fs8.statSync(file).isFile();
2497
- } catch {
2498
- return false;
2499
- }
2500
- }
2501
- function onPath(name) {
2502
- for (const dir of (process.env.PATH || "").split(path6.delimiter)) {
2503
- if (dir && executable(path6.join(dir, name))) return path6.join(dir, name);
2504
- }
2505
- return null;
2506
- }
2507
2630
  async function ensureDevtunnel({ dir, progress }) {
2508
2631
  const override = process.env.CODEREMOTE_DEVTUNNEL;
2509
2632
  if (override) {
2510
- if (executable(override)) return override;
2633
+ const found = findExecutable(override);
2634
+ if (found) return found;
2511
2635
  throw new Error(`CODEREMOTE_DEVTUNNEL=${override} isn't an executable file.`);
2512
2636
  }
2513
- const fromPath = onPath("devtunnel");
2637
+ const fromPath = findExecutable("devtunnel");
2514
2638
  if (fromPath) return fromPath;
2515
2639
  const build = BUILDS[`${process.platform}-${process.arch}`];
2516
2640
  if (!build) {
@@ -2518,14 +2642,14 @@ async function ensureDevtunnel({ dir, progress }) {
2518
2642
  `No devtunnel build for ${process.platform}-${process.arch}. Install it yourself: https://learn.microsoft.com/azure/developer/dev-tunnels/get-started`
2519
2643
  );
2520
2644
  }
2521
- const bin = path6.join(dir, "devtunnel");
2522
- const metaFile = path6.join(dir, "devtunnel.json");
2645
+ const bin = path7.join(dir, isWindows ? "devtunnel.exe" : "devtunnel");
2646
+ const metaFile = path7.join(dir, "devtunnel.json");
2523
2647
  let meta = {};
2524
2648
  try {
2525
- meta = JSON.parse(fs8.readFileSync(metaFile, "utf8"));
2649
+ meta = JSON.parse(fs9.readFileSync(metaFile, "utf8"));
2526
2650
  } catch {
2527
2651
  }
2528
- const installed = executable(bin);
2652
+ const installed = findExecutable(bin) !== null;
2529
2653
  if (installed && Date.now() - (meta.checkedAt ?? 0) < REFRESH_INTERVAL_MS) return bin;
2530
2654
  const url = `${DOWNLOAD_BASE}/${build}`;
2531
2655
  if (installed) {
@@ -2533,23 +2657,27 @@ async function ensureDevtunnel({ dir, progress }) {
2533
2657
  const head = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3e3) });
2534
2658
  const lastModified = head.headers.get("last-modified");
2535
2659
  if (head.ok && lastModified && lastModified === meta.lastModified) {
2536
- fs8.writeFileSync(metaFile, JSON.stringify({ ...meta, checkedAt: Date.now() }));
2660
+ fs9.writeFileSync(metaFile, JSON.stringify({ ...meta, checkedAt: Date.now() }));
2537
2661
  return bin;
2538
2662
  }
2539
2663
  } catch {
2540
2664
  return bin;
2541
2665
  }
2542
2666
  }
2543
- await download(url, bin, metaFile, progress);
2667
+ try {
2668
+ await download(url, bin, metaFile, progress);
2669
+ } catch (e) {
2670
+ if (!installed) throw e;
2671
+ }
2544
2672
  return bin;
2545
2673
  }
2546
2674
  async function download(url, bin, metaFile, progress) {
2547
- fs8.mkdirSync(path6.dirname(bin), { recursive: true, mode: 448 });
2675
+ fs9.mkdirSync(path7.dirname(bin), { recursive: true, mode: 448 });
2548
2676
  const res = await fetch(url, { signal: AbortSignal.timeout(12e4) });
2549
2677
  if (!res.ok || !res.body) throw new Error(`Downloading devtunnel failed: HTTP ${res.status} from ${url}`);
2550
2678
  const total = Number(res.headers.get("content-length")) || 0;
2551
2679
  const tmp = `${bin}.${process.pid}.download`;
2552
- const out = fs8.createWriteStream(tmp, { mode: 493 });
2680
+ const out = fs9.createWriteStream(tmp, { mode: 493 });
2553
2681
  const md5 = createHash("md5");
2554
2682
  let received = 0;
2555
2683
  try {
@@ -2562,17 +2690,18 @@ async function download(url, bin, metaFile, progress) {
2562
2690
  await new Promise((resolve, reject) => out.end((e) => e ? reject(e) : resolve()));
2563
2691
  if (total && received !== total) throw new Error(`devtunnel download was cut short (${received} of ${total} bytes)`);
2564
2692
  await verify(tmp, md5.digest("base64"), res.headers.get("content-md5"));
2565
- fs8.chmodSync(tmp, 493);
2566
- fs8.renameSync(tmp, bin);
2693
+ fs9.chmodSync(tmp, 493);
2694
+ fs9.renameSync(tmp, bin);
2567
2695
  } catch (e) {
2568
2696
  out.destroy();
2569
- fs8.rmSync(tmp, { force: true });
2697
+ fs9.rmSync(tmp, { force: true });
2570
2698
  throw e;
2571
2699
  }
2572
- fs8.writeFileSync(metaFile, JSON.stringify({ lastModified: res.headers.get("last-modified"), checkedAt: Date.now() }));
2700
+ fs9.writeFileSync(metaFile, JSON.stringify({ lastModified: res.headers.get("last-modified"), checkedAt: Date.now() }));
2573
2701
  }
2574
2702
  async function verify(file, md5, expectedMd5) {
2575
2703
  if (expectedMd5 && md5 !== expectedMd5) throw new Error("devtunnel download is corrupt (MD5 mismatch)");
2704
+ if (isWindows) return verifyAuthenticode(file);
2576
2705
  if (process.platform !== "darwin") return;
2577
2706
  try {
2578
2707
  await run4("codesign", ["--verify", "--strict", file]);
@@ -2582,52 +2711,69 @@ async function verify(file, md5, expectedMd5) {
2582
2711
  throw new Error(`devtunnel download failed its signature check: ${e.message}`);
2583
2712
  }
2584
2713
  }
2714
+ async function verifyAuthenticode(file) {
2715
+ const script = '$s = Get-AuthenticodeSignature -LiteralPath $env:CODEREMOTE_VERIFY; "$($s.Status)|$($s.SignerCertificate.Subject)"';
2716
+ let status2;
2717
+ let signer;
2718
+ try {
2719
+ const { stdout } = await run4("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
2720
+ env: { ...process.env, CODEREMOTE_VERIFY: file }
2721
+ });
2722
+ [status2, signer] = stdout.trim().split("|");
2723
+ } catch (e) {
2724
+ throw new Error(`devtunnel download failed its signature check: ${e.message}`);
2725
+ }
2726
+ if (status2 !== "Valid" || !MICROSOFT_SIGNER.test(signer ?? "")) {
2727
+ throw new Error(`devtunnel download failed its signature check: ${status2}, signed by ${signer || "nobody"}`);
2728
+ }
2729
+ }
2585
2730
 
2586
2731
  // src/hook.js
2587
2732
  import { randomBytes as randomBytes2 } from "node:crypto";
2588
- import fs9 from "node:fs";
2733
+ import fs10 from "node:fs";
2589
2734
  import http3 from "node:http";
2590
- import path7 from "node:path";
2735
+ import path8 from "node:path";
2591
2736
  var CONNECT_TIMEOUT = 1e3;
2592
2737
  var EVENT_TIMEOUT = 5e3;
2593
2738
  function ensureHookSecret(dir) {
2594
- const file = path7.join(dir, "hook-secret");
2739
+ const file = path8.join(dir, "hook-secret");
2595
2740
  try {
2596
- const secret2 = fs9.readFileSync(file, "utf8").trim();
2741
+ const secret2 = fs10.readFileSync(file, "utf8").trim();
2597
2742
  if (secret2) return secret2;
2598
2743
  } catch {
2599
2744
  }
2600
- fs9.mkdirSync(dir, { recursive: true, mode: 448 });
2745
+ fs10.mkdirSync(dir, { recursive: true, mode: 448 });
2601
2746
  const secret = randomBytes2(32).toString("base64url");
2602
- fs9.writeFileSync(file, secret, { mode: 384 });
2747
+ fs10.writeFileSync(file, secret, { mode: 384 });
2603
2748
  return secret;
2604
2749
  }
2605
2750
  function writeHookLauncher(dir, { node = process.execPath, script }) {
2606
- const bin = path7.join(dir, "bin");
2607
- fs9.mkdirSync(bin, { recursive: true, mode: 448 });
2751
+ const bin = path8.join(dir, "bin");
2752
+ fs10.mkdirSync(bin, { recursive: true, mode: 448 });
2608
2753
  const quote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
2609
- const file = path7.join(bin, "hook");
2610
- fs9.writeFileSync(file, `#!/bin/sh
2754
+ const file = path8.join(bin, "hook");
2755
+ fs10.writeFileSync(file, `#!/bin/sh
2611
2756
  exec ${quote(node)} ${quote(script)} hook "$@"
2612
2757
  `, { mode: 493 });
2613
- fs9.chmodSync(file, 493);
2758
+ fs10.chmodSync(file, 493);
2759
+ fs10.writeFileSync(path8.join(bin, "hook.json"), JSON.stringify({ node, script }), { mode: 384 });
2614
2760
  return file;
2615
2761
  }
2616
2762
  function readHookSecret(dir) {
2617
2763
  try {
2618
- return fs9.readFileSync(path7.join(dir, "hook-secret"), "utf8").trim() || null;
2764
+ return fs10.readFileSync(path8.join(dir, "hook-secret"), "utf8").trim() || null;
2619
2765
  } catch {
2620
2766
  return null;
2621
2767
  }
2622
2768
  }
2623
2769
  function real(p) {
2624
2770
  try {
2625
- return fs9.realpathSync(p);
2771
+ return fs10.realpathSync.native(p);
2626
2772
  } catch {
2627
- return path7.resolve(p);
2773
+ return path8.resolve(p);
2628
2774
  }
2629
2775
  }
2630
- var within = (dir, root2) => dir === root2 || dir.startsWith(root2.endsWith(path7.sep) ? root2 : root2 + path7.sep);
2776
+ var within = (dir, root2) => dir === root2 || dir.startsWith(root2.endsWith(path8.sep) ? root2 : root2 + path8.sep);
2631
2777
  function hookTarget(agents, cwd, env = process.env) {
2632
2778
  const port2 = parseInt(env.CODEREMOTE_AGENT_PORT, 10);
2633
2779
  if (port2 && agents.some((a) => a.port === port2)) return { port: port2, terminal: env.CODEREMOTE_TERM_ID || null };
@@ -2683,8 +2829,80 @@ async function runHook({ input, agents, secret, agent = "claude", env = process.
2683
2829
  }
2684
2830
 
2685
2831
  // src/preview.js
2686
- import { spawn } from "node:child_process";
2832
+ import { spawn as spawn2 } from "node:child_process";
2833
+
2834
+ // src/tunnels.js
2835
+ var TUNNEL_LIMIT = 10;
2836
+ var PREVIEW_LABEL = "coderemote-preview";
2837
+ var base = (id) => String(id).split(".")[0];
2838
+ async function listTunnels(devtunnel2) {
2839
+ const res = await devtunnel2("list", "--limit", "100", "--json");
2840
+ if (!res.ok) return { error: res.out.trim() };
2841
+ try {
2842
+ const { tunnels } = JSON.parse(res.out.slice(res.out.indexOf("{")));
2843
+ if (Array.isArray(tunnels)) return { tunnels };
2844
+ } catch {
2845
+ }
2846
+ return { error: `unexpected devtunnel list output:
2847
+ ${res.out}` };
2848
+ }
2849
+ async function tunnelsAtLimit(devtunnel2) {
2850
+ const { tunnels } = await listTunnels(devtunnel2);
2851
+ return tunnels && tunnels.length >= TUNNEL_LIMIT ? tunnels : null;
2852
+ }
2853
+ async function makeRoom(devtunnel2) {
2854
+ const tunnels = await tunnelsAtLimit(devtunnel2);
2855
+ if (!tunnels) return null;
2856
+ const idle = tunnels.filter((t) => t.labels?.includes(PREVIEW_LABEL) && !(t.hostConnections > 0));
2857
+ const deleted = await Promise.all(idle.map((t) => devtunnel2("delete", t.tunnelId, "-f")));
2858
+ const freed = idle.filter((_, i) => deleted[i].ok).map((t) => t.tunnelId);
2859
+ return freed.length ? { freed } : { full: tunnels };
2860
+ }
2861
+ function ownUse(cfg2, id) {
2862
+ for (const [port2, t] of Object.entries(cfg2.tunnels ?? {})) if (base(t) === base(id)) return `agent on port ${port2}`;
2863
+ if (cfg2.tunnelId && base(cfg2.tunnelId) === base(id)) return "agent";
2864
+ for (const [port2, t] of Object.entries(cfg2.previewTunnels ?? {})) if (base(t) === base(id)) return `previews on port ${port2}`;
2865
+ return null;
2866
+ }
2867
+ function forgetTunnel(cfg2, id) {
2868
+ let changed = false;
2869
+ for (const key of ["tunnels", "previewTunnels"]) {
2870
+ for (const [port2, t] of Object.entries(cfg2[key] ?? {})) {
2871
+ if (base(t) !== base(id)) continue;
2872
+ delete cfg2[key][port2];
2873
+ changed = true;
2874
+ }
2875
+ }
2876
+ if (cfg2.tunnelId && base(cfg2.tunnelId) === base(id)) {
2877
+ delete cfg2.tunnelId;
2878
+ changed = true;
2879
+ }
2880
+ return changed;
2881
+ }
2882
+ function findTunnel(tunnels, id) {
2883
+ return tunnels.find((t) => t.tunnelId === id || base(t.tunnelId) === base(id)) ?? null;
2884
+ }
2885
+ function formatTunnels(tunnels, cfg2) {
2886
+ const width = Math.max(...tunnels.map((t) => t.tunnelId.length));
2887
+ return tunnels.map((t) => {
2888
+ const notes = [t.hostConnections > 0 ? "online" : null, ownUse(cfg2, t.tunnelId) && `this computer's ${ownUse(cfg2, t.tunnelId)}`];
2889
+ const about = t.description || t.labels?.join(", ") || "(no description)";
2890
+ return ` ${t.tunnelId.padEnd(width)} ${[...notes.filter(Boolean), about].join(" \xB7 ")}`;
2891
+ });
2892
+ }
2893
+ function tunnelLimitHelp(tunnels, cfg2 = {}) {
2894
+ return [
2895
+ `Your Dev Tunnels account already has ${tunnels.length} tunnels, the most it may keep:`,
2896
+ ...formatTunnels(tunnels, cfg2),
2897
+ "Delete the ones nothing uses any more, then try again. Deleting a tunnel an agent still uses",
2898
+ "changes its URL and unpairs its phones.",
2899
+ " coderemote tunnels delete <tunnel id>..."
2900
+ ].join("\n");
2901
+ }
2902
+
2903
+ // src/preview.js
2687
2904
  var HOST_READY_TIMEOUT_MS = 3e4;
2905
+ var CLOSE_TIMEOUT_MS = 5e3;
2688
2906
  var TOKEN_REUSE_MS = 12 * 36e5;
2689
2907
  var PreviewTunnel = class {
2690
2908
  #queue = Promise.resolve();
@@ -2764,8 +2982,13 @@ var PreviewTunnel = class {
2764
2982
  return this.#serial(async () => {
2765
2983
  const url = this.#host?.urls.get(port2);
2766
2984
  if (url) return { port: port2, url };
2767
- const id = await this.#ensureTunnel();
2768
- const res = await this.devtunnel("port", "create", id, "-p", String(port2), "--protocol", "http");
2985
+ let id = await this.#ensureTunnel();
2986
+ let res = await this.devtunnel("port", "create", id, "-p", String(port2), "--protocol", "http");
2987
+ if (!res.ok && /not found/i.test(res.out)) {
2988
+ this.#forgetTunnel();
2989
+ id = await this.#ensureTunnel();
2990
+ res = await this.devtunnel("port", "create", id, "-p", String(port2), "--protocol", "http");
2991
+ }
2769
2992
  if (!res.ok && !/already exists|conflict/i.test(res.out)) throw new Error(`devtunnel port create failed: ${res.out.trim()}`);
2770
2993
  this.#ports.add(port2);
2771
2994
  try {
@@ -2800,20 +3023,42 @@ var PreviewTunnel = class {
2800
3023
  stop() {
2801
3024
  this.#stopHost();
2802
3025
  }
3026
+ /**
3027
+ * Stops hosting and deletes the tunnel (or the last run's), which nothing reuses: each run
3028
+ * starts a new one. If that fails or takes too long, the next run deletes it.
3029
+ */
3030
+ async close() {
3031
+ this.#stopHost();
3032
+ const id = this.#tunnelId ?? this.leftover;
3033
+ if (!id) return;
3034
+ const res = await Promise.race([
3035
+ this.devtunnel("delete", id, "-f"),
3036
+ new Promise((resolve) => setTimeout(resolve, CLOSE_TIMEOUT_MS, { ok: false, out: "timed out" }))
3037
+ ]);
3038
+ if (res.ok || /not found/i.test(res.out)) this.remember(null);
3039
+ }
3040
+ #forgetTunnel() {
3041
+ this.#stopHost();
3042
+ this.#tunnelId = null;
3043
+ this.#token = null;
3044
+ this.#ports.clear();
3045
+ this.#shares.clear();
3046
+ }
2803
3047
  async #ensureTunnel() {
2804
3048
  if (this.#tunnelId) return this.#tunnelId;
2805
3049
  if (this.leftover) await this.devtunnel("delete", this.leftover, "-f");
2806
- const created = await this.devtunnel(
2807
- "create",
2808
- "--expiration",
2809
- "30d",
2810
- "-d",
2811
- this.description,
2812
- "--labels",
2813
- "coderemote-preview",
2814
- "--json"
2815
- );
2816
- if (!created.ok) throw new Error(`devtunnel create failed: ${created.out.trim()}`);
3050
+ const create = () => this.devtunnel("create", "--expiration", "30d", "-d", this.description, "--labels", PREVIEW_LABEL, "--json");
3051
+ let created = await create();
3052
+ if (!created.ok) {
3053
+ const room = await makeRoom(this.devtunnel);
3054
+ if (room?.freed) created = await create();
3055
+ if (room?.full) {
3056
+ throw new Error(
3057
+ `Your Dev Tunnels account already has ${TUNNEL_LIMIT} tunnels, the most it may keep. Delete ones nothing uses on the computer (coderemote tunnels, coderemote tunnels delete <id>), then try again.`
3058
+ );
3059
+ }
3060
+ if (!created.ok) throw new Error(`devtunnel create failed: ${created.out.trim()}`);
3061
+ }
2817
3062
  const id = JSON.parse(created.out.slice(created.out.indexOf("{"))).tunnel?.tunnelId;
2818
3063
  if (!id) throw new Error(`could not parse tunnel id from:
2819
3064
  ${created.out}`);
@@ -2831,7 +3076,7 @@ ${created.out}`);
2831
3076
  }
2832
3077
  #startHost() {
2833
3078
  return new Promise((resolve, reject) => {
2834
- const child = spawn(this.command(), ["host", this.#tunnelId], { stdio: ["ignore", "pipe", "pipe"] });
3079
+ const child = spawn2(this.command(), ["host", this.#tunnelId], { stdio: ["ignore", "pipe", "pipe"] });
2835
3080
  const urls = /* @__PURE__ */ new Map();
2836
3081
  let log2 = "";
2837
3082
  const timer = setTimeout(() => {
@@ -2866,40 +3111,40 @@ ${log2}`));
2866
3111
  };
2867
3112
 
2868
3113
  // src/push-store.js
2869
- import fs10 from "node:fs";
2870
- import path8 from "node:path";
3114
+ import fs11 from "node:fs";
3115
+ import path9 from "node:path";
2871
3116
  function pushStore(dir, port2) {
2872
- const file = path8.join(dir, `${port2}.json`);
3117
+ const file = path9.join(dir, `${port2}.json`);
2873
3118
  return {
2874
3119
  load() {
2875
3120
  try {
2876
- const devices = JSON.parse(fs10.readFileSync(file, "utf8"));
3121
+ const devices = JSON.parse(fs11.readFileSync(file, "utf8"));
2877
3122
  return Array.isArray(devices) ? devices : [];
2878
3123
  } catch {
2879
3124
  return [];
2880
3125
  }
2881
3126
  },
2882
3127
  save(devices) {
2883
- fs10.mkdirSync(dir, { recursive: true, mode: 448 });
2884
- fs10.writeFileSync(file, JSON.stringify(devices, null, 2), { mode: 384 });
3128
+ fs11.mkdirSync(dir, { recursive: true, mode: 448 });
3129
+ fs11.writeFileSync(file, JSON.stringify(devices, null, 2), { mode: 384 });
2885
3130
  }
2886
3131
  };
2887
3132
  }
2888
3133
 
2889
3134
  // src/registry.js
2890
- import fs11 from "node:fs";
2891
- import path9 from "node:path";
3135
+ import fs12 from "node:fs";
3136
+ import path10 from "node:path";
2892
3137
  var AgentRegistry = class {
2893
3138
  constructor(dir) {
2894
3139
  this.dir = dir;
2895
3140
  }
2896
3141
  register({ port: port2, root: root2, url }) {
2897
- fs11.mkdirSync(this.dir, { recursive: true, mode: 448 });
3142
+ fs12.mkdirSync(this.dir, { recursive: true, mode: 448 });
2898
3143
  const file = this.#file(port2);
2899
- fs11.writeFileSync(file, JSON.stringify({ pid: process.pid, port: port2, root: root2, url, startedAt: Date.now() }), { mode: 384 });
3144
+ fs12.writeFileSync(file, JSON.stringify({ pid: process.pid, port: port2, root: root2, url, startedAt: Date.now() }), { mode: 384 });
2900
3145
  const cleanup = () => {
2901
3146
  try {
2902
- if (JSON.parse(fs11.readFileSync(file, "utf8")).pid === process.pid) fs11.rmSync(file);
3147
+ if (JSON.parse(fs12.readFileSync(file, "utf8")).pid === process.pid) fs12.rmSync(file);
2903
3148
  } catch {
2904
3149
  }
2905
3150
  };
@@ -2910,25 +3155,25 @@ var AgentRegistry = class {
2910
3155
  list() {
2911
3156
  let names = [];
2912
3157
  try {
2913
- names = fs11.readdirSync(this.dir).filter((n) => n.endsWith(".json"));
3158
+ names = fs12.readdirSync(this.dir).filter((n) => n.endsWith(".json"));
2914
3159
  } catch {
2915
3160
  return [];
2916
3161
  }
2917
3162
  const agents = [];
2918
3163
  for (const name of names) {
2919
- const file = path9.join(this.dir, name);
3164
+ const file = path10.join(this.dir, name);
2920
3165
  try {
2921
- const agent = JSON.parse(fs11.readFileSync(file, "utf8"));
3166
+ const agent = JSON.parse(fs12.readFileSync(file, "utf8"));
2922
3167
  process.kill(agent.pid, 0);
2923
3168
  agents.push(agent);
2924
3169
  } catch {
2925
- fs11.rmSync(file, { force: true });
3170
+ fs12.rmSync(file, { force: true });
2926
3171
  }
2927
3172
  }
2928
3173
  return agents.sort((a, b) => a.port - b.port);
2929
3174
  }
2930
3175
  #file(port2) {
2931
- return path9.join(this.dir, `${port2}.json`);
3176
+ return path10.join(this.dir, `${port2}.json`);
2932
3177
  }
2933
3178
  };
2934
3179
 
@@ -2992,8 +3237,8 @@ function tunnelStatus({ devtunnel: devtunnel2, tunnelId, account, anonymous: ano
2992
3237
  }
2993
3238
 
2994
3239
  // src/update.js
2995
- import fs12 from "node:fs";
2996
- import path10 from "node:path";
3240
+ import fs13 from "node:fs";
3241
+ import path11 from "node:path";
2997
3242
  var LATEST_URL = "https://registry.npmjs.org/coderemote/latest";
2998
3243
  var CHECK_INTERVAL_MS = 24 * 36e5;
2999
3244
  var SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
@@ -3017,10 +3262,10 @@ async function fetchLatest({ fetch: fetch2 = globalThis.fetch, timeoutMs = 3e3 }
3017
3262
  }
3018
3263
  }
3019
3264
  async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }) {
3020
- const file = path10.join(dir, "update.json");
3265
+ const file = path11.join(dir, "update.json");
3021
3266
  let cache = {};
3022
3267
  try {
3023
- cache = JSON.parse(fs12.readFileSync(file, "utf8"));
3268
+ cache = JSON.parse(fs13.readFileSync(file, "utf8"));
3024
3269
  } catch {
3025
3270
  }
3026
3271
  let latest = cache.latest;
@@ -3029,8 +3274,8 @@ async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }
3029
3274
  if (fetched) {
3030
3275
  latest = fetched;
3031
3276
  try {
3032
- fs12.mkdirSync(dir, { recursive: true, mode: 448 });
3033
- fs12.writeFileSync(file, JSON.stringify({ latest, checkedAt: now }));
3277
+ fs13.mkdirSync(dir, { recursive: true, mode: 448 });
3278
+ fs13.writeFileSync(file, JSON.stringify({ latest, checkedAt: now }));
3034
3279
  } catch {
3035
3280
  }
3036
3281
  }
@@ -3038,7 +3283,14 @@ async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }
3038
3283
  return isNewer(latest, current) ? latest : null;
3039
3284
  }
3040
3285
  function installCommand(installedAt) {
3041
- const p = installedAt.split(path10.sep).join("/");
3286
+ const p = installedAt.split(path11.sep).join("/");
3287
+ const runtime = p.match(/^(.*\/runtime\/node)\/(lib\/)?node_modules\/coderemote\//);
3288
+ if (runtime) {
3289
+ const prefix = installedAt.slice(0, runtime[1].length);
3290
+ const [node, modules] = runtime[2] ? [path11.join(prefix, "bin", "node"), path11.join(prefix, "lib", "node_modules")] : [path11.join(prefix, "node.exe"), path11.join(prefix, "node_modules")];
3291
+ return [node, [path11.join(modules, "npm", "bin", "npm-cli.js"), "install", "-g", "--prefix", prefix, "coderemote@latest"]];
3292
+ }
3293
+ if (p.includes("/Cellar/coderemote/")) return ["brew", ["upgrade", "coderemote"]];
3042
3294
  if (p.includes("/pnpm/") || p.includes("/.pnpm/")) return ["pnpm", ["add", "-g", "coderemote@latest"]];
3043
3295
  if (p.includes("/.bun/")) return ["bun", ["add", "-g", "coderemote@latest"]];
3044
3296
  if (p.includes("/.yarn/") || p.includes("/yarn/global/")) return ["yarn", ["global", "add", "coderemote@latest"]];
@@ -3048,7 +3300,7 @@ function installCommand(installedAt) {
3048
3300
  // package.json
3049
3301
  var package_default = {
3050
3302
  name: "coderemote",
3051
- version: "0.3.5",
3303
+ version: "0.3.7",
3052
3304
  description: "Use your computer from your iPhone: files, git and a real terminal over a secure Microsoft Dev Tunnel. No SSH, no port forwarding, no VPN.",
3053
3305
  keywords: [
3054
3306
  "remote",
@@ -3089,19 +3341,15 @@ var package_default = {
3089
3341
  "scripts/fix-node-pty.js"
3090
3342
  ],
3091
3343
  engines: {
3092
- node: ">=20"
3344
+ node: ">=22"
3093
3345
  },
3094
- os: [
3095
- "darwin",
3096
- "linux"
3097
- ],
3098
3346
  scripts: {
3099
3347
  build: "node scripts/build.js",
3100
3348
  prepare: "node scripts/build.js",
3101
- prepack: "node scripts/build.js && cp ../../LICENSE LICENSE",
3102
- postpack: "rm -f LICENSE",
3349
+ prepack: `node scripts/build.js && node -e "require('fs').copyFileSync('../../LICENSE', 'LICENSE')"`,
3350
+ postpack: `node -e "require('fs').rmSync('LICENSE', { force: true })"`,
3103
3351
  postinstall: "node scripts/fix-node-pty.js",
3104
- test: "node --test 'test/*.test.js'"
3352
+ test: 'node --test "test/*.test.js"'
3105
3353
  },
3106
3354
  dependencies: {
3107
3355
  "node-pty": "^1.1.0",
@@ -3327,15 +3575,12 @@ var Spinner = class {
3327
3575
  };
3328
3576
 
3329
3577
  // src/vscode-tunnel.js
3330
- import { execFile as execFile7, spawn as spawn2 } from "node:child_process";
3331
- import { promisify as promisify4 } from "node:util";
3332
- var run5 = promisify4(execFile7);
3333
3578
  function findCode() {
3334
- return process.env.CODEREMOTE_CODE || onPath("code");
3579
+ return process.env.CODEREMOTE_CODE || findExecutable("code");
3335
3580
  }
3336
3581
  async function codeTunnelSignedIn(code) {
3337
3582
  try {
3338
- const { stdout } = await run5(code, ["tunnel", "user", "show"]);
3583
+ const { stdout } = await execCommand([code, "tunnel", "user", "show"]);
3339
3584
  return /^logged in/im.test(stdout);
3340
3585
  } catch {
3341
3586
  return false;
@@ -3343,12 +3588,12 @@ async function codeTunnelSignedIn(code) {
3343
3588
  }
3344
3589
  function codeTunnelLogin(code) {
3345
3590
  return new Promise((resolve) => {
3346
- spawn2(code, ["tunnel", "user", "login", "--provider", "github"], { stdio: "inherit" }).on("exit", resolve);
3591
+ spawnCommand([code, "tunnel", "user", "login", "--provider", "github"], { stdio: "inherit" }).on("exit", resolve);
3347
3592
  });
3348
3593
  }
3349
3594
  function hostCodeTunnel(code, root2, timeoutMs = 6e4) {
3350
3595
  return new Promise((resolve, reject) => {
3351
- const child = spawn2(code, ["tunnel"], { cwd: root2, stdio: ["ignore", "pipe", "pipe"] });
3596
+ const child = spawnCommand([code, "tunnel"], { cwd: root2, stdio: ["ignore", "pipe", "pipe"] });
3352
3597
  let log2 = "";
3353
3598
  const timer = setTimeout(() => {
3354
3599
  child.kill();
@@ -3360,7 +3605,8 @@ ${log2}`));
3360
3605
  const m = log2.match(/https:\/\/vscode\.dev\/tunnel\/([^/\s]+)/);
3361
3606
  if (!m) return;
3362
3607
  clearTimeout(timer);
3363
- resolve({ child, url: `https://vscode.dev/tunnel/${m[1]}${encodeURI(root2)}` });
3608
+ const folder = isWindows ? `/${root2.replaceAll("\\", "/")}` : root2;
3609
+ resolve({ child, url: `https://vscode.dev/tunnel/${m[1]}${encodeURI(folder)}` });
3364
3610
  };
3365
3611
  child.stdout.on("data", onData);
3366
3612
  child.stderr.on("data", onData);
@@ -3377,28 +3623,28 @@ ${log2}`));
3377
3623
  }
3378
3624
 
3379
3625
  // bin/coderemote.js
3380
- var run6 = promisify5(execFile8);
3381
- var CONFIG_DIR = path11.join(os6.homedir(), ".coderemote");
3382
- var LEGACY_CONFIG_DIR = path11.join(os6.homedir(), ".vsremote");
3383
- if (!fs13.existsSync(CONFIG_DIR) && fs13.existsSync(LEGACY_CONFIG_DIR)) fs13.renameSync(LEGACY_CONFIG_DIR, CONFIG_DIR);
3384
- var CONFIG_FILE = path11.join(CONFIG_DIR, "config.json");
3385
- var PUSH_DIR = path11.join(CONFIG_DIR, "push");
3386
- var UPLOADS_DIR = path11.join(CONFIG_DIR, "uploads");
3626
+ var run5 = promisify4(execFile8);
3627
+ var CONFIG_DIR = path12.join(os6.homedir(), ".coderemote");
3628
+ var LEGACY_CONFIG_DIR = path12.join(os6.homedir(), ".vsremote");
3629
+ if (!fs14.existsSync(CONFIG_DIR) && fs14.existsSync(LEGACY_CONFIG_DIR)) fs14.renameSync(LEGACY_CONFIG_DIR, CONFIG_DIR);
3630
+ var CONFIG_FILE = path12.join(CONFIG_DIR, "config.json");
3631
+ var PUSH_DIR = path12.join(CONFIG_DIR, "push");
3632
+ var UPLOADS_DIR = path12.join(CONFIG_DIR, "uploads");
3387
3633
  var DEFAULT_PORT = 31580;
3388
3634
  var VERSION = package_default.version;
3389
- var PACKAGED = path11.basename(path11.dirname(fileURLToPath(import.meta.url))) === "dist";
3635
+ var PACKAGED = path12.basename(path12.dirname(fileURLToPath(import.meta.url))) === "dist";
3390
3636
  var spinner = new Spinner();
3391
3637
  function loadConfig() {
3392
3638
  let cfg2 = {};
3393
3639
  try {
3394
- cfg2 = JSON.parse(fs13.readFileSync(CONFIG_FILE, "utf8"));
3640
+ cfg2 = JSON.parse(fs14.readFileSync(CONFIG_FILE, "utf8"));
3395
3641
  } catch {
3396
3642
  }
3397
3643
  if (!cfg2.token) {
3398
3644
  cfg2.token = randomBytes3(32).toString("base64url");
3399
3645
  saveConfig(cfg2);
3400
- fs13.rmSync(PUSH_DIR, { recursive: true, force: true });
3401
- fs13.rmSync(UPLOADS_DIR, { recursive: true, force: true });
3646
+ fs14.rmSync(PUSH_DIR, { recursive: true, force: true });
3647
+ fs14.rmSync(UPLOADS_DIR, { recursive: true, force: true });
3402
3648
  }
3403
3649
  if (cfg2.tunnelId && !cfg2.tunnels) {
3404
3650
  cfg2.tunnels = { [DEFAULT_PORT]: cfg2.tunnelId };
@@ -3406,9 +3652,16 @@ function loadConfig() {
3406
3652
  }
3407
3653
  return cfg2;
3408
3654
  }
3655
+ function stopAgent(pid) {
3656
+ if (!isWindows) return process.kill(pid, "SIGTERM");
3657
+ try {
3658
+ execFileSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore" });
3659
+ } catch {
3660
+ }
3661
+ }
3409
3662
  function saveConfig(cfg2) {
3410
- fs13.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
3411
- fs13.writeFileSync(CONFIG_FILE, JSON.stringify(cfg2, null, 2), { mode: 384 });
3663
+ fs14.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
3664
+ fs14.writeFileSync(CONFIG_FILE, JSON.stringify(cfg2, null, 2), { mode: 384 });
3412
3665
  }
3413
3666
  function portFree(port2) {
3414
3667
  return new Promise((resolve) => {
@@ -3419,7 +3672,7 @@ function portFree(port2) {
3419
3672
  }
3420
3673
  function listen(token, port2, root2, tunnelToken, preview, status2) {
3421
3674
  const hookSecret = ensureHookSecret(CONFIG_DIR);
3422
- writeHookLauncher(CONFIG_DIR, { script: fs13.realpathSync(process.argv[1]) });
3675
+ writeHookLauncher(CONFIG_DIR, { script: fs14.realpathSync(process.argv[1]) });
3423
3676
  const server = createAgentServer({
3424
3677
  token,
3425
3678
  root: root2,
@@ -3428,7 +3681,7 @@ function listen(token, port2, root2, tunnelToken, preview, status2) {
3428
3681
  tunnelStatus: status2,
3429
3682
  hookSecret,
3430
3683
  // This very CLI, so coding agents in the app's terminals report through the same version.
3431
- hookBin: fs13.realpathSync(process.argv[1]),
3684
+ hookBin: fs14.realpathSync(process.argv[1]),
3432
3685
  pushStore: pushStore(PUSH_DIR, port2),
3433
3686
  uploadsDir: UPLOADS_DIR
3434
3687
  });
@@ -3441,7 +3694,7 @@ var DEVTUNNEL = "devtunnel";
3441
3694
  async function resolveDevtunnel() {
3442
3695
  let installing = false;
3443
3696
  DEVTUNNEL = await ensureDevtunnel({
3444
- dir: path11.join(CONFIG_DIR, "bin"),
3697
+ dir: path12.join(CONFIG_DIR, "bin"),
3445
3698
  progress: (detail2) => {
3446
3699
  if (!installing) {
3447
3700
  installing = true;
@@ -3454,7 +3707,7 @@ async function resolveDevtunnel() {
3454
3707
  }
3455
3708
  async function devtunnel(...args) {
3456
3709
  try {
3457
- const { stdout } = await run6(DEVTUNNEL, args);
3710
+ const { stdout } = await run5(DEVTUNNEL, args);
3458
3711
  return { ok: true, out: stdout };
3459
3712
  } catch (e) {
3460
3713
  return { ok: false, out: `${e.stdout || ""}${e.stderr || ""}` || e.message };
@@ -3515,9 +3768,23 @@ ${updated.out}`);
3515
3768
  const name = port2 === DEFAULT_PORT ? os6.hostname() : `${os6.hostname()} :${port2}`;
3516
3769
  const createArgs = ["create", "--expiration", "30d", "-d", `coderemote ${name}`, "--labels", MACHINE_LABEL, "--json"];
3517
3770
  if (anonymous2) createArgs.push("--allow-anonymous");
3518
- const created = await devtunnel(...createArgs);
3519
- if (!created.ok) throw new Error(`devtunnel create failed:
3771
+ let created = await devtunnel(...createArgs);
3772
+ if (!created.ok) {
3773
+ const room = await makeRoom(devtunnel);
3774
+ if (room?.full) {
3775
+ spinner.fail();
3776
+ console.error(`
3777
+ ${tunnelLimitHelp(room.full, cfg2)}`);
3778
+ process.exit(1);
3779
+ }
3780
+ if (room?.freed) {
3781
+ spinner.update(`(deleted ${room.freed.length} idle preview tunnel${room.freed.length > 1 ? "s" : ""} to make room)`);
3782
+ if (room.freed.filter((id) => forgetTunnel(cfg2, id)).length) saveConfig(cfg2);
3783
+ created = await devtunnel(...createArgs);
3784
+ }
3785
+ if (!created.ok) throw new Error(`devtunnel create failed:
3520
3786
  ${created.out}`);
3787
+ }
3521
3788
  const tunnelId = JSON.parse(created.out.slice(created.out.indexOf("{"))).tunnel?.tunnelId;
3522
3789
  if (!tunnelId) throw new Error(`could not parse tunnel id from:
3523
3790
  ${created.out}`);
@@ -3610,6 +3877,9 @@ var USAGE = `Usage: coderemote <command> [options]
3610
3877
  kill <id>... | --all kill terminal sessions (an id prefix is enough)
3611
3878
  stop [--all] stop the agent for this folder (or every agent)
3612
3879
  rotate-token revoke every paired device and stop running agents
3880
+ tunnels list your dev tunnels account's tunnels (it may keep 10)
3881
+ tunnels delete <id>... delete tunnels nothing uses; one that is online or is this
3882
+ computer's needs --force (its URL changes, its phones unpair)
3613
3883
  update install the latest coderemote (up says when there's one)
3614
3884
  requests [PORT] list the requests that reached a previewed port, oldest first;
3615
3885
  --grep TEXT, --failed (errors, 4xx and 5xx), --limit N (default 50)
@@ -3641,9 +3911,9 @@ var flag = (name) => {
3641
3911
  };
3642
3912
  var anonymous = rest.includes("--allow-anonymous") || rest.includes("--no-auth");
3643
3913
  var port = flag("--port") ? parseInt(flag("--port"), 10) : DEFAULT_PORT;
3644
- var root = path11.resolve(flag("--root") ?? process.cwd());
3914
+ var root = path12.resolve(flag("--root") ?? process.cwd());
3645
3915
  var cfg = loadConfig();
3646
- var registry = new AgentRegistry(path11.join(CONFIG_DIR, "agents"));
3916
+ var registry = new AgentRegistry(path12.join(CONFIG_DIR, "agents"));
3647
3917
  async function start({ tunnel }) {
3648
3918
  const agents = registry.list();
3649
3919
  if (flag("--port")) {
@@ -3682,6 +3952,7 @@ async function start({ tunnel }) {
3682
3952
  leftover: cfg.previewTunnels?.[port],
3683
3953
  remember: (id) => {
3684
3954
  cfg.previewTunnels = { ...cfg.previewTunnels, [port]: id };
3955
+ if (!id) delete cfg.previewTunnels[port];
3685
3956
  saveConfig(cfg);
3686
3957
  },
3687
3958
  mintToken: connectToken
@@ -3695,7 +3966,7 @@ async function start({ tunnel }) {
3695
3966
  spinner.fail();
3696
3967
  if (e.code !== "EADDRINUSE") throw e;
3697
3968
  console.error(`Port ${port} is already in use by another process.`);
3698
- console.error(`See what holds it with: lsof -nP -iTCP:${port} -sTCP:LISTEN`);
3969
+ console.error(`See what holds it with: ${isWindows ? `netstat -ano | findstr :${port}` : `lsof -nP -iTCP:${port} -sTCP:LISTEN`}`);
3699
3970
  console.error("Stop that process, or pick another port with --port.");
3700
3971
  process.exit(1);
3701
3972
  }
@@ -3725,10 +3996,10 @@ async function start({ tunnel }) {
3725
3996
  }
3726
3997
  spinner.succeed();
3727
3998
  registry.register({ port, root, url });
3728
- const shutdown = () => {
3999
+ const shutdown = async () => {
3729
4000
  child?.kill();
3730
- preview?.stop();
3731
- server.killAllSessions();
4001
+ await preview?.close();
4002
+ await server.killAllSessions();
3732
4003
  server.close();
3733
4004
  process.exit(0);
3734
4005
  };
@@ -3744,7 +4015,7 @@ async function start({ tunnel }) {
3744
4015
  var PLUGIN = "coderemote@coderemote";
3745
4016
  async function claudeCli(...args) {
3746
4017
  try {
3747
- return (await run6("claude", args, { timeout: 12e4 })).stdout.trim();
4018
+ return (await execCommand(["claude", ...args], { timeout: 12e4 })).stdout.trim();
3748
4019
  } catch (e) {
3749
4020
  if (e.code === "ENOENT") return null;
3750
4021
  throw new Error((e.stderr || e.stdout || e.message).trim());
@@ -3785,7 +4056,7 @@ async function setupClaude() {
3785
4056
  var CODEX_PLUGIN = "coderemote@coderemote";
3786
4057
  async function codexCli(...args) {
3787
4058
  try {
3788
- return (await run6("codex", args, { timeout: 12e4 })).stdout.trim();
4059
+ return (await execCommand(["codex", ...args], { timeout: 12e4 })).stdout.trim();
3789
4060
  } catch (e) {
3790
4061
  if (e.code === "ENOENT") return null;
3791
4062
  throw new Error((e.stderr || e.stdout || e.message).trim());
@@ -3860,7 +4131,7 @@ function currentAgent() {
3860
4131
  const agents = registry.list();
3861
4132
  if (flag("--port")) return agents.find((a) => a.port === port);
3862
4133
  const here = process.cwd();
3863
- const containing = agents.filter((a) => here === a.root || here.startsWith(a.root + path11.sep)).sort((a, b) => b.root.length - a.root.length);
4134
+ const containing = agents.filter((a) => here === a.root || here.startsWith(a.root + path12.sep)).sort((a, b) => b.root.length - a.root.length);
3864
4135
  return containing[0] ?? (agents.length === 1 ? agents[0] : void 0);
3865
4136
  }
3866
4137
  async function previewPorts(agent, given) {
@@ -3990,7 +4261,7 @@ Token: ${cfg.token}`);
3990
4261
  break;
3991
4262
  }
3992
4263
  for (const agent of targets) {
3993
- process.kill(agent.pid, "SIGTERM");
4264
+ stopAgent(agent.pid);
3994
4265
  console.log(`stopped agent for ${agent.root} (pid ${agent.pid})`);
3995
4266
  }
3996
4267
  break;
@@ -4000,13 +4271,57 @@ Token: ${cfg.token}`);
4000
4271
  saveConfig(cfg);
4001
4272
  const agents = registry.list();
4002
4273
  for (const agent of agents) {
4003
- process.kill(agent.pid, "SIGTERM");
4274
+ stopAgent(agent.pid);
4004
4275
  console.log(`stopped agent for ${agent.root} (pid ${agent.pid})`);
4005
4276
  }
4006
4277
  console.log("Token rotated. Re-pair your devices.");
4007
4278
  if (agents.length) console.log("Run `coderemote up` again in each folder above; each keeps its tunnel URL.");
4008
4279
  break;
4009
4280
  }
4281
+ case "tunnels": {
4282
+ await resolveDevtunnel();
4283
+ const { tunnels, error } = await listTunnels(devtunnel);
4284
+ if (error) {
4285
+ console.error(error);
4286
+ console.error("Signed in? Run `coderemote login`.");
4287
+ process.exit(1);
4288
+ }
4289
+ if (rest[0] !== "delete") {
4290
+ console.log(tunnels.length ? formatTunnels(tunnels, cfg).join("\n") : "No tunnels on this account.");
4291
+ break;
4292
+ }
4293
+ const ids = rest.slice(1).filter((a) => !a.startsWith("--"));
4294
+ if (!ids.length) {
4295
+ console.error("Usage: coderemote tunnels delete <tunnel id>... [--force]");
4296
+ process.exit(1);
4297
+ }
4298
+ let failed = false;
4299
+ for (const id of ids) {
4300
+ const tunnel = findTunnel(tunnels, id);
4301
+ if (!tunnel) {
4302
+ console.error(`${id}: no such tunnel on this account`);
4303
+ failed = true;
4304
+ continue;
4305
+ }
4306
+ const use = ownUse(cfg, tunnel.tunnelId);
4307
+ if (!rest.includes("--force") && (tunnel.hostConnections > 0 || use)) {
4308
+ const why = tunnel.hostConnections > 0 ? "an agent is serving it right now" : `it is this computer's ${use}`;
4309
+ console.error(`${tunnel.tunnelId}: kept, ${why}. Deleting it unpairs its phones; add --force to delete it anyway.`);
4310
+ failed = true;
4311
+ continue;
4312
+ }
4313
+ const res = await devtunnel("delete", tunnel.tunnelId, "-f");
4314
+ if (!res.ok) {
4315
+ console.error(`${tunnel.tunnelId}: ${res.out.trim()}`);
4316
+ failed = true;
4317
+ continue;
4318
+ }
4319
+ if (forgetTunnel(cfg, tunnel.tunnelId)) saveConfig(cfg);
4320
+ console.log(`deleted ${tunnel.tunnelId}${use ? ` (this computer's ${use}: the next \`coderemote up\` makes a new one)` : ""}`);
4321
+ }
4322
+ if (failed) process.exit(1);
4323
+ break;
4324
+ }
4010
4325
  case "login": {
4011
4326
  await resolveDevtunnel();
4012
4327
  process.exit(await interactiveLogin(loginArgs()));
@@ -4025,16 +4340,16 @@ Token: ${cfg.token}`);
4025
4340
  console.log(`coderemote ${VERSION} is the latest version.`);
4026
4341
  break;
4027
4342
  }
4028
- const [command, args] = installCommand(fs13.realpathSync(process.argv[1]));
4343
+ const [command, args] = installCommand(fs14.realpathSync(process.argv[1]));
4029
4344
  console.log(`Updating coderemote ${VERSION} \u2192 ${latest}: ${command} ${args.join(" ")}
4030
4345
  `);
4031
4346
  const code = await new Promise((resolve) => {
4032
- spawn3(command, args, { stdio: "inherit" }).on("exit", resolve).on("error", () => resolve(127));
4347
+ spawnCommand([command, ...args], { stdio: "inherit" }).on("exit", resolve).on("error", () => resolve(127));
4033
4348
  });
4034
4349
  if (code !== 0) {
4035
4350
  console.error(`
4036
4351
  The update failed (${command} exited with ${code}).`);
4037
- console.error(`If it was a permission error (EACCES), run it with sudo: sudo ${command} ${args.join(" ")}`);
4352
+ if (!isWindows && command === "npm") console.error(`If it was a permission error (EACCES), run it with sudo: sudo ${command} ${args.join(" ")}`);
4038
4353
  process.exit(code || 1);
4039
4354
  }
4040
4355
  const running = registry.list();