claude-relay 2.3.0 → 2.3.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
@@ -133,6 +133,7 @@ Scan the QR code with your phone to connect instantly, or open the URL displayed
133
133
  * **Send While Processing** - Queue messages without waiting for the current response to finish.
134
134
  * **Sticky Todo Overlay** - TodoWrite tasks float as a progress bar while you scroll through the conversation.
135
135
  * **Scroll Position Hold** - Reading earlier messages will not get interrupted by new content arriving.
136
+ * **RTL Text Support** - Automatic bidirectional text rendering for Arabic, Hebrew, and other RTL languages.
136
137
 
137
138
  **Server and Security**
138
139
 
package/bin/cli.js CHANGED
@@ -6,6 +6,13 @@ var path = require("path");
6
6
  var { execSync, execFileSync, spawn } = require("child_process");
7
7
  var qrcode = require("qrcode-terminal");
8
8
  var net = require("net");
9
+
10
+ // Detect dev mode before loading config (env must be set before require)
11
+ var _isDev = process.argv[1] && path.basename(process.argv[1]) === "claude-relay-dev";
12
+ if (_isDev) {
13
+ process.env.CLAUDE_RELAY_HOME = path.join(os.homedir(), ".claude-relay-dev");
14
+ }
15
+
9
16
  var { loadConfig, saveConfig, configPath, socketPath, logPath, ensureConfigDir, isDaemonAlive, isDaemonAliveAsync, generateSlug, clearStaleConfig, loadClayrc, saveClayrc, readCrashInfo } = require("../lib/config");
10
17
  var { sendIPCCommand } = require("../lib/ipc");
11
18
  var { generateAuthToken } = require("../lib/server");
@@ -22,7 +29,7 @@ function openUrl(url) {
22
29
  }
23
30
 
24
31
  var args = process.argv.slice(2);
25
- var port = 2633;
32
+ var port = _isDev ? 2635 : 2633;
26
33
  var useHttps = true;
27
34
  var skipUpdate = false;
28
35
  var debugMode = false;
@@ -467,7 +474,7 @@ function getAllIPs() {
467
474
 
468
475
  function ensureCerts(ip) {
469
476
  var homeDir = os.homedir();
470
- var certDir = path.join(homeDir, ".claude-relay", "certs");
477
+ var certDir = path.join(process.env.CLAUDE_RELAY_HOME || path.join(homeDir, ".claude-relay"), "certs");
471
478
  var keyPath = path.join(certDir, "key.pem");
472
479
  var certPath = path.join(certDir, "cert.pem");
473
480
 
package/lib/config.js CHANGED
@@ -3,7 +3,7 @@ var path = require("path");
3
3
  var os = require("os");
4
4
  var net = require("net");
5
5
 
6
- var CONFIG_DIR = path.join(os.homedir(), ".claude-relay");
6
+ var CONFIG_DIR = process.env.CLAUDE_RELAY_HOME || path.join(os.homedir(), ".claude-relay");
7
7
  var CLAYRC_PATH = path.join(os.homedir(), ".clayrc");
8
8
  var CRASH_INFO_PATH = path.join(CONFIG_DIR, "crash.json");
9
9
 
@@ -13,7 +13,8 @@ function configPath() {
13
13
 
14
14
  function socketPath() {
15
15
  if (process.platform === "win32") {
16
- return "\\\\.\\pipe\\claude-relay-daemon";
16
+ var pipeName = process.env.CLAUDE_RELAY_HOME ? "claude-relay-dev-daemon" : "claude-relay-daemon";
17
+ return "\\\\.\\pipe\\" + pipeName;
17
18
  }
18
19
  return path.join(CONFIG_DIR, "daemon.sock");
19
20
  }
package/lib/daemon.js CHANGED
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // Polyfill Symbol.dispose/asyncDispose for Node 18 (used by claude-agent-sdk)
4
+ if (!Symbol.dispose) Symbol.dispose = Symbol("Symbol.dispose");
5
+ if (!Symbol.asyncDispose) Symbol.asyncDispose = Symbol("Symbol.asyncDispose");
6
+
3
7
  var fs = require("fs");
4
8
  var path = require("path");
5
9
  var { loadConfig, saveConfig, socketPath, generateSlug, syncClayrc, writeCrashInfo, readCrashInfo, clearCrashInfo } = require("./config");
@@ -20,7 +24,7 @@ try {
20
24
  var tlsOptions = null;
21
25
  if (config.tls) {
22
26
  var os = require("os");
23
- var certDir = path.join(os.homedir(), ".claude-relay", "certs");
27
+ var certDir = path.join(process.env.CLAUDE_RELAY_HOME || path.join(os.homedir(), ".claude-relay"), "certs");
24
28
  var keyPath = path.join(certDir, "key.pem");
25
29
  var certPath = path.join(certDir, "cert.pem");
26
30
  try {
package/lib/pages.js CHANGED
@@ -695,8 +695,15 @@ function dashboardPageHtml(projects, version) {
695
695
  '<div class="subtitle">Select a project</div>' +
696
696
  '<div class="cards">' + cards + '</div>' +
697
697
  '<div class="footer">v' + escapeHtml(version || "") + '</div>' +
698
+ '<style>' +
699
+ '.toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#3A3936;border:1px solid #DA7756;color:#E8E5DE;padding:12px 24px;border-radius:8px;font-size:14px;z-index:999;opacity:0;transition:opacity .3s}' +
700
+ '.toast.show{opacity:1}' +
701
+ '</style>' +
698
702
  '<script>var s=window.matchMedia("(display-mode:standalone)").matches||navigator.standalone;' +
699
- 'if(s&&!localStorage.getItem("setup-done")){var t=/^100\\./.test(location.hostname);location.replace("/setup"+(t?"":"?mode=lan"));}</script>' +
703
+ 'if(s&&!localStorage.getItem("setup-done")){var t=/^100\\./.test(location.hostname);location.replace("/setup"+(t?"":"?mode=lan"));}' +
704
+ 'var p=new URLSearchParams(location.search);var g=p.get("gone");' +
705
+ 'if(g){history.replaceState(null,"","/");var d=document.createElement("div");d.className="toast";d.textContent="Project \\""+g+"\\" is no longer available";document.body.appendChild(d);' +
706
+ 'requestAnimationFrame(function(){d.className="toast show"});setTimeout(function(){d.className="toast";setTimeout(function(){d.remove()},300)},5000);}</script>' +
700
707
  '</body></html>';
701
708
  }
702
709
 
package/lib/project.js CHANGED
@@ -911,17 +911,6 @@ function createProjectContext(opts) {
911
911
  if (clients.size === 0) {
912
912
  stopFileWatch();
913
913
  stopAllDirWatches();
914
- // Abort all running queries when no clients are connected
915
- var aborted = 0;
916
- sm.sessions.forEach(function (session) {
917
- if (session.isProcessing && session.abortController) {
918
- try { session.abortController.abort(); } catch (e) {}
919
- aborted++;
920
- }
921
- });
922
- if (aborted > 0) {
923
- console.log("[project:" + slug + "] No clients connected, aborted " + aborted + " active queries");
924
- }
925
914
  }
926
915
  broadcastClientCount();
927
916
  }
package/lib/public/app.js CHANGED
@@ -185,6 +185,8 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
185
185
  // isComposing -> modules/input.js
186
186
  var reconnectTimer = null;
187
187
  var reconnectDelay = 1000;
188
+ var disconnectNotifTimer = null;
189
+ var disconnectNotifShown = false;
188
190
  var activityEl = null;
189
191
  var currentMsgEl = null;
190
192
  var currentFullText = "";
@@ -974,6 +976,7 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
974
976
  div.dataset.turn = ++turnCounter;
975
977
  var bubble = document.createElement("div");
976
978
  bubble.className = "bubble";
979
+ bubble.dir = "auto";
977
980
 
978
981
  if (images && images.length > 0) {
979
982
  var imgRow = document.createElement("div");
@@ -1024,7 +1027,7 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
1024
1027
  currentMsgEl = document.createElement("div");
1025
1028
  currentMsgEl.className = "msg-assistant";
1026
1029
  currentMsgEl.dataset.turn = turnCounter;
1027
- currentMsgEl.innerHTML = '<div class="md-content"></div>';
1030
+ currentMsgEl.innerHTML = '<div class="md-content" dir="auto"></div>';
1028
1031
  addToMessages(currentMsgEl);
1029
1032
  currentFullText = "";
1030
1033
  }
@@ -1164,8 +1167,13 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
1164
1167
 
1165
1168
  ws.onopen = function () {
1166
1169
  if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
1167
- // Local notification on reconnect (only if not focused)
1168
- if (wasConnected && !document.hasFocus() && "serviceWorker" in navigator) {
1170
+ // Cancel pending "connection lost" notification if reconnected quickly
1171
+ if (disconnectNotifTimer) {
1172
+ clearTimeout(disconnectNotifTimer);
1173
+ disconnectNotifTimer = null;
1174
+ }
1175
+ // Only show "restored" notification if "lost" was actually shown
1176
+ if (wasConnected && disconnectNotifShown && !document.hasFocus() && "serviceWorker" in navigator) {
1169
1177
  navigator.serviceWorker.ready.then(function (reg) {
1170
1178
  reg.showNotification("Claude Relay", {
1171
1179
  body: "Server connection restored",
@@ -1173,6 +1181,7 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
1173
1181
  });
1174
1182
  }).catch(function () {});
1175
1183
  }
1184
+ disconnectNotifShown = false;
1176
1185
  wasConnected = true;
1177
1186
  setStatus("connected");
1178
1187
  reconnectDelay = 1000;
@@ -1198,14 +1207,20 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
1198
1207
  setStatus("disconnected");
1199
1208
  processing = false;
1200
1209
  setActivity(null);
1201
- // Local notification when connection drops (only if not focused)
1202
- if (!document.hasFocus() && "serviceWorker" in navigator) {
1203
- navigator.serviceWorker.ready.then(function (reg) {
1204
- reg.showNotification("Claude Relay", {
1205
- body: "Server connection lost",
1206
- tag: "claude-disconnect",
1207
- });
1208
- }).catch(function () {});
1210
+ // Delay "connection lost" notification by 5s to suppress brief disconnects
1211
+ if (!disconnectNotifTimer) {
1212
+ disconnectNotifTimer = setTimeout(function () {
1213
+ disconnectNotifTimer = null;
1214
+ disconnectNotifShown = true;
1215
+ if (!document.hasFocus() && "serviceWorker" in navigator) {
1216
+ navigator.serviceWorker.ready.then(function (reg) {
1217
+ reg.showNotification("Claude Relay", {
1218
+ body: "Server connection lost",
1219
+ tag: "claude-disconnect",
1220
+ });
1221
+ }).catch(function () {});
1222
+ }
1223
+ }, 5000);
1209
1224
  }
1210
1225
  scheduleReconnect();
1211
1226
  };
@@ -598,6 +598,36 @@
598
598
  font-style: italic;
599
599
  }
600
600
 
601
+ /* --- Terminal context menu --- */
602
+ .term-ctx-menu {
603
+ position: fixed;
604
+ background: var(--bg-alt);
605
+ border: 1px solid var(--border);
606
+ border-radius: 10px;
607
+ padding: 4px 0;
608
+ min-width: 160px;
609
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
610
+ z-index: 500;
611
+ }
612
+
613
+ .term-ctx-item {
614
+ display: flex;
615
+ align-items: center;
616
+ gap: 8px;
617
+ width: 100%;
618
+ padding: 8px 12px;
619
+ font-size: 13px;
620
+ color: var(--text-secondary);
621
+ background: none;
622
+ border: none;
623
+ font-family: inherit;
624
+ cursor: pointer;
625
+ transition: background 0.15s;
626
+ }
627
+
628
+ .term-ctx-item .lucide { width: 14px; height: 14px; flex-shrink: 0; }
629
+ .term-ctx-item:hover { background: rgba(255, 255, 255, 0.05); }
630
+
601
631
  /* --- File Edit History --- */
602
632
 
603
633
  .file-history-panel {
@@ -245,6 +245,8 @@
245
245
  max-height: 120px;
246
246
  overflow-y: auto;
247
247
  box-sizing: border-box;
248
+ unicode-bidi: plaintext;
249
+ text-align: start;
248
250
  }
249
251
 
250
252
  #input::placeholder {
@@ -130,6 +130,8 @@
130
130
  word-break: break-word;
131
131
  white-space: pre-wrap;
132
132
  color: var(--text);
133
+ unicode-bidi: plaintext;
134
+ text-align: start;
133
135
  }
134
136
 
135
137
  /* --- Assistant message --- */
@@ -176,6 +178,8 @@
176
178
  line-height: 1.7;
177
179
  word-break: break-word;
178
180
  color: var(--text);
181
+ unicode-bidi: plaintext;
182
+ text-align: start;
179
183
  }
180
184
 
181
185
  .md-content p { margin-bottom: 14px; }
@@ -282,7 +286,7 @@ pre:hover .code-copy-btn { opacity: 1; }
282
286
  .md-content th, .md-content td {
283
287
  border: 1px solid var(--border);
284
288
  padding: 8px 12px;
285
- text-align: left;
289
+ text-align: start;
286
290
  }
287
291
  .md-content th {
288
292
  background: rgba(255, 255, 255, 0.04);
@@ -139,7 +139,7 @@
139
139
  </div>
140
140
  </div>
141
141
  <div id="notif-menu-wrap">
142
- <button id="notif-btn" title="Notifications"><i data-lucide="sliders-horizontal"></i></button>
142
+ <button id="notif-btn" title="Notifications"><i data-lucide="bell"></i></button>
143
143
  <div id="notif-menu" class="hidden">
144
144
  <label class="notif-option" id="notif-push-row">
145
145
  <span><i data-lucide="smartphone" style="width:14px;height:14px"></i> Push notifications</span>
@@ -259,7 +259,7 @@
259
259
  <span class="context-mini-label" id="context-mini-label">0%</span>
260
260
  </div>
261
261
  <div id="image-preview-bar"></div>
262
- <textarea id="input" rows="1" placeholder="Message Claude Code..." enterkeyhint="send"></textarea>
262
+ <textarea id="input" rows="1" placeholder="Message Claude Code..." enterkeyhint="send" dir="auto"></textarea>
263
263
  <div id="input-bottom">
264
264
  <div id="attach-wrap">
265
265
  <button id="attach-btn" type="button" aria-label="Attach"><i data-lucide="plus"></i></button>
@@ -1,6 +1,7 @@
1
1
  import { iconHtml, refreshIcons } from './icons.js';
2
2
  import { closeSidebar } from './sidebar.js';
3
3
  import { closeFileViewer } from './filebrowser.js';
4
+ import { copyToClipboard } from './utils.js';
4
5
 
5
6
  var ctx;
6
7
  var tabs = new Map(); // termId -> { id, title, exited, xterm, fitAddon, bodyEl }
@@ -11,6 +12,7 @@ var isTouchDevice = "ontouchstart" in window;
11
12
  var viewportHandler = null;
12
13
  var resizeObserver = null;
13
14
  var toolbarBound = false;
15
+ var termCtxMenu = null;
14
16
 
15
17
  // --- Init ---
16
18
  export function initTerminal(_ctx) {
@@ -239,6 +241,11 @@ function createXtermForTab(tab) {
239
241
  }
240
242
  });
241
243
 
244
+ // Right-click context menu
245
+ bodyEl.addEventListener("contextmenu", function (e) {
246
+ showTermCtxMenu(e, tab);
247
+ });
248
+
242
249
  tab.xterm = xterm;
243
250
  tab.fitAddon = fitAddon;
244
251
  tab.bodyEl = bodyEl;
@@ -527,6 +534,72 @@ export function resetTerminals() {
527
534
  renderTabBar();
528
535
  }
529
536
 
537
+ // --- Terminal context menu ---
538
+ function closeTermCtxMenu() {
539
+ if (termCtxMenu) {
540
+ termCtxMenu.remove();
541
+ termCtxMenu = null;
542
+ }
543
+ }
544
+
545
+ function showTermCtxMenu(e, tab) {
546
+ e.preventDefault();
547
+ e.stopPropagation();
548
+ closeTermCtxMenu();
549
+
550
+ var menu = document.createElement("div");
551
+ menu.className = "term-ctx-menu";
552
+
553
+ // Copy
554
+ var copyItem = document.createElement("button");
555
+ copyItem.className = "term-ctx-item";
556
+ copyItem.innerHTML = iconHtml("clipboard-copy") + " <span>Copy Terminal</span>";
557
+ copyItem.addEventListener("click", function (ev) {
558
+ ev.stopPropagation();
559
+ closeTermCtxMenu();
560
+ if (!tab.xterm) return;
561
+ tab.xterm.selectAll();
562
+ var text = tab.xterm.getSelection();
563
+ tab.xterm.clearSelection();
564
+ if (text) copyToClipboard(text);
565
+ });
566
+ menu.appendChild(copyItem);
567
+
568
+ // Clear
569
+ var clearItem = document.createElement("button");
570
+ clearItem.className = "term-ctx-item";
571
+ clearItem.innerHTML = iconHtml("trash-2") + " <span>Clear Terminal</span>";
572
+ clearItem.addEventListener("click", function (ev) {
573
+ ev.stopPropagation();
574
+ closeTermCtxMenu();
575
+ if (!tab.xterm) return;
576
+ tab.xterm.clear();
577
+ });
578
+ menu.appendChild(clearItem);
579
+
580
+ // Position at mouse cursor
581
+ menu.style.left = e.clientX + "px";
582
+ menu.style.top = e.clientY + "px";
583
+ document.body.appendChild(menu);
584
+
585
+ // Clamp to viewport
586
+ var rect = menu.getBoundingClientRect();
587
+ if (rect.right > window.innerWidth) {
588
+ menu.style.left = (window.innerWidth - rect.width - 4) + "px";
589
+ }
590
+ if (rect.bottom > window.innerHeight) {
591
+ menu.style.top = (window.innerHeight - rect.height - 4) + "px";
592
+ }
593
+
594
+ termCtxMenu = menu;
595
+ refreshIcons();
596
+
597
+ // Close on outside click (next tick to avoid immediate trigger)
598
+ setTimeout(function () {
599
+ document.addEventListener("click", closeTermCtxMenu, { once: true });
600
+ }, 0);
601
+ }
602
+
530
603
  // --- Mobile toolbar ---
531
604
  var KEY_MAP = {
532
605
  tab: "\t",
@@ -258,6 +258,7 @@ function permissionInputSummary(toolName, input) {
258
258
  }
259
259
 
260
260
  export function renderPermissionRequest(requestId, toolName, toolInput, decisionReason) {
261
+ if (pendingPermissions[requestId]) return;
261
262
  ctx.finalizeAssistantBlock();
262
263
  stopThinking();
263
264
 
@@ -353,6 +354,7 @@ export function renderPermissionRequest(requestId, toolName, toolInput, decision
353
354
  }
354
355
 
355
356
  function renderPlanPermission(requestId) {
357
+ if (pendingPermissions[requestId]) return;
356
358
  var container = document.createElement("div");
357
359
  container.className = "permission-container plan-permission";
358
360
  container.dataset.requestId = requestId;
package/lib/server.js CHANGED
@@ -250,7 +250,8 @@ function createServer(opts) {
250
250
  res.end(pinPage);
251
251
  return;
252
252
  }
253
- if (projects.size === 1) {
253
+ var hasGoneParam = req.url.indexOf("gone=") !== -1;
254
+ if (projects.size === 1 && !hasGoneParam) {
254
255
  var slug = projects.keys().next().value;
255
256
  res.writeHead(302, { "Location": "/p/" + slug + "/" });
256
257
  res.end();
@@ -297,8 +298,8 @@ function createServer(opts) {
297
298
 
298
299
  var ctx = projects.get(slug);
299
300
  if (!ctx) {
300
- res.writeHead(404);
301
- res.end("Project not found: " + slug);
301
+ res.writeHead(302, { "Location": "/?gone=" + encodeURIComponent(slug) });
302
+ res.end();
302
303
  return;
303
304
  }
304
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-relay",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "claude-relay": "./bin/cli.js"