claude-relay 2.1.2 → 2.2.0

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
 
@@ -205,6 +220,16 @@ graph LR
205
220
 
206
221
  For a detailed sequence diagram, daemon structure, and design decisions, refer to [docs/architecture.md](docs/architecture.md).
207
222
 
223
+ ## Star History
224
+
225
+ <a href="https://star-history.com/#chadbyte/claude-relay&Date">
226
+ <picture>
227
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=chadbyte/claude-relay&type=Date&theme=dark" />
228
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=chadbyte/claude-relay&type=Date" />
229
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=chadbyte/claude-relay&type=Date" width="600" />
230
+ </picture>
231
+ </a>
232
+
208
233
  ---
209
234
 
210
235
  ## Contributing
package/bin/cli.js CHANGED
@@ -78,6 +78,7 @@ if (shutdownMode) {
78
78
  var cwd = process.cwd();
79
79
 
80
80
  // --- ANSI helpers ---
81
+ var isBasicTerm = process.env.TERM_PROGRAM === "Apple_Terminal";
81
82
  var a = {
82
83
  reset: "\x1b[0m",
83
84
  bold: "\x1b[1m",
@@ -89,6 +90,9 @@ var a = {
89
90
  };
90
91
 
91
92
  function gradient(text) {
93
+ if (isBasicTerm) {
94
+ return a.yellow + text + a.reset;
95
+ }
92
96
  // Orange (#DA7756) → Gold (#D4A574)
93
97
  var r0 = 218, g0 = 119, b0 = 86;
94
98
  var r1 = 212, g1 = 165, b1 = 116;
@@ -258,7 +262,7 @@ function ensureCerts(ip) {
258
262
 
259
263
  // --- Logo ---
260
264
  function printLogo() {
261
- var c = "\x1b[38;2;218;119;86m";
265
+ var c = isBasicTerm ? a.yellow : "\x1b[38;2;218;119;86m";
262
266
  var r = a.reset;
263
267
  var lines = [
264
268
  " ██████╗ ██╗ █████╗ ██╗ ██╗ ██████╗ ███████╗ ██████╗ ███████╗ ██╗ █████╗ ██╗ ██╗",
@@ -1029,7 +1033,7 @@ function showMainMenu(config, ip) {
1029
1033
  }
1030
1034
 
1031
1035
  if (ip !== "localhost") {
1032
- qrcode.generate(url, { small: true }, function (code) {
1036
+ qrcode.generate(url, { small: !isBasicTerm }, function (code) {
1033
1037
  var lines = code.split("\n").map(function (l) { return " " + l; }).join("\n");
1034
1038
  console.log(lines);
1035
1039
  afterQr();
@@ -1464,7 +1468,7 @@ function showSetupGuide(config, ip, goBack) {
1464
1468
  log(sym.bar + " " + a.dim + "Scan the QR code or open:" + a.reset);
1465
1469
  log(sym.bar + " " + a.bold + setupUrl + a.reset);
1466
1470
  log(sym.bar);
1467
- qrcode.generate(setupUrl, { small: true }, function (code) {
1471
+ qrcode.generate(setupUrl, { small: !isBasicTerm }, function (code) {
1468
1472
  var lines = code.split("\n").map(function (l) { return " " + sym.bar + " " + l; }).join("\n");
1469
1473
  console.log(lines);
1470
1474
  log(sym.bar);
package/lib/pages.js CHANGED
@@ -444,8 +444,9 @@ function checkHttps() {
444
444
 
445
445
  var ac = new AbortController();
446
446
  setTimeout(function() { ac.abort(); }, 3000);
447
- fetch(httpsUrl + "/info", { signal: ac.signal })
447
+ fetch(httpsUrl + "/info", { signal: ac.signal, mode: "no-cors" })
448
448
  .then(function() {
449
+ // Any response (even opaque/401) means TLS handshake succeeded = cert is trusted
449
450
  certStatus.className = "check-status ok";
450
451
  certStatus.textContent = "HTTPS connection verified. Certificate is trusted.";
451
452
  certNext.disabled = false;
@@ -614,7 +615,7 @@ if (!isHttps && !isLocal) {
614
615
  if (!info.httpsUrl) { init(); return; }
615
616
  var ac = new AbortController();
616
617
  setTimeout(function() { ac.abort(); }, 3000);
617
- fetch(info.httpsUrl + "/info", { signal: ac.signal })
618
+ fetch(info.httpsUrl + "/info", { signal: ac.signal, mode: "no-cors" })
618
619
  .then(function() { location.replace(info.httpsUrl + "/setup"); })
619
620
  .catch(function() { init(); });
620
621
  }).catch(function() { init(); });
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();