coderemote 0.3.4 → 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;
@@ -66,6 +65,8 @@ var Routes = Object.freeze({
66
65
  agentsSetup: "GET /api/agents/setup",
67
66
  agentsPrompt: "POST /api/agents/prompt",
68
67
  agentsKeys: "POST /api/agents/keys",
68
+ agentsStop: "POST /api/agents/stop",
69
+ agentsForget: "POST /api/agents/forget",
69
70
  agentsUpload: "POST /api/agents/uploads",
70
71
  pushDeviceSet: "PUT /api/push/device",
71
72
  pushDeviceDelete: "DELETE /api/push/device",
@@ -116,7 +117,7 @@ var HttpError = class extends Error {
116
117
  var Sandbox = class {
117
118
  constructor(root2) {
118
119
  this.root = path.resolve(root2);
119
- this.realRoot = fsSync.realpathSync(this.root);
120
+ this.realRoot = fsSync.realpathSync.native(this.root);
120
121
  }
121
122
  #containsReal(p) {
122
123
  return p === this.realRoot || p.startsWith(this.realRoot + path.sep);
@@ -633,6 +634,17 @@ var AgentHub = class extends EventEmitter {
633
634
  }
634
635
  }
635
636
  }
637
+ /**
638
+ * Drops a session from the list, withdrawing its requests. One still running elsewhere comes
639
+ * back with its next hook event.
640
+ */
641
+ forget(id) {
642
+ const s = this.#sessions.get(id);
643
+ if (!s) throw new HttpError(404, "no such session");
644
+ if (s.state !== "ended") this.#end(s);
645
+ this.#sessions.delete(id);
646
+ this.emit("message", { type: "session_removed", session: id, replacedBy: null });
647
+ }
636
648
  /** Forgets sessions that ended more than a day ago. */
637
649
  sweep() {
638
650
  const cutoff = this.#now() - ENDED_TTL;
@@ -1039,12 +1051,100 @@ var LANDING_HTML = `<!doctype html>
1039
1051
  `;
1040
1052
 
1041
1053
  // ../../packages/agent-core/src/ports.js
1042
- import { execFile as execFile2 } from "node:child_process";
1043
- import fs5 from "node:fs/promises";
1054
+ import { execFile as execFile3 } from "node:child_process";
1055
+ import fs6 from "node:fs/promises";
1044
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
1045
1145
  function run(cmd2, args) {
1046
1146
  return new Promise((resolve, reject) => {
1047
- execFile2(cmd2, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
1147
+ execFile3(cmd2, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
1048
1148
  if (err?.code === "ENOENT") reject(err);
1049
1149
  else resolve(stdout || "");
1050
1150
  });
@@ -1096,9 +1196,9 @@ async function processTable() {
1096
1196
  }
1097
1197
  async function workingDirs(pids) {
1098
1198
  const dirs = /* @__PURE__ */ new Map();
1099
- if (!pids.length) return dirs;
1199
+ if (!pids.length || isWindows) return dirs;
1100
1200
  if (os.platform() === "linux") {
1101
- 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))));
1102
1202
  return dirs;
1103
1203
  }
1104
1204
  const out = await run("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-F", "pn"]).catch(() => "");
@@ -1109,9 +1209,39 @@ async function workingDirs(pids) {
1109
1209
  }
1110
1210
  return dirs;
1111
1211
  }
1112
- 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|$)/;
1113
1243
  async function listPorts(shells = /* @__PURE__ */ new Map()) {
1114
- const [sockets, table] = await Promise.all([listeningSockets(), processTable()]);
1244
+ const [sockets, table] = isWindows ? await windowsListeners() : await Promise.all([listeningSockets(), processTable()]);
1115
1245
  const byKey = /* @__PURE__ */ new Map();
1116
1246
  for (const s of sockets) {
1117
1247
  const key = `${s.pid}:${s.port}`;
@@ -1159,11 +1289,11 @@ async function killListener(pid, { force = false, shells } = {}) {
1159
1289
  }
1160
1290
 
1161
1291
  // ../../packages/agent-core/src/push.js
1162
- import { execFile as execFile3 } from "node:child_process";
1292
+ import { execFile as execFile4 } from "node:child_process";
1163
1293
  import { createCipheriv, randomBytes } from "node:crypto";
1164
1294
  import os2 from "node:os";
1165
1295
  import { promisify } from "node:util";
1166
- var run2 = promisify(execFile3);
1296
+ var run2 = promisify(execFile4);
1167
1297
  var FINISHED_AFTER = 60 * 1e3;
1168
1298
  var ACTIVE_WITHIN = 2 * 60;
1169
1299
  async function macIdleSeconds() {
@@ -1555,17 +1685,25 @@ function detail(e) {
1555
1685
  }
1556
1686
 
1557
1687
  // ../../packages/agent-core/src/sessions.js
1688
+ import { execFile as execFile5 } from "node:child_process";
1558
1689
  import { randomUUID as randomUUID2 } from "node:crypto";
1559
1690
  import os3 from "node:os";
1560
1691
  import pty from "node-pty";
1561
1692
  var SCROLLBACK_LIMIT = 256 * 1024;
1562
- function shellQuote(arg) {
1563
- 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?.();
1564
1702
  }
1565
1703
  var TerminalSession = class {
1566
1704
  /**
1567
- * `command` (an argv array) runs that program through a login shell (for the user's PATH)
1568
- * 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.
1569
1707
  */
1570
1708
  constructor({ cols, rows, cwd, env = {}, command = null }) {
1571
1709
  this.id = randomUUID2();
@@ -1574,11 +1712,10 @@ var TerminalSession = class {
1574
1712
  this.bufferedBytes = 0;
1575
1713
  this.clients = /* @__PURE__ */ new Set();
1576
1714
  this.exitCode = null;
1577
- const shell = process.env.SHELL || (os3.platform() === "win32" ? "powershell.exe" : "/bin/sh");
1578
- this.shell = shell;
1715
+ const { file, args } = command ? terminalCommand(command) : interactiveShell();
1716
+ this.shell = command ? interactiveShell().file : file;
1579
1717
  this.command = command;
1580
- const args = command ? ["-l", "-c", `exec ${command.map(shellQuote).join(" ")}`] : ["-l"];
1581
- this.pty = pty.spawn(shell, args, {
1718
+ this.pty = pty.spawn(file, args, {
1582
1719
  name: "xterm-256color",
1583
1720
  cols,
1584
1721
  rows,
@@ -1586,9 +1723,11 @@ var TerminalSession = class {
1586
1723
  // CODEREMOTE_TERM_ID lets `coderemote hook` tell which terminal a coding agent runs in.
1587
1724
  env: { ...process.env, ...env, CODEREMOTE_TERM_ID: this.id, TERM: "xterm-256color", COLORTERM: "truecolor" }
1588
1725
  });
1726
+ if (isWindows) guardConpty(this.pty);
1589
1727
  this.pty.onData((data) => this.#output(Buffer.from(data, "utf8")));
1590
1728
  this.pty.onExit(({ exitCode }) => {
1591
1729
  this.exitCode = exitCode;
1730
+ if (isWindows) releaseConpty(this.pty);
1592
1731
  for (const ws of this.clients) {
1593
1732
  ws.send(JSON.stringify({ type: "exit", code: exitCode }));
1594
1733
  ws.close(1e3, "session exited");
@@ -1611,7 +1750,7 @@ var TerminalSession = class {
1611
1750
  if (replay.length) ws.send(replay, { binary: true });
1612
1751
  ws.on("message", (data, isBinary) => {
1613
1752
  if (isBinary) {
1614
- this.pty.write(data.toString("utf8"));
1753
+ this.write(data.toString("utf8"));
1615
1754
  return;
1616
1755
  }
1617
1756
  let msg;
@@ -1620,22 +1759,25 @@ var TerminalSession = class {
1620
1759
  } catch {
1621
1760
  return;
1622
1761
  }
1623
- 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);
1624
1763
  else if (msg.type === "resize") this.resize(msg.cols, msg.rows);
1625
1764
  });
1626
1765
  ws.on("close", () => this.clients.delete(ws));
1627
1766
  }
1628
1767
  resize(cols, rows) {
1629
- 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) {
1630
1769
  this.pty.resize(cols, rows);
1631
1770
  }
1632
1771
  }
1633
- kill() {
1634
- this.pty.kill();
1772
+ /** Ends the session; on Windows, which has no signals, always forcibly (see releaseConpty). */
1773
+ 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
+ });
1635
1777
  }
1636
- /** 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. */
1637
1779
  write(data) {
1638
- this.pty.write(data);
1780
+ if (this.exitCode === null) this.pty.write(data);
1639
1781
  }
1640
1782
  toJSON() {
1641
1783
  return {
@@ -1669,28 +1811,42 @@ var SessionManager = class {
1669
1811
  list() {
1670
1812
  return [...this.#sessions.values()].map((s) => s.toJSON());
1671
1813
  }
1672
- killAll() {
1673
- 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));
1674
1830
  }
1675
1831
  };
1676
1832
 
1677
1833
  // ../../packages/agent-core/src/uploads.js
1678
- import { execFile as execFile4 } from "node:child_process";
1834
+ import { execFile as execFile6 } from "node:child_process";
1679
1835
  import { randomUUID as randomUUID3 } from "node:crypto";
1680
1836
  import { createWriteStream } from "node:fs";
1681
- import fs6 from "node:fs/promises";
1837
+ import fs7 from "node:fs/promises";
1682
1838
  import os4 from "node:os";
1683
- import path4 from "node:path";
1839
+ import path5 from "node:path";
1684
1840
  import { Transform } from "node:stream";
1685
1841
  import { pipeline } from "node:stream/promises";
1686
1842
  import { promisify as promisify2 } from "node:util";
1687
- var run3 = promisify2(execFile4);
1843
+ var run3 = promisify2(execFile6);
1688
1844
  var UPLOAD_LIMIT = 100 * 1024 * 1024;
1689
1845
  var KEEP = 7 * 24 * 60 * 60 * 1e3;
1690
1846
  var ID = /^[0-9a-f-]{36}$/;
1691
1847
  function safeName(name) {
1692
- const base = path4.basename(String(name ?? "").replace(/\\/g, "/"));
1693
- 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();
1694
1850
  return clean || "file";
1695
1851
  }
1696
1852
  var UploadStore = class {
@@ -1703,8 +1859,8 @@ var UploadStore = class {
1703
1859
  if (Number(length) > limit) throw new HttpError(413, `files are limited to ${limit / 1024 / 1024} MB`);
1704
1860
  const id = randomUUID3();
1705
1861
  const day = new Date(this.now()).toISOString().slice(0, 10);
1706
- await fs6.mkdir(path4.join(this.dir, day), { recursive: true, mode: 448 });
1707
- 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)}`);
1708
1864
  let size = 0;
1709
1865
  const counter = new Transform({
1710
1866
  transform(chunk, _enc, done) {
@@ -1715,7 +1871,7 @@ var UploadStore = class {
1715
1871
  try {
1716
1872
  await pipeline(body, counter, createWriteStream(file, { mode: 384 }));
1717
1873
  } catch (e) {
1718
- await fs6.rm(file, { force: true });
1874
+ await fs7.rm(file, { force: true });
1719
1875
  throw e;
1720
1876
  }
1721
1877
  let kind = type || "application/octet-stream";
@@ -1723,31 +1879,31 @@ var UploadStore = class {
1723
1879
  const jpeg = file.replace(/(\.heic)?$/i, ".jpg");
1724
1880
  try {
1725
1881
  await run3("sips", ["-s", "format", "jpeg", file, "--out", jpeg], { timeout: 3e4 });
1726
- await fs6.rm(file);
1882
+ await fs7.rm(file);
1727
1883
  file = jpeg;
1728
1884
  kind = "image/jpeg";
1729
- size = (await fs6.stat(file)).size;
1885
+ size = (await fs7.stat(file)).size;
1730
1886
  } catch {
1731
1887
  }
1732
1888
  }
1733
- 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 };
1734
1890
  }
1735
1891
  /** The file of an upload; 404 once it's gone (swept, or never existed). */
1736
1892
  async path(id) {
1737
1893
  if (typeof id !== "string" || !ID.test(id)) throw new HttpError(400, "invalid upload id");
1738
1894
  let days = [];
1739
1895
  try {
1740
- days = (await fs6.readdir(this.dir)).sort().reverse();
1896
+ days = (await fs7.readdir(this.dir)).sort().reverse();
1741
1897
  } catch {
1742
1898
  }
1743
1899
  for (const day of days) {
1744
1900
  let names = [];
1745
1901
  try {
1746
- names = await fs6.readdir(path4.join(this.dir, day));
1902
+ names = await fs7.readdir(path5.join(this.dir, day));
1747
1903
  } catch {
1748
1904
  }
1749
1905
  const hit = names.find((n) => n.startsWith(`${id}-`));
1750
- if (hit) return path4.join(this.dir, day, hit);
1906
+ if (hit) return path5.join(this.dir, day, hit);
1751
1907
  }
1752
1908
  throw new HttpError(404, "no such upload (they are kept for 7 days)");
1753
1909
  }
@@ -1755,25 +1911,25 @@ var UploadStore = class {
1755
1911
  async sweep() {
1756
1912
  let days = [];
1757
1913
  try {
1758
- days = await fs6.readdir(this.dir);
1914
+ days = await fs7.readdir(this.dir);
1759
1915
  } catch {
1760
1916
  return;
1761
1917
  }
1762
1918
  const cutoff = this.now() - KEEP;
1763
1919
  for (const day of days) {
1764
- const dir = path4.join(this.dir, day);
1920
+ const dir = path5.join(this.dir, day);
1765
1921
  let names = [];
1766
1922
  try {
1767
- names = await fs6.readdir(dir);
1923
+ names = await fs7.readdir(dir);
1768
1924
  } catch {
1769
1925
  continue;
1770
1926
  }
1771
1927
  for (const n of names) {
1772
- const file = path4.join(dir, n);
1773
- const stat = await fs6.stat(file).catch(() => null);
1774
- 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 });
1775
1931
  }
1776
- 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 });
1777
1933
  }
1778
1934
  }
1779
1935
  };
@@ -1786,11 +1942,11 @@ function kindOf(stat) {
1786
1942
  return "other";
1787
1943
  }
1788
1944
  async function describe(fullPath, name) {
1789
- const lst = await fs7.lstat(fullPath);
1945
+ const lst = await fs8.lstat(fullPath);
1790
1946
  const entry2 = { name, type: kindOf(lst), size: lst.size, mtime: lst.mtimeMs };
1791
1947
  if (lst.isSymbolicLink()) {
1792
1948
  try {
1793
- entry2.target = kindOf(await fs7.stat(fullPath));
1949
+ entry2.target = kindOf(await fs8.stat(fullPath));
1794
1950
  } catch {
1795
1951
  entry2.target = "broken";
1796
1952
  }
@@ -1830,7 +1986,7 @@ function createAgentServer({
1830
1986
  uploadsDir = null,
1831
1987
  claudeCommand = "claude",
1832
1988
  codexCommand = "codex",
1833
- codexHome = process.env.CODEX_HOME || path5.join(os5.homedir(), ".codex"),
1989
+ codexHome = process.env.CODEX_HOME || path6.join(os5.homedir(), ".codex"),
1834
1990
  pluginInstalled = null,
1835
1991
  agents = new AgentHub(),
1836
1992
  pushStore: pushStore2 = null,
@@ -1885,17 +2041,6 @@ function createAgentServer({
1885
2041
  if (!Array.isArray(ids) || ids.some((a) => typeof a !== "string")) throw new HttpError(400, "attachments must be upload ids");
1886
2042
  return Promise.all(ids.map((a) => uploadPath(a)));
1887
2043
  }
1888
- function loginShell(script) {
1889
- const shell = process.env.SHELL || "/bin/sh";
1890
- return new Promise((resolve) => {
1891
- execFile5(
1892
- shell,
1893
- ["-l", "-c", script],
1894
- { timeout: 2e4, maxBuffer: 16 * 1024 * 1024 },
1895
- (err, out) => resolve(err ? null : out)
1896
- );
1897
- });
1898
- }
1899
2044
  const launchers = {
1900
2045
  claude: {
1901
2046
  command: claudeCommand,
@@ -1910,7 +2055,7 @@ function createAgentServer({
1910
2055
  // terminal's queries; the classic renderer doesn't need them.
1911
2056
  env: { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" },
1912
2057
  async plugin() {
1913
- const list = JSON.parse(await loginShell(`${shellQuote(claudeCommand)} plugin list --json`) ?? "[]");
2058
+ const list = JSON.parse(await capture([claudeCommand, "plugin", "list", "--json"]) ?? "[]");
1914
2059
  return list.some((p) => p.id === "coderemote@coderemote" && p.enabled !== false);
1915
2060
  },
1916
2061
  missingPlugin: "Claude Code needs the Code Remote plugin first: run `coderemote setup claude` once on the computer."
@@ -1928,14 +2073,14 @@ function createAgentServer({
1928
2073
  ],
1929
2074
  env: {},
1930
2075
  async plugin() {
1931
- 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"]) ?? "{}");
1932
2077
  return (list.installed ?? []).some((p) => p.pluginId === "coderemote@coderemote" && p.enabled !== false);
1933
2078
  },
1934
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."
1935
2080
  }
1936
2081
  };
1937
- async function installed(agent) {
1938
- return Boolean((await loginShell(`command -v ${shellQuote(launchers[agent].command)}`))?.trim());
2082
+ function installed(agent) {
2083
+ return commandExists(launchers[agent].command);
1939
2084
  }
1940
2085
  const plugins = {};
1941
2086
  async function hasPlugin(agent) {
@@ -1951,7 +2096,7 @@ function createAgentServer({
1951
2096
  }
1952
2097
  async function codexModels() {
1953
2098
  try {
1954
- 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"));
1955
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 ?? "" }));
1956
2101
  } catch {
1957
2102
  return [];
@@ -2001,20 +2146,20 @@ function createAgentServer({
2001
2146
  async function repoOf(p) {
2002
2147
  const repo = await sandbox.resolve(p);
2003
2148
  try {
2004
- await fs7.access(path5.join(repo, ".git"));
2149
+ await fs8.access(path6.join(repo, ".git"));
2005
2150
  } catch {
2006
2151
  throw new HttpError(400, "not a git repository");
2007
2152
  }
2008
2153
  return repo;
2009
2154
  }
2010
2155
  async function filesIn(repo, files) {
2011
- 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))) {
2012
2157
  throw new HttpError(400, "files must be a non-empty list of paths relative to the repository");
2013
2158
  }
2014
2159
  return Promise.all(
2015
2160
  files.map(async (f) => {
2016
- const abs = await sandbox.resolve(path5.join(repo, f), { allowMissing: true });
2017
- 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);
2018
2163
  if (!rel || rel.startsWith("..")) throw new HttpError(403, "path is outside the repository");
2019
2164
  return rel;
2020
2165
  })
@@ -2027,21 +2172,22 @@ function createAgentServer({
2027
2172
  platform: os5.platform(),
2028
2173
  root: sandbox.root,
2029
2174
  user: os5.userInfo().username,
2030
- 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
2031
2177
  }),
2032
2178
  [Routes.fsList]: async (url) => {
2033
2179
  const dir = await sandbox.resolve(url.searchParams.get("path"));
2034
- const names = await fs7.readdir(dir);
2035
- 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);
2036
2182
  return { path: dir, entries };
2037
2183
  },
2038
2184
  [Routes.fsStat]: async (url) => {
2039
2185
  const p = await sandbox.resolve(url.searchParams.get("path"));
2040
- return { path: p, ...await describe(p, path5.basename(p)) };
2186
+ return { path: p, ...await describe(p, path6.basename(p)) };
2041
2187
  },
2042
2188
  [Routes.fsRead]: async (url, req, res) => {
2043
2189
  const p = await sandbox.resolve(url.searchParams.get("path"));
2044
- const stat = await fs7.stat(p);
2190
+ const stat = await fs8.stat(p);
2045
2191
  if (!stat.isFile()) throw new HttpError(400, "not a file");
2046
2192
  res.writeHead(200, { "content-type": "application/octet-stream", "content-length": stat.size });
2047
2193
  await pipeline2(createReadStream(p), res);
@@ -2051,24 +2197,24 @@ function createAgentServer({
2051
2197
  const tmp = `${p}.vsremote-${process.pid}-${Date.now()}.tmp`;
2052
2198
  try {
2053
2199
  await pipeline2(req, createWriteStream2(tmp));
2054
- await fs7.rename(tmp, p);
2200
+ await fs8.rename(tmp, p);
2055
2201
  } catch (e) {
2056
- await fs7.rm(tmp, { force: true });
2202
+ await fs8.rm(tmp, { force: true });
2057
2203
  throw e;
2058
2204
  }
2059
- return describe(p, path5.basename(p));
2205
+ return describe(p, path6.basename(p));
2060
2206
  },
2061
2207
  [Routes.fsMkdir]: async (url) => {
2062
- 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 });
2063
2209
  return {};
2064
2210
  },
2065
2211
  [Routes.fsRename]: async (url, req) => {
2066
2212
  const { from, to } = await readJson(req);
2067
- 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 }));
2068
2214
  return {};
2069
2215
  },
2070
2216
  [Routes.fsDelete]: async (url) => {
2071
- 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 });
2072
2218
  return {};
2073
2219
  },
2074
2220
  [Routes.gitRepos]: async () => ({ repos: await findRepos(sandbox.root) }),
@@ -2203,7 +2349,7 @@ function createAgentServer({
2203
2349
  } catch (e) {
2204
2350
  throw new HttpError(400, e.message);
2205
2351
  }
2206
- 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");
2207
2353
  if (model !== void 0 && (typeof model !== "string" || !/^[A-Za-z0-9._[\]-]{1,80}$/.test(model))) {
2208
2354
  throw new HttpError(400, "invalid model");
2209
2355
  }
@@ -2225,7 +2371,7 @@ function createAgentServer({
2225
2371
  throw Object.assign(new HttpError(400, launcher.missingPlugin), { code: "ENOPLUGIN" });
2226
2372
  }
2227
2373
  const id = resume ?? randomUUID4();
2228
- if (uploadsDir) await fs7.mkdir(uploadsDir, { recursive: true, mode: 448 });
2374
+ if (uploadsDir) await fs8.mkdir(uploadsDir, { recursive: true, mode: 448 });
2229
2375
  const argv2 = [
2230
2376
  launcher.command,
2231
2377
  ...launcher.args({
@@ -2288,6 +2434,25 @@ function createAgentServer({
2288
2434
  }
2289
2435
  return {};
2290
2436
  },
2437
+ [Routes.agentsStop]: async (url, req) => {
2438
+ const { session: id } = await readJson(req);
2439
+ const { term } = typingTarget(id, { whileWaiting: true });
2440
+ term.kill();
2441
+ setTimeout(() => {
2442
+ if (term.exitCode === null) term.kill("SIGKILL");
2443
+ }, 3e3).unref();
2444
+ return {};
2445
+ },
2446
+ [Routes.agentsForget]: async (url, req) => {
2447
+ const { session: id } = await readJson(req);
2448
+ const session = typeof id === "string" ? agents.session(id) : null;
2449
+ if (!session) throw new HttpError(404, "no such session");
2450
+ if (session.state !== "ended" && session.terminal && sessions.get(session.terminal)) {
2451
+ throw new HttpError(409, "this session still runs here; stop it first");
2452
+ }
2453
+ agents.forget(id);
2454
+ return {};
2455
+ },
2291
2456
  [Routes.agentsAnswer]: async (url, req) => {
2292
2457
  agents.answer(await readJson(req));
2293
2458
  return {};
@@ -2443,42 +2608,33 @@ function createAgentServer({
2443
2608
  }
2444
2609
 
2445
2610
  // src/devtunnel.js
2446
- import { execFile as execFile6 } from "node:child_process";
2611
+ import { execFile as execFile7 } from "node:child_process";
2447
2612
  import { createHash } from "node:crypto";
2448
- import fs8 from "node:fs";
2449
- import path6 from "node:path";
2613
+ import fs9 from "node:fs";
2614
+ import path7 from "node:path";
2450
2615
  import { promisify as promisify3 } from "node:util";
2451
- var run4 = promisify3(execFile6);
2616
+ var run4 = promisify3(execFile7);
2452
2617
  var DOWNLOAD_BASE = "https://tunnelsassetsprod.blob.core.windows.net/cli";
2453
2618
  var BUILDS = {
2454
2619
  "darwin-arm64": "osx-arm64-devtunnel",
2455
2620
  "darwin-x64": "osx-x64-devtunnel",
2456
2621
  "linux-arm64": "linux-arm64-devtunnel",
2457
- "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"
2458
2626
  };
2459
2627
  var MICROSOFT_TEAM_ID = "UBF8T346G9";
2628
+ var MICROSOFT_SIGNER = /^CN=Microsoft Corporation, O=Microsoft Corporation,/;
2460
2629
  var REFRESH_INTERVAL_MS = 24 * 36e5;
2461
- function executable(file) {
2462
- try {
2463
- fs8.accessSync(file, fs8.constants.X_OK);
2464
- return fs8.statSync(file).isFile();
2465
- } catch {
2466
- return false;
2467
- }
2468
- }
2469
- function onPath(name) {
2470
- for (const dir of (process.env.PATH || "").split(path6.delimiter)) {
2471
- if (dir && executable(path6.join(dir, name))) return path6.join(dir, name);
2472
- }
2473
- return null;
2474
- }
2475
2630
  async function ensureDevtunnel({ dir, progress }) {
2476
2631
  const override = process.env.CODEREMOTE_DEVTUNNEL;
2477
2632
  if (override) {
2478
- if (executable(override)) return override;
2633
+ const found = findExecutable(override);
2634
+ if (found) return found;
2479
2635
  throw new Error(`CODEREMOTE_DEVTUNNEL=${override} isn't an executable file.`);
2480
2636
  }
2481
- const fromPath = onPath("devtunnel");
2637
+ const fromPath = findExecutable("devtunnel");
2482
2638
  if (fromPath) return fromPath;
2483
2639
  const build = BUILDS[`${process.platform}-${process.arch}`];
2484
2640
  if (!build) {
@@ -2486,14 +2642,14 @@ async function ensureDevtunnel({ dir, progress }) {
2486
2642
  `No devtunnel build for ${process.platform}-${process.arch}. Install it yourself: https://learn.microsoft.com/azure/developer/dev-tunnels/get-started`
2487
2643
  );
2488
2644
  }
2489
- const bin = path6.join(dir, "devtunnel");
2490
- 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");
2491
2647
  let meta = {};
2492
2648
  try {
2493
- meta = JSON.parse(fs8.readFileSync(metaFile, "utf8"));
2649
+ meta = JSON.parse(fs9.readFileSync(metaFile, "utf8"));
2494
2650
  } catch {
2495
2651
  }
2496
- const installed = executable(bin);
2652
+ const installed = findExecutable(bin) !== null;
2497
2653
  if (installed && Date.now() - (meta.checkedAt ?? 0) < REFRESH_INTERVAL_MS) return bin;
2498
2654
  const url = `${DOWNLOAD_BASE}/${build}`;
2499
2655
  if (installed) {
@@ -2501,23 +2657,27 @@ async function ensureDevtunnel({ dir, progress }) {
2501
2657
  const head = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3e3) });
2502
2658
  const lastModified = head.headers.get("last-modified");
2503
2659
  if (head.ok && lastModified && lastModified === meta.lastModified) {
2504
- fs8.writeFileSync(metaFile, JSON.stringify({ ...meta, checkedAt: Date.now() }));
2660
+ fs9.writeFileSync(metaFile, JSON.stringify({ ...meta, checkedAt: Date.now() }));
2505
2661
  return bin;
2506
2662
  }
2507
2663
  } catch {
2508
2664
  return bin;
2509
2665
  }
2510
2666
  }
2511
- await download(url, bin, metaFile, progress);
2667
+ try {
2668
+ await download(url, bin, metaFile, progress);
2669
+ } catch (e) {
2670
+ if (!installed) throw e;
2671
+ }
2512
2672
  return bin;
2513
2673
  }
2514
2674
  async function download(url, bin, metaFile, progress) {
2515
- fs8.mkdirSync(path6.dirname(bin), { recursive: true, mode: 448 });
2675
+ fs9.mkdirSync(path7.dirname(bin), { recursive: true, mode: 448 });
2516
2676
  const res = await fetch(url, { signal: AbortSignal.timeout(12e4) });
2517
2677
  if (!res.ok || !res.body) throw new Error(`Downloading devtunnel failed: HTTP ${res.status} from ${url}`);
2518
2678
  const total = Number(res.headers.get("content-length")) || 0;
2519
2679
  const tmp = `${bin}.${process.pid}.download`;
2520
- const out = fs8.createWriteStream(tmp, { mode: 493 });
2680
+ const out = fs9.createWriteStream(tmp, { mode: 493 });
2521
2681
  const md5 = createHash("md5");
2522
2682
  let received = 0;
2523
2683
  try {
@@ -2530,17 +2690,18 @@ async function download(url, bin, metaFile, progress) {
2530
2690
  await new Promise((resolve, reject) => out.end((e) => e ? reject(e) : resolve()));
2531
2691
  if (total && received !== total) throw new Error(`devtunnel download was cut short (${received} of ${total} bytes)`);
2532
2692
  await verify(tmp, md5.digest("base64"), res.headers.get("content-md5"));
2533
- fs8.chmodSync(tmp, 493);
2534
- fs8.renameSync(tmp, bin);
2693
+ fs9.chmodSync(tmp, 493);
2694
+ fs9.renameSync(tmp, bin);
2535
2695
  } catch (e) {
2536
2696
  out.destroy();
2537
- fs8.rmSync(tmp, { force: true });
2697
+ fs9.rmSync(tmp, { force: true });
2538
2698
  throw e;
2539
2699
  }
2540
- 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() }));
2541
2701
  }
2542
2702
  async function verify(file, md5, expectedMd5) {
2543
2703
  if (expectedMd5 && md5 !== expectedMd5) throw new Error("devtunnel download is corrupt (MD5 mismatch)");
2704
+ if (isWindows) return verifyAuthenticode(file);
2544
2705
  if (process.platform !== "darwin") return;
2545
2706
  try {
2546
2707
  await run4("codesign", ["--verify", "--strict", file]);
@@ -2550,52 +2711,69 @@ async function verify(file, md5, expectedMd5) {
2550
2711
  throw new Error(`devtunnel download failed its signature check: ${e.message}`);
2551
2712
  }
2552
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
+ }
2553
2730
 
2554
2731
  // src/hook.js
2555
2732
  import { randomBytes as randomBytes2 } from "node:crypto";
2556
- import fs9 from "node:fs";
2733
+ import fs10 from "node:fs";
2557
2734
  import http3 from "node:http";
2558
- import path7 from "node:path";
2735
+ import path8 from "node:path";
2559
2736
  var CONNECT_TIMEOUT = 1e3;
2560
2737
  var EVENT_TIMEOUT = 5e3;
2561
2738
  function ensureHookSecret(dir) {
2562
- const file = path7.join(dir, "hook-secret");
2739
+ const file = path8.join(dir, "hook-secret");
2563
2740
  try {
2564
- const secret2 = fs9.readFileSync(file, "utf8").trim();
2741
+ const secret2 = fs10.readFileSync(file, "utf8").trim();
2565
2742
  if (secret2) return secret2;
2566
2743
  } catch {
2567
2744
  }
2568
- fs9.mkdirSync(dir, { recursive: true, mode: 448 });
2745
+ fs10.mkdirSync(dir, { recursive: true, mode: 448 });
2569
2746
  const secret = randomBytes2(32).toString("base64url");
2570
- fs9.writeFileSync(file, secret, { mode: 384 });
2747
+ fs10.writeFileSync(file, secret, { mode: 384 });
2571
2748
  return secret;
2572
2749
  }
2573
2750
  function writeHookLauncher(dir, { node = process.execPath, script }) {
2574
- const bin = path7.join(dir, "bin");
2575
- fs9.mkdirSync(bin, { recursive: true, mode: 448 });
2751
+ const bin = path8.join(dir, "bin");
2752
+ fs10.mkdirSync(bin, { recursive: true, mode: 448 });
2576
2753
  const quote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
2577
- const file = path7.join(bin, "hook");
2578
- fs9.writeFileSync(file, `#!/bin/sh
2754
+ const file = path8.join(bin, "hook");
2755
+ fs10.writeFileSync(file, `#!/bin/sh
2579
2756
  exec ${quote(node)} ${quote(script)} hook "$@"
2580
2757
  `, { mode: 493 });
2581
- fs9.chmodSync(file, 493);
2758
+ fs10.chmodSync(file, 493);
2759
+ fs10.writeFileSync(path8.join(bin, "hook.json"), JSON.stringify({ node, script }), { mode: 384 });
2582
2760
  return file;
2583
2761
  }
2584
2762
  function readHookSecret(dir) {
2585
2763
  try {
2586
- return fs9.readFileSync(path7.join(dir, "hook-secret"), "utf8").trim() || null;
2764
+ return fs10.readFileSync(path8.join(dir, "hook-secret"), "utf8").trim() || null;
2587
2765
  } catch {
2588
2766
  return null;
2589
2767
  }
2590
2768
  }
2591
2769
  function real(p) {
2592
2770
  try {
2593
- return fs9.realpathSync(p);
2771
+ return fs10.realpathSync.native(p);
2594
2772
  } catch {
2595
- return path7.resolve(p);
2773
+ return path8.resolve(p);
2596
2774
  }
2597
2775
  }
2598
- 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);
2599
2777
  function hookTarget(agents, cwd, env = process.env) {
2600
2778
  const port2 = parseInt(env.CODEREMOTE_AGENT_PORT, 10);
2601
2779
  if (port2 && agents.some((a) => a.port === port2)) return { port: port2, terminal: env.CODEREMOTE_TERM_ID || null };
@@ -2651,8 +2829,80 @@ async function runHook({ input, agents, secret, agent = "claude", env = process.
2651
2829
  }
2652
2830
 
2653
2831
  // src/preview.js
2654
- 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
2655
2904
  var HOST_READY_TIMEOUT_MS = 3e4;
2905
+ var CLOSE_TIMEOUT_MS = 5e3;
2656
2906
  var TOKEN_REUSE_MS = 12 * 36e5;
2657
2907
  var PreviewTunnel = class {
2658
2908
  #queue = Promise.resolve();
@@ -2732,8 +2982,13 @@ var PreviewTunnel = class {
2732
2982
  return this.#serial(async () => {
2733
2983
  const url = this.#host?.urls.get(port2);
2734
2984
  if (url) return { port: port2, url };
2735
- const id = await this.#ensureTunnel();
2736
- 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
+ }
2737
2992
  if (!res.ok && !/already exists|conflict/i.test(res.out)) throw new Error(`devtunnel port create failed: ${res.out.trim()}`);
2738
2993
  this.#ports.add(port2);
2739
2994
  try {
@@ -2768,20 +3023,42 @@ var PreviewTunnel = class {
2768
3023
  stop() {
2769
3024
  this.#stopHost();
2770
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
+ }
2771
3047
  async #ensureTunnel() {
2772
3048
  if (this.#tunnelId) return this.#tunnelId;
2773
3049
  if (this.leftover) await this.devtunnel("delete", this.leftover, "-f");
2774
- const created = await this.devtunnel(
2775
- "create",
2776
- "--expiration",
2777
- "30d",
2778
- "-d",
2779
- this.description,
2780
- "--labels",
2781
- "coderemote-preview",
2782
- "--json"
2783
- );
2784
- 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
+ }
2785
3062
  const id = JSON.parse(created.out.slice(created.out.indexOf("{"))).tunnel?.tunnelId;
2786
3063
  if (!id) throw new Error(`could not parse tunnel id from:
2787
3064
  ${created.out}`);
@@ -2799,7 +3076,7 @@ ${created.out}`);
2799
3076
  }
2800
3077
  #startHost() {
2801
3078
  return new Promise((resolve, reject) => {
2802
- 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"] });
2803
3080
  const urls = /* @__PURE__ */ new Map();
2804
3081
  let log2 = "";
2805
3082
  const timer = setTimeout(() => {
@@ -2834,40 +3111,40 @@ ${log2}`));
2834
3111
  };
2835
3112
 
2836
3113
  // src/push-store.js
2837
- import fs10 from "node:fs";
2838
- import path8 from "node:path";
3114
+ import fs11 from "node:fs";
3115
+ import path9 from "node:path";
2839
3116
  function pushStore(dir, port2) {
2840
- const file = path8.join(dir, `${port2}.json`);
3117
+ const file = path9.join(dir, `${port2}.json`);
2841
3118
  return {
2842
3119
  load() {
2843
3120
  try {
2844
- const devices = JSON.parse(fs10.readFileSync(file, "utf8"));
3121
+ const devices = JSON.parse(fs11.readFileSync(file, "utf8"));
2845
3122
  return Array.isArray(devices) ? devices : [];
2846
3123
  } catch {
2847
3124
  return [];
2848
3125
  }
2849
3126
  },
2850
3127
  save(devices) {
2851
- fs10.mkdirSync(dir, { recursive: true, mode: 448 });
2852
- 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 });
2853
3130
  }
2854
3131
  };
2855
3132
  }
2856
3133
 
2857
3134
  // src/registry.js
2858
- import fs11 from "node:fs";
2859
- import path9 from "node:path";
3135
+ import fs12 from "node:fs";
3136
+ import path10 from "node:path";
2860
3137
  var AgentRegistry = class {
2861
3138
  constructor(dir) {
2862
3139
  this.dir = dir;
2863
3140
  }
2864
3141
  register({ port: port2, root: root2, url }) {
2865
- fs11.mkdirSync(this.dir, { recursive: true, mode: 448 });
3142
+ fs12.mkdirSync(this.dir, { recursive: true, mode: 448 });
2866
3143
  const file = this.#file(port2);
2867
- 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 });
2868
3145
  const cleanup = () => {
2869
3146
  try {
2870
- 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);
2871
3148
  } catch {
2872
3149
  }
2873
3150
  };
@@ -2878,25 +3155,25 @@ var AgentRegistry = class {
2878
3155
  list() {
2879
3156
  let names = [];
2880
3157
  try {
2881
- names = fs11.readdirSync(this.dir).filter((n) => n.endsWith(".json"));
3158
+ names = fs12.readdirSync(this.dir).filter((n) => n.endsWith(".json"));
2882
3159
  } catch {
2883
3160
  return [];
2884
3161
  }
2885
3162
  const agents = [];
2886
3163
  for (const name of names) {
2887
- const file = path9.join(this.dir, name);
3164
+ const file = path10.join(this.dir, name);
2888
3165
  try {
2889
- const agent = JSON.parse(fs11.readFileSync(file, "utf8"));
3166
+ const agent = JSON.parse(fs12.readFileSync(file, "utf8"));
2890
3167
  process.kill(agent.pid, 0);
2891
3168
  agents.push(agent);
2892
3169
  } catch {
2893
- fs11.rmSync(file, { force: true });
3170
+ fs12.rmSync(file, { force: true });
2894
3171
  }
2895
3172
  }
2896
3173
  return agents.sort((a, b) => a.port - b.port);
2897
3174
  }
2898
3175
  #file(port2) {
2899
- return path9.join(this.dir, `${port2}.json`);
3176
+ return path10.join(this.dir, `${port2}.json`);
2900
3177
  }
2901
3178
  };
2902
3179
 
@@ -2960,8 +3237,8 @@ function tunnelStatus({ devtunnel: devtunnel2, tunnelId, account, anonymous: ano
2960
3237
  }
2961
3238
 
2962
3239
  // src/update.js
2963
- import fs12 from "node:fs";
2964
- import path10 from "node:path";
3240
+ import fs13 from "node:fs";
3241
+ import path11 from "node:path";
2965
3242
  var LATEST_URL = "https://registry.npmjs.org/coderemote/latest";
2966
3243
  var CHECK_INTERVAL_MS = 24 * 36e5;
2967
3244
  var SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
@@ -2985,10 +3262,10 @@ async function fetchLatest({ fetch: fetch2 = globalThis.fetch, timeoutMs = 3e3 }
2985
3262
  }
2986
3263
  }
2987
3264
  async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }) {
2988
- const file = path10.join(dir, "update.json");
3265
+ const file = path11.join(dir, "update.json");
2989
3266
  let cache = {};
2990
3267
  try {
2991
- cache = JSON.parse(fs12.readFileSync(file, "utf8"));
3268
+ cache = JSON.parse(fs13.readFileSync(file, "utf8"));
2992
3269
  } catch {
2993
3270
  }
2994
3271
  let latest = cache.latest;
@@ -2997,8 +3274,8 @@ async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }
2997
3274
  if (fetched) {
2998
3275
  latest = fetched;
2999
3276
  try {
3000
- fs12.mkdirSync(dir, { recursive: true, mode: 448 });
3001
- 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 }));
3002
3279
  } catch {
3003
3280
  }
3004
3281
  }
@@ -3006,7 +3283,14 @@ async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }
3006
3283
  return isNewer(latest, current) ? latest : null;
3007
3284
  }
3008
3285
  function installCommand(installedAt) {
3009
- 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"]];
3010
3294
  if (p.includes("/pnpm/") || p.includes("/.pnpm/")) return ["pnpm", ["add", "-g", "coderemote@latest"]];
3011
3295
  if (p.includes("/.bun/")) return ["bun", ["add", "-g", "coderemote@latest"]];
3012
3296
  if (p.includes("/.yarn/") || p.includes("/yarn/global/")) return ["yarn", ["global", "add", "coderemote@latest"]];
@@ -3016,7 +3300,7 @@ function installCommand(installedAt) {
3016
3300
  // package.json
3017
3301
  var package_default = {
3018
3302
  name: "coderemote",
3019
- version: "0.3.4",
3303
+ version: "0.3.7",
3020
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.",
3021
3305
  keywords: [
3022
3306
  "remote",
@@ -3057,19 +3341,15 @@ var package_default = {
3057
3341
  "scripts/fix-node-pty.js"
3058
3342
  ],
3059
3343
  engines: {
3060
- node: ">=20"
3344
+ node: ">=22"
3061
3345
  },
3062
- os: [
3063
- "darwin",
3064
- "linux"
3065
- ],
3066
3346
  scripts: {
3067
3347
  build: "node scripts/build.js",
3068
3348
  prepare: "node scripts/build.js",
3069
- prepack: "node scripts/build.js && cp ../../LICENSE LICENSE",
3070
- 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 })"`,
3071
3351
  postinstall: "node scripts/fix-node-pty.js",
3072
- test: "node --test 'test/*.test.js'"
3352
+ test: 'node --test "test/*.test.js"'
3073
3353
  },
3074
3354
  dependencies: {
3075
3355
  "node-pty": "^1.1.0",
@@ -3295,15 +3575,12 @@ var Spinner = class {
3295
3575
  };
3296
3576
 
3297
3577
  // src/vscode-tunnel.js
3298
- import { execFile as execFile7, spawn as spawn2 } from "node:child_process";
3299
- import { promisify as promisify4 } from "node:util";
3300
- var run5 = promisify4(execFile7);
3301
3578
  function findCode() {
3302
- return process.env.CODEREMOTE_CODE || onPath("code");
3579
+ return process.env.CODEREMOTE_CODE || findExecutable("code");
3303
3580
  }
3304
3581
  async function codeTunnelSignedIn(code) {
3305
3582
  try {
3306
- const { stdout } = await run5(code, ["tunnel", "user", "show"]);
3583
+ const { stdout } = await execCommand([code, "tunnel", "user", "show"]);
3307
3584
  return /^logged in/im.test(stdout);
3308
3585
  } catch {
3309
3586
  return false;
@@ -3311,12 +3588,12 @@ async function codeTunnelSignedIn(code) {
3311
3588
  }
3312
3589
  function codeTunnelLogin(code) {
3313
3590
  return new Promise((resolve) => {
3314
- 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);
3315
3592
  });
3316
3593
  }
3317
3594
  function hostCodeTunnel(code, root2, timeoutMs = 6e4) {
3318
3595
  return new Promise((resolve, reject) => {
3319
- const child = spawn2(code, ["tunnel"], { cwd: root2, stdio: ["ignore", "pipe", "pipe"] });
3596
+ const child = spawnCommand([code, "tunnel"], { cwd: root2, stdio: ["ignore", "pipe", "pipe"] });
3320
3597
  let log2 = "";
3321
3598
  const timer = setTimeout(() => {
3322
3599
  child.kill();
@@ -3328,7 +3605,8 @@ ${log2}`));
3328
3605
  const m = log2.match(/https:\/\/vscode\.dev\/tunnel\/([^/\s]+)/);
3329
3606
  if (!m) return;
3330
3607
  clearTimeout(timer);
3331
- 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)}` });
3332
3610
  };
3333
3611
  child.stdout.on("data", onData);
3334
3612
  child.stderr.on("data", onData);
@@ -3345,28 +3623,28 @@ ${log2}`));
3345
3623
  }
3346
3624
 
3347
3625
  // bin/coderemote.js
3348
- var run6 = promisify5(execFile8);
3349
- var CONFIG_DIR = path11.join(os6.homedir(), ".coderemote");
3350
- var LEGACY_CONFIG_DIR = path11.join(os6.homedir(), ".vsremote");
3351
- if (!fs13.existsSync(CONFIG_DIR) && fs13.existsSync(LEGACY_CONFIG_DIR)) fs13.renameSync(LEGACY_CONFIG_DIR, CONFIG_DIR);
3352
- var CONFIG_FILE = path11.join(CONFIG_DIR, "config.json");
3353
- var PUSH_DIR = path11.join(CONFIG_DIR, "push");
3354
- 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");
3355
3633
  var DEFAULT_PORT = 31580;
3356
3634
  var VERSION = package_default.version;
3357
- var PACKAGED = path11.basename(path11.dirname(fileURLToPath(import.meta.url))) === "dist";
3635
+ var PACKAGED = path12.basename(path12.dirname(fileURLToPath(import.meta.url))) === "dist";
3358
3636
  var spinner = new Spinner();
3359
3637
  function loadConfig() {
3360
3638
  let cfg2 = {};
3361
3639
  try {
3362
- cfg2 = JSON.parse(fs13.readFileSync(CONFIG_FILE, "utf8"));
3640
+ cfg2 = JSON.parse(fs14.readFileSync(CONFIG_FILE, "utf8"));
3363
3641
  } catch {
3364
3642
  }
3365
3643
  if (!cfg2.token) {
3366
3644
  cfg2.token = randomBytes3(32).toString("base64url");
3367
3645
  saveConfig(cfg2);
3368
- fs13.rmSync(PUSH_DIR, { recursive: true, force: true });
3369
- 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 });
3370
3648
  }
3371
3649
  if (cfg2.tunnelId && !cfg2.tunnels) {
3372
3650
  cfg2.tunnels = { [DEFAULT_PORT]: cfg2.tunnelId };
@@ -3374,9 +3652,16 @@ function loadConfig() {
3374
3652
  }
3375
3653
  return cfg2;
3376
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
+ }
3377
3662
  function saveConfig(cfg2) {
3378
- fs13.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
3379
- 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 });
3380
3665
  }
3381
3666
  function portFree(port2) {
3382
3667
  return new Promise((resolve) => {
@@ -3387,7 +3672,7 @@ function portFree(port2) {
3387
3672
  }
3388
3673
  function listen(token, port2, root2, tunnelToken, preview, status2) {
3389
3674
  const hookSecret = ensureHookSecret(CONFIG_DIR);
3390
- writeHookLauncher(CONFIG_DIR, { script: fs13.realpathSync(process.argv[1]) });
3675
+ writeHookLauncher(CONFIG_DIR, { script: fs14.realpathSync(process.argv[1]) });
3391
3676
  const server = createAgentServer({
3392
3677
  token,
3393
3678
  root: root2,
@@ -3396,7 +3681,7 @@ function listen(token, port2, root2, tunnelToken, preview, status2) {
3396
3681
  tunnelStatus: status2,
3397
3682
  hookSecret,
3398
3683
  // This very CLI, so coding agents in the app's terminals report through the same version.
3399
- hookBin: fs13.realpathSync(process.argv[1]),
3684
+ hookBin: fs14.realpathSync(process.argv[1]),
3400
3685
  pushStore: pushStore(PUSH_DIR, port2),
3401
3686
  uploadsDir: UPLOADS_DIR
3402
3687
  });
@@ -3409,7 +3694,7 @@ var DEVTUNNEL = "devtunnel";
3409
3694
  async function resolveDevtunnel() {
3410
3695
  let installing = false;
3411
3696
  DEVTUNNEL = await ensureDevtunnel({
3412
- dir: path11.join(CONFIG_DIR, "bin"),
3697
+ dir: path12.join(CONFIG_DIR, "bin"),
3413
3698
  progress: (detail2) => {
3414
3699
  if (!installing) {
3415
3700
  installing = true;
@@ -3422,7 +3707,7 @@ async function resolveDevtunnel() {
3422
3707
  }
3423
3708
  async function devtunnel(...args) {
3424
3709
  try {
3425
- const { stdout } = await run6(DEVTUNNEL, args);
3710
+ const { stdout } = await run5(DEVTUNNEL, args);
3426
3711
  return { ok: true, out: stdout };
3427
3712
  } catch (e) {
3428
3713
  return { ok: false, out: `${e.stdout || ""}${e.stderr || ""}` || e.message };
@@ -3483,9 +3768,23 @@ ${updated.out}`);
3483
3768
  const name = port2 === DEFAULT_PORT ? os6.hostname() : `${os6.hostname()} :${port2}`;
3484
3769
  const createArgs = ["create", "--expiration", "30d", "-d", `coderemote ${name}`, "--labels", MACHINE_LABEL, "--json"];
3485
3770
  if (anonymous2) createArgs.push("--allow-anonymous");
3486
- const created = await devtunnel(...createArgs);
3487
- 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:
3488
3786
  ${created.out}`);
3787
+ }
3489
3788
  const tunnelId = JSON.parse(created.out.slice(created.out.indexOf("{"))).tunnel?.tunnelId;
3490
3789
  if (!tunnelId) throw new Error(`could not parse tunnel id from:
3491
3790
  ${created.out}`);
@@ -3578,6 +3877,9 @@ var USAGE = `Usage: coderemote <command> [options]
3578
3877
  kill <id>... | --all kill terminal sessions (an id prefix is enough)
3579
3878
  stop [--all] stop the agent for this folder (or every agent)
3580
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)
3581
3883
  update install the latest coderemote (up says when there's one)
3582
3884
  requests [PORT] list the requests that reached a previewed port, oldest first;
3583
3885
  --grep TEXT, --failed (errors, 4xx and 5xx), --limit N (default 50)
@@ -3609,9 +3911,9 @@ var flag = (name) => {
3609
3911
  };
3610
3912
  var anonymous = rest.includes("--allow-anonymous") || rest.includes("--no-auth");
3611
3913
  var port = flag("--port") ? parseInt(flag("--port"), 10) : DEFAULT_PORT;
3612
- var root = path11.resolve(flag("--root") ?? process.cwd());
3914
+ var root = path12.resolve(flag("--root") ?? process.cwd());
3613
3915
  var cfg = loadConfig();
3614
- var registry = new AgentRegistry(path11.join(CONFIG_DIR, "agents"));
3916
+ var registry = new AgentRegistry(path12.join(CONFIG_DIR, "agents"));
3615
3917
  async function start({ tunnel }) {
3616
3918
  const agents = registry.list();
3617
3919
  if (flag("--port")) {
@@ -3650,6 +3952,7 @@ async function start({ tunnel }) {
3650
3952
  leftover: cfg.previewTunnels?.[port],
3651
3953
  remember: (id) => {
3652
3954
  cfg.previewTunnels = { ...cfg.previewTunnels, [port]: id };
3955
+ if (!id) delete cfg.previewTunnels[port];
3653
3956
  saveConfig(cfg);
3654
3957
  },
3655
3958
  mintToken: connectToken
@@ -3663,7 +3966,7 @@ async function start({ tunnel }) {
3663
3966
  spinner.fail();
3664
3967
  if (e.code !== "EADDRINUSE") throw e;
3665
3968
  console.error(`Port ${port} is already in use by another process.`);
3666
- 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`}`);
3667
3970
  console.error("Stop that process, or pick another port with --port.");
3668
3971
  process.exit(1);
3669
3972
  }
@@ -3693,10 +3996,10 @@ async function start({ tunnel }) {
3693
3996
  }
3694
3997
  spinner.succeed();
3695
3998
  registry.register({ port, root, url });
3696
- const shutdown = () => {
3999
+ const shutdown = async () => {
3697
4000
  child?.kill();
3698
- preview?.stop();
3699
- server.killAllSessions();
4001
+ await preview?.close();
4002
+ await server.killAllSessions();
3700
4003
  server.close();
3701
4004
  process.exit(0);
3702
4005
  };
@@ -3712,7 +4015,7 @@ async function start({ tunnel }) {
3712
4015
  var PLUGIN = "coderemote@coderemote";
3713
4016
  async function claudeCli(...args) {
3714
4017
  try {
3715
- return (await run6("claude", args, { timeout: 12e4 })).stdout.trim();
4018
+ return (await execCommand(["claude", ...args], { timeout: 12e4 })).stdout.trim();
3716
4019
  } catch (e) {
3717
4020
  if (e.code === "ENOENT") return null;
3718
4021
  throw new Error((e.stderr || e.stdout || e.message).trim());
@@ -3753,7 +4056,7 @@ async function setupClaude() {
3753
4056
  var CODEX_PLUGIN = "coderemote@coderemote";
3754
4057
  async function codexCli(...args) {
3755
4058
  try {
3756
- return (await run6("codex", args, { timeout: 12e4 })).stdout.trim();
4059
+ return (await execCommand(["codex", ...args], { timeout: 12e4 })).stdout.trim();
3757
4060
  } catch (e) {
3758
4061
  if (e.code === "ENOENT") return null;
3759
4062
  throw new Error((e.stderr || e.stdout || e.message).trim());
@@ -3828,7 +4131,7 @@ function currentAgent() {
3828
4131
  const agents = registry.list();
3829
4132
  if (flag("--port")) return agents.find((a) => a.port === port);
3830
4133
  const here = process.cwd();
3831
- 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);
3832
4135
  return containing[0] ?? (agents.length === 1 ? agents[0] : void 0);
3833
4136
  }
3834
4137
  async function previewPorts(agent, given) {
@@ -3958,7 +4261,7 @@ Token: ${cfg.token}`);
3958
4261
  break;
3959
4262
  }
3960
4263
  for (const agent of targets) {
3961
- process.kill(agent.pid, "SIGTERM");
4264
+ stopAgent(agent.pid);
3962
4265
  console.log(`stopped agent for ${agent.root} (pid ${agent.pid})`);
3963
4266
  }
3964
4267
  break;
@@ -3968,13 +4271,57 @@ Token: ${cfg.token}`);
3968
4271
  saveConfig(cfg);
3969
4272
  const agents = registry.list();
3970
4273
  for (const agent of agents) {
3971
- process.kill(agent.pid, "SIGTERM");
4274
+ stopAgent(agent.pid);
3972
4275
  console.log(`stopped agent for ${agent.root} (pid ${agent.pid})`);
3973
4276
  }
3974
4277
  console.log("Token rotated. Re-pair your devices.");
3975
4278
  if (agents.length) console.log("Run `coderemote up` again in each folder above; each keeps its tunnel URL.");
3976
4279
  break;
3977
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
+ }
3978
4325
  case "login": {
3979
4326
  await resolveDevtunnel();
3980
4327
  process.exit(await interactiveLogin(loginArgs()));
@@ -3993,16 +4340,16 @@ Token: ${cfg.token}`);
3993
4340
  console.log(`coderemote ${VERSION} is the latest version.`);
3994
4341
  break;
3995
4342
  }
3996
- const [command, args] = installCommand(fs13.realpathSync(process.argv[1]));
4343
+ const [command, args] = installCommand(fs14.realpathSync(process.argv[1]));
3997
4344
  console.log(`Updating coderemote ${VERSION} \u2192 ${latest}: ${command} ${args.join(" ")}
3998
4345
  `);
3999
4346
  const code = await new Promise((resolve) => {
4000
- 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));
4001
4348
  });
4002
4349
  if (code !== 0) {
4003
4350
  console.error(`
4004
4351
  The update failed (${command} exited with ${code}).`);
4005
- 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(" ")}`);
4006
4353
  process.exit(code || 1);
4007
4354
  }
4008
4355
  const running = registry.list();