forge-remote 0.1.1 → 0.1.2

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.
@@ -226,6 +226,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
226
226
  log.command("kill_session", sessionId.slice(0, 8));
227
227
  await killSession(sessionId);
228
228
  break;
229
+ case "retry_tunnel":
230
+ log.command("retry_tunnel", sessionId.slice(0, 8));
231
+ await retryTunnel(sessionId);
232
+ break;
229
233
  default:
230
234
  log.warn(`Unknown session command type: ${data.type}`);
231
235
  }
@@ -233,6 +237,17 @@ async function handleSessionCommand(sessionId, commandDoc) {
233
237
  } catch (e) {
234
238
  log.error(`Session command failed: ${e.message}`);
235
239
  await cmdRef.update({ status: "failed", error: e.message });
240
+
241
+ // Surface the error to the mobile app as a system message.
242
+ await db
243
+ .collection("sessions")
244
+ .doc(sessionId)
245
+ .collection("messages")
246
+ .add({
247
+ type: "system",
248
+ content: `Command failed: ${e.message}`,
249
+ timestamp: FieldValue.serverTimestamp(),
250
+ });
236
251
  }
237
252
  }
238
253
 
@@ -357,10 +372,32 @@ async function sendFollowUpPrompt(sessionId, prompt) {
357
372
  throw new Error("Session not found. It may have ended.");
358
373
  }
359
374
 
375
+ // If Claude is currently processing, interrupt it first.
360
376
  if (session.process) {
361
- throw new Error(
362
- "Claude is still processing. Wait for the current turn to finish.",
363
- );
377
+ log.session(sessionId, "Interrupting current process for new prompt...");
378
+ const proc = session.process;
379
+ session.process = null; // Prevent the close handler from changing status.
380
+ try {
381
+ process.kill(-proc.pid, "SIGTERM");
382
+ } catch {
383
+ proc.kill("SIGTERM");
384
+ }
385
+ // Give it a moment to exit gracefully.
386
+ await new Promise((resolve) => {
387
+ const timeout = setTimeout(() => {
388
+ try {
389
+ process.kill(-proc.pid, "SIGKILL");
390
+ } catch {
391
+ proc.kill("SIGKILL");
392
+ }
393
+ resolve();
394
+ }, 3000);
395
+ proc.on("close", () => {
396
+ clearTimeout(timeout);
397
+ resolve();
398
+ });
399
+ });
400
+ log.session(sessionId, "Previous process interrupted.");
364
401
  }
365
402
 
366
403
  // Cancel any pending permission watcher — user is overriding with a prompt.
@@ -426,6 +463,9 @@ async function runClaudeProcess(sessionId, prompt) {
426
463
  cwd: session.projectPath,
427
464
  env: shellEnv,
428
465
  stdio: ["pipe", "pipe", "pipe"],
466
+ // Detach from relay's process group so Ctrl+C doesn't kill Claude directly.
467
+ // The relay's cleanup handler will SIGTERM it explicitly via shutdownAllSessions().
468
+ detached: true,
429
469
  });
430
470
 
431
471
  session.process = claudeProcess;
@@ -701,9 +741,13 @@ async function runClaudeProcess(sessionId, prompt) {
701
741
  async function stopSession(sessionId) {
702
742
  const session = activeSessions.get(sessionId);
703
743
 
704
- // Kill running process if any.
744
+ // Kill running process (and its process group) if any.
705
745
  if (session?.process) {
706
- session.process.kill("SIGTERM");
746
+ try {
747
+ process.kill(-session.process.pid, "SIGTERM");
748
+ } catch {
749
+ session.process.kill("SIGTERM");
750
+ }
707
751
  }
708
752
 
709
753
  // Cancel permission watcher if active.
@@ -731,6 +775,8 @@ async function stopSession(sessionId) {
731
775
  status: "completed",
732
776
  lastActivity: FieldValue.serverTimestamp(),
733
777
  durationSeconds: duration,
778
+ tunnelStatus: null,
779
+ tunnelError: null,
734
780
  });
735
781
 
736
782
  await db
@@ -773,12 +819,16 @@ async function killSession(sessionId) {
773
819
  stopCapturing(sessionId);
774
820
  capturingSessions.delete(sessionId);
775
821
 
776
- // Force-kill running process.
822
+ // Force-kill running process (and its process group).
777
823
  if (session?.process) {
778
824
  log.warn(
779
825
  `Force-killing Claude process for session ${sessionId.slice(0, 8)}`,
780
826
  );
781
- session.process.kill("SIGKILL");
827
+ try {
828
+ process.kill(-session.process.pid, "SIGKILL");
829
+ } catch {
830
+ session.process.kill("SIGKILL");
831
+ }
782
832
  }
783
833
 
784
834
  const db = getDb();
@@ -1165,16 +1215,24 @@ async function detectAndTunnelDevServer(sessionId, text) {
1165
1215
 
1166
1216
  log.info(`Dev server detected on port ${port} — creating tunnel...`);
1167
1217
 
1218
+ const db = getDb();
1219
+ const sessionRef = db.collection("sessions").doc(sessionId);
1220
+
1221
+ // Write pending status immediately so the app can show a spinner.
1222
+ await sessionRef.update({
1223
+ devServerPort: port,
1224
+ tunnelStatus: "pending",
1225
+ tunnelError: null,
1226
+ });
1227
+
1168
1228
  const tunnelUrl = await startTunnel(sessionId, port);
1169
1229
  if (tunnelUrl) {
1170
- // Store tunnel URL in Firestore session doc.
1171
- const db = getDb();
1172
- await db.collection("sessions").doc(sessionId).update({
1230
+ await sessionRef.update({
1173
1231
  devServerUrl: tunnelUrl,
1174
- devServerPort: port,
1232
+ tunnelStatus: "active",
1233
+ tunnelError: null,
1175
1234
  });
1176
1235
 
1177
- // Notify in messages.
1178
1236
  await db
1179
1237
  .collection("sessions")
1180
1238
  .doc(sessionId)
@@ -1188,12 +1246,52 @@ async function detectAndTunnelDevServer(sessionId, text) {
1188
1246
  log.success(
1189
1247
  `[${sessionId.slice(0, 8)}] Preview: localhost:${port} → ${tunnelUrl}`,
1190
1248
  );
1249
+ } else {
1250
+ // Tunnel failed — surface error to the app.
1251
+ await sessionRef.update({
1252
+ tunnelStatus: "failed",
1253
+ tunnelError: "Tunnel creation failed. Check relay logs for details.",
1254
+ });
1255
+
1256
+ await db
1257
+ .collection("sessions")
1258
+ .doc(sessionId)
1259
+ .collection("messages")
1260
+ .add({
1261
+ type: "system",
1262
+ content: `Failed to create preview tunnel for port ${port}. Tap "Retry" in the Preview tab.`,
1263
+ timestamp: FieldValue.serverTimestamp(),
1264
+ });
1191
1265
  }
1192
1266
  return; // Only tunnel the first detected port.
1193
1267
  }
1194
1268
  }
1195
1269
  }
1196
1270
 
1271
+ /**
1272
+ * Retry creating a tunnel for the session's detected dev server port.
1273
+ * Called when the user taps "Retry" in the Preview tab.
1274
+ */
1275
+ async function retryTunnel(sessionId) {
1276
+ const db = getDb();
1277
+ const sessionDoc = await db.collection("sessions").doc(sessionId).get();
1278
+ if (!sessionDoc.exists) throw new Error("Session not found");
1279
+
1280
+ const data = sessionDoc.data();
1281
+ const port = data.devServerPort;
1282
+ if (!port) throw new Error("No dev server port recorded for this session");
1283
+
1284
+ // Stop any existing tunnel first.
1285
+ await stopTunnel(sessionId);
1286
+
1287
+ // Clear the tunneled ports set so it doesn't skip this port.
1288
+ const ported = tunneledPorts.get(sessionId);
1289
+ if (ported) ported.delete(port);
1290
+
1291
+ // Re-detect and tunnel (uses the port directly).
1292
+ await detectAndTunnelDevServer(sessionId, `http://localhost:${port}`);
1293
+ }
1294
+
1197
1295
  // ---------------------------------------------------------------------------
1198
1296
  // Graceful shutdown — mark all active sessions as completed
1199
1297
  // ---------------------------------------------------------------------------
@@ -1212,9 +1310,13 @@ export async function shutdownAllSessions() {
1212
1310
  const session = activeSessions.get(sessionId);
1213
1311
  if (!session) continue;
1214
1312
 
1215
- // Kill running process.
1313
+ // Kill running process (and its process group since we spawn detached).
1216
1314
  if (session.process) {
1217
- session.process.kill("SIGTERM");
1315
+ try {
1316
+ process.kill(-session.process.pid, "SIGTERM");
1317
+ } catch {
1318
+ session.process.kill("SIGTERM");
1319
+ }
1218
1320
  }
1219
1321
 
1220
1322
  // Cancel permission watcher.
@@ -1,40 +1,68 @@
1
1
  import { spawn, execSync } from "child_process";
2
+ import { createServer, request as httpRequest } from "http";
3
+ import { connect } from "net";
2
4
  import * as log from "./logger.js";
5
+ import {
6
+ getLocalCloudflaredPath,
7
+ isCloudflaredWorking,
8
+ } from "./cloudflared-installer.js";
3
9
 
4
10
  /**
5
11
  * Manages localhost tunnels for dev server previews.
6
12
  *
7
13
  * Prefers `cloudflared` (no splash page, more reliable) and falls back
8
14
  * to `localtunnel` if cloudflared is not installed.
15
+ *
16
+ * A local proxy rewrites the Host header so Vite/Webpack/Next.js dev
17
+ * servers don't reject requests from the tunnel hostname.
9
18
  */
10
19
 
11
- const activeTunnels = new Map(); // sessionId → { process, port, url }
20
+ const activeTunnels = new Map(); // sessionId → { process, port, url, proxy? }
12
21
 
13
- // Detect which tunnel binary is available (checked once at startup).
22
+ // Resolve cloudflared binary: local install PATH localtunnel fallback.
14
23
  let tunnelBackend = "none";
15
- try {
16
- execSync("which cloudflared", { stdio: "pipe" });
24
+ let cloudflaredBinary = null;
25
+
26
+ const localCf = getLocalCloudflaredPath();
27
+ if (localCf && isCloudflaredWorking(localCf)) {
17
28
  tunnelBackend = "cloudflared";
18
- log.info("Tunnel backend: cloudflared");
19
- } catch {
29
+ cloudflaredBinary = localCf;
30
+ log.info(`Tunnel backend: cloudflared (local: ${localCf})`);
31
+ } else {
20
32
  try {
21
- execSync("which npx", { stdio: "pipe" });
22
- tunnelBackend = "localtunnel";
23
- log.info(
24
- "Tunnel backend: localtunnel (install cloudflared for better experience)",
25
- );
33
+ execSync("which cloudflared", { stdio: "pipe" });
34
+ tunnelBackend = "cloudflared";
35
+ cloudflaredBinary = "cloudflared";
36
+ log.info("Tunnel backend: cloudflared (from PATH)");
26
37
  } catch {
27
- log.warn("No tunnel backend available — live preview will not work");
38
+ try {
39
+ execSync("which npx", { stdio: "pipe" });
40
+ tunnelBackend = "localtunnel";
41
+ log.info(
42
+ "Tunnel backend: localtunnel (install cloudflared for better experience)",
43
+ );
44
+ } catch {
45
+ log.warn("No tunnel backend available — live preview will not work");
46
+ }
28
47
  }
29
48
  }
30
49
 
31
50
  /**
32
51
  * Start a tunnel for a localhost port.
33
52
  * Returns the public tunnel URL, or null on failure.
53
+ *
54
+ * @param {string} sessionId
55
+ * @param {number} port
56
+ * @param {(status: 'pending'|'active'|'failed', error?: string) => Promise<void>} [statusCallback]
57
+ * @returns {Promise<string|null>}
34
58
  */
35
- export async function startTunnel(sessionId, port) {
59
+ export async function startTunnel(sessionId, port, statusCallback) {
36
60
  if (tunnelBackend === "none") {
37
61
  log.warn("No tunnel backend available — skipping tunnel creation");
62
+ await statusCallback?.(
63
+ "failed",
64
+ "No tunnel backend installed. Run `forge-remote init` to install cloudflared.",
65
+ );
38
66
  return null;
39
67
  }
40
68
 
@@ -52,41 +80,195 @@ export async function startTunnel(sessionId, port) {
52
80
  await stopTunnel(sessionId);
53
81
  }
54
82
 
83
+ await statusCallback?.("pending");
55
84
  log.info(`Starting ${tunnelBackend} tunnel for localhost:${port}...`);
56
85
 
57
86
  if (tunnelBackend === "cloudflared") {
58
- return startCloudflaredTunnel(sessionId, port);
87
+ return startCloudflaredTunnel(sessionId, port, statusCallback);
59
88
  }
60
- return startLocaltunnel(sessionId, port);
89
+ return startLocaltunnel(sessionId, port, statusCallback);
90
+ }
91
+
92
+ /** Returns the active tunnel backend name. */
93
+ export function getTunnelBackend() {
94
+ return tunnelBackend;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Host-rewriting proxy
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Scrub headers that leak the tunnel hostname to the dev server.
103
+ * Vite checks req.headers.host — we must rewrite it to localhost.
104
+ */
105
+ function scrubHeaders(headers, targetPort) {
106
+ const clean = {};
107
+ for (const [key, val] of Object.entries(headers)) {
108
+ const lower = key.toLowerCase();
109
+ // Strip all forwarded/tunnel/proxy headers that could leak the tunnel hostname.
110
+ if (
111
+ lower === "x-forwarded-host" ||
112
+ lower === "x-forwarded-for" ||
113
+ lower === "x-forwarded-proto" ||
114
+ lower === "x-real-ip" ||
115
+ lower === ":authority" ||
116
+ lower === "forwarded" ||
117
+ lower.startsWith("cf-")
118
+ ) {
119
+ continue;
120
+ }
121
+ clean[key] = val;
122
+ }
123
+ // Force Host to localhost so Vite's allowedHosts check passes.
124
+ clean.host = `localhost:${targetPort}`;
125
+ return clean;
126
+ }
127
+
128
+ /**
129
+ * Start a tiny local HTTP proxy that rewrites the Host header to localhost
130
+ * and strips Cloudflare forwarded headers. Also handles WebSocket upgrades
131
+ * (needed for Vite HMR). Returns the proxy port.
132
+ */
133
+ function startHostProxy(targetPort) {
134
+ return new Promise((resolve, reject) => {
135
+ const proxy = createServer((clientReq, clientRes) => {
136
+ const scrubbed = scrubHeaders(clientReq.headers, targetPort);
137
+ log.info(
138
+ `[proxy] ${clientReq.method} ${clientReq.url} ` +
139
+ `Host: ${clientReq.headers.host} → ${scrubbed.host}`,
140
+ );
141
+
142
+ const opts = {
143
+ hostname: "127.0.0.1",
144
+ port: targetPort,
145
+ path: clientReq.url,
146
+ method: clientReq.method,
147
+ headers: scrubbed,
148
+ };
149
+
150
+ const proxyReq = httpRequest(opts, (proxyRes) => {
151
+ // Detect Vite's "Blocked request" to aid debugging.
152
+ if (proxyRes.statusCode === 403) {
153
+ let body = "";
154
+ proxyRes.on("data", (chunk) => (body += chunk));
155
+ proxyRes.on("end", () => {
156
+ if (body.includes("Blocked request")) {
157
+ log.error(
158
+ `[proxy] Vite STILL blocked the request (status 403). ` +
159
+ `This means Host rewriting is not working as expected. ` +
160
+ `Response: ${body.slice(0, 200)}`,
161
+ );
162
+ }
163
+ clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
164
+ clientRes.end(body);
165
+ });
166
+ return;
167
+ }
168
+ clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
169
+ proxyRes.pipe(clientRes, { end: true });
170
+ });
171
+
172
+ proxyReq.on("error", (err) => {
173
+ log.error(
174
+ `[proxy] Request to localhost:${targetPort} failed: ${err.message}`,
175
+ );
176
+ clientRes.writeHead(502);
177
+ clientRes.end(`Proxy error: ${err.message}`);
178
+ });
179
+
180
+ clientReq.pipe(proxyReq, { end: true });
181
+ });
182
+
183
+ // Handle WebSocket upgrades (Vite HMR needs this).
184
+ proxy.on("upgrade", (req, clientSocket, head) => {
185
+ const serverSocket = connect(targetPort, "127.0.0.1", () => {
186
+ // Rebuild the upgrade request with scrubbed headers.
187
+ const headers = scrubHeaders(req.headers, targetPort);
188
+ let reqStr = `${req.method} ${req.url} HTTP/1.1\r\n`;
189
+ for (const [key, val] of Object.entries(headers)) {
190
+ reqStr += `${key}: ${val}\r\n`;
191
+ }
192
+ reqStr += "\r\n";
193
+
194
+ serverSocket.write(reqStr);
195
+ if (head.length > 0) serverSocket.write(head);
196
+
197
+ serverSocket.pipe(clientSocket);
198
+ clientSocket.pipe(serverSocket);
199
+ });
200
+
201
+ serverSocket.on("error", () => clientSocket.destroy());
202
+ clientSocket.on("error", () => serverSocket.destroy());
203
+ });
204
+
205
+ // Listen on a random available port.
206
+ proxy.listen(0, "127.0.0.1", () => {
207
+ const proxyPort = proxy.address().port;
208
+ log.info(`Host-rewrite proxy: :${proxyPort} → localhost:${targetPort}`);
209
+ resolve({ server: proxy, port: proxyPort });
210
+ });
211
+
212
+ proxy.on("error", (err) => {
213
+ log.error(`Proxy failed to start: ${err.message}`);
214
+ reject(err);
215
+ });
216
+ });
61
217
  }
62
218
 
63
219
  // ---------------------------------------------------------------------------
64
220
  // Cloudflared (preferred — no splash page)
65
221
  // ---------------------------------------------------------------------------
66
222
 
67
- function startCloudflaredTunnel(sessionId, port) {
68
- return new Promise((resolve) => {
69
- const proc = spawn(
70
- "cloudflared",
71
- ["tunnel", "--url", `http://localhost:${port}`],
72
- { stdio: ["pipe", "pipe", "pipe"] },
223
+ async function startCloudflaredTunnel(sessionId, port, statusCallback) {
224
+ // Start a local proxy that rewrites Host header to localhost.
225
+ let proxy = null;
226
+ let tunnelPort = port;
227
+ try {
228
+ proxy = await startHostProxy(port);
229
+ tunnelPort = proxy.port;
230
+ log.info(
231
+ `Host-rewrite proxy active: cloudflared → :${tunnelPort} → localhost:${port}`,
232
+ );
233
+ } catch (err) {
234
+ log.warn(
235
+ `Could not start host-rewrite proxy (${err.message}) — ` +
236
+ `relying on --http-host-header flag only`,
73
237
  );
238
+ }
239
+
240
+ // --http-host-header tells cloudflared to rewrite the Host header
241
+ // to localhost before sending to our proxy/dev server.
242
+ // Belt-and-suspenders with the proxy approach.
243
+ const args = [
244
+ "tunnel",
245
+ "--url",
246
+ `http://127.0.0.1:${tunnelPort}`,
247
+ "--http-host-header",
248
+ `localhost:${port}`,
249
+ ];
250
+
251
+ return new Promise((resolve) => {
252
+ const proc = spawn(cloudflaredBinary, args, {
253
+ stdio: ["pipe", "pipe", "pipe"],
254
+ });
74
255
 
75
256
  let url = null;
76
257
  let stderrBuf = "";
77
258
 
78
- const timeout = setTimeout(() => {
259
+ const timeout = setTimeout(async () => {
79
260
  if (!url) {
80
- log.warn(
81
- `cloudflared tunnel startup timed out after 30s for port ${port}`,
82
- );
261
+ const msg = `cloudflared tunnel startup timed out after 30s for port ${port}`;
262
+ log.warn(msg);
83
263
  proc.kill("SIGTERM");
264
+ if (proxy) proxy.server.close();
265
+ await statusCallback?.("failed", msg);
84
266
  resolve(null);
85
267
  }
86
268
  }, 30_000);
87
269
 
88
270
  // cloudflared prints the URL to stderr, not stdout.
89
- proc.stderr.on("data", (data) => {
271
+ proc.stderr.on("data", async (data) => {
90
272
  stderrBuf += data.toString();
91
273
  // Matches: https://some-random-name.trycloudflare.com
92
274
  const match = stderrBuf.match(
@@ -95,20 +277,25 @@ function startCloudflaredTunnel(sessionId, port) {
95
277
  if (match && !url) {
96
278
  url = match[0].trim();
97
279
  clearTimeout(timeout);
98
- activeTunnels.set(sessionId, { process: proc, port, url });
280
+ activeTunnels.set(sessionId, { process: proc, port, url, proxy });
99
281
  log.success(`Tunnel active: localhost:${port} → ${url}`);
282
+ await statusCallback?.("active");
100
283
  resolve(url);
101
284
  }
102
285
  });
103
286
 
104
- proc.on("error", (err) => {
287
+ proc.on("error", async (err) => {
105
288
  clearTimeout(timeout);
106
289
  log.error(`cloudflared failed to start: ${err.message}`);
290
+ if (proxy) proxy.server.close();
291
+ await statusCallback?.("failed", `cloudflared failed: ${err.message}`);
107
292
  resolve(null);
108
293
  });
109
294
 
110
295
  proc.on("close", (code) => {
111
296
  clearTimeout(timeout);
297
+ const entry = activeTunnels.get(sessionId);
298
+ if (entry?.proxy) entry.proxy.server.close();
112
299
  activeTunnels.delete(sessionId);
113
300
  if (code !== 0 && code !== null) {
114
301
  log.warn(`cloudflared process exited with code ${code}`);
@@ -122,7 +309,7 @@ function startCloudflaredTunnel(sessionId, port) {
122
309
  // Localtunnel (fallback)
123
310
  // ---------------------------------------------------------------------------
124
311
 
125
- function startLocaltunnel(sessionId, port) {
312
+ function startLocaltunnel(sessionId, port, statusCallback) {
126
313
  return new Promise((resolve) => {
127
314
  const proc = spawn("npx", ["localtunnel", "--port", String(port)], {
128
315
  stdio: ["pipe", "pipe", "pipe"],
@@ -131,15 +318,17 @@ function startLocaltunnel(sessionId, port) {
131
318
  let url = null;
132
319
  let stdoutBuf = "";
133
320
 
134
- const timeout = setTimeout(() => {
321
+ const timeout = setTimeout(async () => {
135
322
  if (!url) {
136
- log.warn(`localtunnel startup timed out after 30s for port ${port}`);
323
+ const msg = `localtunnel startup timed out after 30s for port ${port}`;
324
+ log.warn(msg);
137
325
  proc.kill("SIGTERM");
326
+ await statusCallback?.("failed", msg);
138
327
  resolve(null);
139
328
  }
140
329
  }, 30_000);
141
330
 
142
- proc.stdout.on("data", (data) => {
331
+ proc.stdout.on("data", async (data) => {
143
332
  stdoutBuf += data.toString();
144
333
  // localtunnel outputs: "your url is: https://xyz.loca.lt"
145
334
  const match = stdoutBuf.match(/your url is:\s*(https?:\/\/\S+)/i);
@@ -149,6 +338,7 @@ function startLocaltunnel(sessionId, port) {
149
338
 
150
339
  activeTunnels.set(sessionId, { process: proc, port, url });
151
340
  log.success(`Tunnel active: localhost:${port} → ${url}`);
341
+ await statusCallback?.("active");
152
342
  resolve(url);
153
343
  }
154
344
  });
@@ -158,9 +348,10 @@ function startLocaltunnel(sessionId, port) {
158
348
  if (msg) log.warn(`[tunnel] ${msg}`);
159
349
  });
160
350
 
161
- proc.on("error", (err) => {
351
+ proc.on("error", async (err) => {
162
352
  clearTimeout(timeout);
163
353
  log.error(`localtunnel failed to start: ${err.message}`);
354
+ await statusCallback?.("failed", `localtunnel failed: ${err.message}`);
164
355
  resolve(null);
165
356
  });
166
357
 
@@ -186,6 +377,7 @@ export async function stopTunnel(sessionId) {
186
377
  `Stopping tunnel for session ${sessionId.slice(0, 8)} (port ${tunnel.port})`,
187
378
  );
188
379
  tunnel.process.kill("SIGTERM");
380
+ if (tunnel.proxy) tunnel.proxy.server.close();
189
381
  activeTunnels.delete(sessionId);
190
382
  }
191
383
 
@@ -196,6 +388,7 @@ export async function stopAllTunnels() {
196
388
  for (const [sessionId, tunnel] of activeTunnels) {
197
389
  log.info(`Cleanup: stopping tunnel for ${sessionId.slice(0, 8)}`);
198
390
  tunnel.process.kill("SIGTERM");
391
+ if (tunnel.proxy) tunnel.proxy.server.close();
199
392
  }
200
393
  activeTunnels.clear();
201
394
  }