claude-relay 2.1.3 → 2.2.1

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.
package/README.md CHANGED
@@ -6,13 +6,14 @@
6
6
 
7
7
  <h3 align="center">Web UI for Claude Code. Any device. Push notifications.</h3>
8
8
 
9
+ [![npm version](https://img.shields.io/npm/v/claude-relay)](https://www.npmjs.com/package/claude-relay) [![npm downloads](https://img.shields.io/npm/dw/claude-relay)](https://www.npmjs.com/package/claude-relay) [![GitHub stars](https://img.shields.io/github/stars/chadbyte/claude-relay)](https://github.com/chadbyte/claude-relay)
10
+
9
11
  Claude Code. Anywhere.
10
12
  Same session. Same files. Same machine.
11
13
  Your files stay on your computer. Nothing leaves for the cloud.
12
14
 
13
15
  Pick up the same Claude Code session on your phone.
14
- Start in the terminal, continue on your phone, switch back anytime.
15
- Same session, same files, now in your pocket.
16
+ Start in the terminal, continue on your phone, switch back anytime.
16
17
 
17
18
  Claude Code is automating more of your editing and execution workflow.
18
19
  But when it needs approval or asks a question, it halts in the terminal. If you walk away, it just sits there waiting.
@@ -45,6 +46,10 @@ It works in browser tabs too. When input is awaited, the favicon blinks and the
45
46
 
46
47
  ## Side by Side Workflow
47
48
 
49
+ <p align="center">
50
+ <img src="media/split.gif" alt="split-screen workflow" width="700">
51
+ </p>
52
+
48
53
  Keep claude-relay on one side and your localhost on the other.
49
54
  Watch the results update live while Claude Code fixes your source files.
50
55
 
@@ -82,8 +87,10 @@ Scan the QR code with your phone to connect instantly, or open the URL displayed
82
87
 
83
88
  * **Push Approvals** - Approve or reject from your phone while away, so Claude Code does not get stuck waiting.
84
89
  * **Multi Project Daemon** - Manage all projects via a single port.
90
+ * **Usage and Model Switching** - View token usage, rate limit bars, and switch models from the browser.
91
+ * **Session Search** - Full-text search across all session messages with hit timeline.
85
92
  * **Auto Session Logs (JSONL)** - Conversations and execution history are always saved locally. No data loss on crashes or restarts. Location: `./.claude-relay/sessions/`
86
- * **File Browser and Terminal** - Inspect files and execute commands directly from the browser.
93
+ * **File Browser and Terminal** - Inspect files, execute commands, and manage multiple terminal tabs from the browser.
87
94
 
88
95
  > Note: Session logs may contain prompts, outputs, and commands. Do not share this folder.
89
96
 
@@ -102,22 +109,30 @@ Scan the QR code with your phone to connect instantly, or open the URL displayed
102
109
  * **Project Names** - Custom names make it easy to distinguish tabs.
103
110
  * **Session Persistence** - Sessions survive server restarts, browser crashes, and network drops.
104
111
  * **Session Handoff** - Start in the terminal, continue on your phone, pass back to desktop.
112
+ * **Session Search** - Full-text search across all session content with highlighted results and a rewind-style hit timeline.
105
113
  * **Rewind (Native Claude Code)** - Accessible directly from the browser UI.
114
+ * **Draft Persistence** - Unsent messages are saved per session and restored when you switch back.
106
115
 
107
116
  **Rendering and Tools**
108
117
 
109
118
  * **Mermaid and Markdown** - Proper rendering for diagrams, tables, and code blocks.
110
119
  * **Syntax Highlighting** - Support for over 180 languages with copy buttons on every block.
111
- * **File Browser** - Sidebar navigation with file previews and markdown rendering.
112
- * **Built in Terminal** - Full shell access within the browser.
120
+ * **File Browser** - Sidebar navigation with file previews, markdown rendering, and live-reload on external changes.
121
+ * **Built in Terminal** - Multi-tab terminal sessions with rename, reorder, and a mobile special-key toolbar.
113
122
  * **Slash Commands** - Execute standard Claude Code commands from the browser, with autocomplete.
123
+ * **Usage Panel** - View token counts and rate limit progress bars via `/usage` command or header button.
124
+ * **Model Switching** - Change the active model directly from the browser header.
125
+ * **Plan Approval** - Review and approve Claude implementation plans from the browser UI.
114
126
 
115
127
  **UI**
116
128
 
117
129
  * **Mobile Optimized** - Large approve and reject buttons. Behaves like a native app via PWA.
118
130
  * **Real time Sync** - All devices view the exact same session state.
119
131
  * **QR Code** - Scan to connect instantly.
120
- * **Image Paste** - Paste images directly from your clipboard into the input.
132
+ * **Image Paste and Camera** - Paste images from clipboard or attach photos directly from your camera.
133
+ * **Send While Processing** - Queue messages without waiting for the current response to finish.
134
+ * **Sticky Todo Overlay** - TodoWrite tasks float as a progress bar while you scroll through the conversation.
135
+ * **Scroll Position Hold** - Reading earlier messages will not get interrupted by new content arriving.
121
136
 
122
137
  **Server and Security**
123
138
 
@@ -170,6 +185,10 @@ npx claude-relay -p 8080 # Specify port
170
185
  npx claude-relay --no-https # Disable HTTPS
171
186
  npx claude-relay --no-update # Skip update check
172
187
  npx claude-relay --debug # Enable debug panel
188
+ npx claude-relay --add . # Add current directory to running daemon
189
+ npx claude-relay --add /path # Add a project by path
190
+ npx claude-relay --remove . # Remove a project
191
+ npx claude-relay --list # List registered projects
173
192
  ```
174
193
 
175
194
  ## Requirements
@@ -205,6 +224,16 @@ graph LR
205
224
 
206
225
  For a detailed sequence diagram, daemon structure, and design decisions, refer to [docs/architecture.md](docs/architecture.md).
207
226
 
227
+ ## Star History
228
+
229
+ <a href="https://star-history.com/#chadbyte/claude-relay&Date">
230
+ <picture>
231
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=chadbyte/claude-relay&type=Date&theme=dark" />
232
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=chadbyte/claude-relay&type=Date" />
233
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=chadbyte/claude-relay&type=Date" width="600" />
234
+ </picture>
235
+ </a>
236
+
208
237
  ---
209
238
 
210
239
  ## Contributing
package/bin/cli.js CHANGED
@@ -18,6 +18,9 @@ var debugMode = false;
18
18
  var autoYes = false;
19
19
  var cliPin = null;
20
20
  var shutdownMode = false;
21
+ var addPath = null;
22
+ var removePath = null;
23
+ var listMode = false;
21
24
 
22
25
  for (var i = 0; i < args.length; i++) {
23
26
  if (args[i] === "-p" || args[i] === "--port") {
@@ -40,8 +43,19 @@ for (var i = 0; i < args.length; i++) {
40
43
  i++;
41
44
  } else if (args[i] === "--shutdown") {
42
45
  shutdownMode = true;
46
+ } else if (args[i] === "--add") {
47
+ addPath = args[i + 1] || ".";
48
+ i++;
49
+ } else if (args[i] === "--remove") {
50
+ removePath = args[i + 1] || null;
51
+ i++;
52
+ } else if (args[i] === "--list") {
53
+ listMode = true;
43
54
  } else if (args[i] === "-h" || args[i] === "--help") {
44
55
  console.log("Usage: claude-relay [-p|--port <port>] [--no-https] [--no-update] [--debug] [-y|--yes] [--pin <pin>] [--shutdown]");
56
+ console.log(" claude-relay --add <path> Add a project to the running daemon");
57
+ console.log(" claude-relay --remove <path> Remove a project from the running daemon");
58
+ console.log(" claude-relay --list List registered projects");
45
59
  console.log("");
46
60
  console.log("Options:");
47
61
  console.log(" -p, --port <port> Port to listen on (default: 2633)");
@@ -51,6 +65,9 @@ for (var i = 0; i < args.length; i++) {
51
65
  console.log(" -y, --yes Skip interactive prompts (accept defaults)");
52
66
  console.log(" --pin <pin> Set 6-digit PIN (use with --yes)");
53
67
  console.log(" --shutdown Shut down the running relay daemon");
68
+ console.log(" --add <path> Add a project directory (use '.' for current)");
69
+ console.log(" --remove <path> Remove a project directory");
70
+ console.log(" --list List all registered projects");
54
71
  process.exit(0);
55
72
  }
56
73
  }
@@ -75,9 +92,97 @@ if (shutdownMode) {
75
92
  return;
76
93
  }
77
94
 
95
+ // --- Handle --add before anything else ---
96
+ if (addPath !== null) {
97
+ var absAdd = path.resolve(addPath);
98
+ try {
99
+ var stat = fs.statSync(absAdd);
100
+ if (!stat.isDirectory()) {
101
+ console.error("Not a directory: " + absAdd);
102
+ process.exit(1);
103
+ }
104
+ } catch (e) {
105
+ console.error("Directory not found: " + absAdd);
106
+ process.exit(1);
107
+ }
108
+ var addConfig = loadConfig();
109
+ isDaemonAliveAsync(addConfig).then(function (alive) {
110
+ if (!alive) {
111
+ console.error("No running daemon. Start with: npx claude-relay");
112
+ process.exit(1);
113
+ }
114
+ sendIPCCommand(socketPath(), { cmd: "add_project", path: absAdd }).then(function (res) {
115
+ if (res.ok) {
116
+ if (res.existing) {
117
+ console.log("Already registered: " + res.slug);
118
+ } else {
119
+ console.log("Added: " + res.slug + " \u2192 " + absAdd);
120
+ }
121
+ process.exit(0);
122
+ } else {
123
+ console.error("Failed: " + (res.error || "unknown error"));
124
+ process.exit(1);
125
+ }
126
+ });
127
+ });
128
+ return;
129
+ }
130
+
131
+ // --- Handle --remove before anything else ---
132
+ if (removePath !== null) {
133
+ var absRemove = path.resolve(removePath);
134
+ var removeConfig = loadConfig();
135
+ isDaemonAliveAsync(removeConfig).then(function (alive) {
136
+ if (!alive) {
137
+ console.error("No running daemon. Start with: npx claude-relay");
138
+ process.exit(1);
139
+ }
140
+ sendIPCCommand(socketPath(), { cmd: "remove_project", path: absRemove }).then(function (res) {
141
+ if (res.ok) {
142
+ console.log("Removed: " + path.basename(absRemove));
143
+ process.exit(0);
144
+ } else {
145
+ console.error("Failed: " + (res.error || "project not found"));
146
+ process.exit(1);
147
+ }
148
+ });
149
+ });
150
+ return;
151
+ }
152
+
153
+ // --- Handle --list before anything else ---
154
+ if (listMode) {
155
+ var listConfig = loadConfig();
156
+ isDaemonAliveAsync(listConfig).then(function (alive) {
157
+ if (!alive) {
158
+ console.error("No running daemon. Start with: npx claude-relay");
159
+ process.exit(1);
160
+ }
161
+ sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (res) {
162
+ if (!res.ok || !res.projects || res.projects.length === 0) {
163
+ console.log("No projects registered.");
164
+ process.exit(0);
165
+ return;
166
+ }
167
+ console.log("Projects (" + res.projects.length + "):\n");
168
+ for (var p = 0; p < res.projects.length; p++) {
169
+ var proj = res.projects[p];
170
+ var label = " " + proj.slug;
171
+ if (proj.title) label += " (" + proj.title + ")";
172
+ label += "\n " + proj.path;
173
+ console.log(label);
174
+ }
175
+ console.log("");
176
+ process.exit(0);
177
+ });
178
+ });
179
+ return;
180
+ }
181
+
78
182
  var cwd = process.cwd();
79
183
 
80
184
  // --- ANSI helpers ---
185
+ var isBasicTerm = process.env.TERM_PROGRAM === "Apple_Terminal";
81
186
  var a = {
82
187
  reset: "\x1b[0m",
83
188
  bold: "\x1b[1m",
@@ -89,6 +194,9 @@ var a = {
89
194
  };
90
195
 
91
196
  function gradient(text) {
197
+ if (isBasicTerm) {
198
+ return a.yellow + text + a.reset;
199
+ }
92
200
  // Orange (#DA7756) → Gold (#D4A574)
93
201
  var r0 = 218, g0 = 119, b0 = 86;
94
202
  var r1 = 212, g1 = 165, b1 = 116;
@@ -258,7 +366,7 @@ function ensureCerts(ip) {
258
366
 
259
367
  // --- Logo ---
260
368
  function printLogo() {
261
- var c = "\x1b[38;2;218;119;86m";
369
+ var c = isBasicTerm ? a.yellow : "\x1b[38;2;218;119;86m";
262
370
  var r = a.reset;
263
371
  var lines = [
264
372
  " ██████╗ ██╗ █████╗ ██╗ ██╗ ██████╗ ███████╗ ██████╗ ███████╗ ██╗ █████╗ ██╗ ██╗",
@@ -1029,7 +1137,7 @@ function showMainMenu(config, ip) {
1029
1137
  }
1030
1138
 
1031
1139
  if (ip !== "localhost") {
1032
- qrcode.generate(url, { small: true }, function (code) {
1140
+ qrcode.generate(url, { small: !isBasicTerm }, function (code) {
1033
1141
  var lines = code.split("\n").map(function (l) { return " " + l; }).join("\n");
1034
1142
  console.log(lines);
1035
1143
  afterQr();
@@ -1464,7 +1572,7 @@ function showSetupGuide(config, ip, goBack) {
1464
1572
  log(sym.bar + " " + a.dim + "Scan the QR code or open:" + a.reset);
1465
1573
  log(sym.bar + " " + a.bold + setupUrl + a.reset);
1466
1574
  log(sym.bar);
1467
- qrcode.generate(setupUrl, { small: true }, function (code) {
1575
+ qrcode.generate(setupUrl, { small: !isBasicTerm }, function (code) {
1468
1576
  var lines = code.split("\n").map(function (l) { return " " + sym.bar + " " + l; }).join("\n");
1469
1577
  console.log(lines);
1470
1578
  log(sym.bar);
package/lib/project.js CHANGED
@@ -2,8 +2,9 @@ var fs = require("fs");
2
2
  var path = require("path");
3
3
  var { createSessionManager } = require("./sessions");
4
4
  var { createSDKBridge } = require("./sdk-bridge");
5
- var { createTerminal } = require("./terminal");
5
+ var { createTerminalManager } = require("./terminal-manager");
6
6
  var { fetchLatestVersion, isNewer } = require("./updater");
7
+ var { fetchUsageData } = require("./usage");
7
8
  var { execFileSync } = require("child_process");
8
9
 
9
10
  // SDK loaded dynamically (ESM module)
@@ -94,6 +95,48 @@ function createProjectContext(opts) {
94
95
  }
95
96
  }
96
97
 
98
+ // --- File watcher ---
99
+ var fileWatcher = null;
100
+ var watchedPath = null;
101
+ var watchDebounce = null;
102
+
103
+ function startFileWatch(relPath) {
104
+ var absPath = safePath(cwd, relPath);
105
+ if (!absPath) return;
106
+ if (watchedPath === relPath) return;
107
+ stopFileWatch();
108
+ watchedPath = relPath;
109
+ try {
110
+ fileWatcher = fs.watch(absPath, function () {
111
+ clearTimeout(watchDebounce);
112
+ watchDebounce = setTimeout(function () {
113
+ try {
114
+ var stat = fs.statSync(absPath);
115
+ var ext = path.extname(absPath).toLowerCase();
116
+ if (stat.size > FS_MAX_SIZE || BINARY_EXTS.has(ext)) return;
117
+ var content = fs.readFileSync(absPath, "utf8");
118
+ send({ type: "fs_file_changed", path: relPath, content: content, size: stat.size });
119
+ } catch (e) {
120
+ stopFileWatch();
121
+ }
122
+ }, 200);
123
+ });
124
+ fileWatcher.on("error", function () { stopFileWatch(); });
125
+ } catch (e) {
126
+ watchedPath = null;
127
+ }
128
+ }
129
+
130
+ function stopFileWatch() {
131
+ if (fileWatcher) {
132
+ try { fileWatcher.close(); } catch (e) {}
133
+ fileWatcher = null;
134
+ }
135
+ clearTimeout(watchDebounce);
136
+ watchDebounce = null;
137
+ watchedPath = null;
138
+ }
139
+
97
140
  // --- Session manager ---
98
141
  var sm = createSessionManager({ cwd: cwd, send: send });
99
142
 
@@ -106,6 +149,9 @@ function createProjectContext(opts) {
106
149
  getSDK: getSDK,
107
150
  });
108
151
 
152
+ // --- Terminal manager ---
153
+ var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
154
+
109
155
  // Check for updates in background
110
156
  fetchLatestVersion().then(function (v) {
111
157
  if (v && isNewer(v, currentVersion)) {
@@ -127,6 +173,10 @@ function createProjectContext(opts) {
127
173
  if (sm.slashCommands) {
128
174
  sendTo(ws, { type: "slash_commands", commands: sm.slashCommands });
129
175
  }
176
+ if (sm.currentModel) {
177
+ sendTo(ws, { type: "model_info", model: sm.currentModel, models: sm.availableModels || [] });
178
+ }
179
+ sendTo(ws, { type: "term_list", terminals: tm.list() });
130
180
 
131
181
  // Session list
132
182
  sendTo(ws, {
@@ -243,6 +293,12 @@ function createProjectContext(opts) {
243
293
  return;
244
294
  }
245
295
 
296
+ if (msg.type === "search_sessions") {
297
+ var results = sm.searchSessions(msg.query || "");
298
+ sendTo(ws, { type: "search_results", query: msg.query || "", results: results });
299
+ return;
300
+ }
301
+
246
302
  if (msg.type === "check_update") {
247
303
  fetchLatestVersion().then(function (v) {
248
304
  if (v && isNewer(v, currentVersion)) {
@@ -261,6 +317,23 @@ function createProjectContext(opts) {
261
317
  return;
262
318
  }
263
319
 
320
+ if (msg.type === "get_usage") {
321
+ fetchUsageData().then(function (data) {
322
+ sendTo(ws, { type: "usage_data", data: data });
323
+ }).catch(function (err) {
324
+ sendTo(ws, { type: "usage_data", error: err.message || "Failed to fetch usage data" });
325
+ });
326
+ return;
327
+ }
328
+
329
+ if (msg.type === "set_model" && msg.model) {
330
+ var session = sm.getActiveSession();
331
+ if (session) {
332
+ sdk.setModel(session, msg.model);
333
+ }
334
+ return;
335
+ }
336
+
264
337
  if (msg.type === "rewind_preview") {
265
338
  var session = sm.getActiveSession();
266
339
  if (!session || !session.cliSessionId || !msg.uuid) return;
@@ -358,6 +431,7 @@ function createProjectContext(opts) {
358
431
  var pending = session.pendingAskUser[toolId];
359
432
  if (!pending) return;
360
433
  delete session.pendingAskUser[toolId];
434
+ sm.sendAndRecord(session, { type: "ask_user_answered", toolId: toolId });
361
435
  pending.resolve({
362
436
  behavior: "allow",
363
437
  updatedInput: Object.assign({}, pending.input, { answers: answers }),
@@ -450,41 +524,64 @@ function createProjectContext(opts) {
450
524
  return;
451
525
  }
452
526
 
527
+ // --- File watcher ---
528
+ if (msg.type === "fs_watch") {
529
+ if (msg.path) startFileWatch(msg.path);
530
+ return;
531
+ }
532
+
533
+ if (msg.type === "fs_unwatch") {
534
+ stopFileWatch();
535
+ return;
536
+ }
537
+
453
538
  // --- Web terminal ---
454
- if (msg.type === "term_open") {
455
- if (ws._term) return;
456
- var term = createTerminal(cwd);
457
- if (!term) {
458
- sendTo(ws, { type: "term_output", data: "\r\n[node-pty not available]\r\n" });
539
+ if (msg.type === "term_create") {
540
+ var t = tm.create(msg.cols || 80, msg.rows || 24);
541
+ if (!t) {
542
+ sendTo(ws, { type: "term_error", error: "Cannot create terminal (node-pty not available or limit reached)" });
459
543
  return;
460
544
  }
461
- ws._term = term;
462
- term.onData(function (data) {
463
- sendTo(ws, { type: "term_output", data: data });
464
- });
465
- term.onExit(function () {
466
- ws._term = null;
467
- sendTo(ws, { type: "term_exited" });
468
- });
545
+ tm.attach(t.id, ws);
546
+ send({ type: "term_list", terminals: tm.list() });
547
+ sendTo(ws, { type: "term_created", id: t.id });
548
+ return;
549
+ }
550
+
551
+ if (msg.type === "term_attach") {
552
+ if (msg.id) tm.attach(msg.id, ws);
553
+ return;
554
+ }
555
+
556
+ if (msg.type === "term_detach") {
557
+ if (msg.id) tm.detach(msg.id, ws);
469
558
  return;
470
559
  }
471
560
 
472
561
  if (msg.type === "term_input") {
473
- if (ws._term) ws._term.write(msg.data);
562
+ if (msg.id) tm.write(msg.id, msg.data);
474
563
  return;
475
564
  }
476
565
 
477
566
  if (msg.type === "term_resize") {
478
- if (ws._term && msg.cols > 0 && msg.rows > 0) {
479
- try { ws._term.resize(msg.cols, msg.rows); } catch (e) {}
567
+ if (msg.id && msg.cols > 0 && msg.rows > 0) {
568
+ tm.resize(msg.id, msg.cols, msg.rows);
480
569
  }
481
570
  return;
482
571
  }
483
572
 
484
573
  if (msg.type === "term_close") {
485
- if (ws._term) {
486
- try { ws._term.kill(); } catch (e) {}
487
- ws._term = null;
574
+ if (msg.id) {
575
+ tm.close(msg.id);
576
+ send({ type: "term_list", terminals: tm.list() });
577
+ }
578
+ return;
579
+ }
580
+
581
+ if (msg.type === "term_rename") {
582
+ if (msg.id && msg.title) {
583
+ tm.rename(msg.id, msg.title);
584
+ send({ type: "term_list", terminals: tm.list() });
488
585
  }
489
586
  return;
490
587
  }
@@ -495,14 +592,6 @@ function createProjectContext(opts) {
495
592
  var session = sm.getActiveSession();
496
593
  if (!session) return;
497
594
 
498
- if (session.isProcessing) {
499
- send({ type: "error", text: "Still processing previous message. Please wait." });
500
- return;
501
- }
502
-
503
- session.isProcessing = true;
504
- session.sentToolResults = {};
505
-
506
595
  var userMsg = { type: "user_message", text: msg.text || "" };
507
596
  if (msg.images && msg.images.length > 0) {
508
597
  userMsg.imageCount = msg.images.length;
@@ -513,7 +602,6 @@ function createProjectContext(opts) {
513
602
  session.history.push(userMsg);
514
603
  sm.appendToSessionFile(session, userMsg);
515
604
  sendToOthers(ws, userMsg);
516
- send({ type: "status", status: "processing" });
517
605
 
518
606
  if (!session.title) {
519
607
  session.title = (msg.text || "Image").substring(0, 50);
@@ -529,8 +617,15 @@ function createProjectContext(opts) {
529
617
  }
530
618
  }
531
619
 
532
- if (!session.queryInstance) {
533
- sdk.startQuery(session, fullText, msg.images);
620
+ if (!session.isProcessing) {
621
+ session.isProcessing = true;
622
+ session.sentToolResults = {};
623
+ send({ type: "status", status: "processing" });
624
+ if (!session.queryInstance) {
625
+ sdk.startQuery(session, fullText, msg.images);
626
+ } else {
627
+ sdk.pushMessage(session, fullText, msg.images);
628
+ }
534
629
  } else {
535
630
  sdk.pushMessage(session, fullText, msg.images);
536
631
  }
@@ -539,11 +634,9 @@ function createProjectContext(opts) {
539
634
 
540
635
  // --- WS disconnection handler ---
541
636
  function handleDisconnection(ws) {
542
- if (ws._term) {
543
- try { ws._term.kill(); } catch (e) {}
544
- ws._term = null;
545
- }
637
+ tm.detachAll(ws);
546
638
  clients.delete(ws);
639
+ if (clients.size === 0) stopFileWatch();
547
640
  broadcastClientCount();
548
641
  }
549
642
 
@@ -651,6 +744,7 @@ function createProjectContext(opts) {
651
744
 
652
745
  // --- Destroy ---
653
746
  function destroy() {
747
+ stopFileWatch();
654
748
  // Abort all active sessions
655
749
  sm.sessions.forEach(function (session) {
656
750
  if (session.abortController) {
@@ -661,11 +755,8 @@ function createProjectContext(opts) {
661
755
  }
662
756
  });
663
757
  // Kill all terminals
758
+ tm.destroyAll();
664
759
  for (var ws of clients) {
665
- if (ws._term) {
666
- try { ws._term.kill(); } catch (e) {}
667
- ws._term = null;
668
- }
669
760
  try { ws.close(); } catch (e) {}
670
761
  }
671
762
  clients.clear();