claude-relay 1.1.1 → 1.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
@@ -71,11 +71,7 @@ Browser (any device) <--> claude-relay (your machine) <--> Claude Agent SDK
71
71
 
72
72
  ```bash
73
73
  # Run directly (no install needed)
74
- npx claude-relay
75
-
76
- # Or install globally
77
- npm install -g claude-relay
78
- claude-relay
74
+ npx claude-relay@latest
79
75
  ```
80
76
 
81
77
  ## Usage
@@ -120,6 +116,10 @@ We strongly recommend using a private network layer such as [Tailscale](https://
120
116
 
121
117
  If you choose to expose it beyond your private network, that's your call. **Entirely at your own risk.** The authors assume no responsibility for any damage, data loss, or security incidents.
122
118
 
119
+ ## Disclaimer
120
+
121
+ claude-relay is an independent, unofficial project. It is not affiliated with, endorsed by, or sponsored by Anthropic. "Claude" is a trademark of Anthropic.
122
+
123
123
  ## License
124
124
 
125
125
  MIT
package/bin/cli.js CHANGED
@@ -10,6 +10,7 @@ const { createServer } = require("../lib/server");
10
10
  const args = process.argv.slice(2);
11
11
  let port = 2633;
12
12
  let useHttps = true;
13
+ let skipUpdate = false;
13
14
 
14
15
  for (let i = 0; i < args.length; i++) {
15
16
  if (args[i] === "-p" || args[i] === "--port") {
@@ -21,12 +22,15 @@ for (let i = 0; i < args.length; i++) {
21
22
  i++;
22
23
  } else if (args[i] === "--no-https") {
23
24
  useHttps = false;
25
+ } else if (args[i] === "--no-update" || args[i] === "--skip-update") {
26
+ skipUpdate = true;
24
27
  } else if (args[i] === "-h" || args[i] === "--help") {
25
- console.log("Usage: claude-relay [-p|--port <port>] [--no-https]");
28
+ console.log("Usage: claude-relay [-p|--port <port>] [--no-https] [--no-update]");
26
29
  console.log("");
27
30
  console.log("Options:");
28
31
  console.log(" -p, --port <port> Port to listen on (default: 2633)");
29
32
  console.log(" --no-https Disable HTTPS (enabled by default via mkcert)");
33
+ console.log(" --no-update Skip auto-update check on startup");
30
34
  process.exit(0);
31
35
  }
32
36
  }
@@ -102,8 +106,8 @@ var caffeinateProc = null;
102
106
  function startCaffeinate() {
103
107
  var { spawn } = require("child_process");
104
108
  caffeinateProc = spawn("caffeinate", ["-di"], { stdio: "ignore", detached: false });
105
- caffeinateProc.on("error", function() { caffeinateProc = null; });
106
- process.on("exit", function() { if (caffeinateProc) caffeinateProc.kill(); });
109
+ caffeinateProc.on("error", function () { caffeinateProc = null; });
110
+ process.on("exit", function () { if (caffeinateProc) caffeinateProc.kill(); });
107
111
  }
108
112
 
109
113
  // --- Certs ---
@@ -119,7 +123,7 @@ function ensureCerts(ip) {
119
123
  "rootCA.pem"
120
124
  );
121
125
  if (!fs.existsSync(caRoot)) caRoot = null;
122
- } catch (e) {}
126
+ } catch (e) { }
123
127
 
124
128
  if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
125
129
  return { key: keyPath, cert: certPath, caRoot: caRoot };
@@ -142,36 +146,61 @@ function ensureCerts(ip) {
142
146
  return { key: keyPath, cert: certPath, caRoot: caRoot };
143
147
  }
144
148
 
149
+ // --- Logo ---
150
+ function printLogo() {
151
+ var c = "\x1b[38;2;218;119;86m";
152
+ var r = a.reset;
153
+ var lines = [
154
+ " ██████╗ ██╗ █████╗ ██╗ ██╗ ██████╗ ███████╗ ██████╗ ███████╗ ██╗ █████╗ ██╗ ██╗",
155
+ " ██╔════╝ ██║ ██╔══██╗ ██║ ██║ ██╔══██╗ ██╔════╝ ██╔══██╗ ██╔════╝ ██║ ██╔══██╗ ╚██╗ ██╔╝",
156
+ " ██║ ██║ ███████║ ██║ ██║ ██║ ██║ █████╗ ██████╔╝ █████╗ ██║ ███████║ ╚████╔╝ ",
157
+ " ██║ ██║ ██╔══██║ ██║ ██║ ██║ ██║ ██╔══╝ ██╔══██╗ ██╔══╝ ██║ ██╔══██║ ╚██╔╝ ",
158
+ " ╚██████╗ ███████╗ ██║ ██║ ╚██████╔╝ ██████╔╝ ███████╗ ██║ ██║ ███████╗ ███████╗ ██║ ██║ ██║ ",
159
+ " ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ",
160
+ ];
161
+ console.log("");
162
+ for (var i = 0; i < lines.length; i++) {
163
+ console.log(c + lines[i] + r);
164
+ }
165
+ }
166
+
145
167
  // --- Interactive setup (clack-style) ---
146
168
  function setup(callback) {
169
+ console.clear();
170
+ printLogo();
147
171
  log("");
148
- log(sym.pointer + " " + a.bold + "Claude Relay" + a.reset);
149
- log(sym.bar);
150
- log(sym.warn + " " + a.yellow + a.bold + "READ BEFORE CONTINUING" + a.reset);
172
+ log(sym.pointer + " " + a.bold + "Claude Relay" + a.reset + a.dim + " · Unofficial, open-source project" + a.reset);
151
173
  log(sym.bar);
152
- log(sym.bar + " Anyone with access to the URL gets " + a.bold + "full Claude Code access" + a.reset);
153
- log(sym.bar + " to this machine, including reading, writing, and executing");
154
- log(sym.bar + " files with your user permissions.");
155
- log(sym.bar);
156
- log(sym.bar + " We strongly recommend using a private network layer such as");
157
- log(sym.bar + " " + a.bold + "Tailscale" + a.reset + ", " + a.bold + "WireGuard" + a.reset + ", or a " + a.bold + "VPN" + a.reset + ".");
174
+ log(sym.bar + " " + a.dim + "Anyone with the URL gets full Claude Code access to this machine." + a.reset);
175
+ log(sym.bar + " " + a.dim + "Use a private network (Tailscale, VPN)." + a.reset);
176
+ log(sym.bar + " " + a.dim + "The authors assume no responsibility for any damage or data loss." + a.reset);
158
177
  log(sym.bar);
159
178
 
160
- promptPin(function(pin) {
161
- promptToggle("Keep awake", "Prevent system sleep while relay is running", function(keepAwake) {
162
- log(sym.bar);
163
- log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
179
+ promptToggle("Accept and continue", null, true, function (accepted) {
180
+ if (!accepted) {
181
+ log(sym.end + " " + a.dim + "Aborted." + a.reset);
164
182
  log("");
183
+ process.exit(0);
184
+ return;
185
+ }
186
+ log(sym.bar);
165
187
 
166
- if (keepAwake) startCaffeinate();
167
- callback(pin);
188
+ promptPin(function (pin) {
189
+ promptToggle("Keep awake", "Prevent system sleep while relay is running", false, function (keepAwake) {
190
+ log(sym.bar);
191
+ log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
192
+ log("");
193
+
194
+ if (keepAwake) startCaffeinate();
195
+ callback(pin);
196
+ });
168
197
  });
169
198
  });
170
199
  }
171
200
 
172
201
  function promptPin(callback) {
173
202
  log(sym.pointer + " " + a.bold + "PIN protection" + a.reset);
174
- log(sym.bar + " " + a.dim + "6-digit PIN, or Enter to skip" + a.reset);
203
+ log(sym.bar + " " + a.dim + "Require a 6-digit PIN to access the web UI. Enter to skip." + a.reset);
175
204
  process.stdout.write(" " + sym.bar + " ");
176
205
 
177
206
  var pin = "";
@@ -221,8 +250,8 @@ function promptPin(callback) {
221
250
  });
222
251
  }
223
252
 
224
- function promptToggle(title, desc, callback) {
225
- var value = false;
253
+ function promptToggle(title, desc, defaultValue, callback) {
254
+ var value = defaultValue || false;
226
255
 
227
256
  function renderToggle() {
228
257
  var yes = value
@@ -234,12 +263,17 @@ function promptToggle(title, desc, callback) {
234
263
  return yes + a.dim + " / " + a.reset + no;
235
264
  }
236
265
 
266
+ var lines = 2;
237
267
  log(sym.pointer + " " + a.bold + title + a.reset);
238
- log(sym.bar + " " + a.dim + desc + a.reset);
268
+ if (desc) {
269
+ log(sym.bar + " " + a.dim + desc + a.reset);
270
+ lines = 3;
271
+ }
239
272
  process.stdout.write(" " + sym.bar + " " + renderToggle());
240
273
 
241
274
  process.stdin.setRawMode(true);
242
275
  process.stdin.resume();
276
+ process.stdin.setEncoding("utf8");
243
277
 
244
278
  process.stdin.on("data", function onToggle(ch) {
245
279
  if (ch === "\x1b[D" || ch === "\x1b[C" || ch === "\t") {
@@ -257,14 +291,14 @@ function promptToggle(title, desc, callback) {
257
291
  process.stdin.removeListener("data", onToggle);
258
292
  process.stdout.write("\n");
259
293
 
260
- clearUp(3);
294
+ clearUp(lines);
261
295
  var result = value ? a.green + "Yes" + a.reset : a.dim + "No" + a.reset;
262
296
  log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + result);
263
297
 
264
298
  callback(value);
265
299
  } else if (ch === "\x03") {
266
300
  process.stdout.write("\n");
267
- clearUp(3);
301
+ clearUp(lines);
268
302
  log(sym.end + " " + a.dim + "Cancelled" + a.reset);
269
303
  process.exit(0);
270
304
  }
@@ -296,7 +330,7 @@ function start(pin) {
296
330
  var entryServer = result.entryServer;
297
331
  var httpsServer = result.httpsServer;
298
332
 
299
- entryServer.on("error", function(err) {
333
+ entryServer.on("error", function (err) {
300
334
  if (err.code === "EADDRINUSE") {
301
335
  log(a.red + "Port " + port + " is already in use." + a.reset);
302
336
  log(a.dim + "Run: claude-relay -p <port>" + a.reset);
@@ -308,7 +342,7 @@ function start(pin) {
308
342
 
309
343
  var httpsPort = port + 1;
310
344
  if (httpsServer) {
311
- httpsServer.on("error", function(err) {
345
+ httpsServer.on("error", function (err) {
312
346
  if (err.code === "EADDRINUSE") {
313
347
  log(a.red + "HTTPS port " + httpsPort + " is already in use." + a.reset);
314
348
  } else {
@@ -319,13 +353,13 @@ function start(pin) {
319
353
  httpsServer.listen(httpsPort);
320
354
  }
321
355
 
322
- entryServer.listen(port, function() {
356
+ entryServer.listen(port, function () {
323
357
  var project = path.basename(cwd);
324
358
  var url = "http://" + ip + ":" + port;
325
359
 
326
360
  if (ip !== "localhost") {
327
- qrcode.generate(url, { small: true }, function(code) {
328
- var lines = code.split("\n").map(function(l) { return " " + l; }).join("\n");
361
+ qrcode.generate(url, { small: true }, function (code) {
362
+ var lines = code.split("\n").map(function (l) { return " " + l; }).join("\n");
329
363
  console.log(lines);
330
364
  console.log("");
331
365
  log(a.bold + "Claude Relay" + a.reset + " running at " + a.bold + url + a.reset);
@@ -340,4 +374,10 @@ function start(pin) {
340
374
  });
341
375
  }
342
376
 
343
- setup(start);
377
+ const { checkAndUpdate } = require("../lib/updater");
378
+ const currentVersion = require("../package.json").version;
379
+
380
+ (async () => {
381
+ const updated = await checkAndUpdate(currentVersion, skipUpdate);
382
+ if (!updated) setup(start);
383
+ })();
package/lib/public/app.js CHANGED
@@ -41,6 +41,7 @@
41
41
  var pendingImages = []; // [{data: base64, mediaType: "image/png"}]
42
42
  var pendingPermissions = {}; // requestId -> container element
43
43
  var cliSessionId = null;
44
+ var projectName = "";
44
45
 
45
46
  var builtinCommands = [
46
47
  { name: "clear", desc: "Clear conversation" },
@@ -129,12 +130,29 @@
129
130
  el.className = "session-item" + (s.active ? " active" : "");
130
131
  el.dataset.sessionId = s.id;
131
132
 
132
- var html = "";
133
+ var textSpan = document.createElement("span");
134
+ textSpan.className = "session-item-text";
135
+ var textHtml = "";
133
136
  if (s.isProcessing) {
134
- html += '<span class="session-processing"></span>';
137
+ textHtml += '<span class="session-processing"></span>';
135
138
  }
136
- html += escapeHtml(s.title || "New Session");
137
- el.innerHTML = html;
139
+ textHtml += escapeHtml(s.title || "New Session");
140
+ textSpan.innerHTML = textHtml;
141
+ el.appendChild(textSpan);
142
+
143
+ var deleteBtn = document.createElement("button");
144
+ deleteBtn.className = "session-delete-btn";
145
+ deleteBtn.innerHTML = iconHtml("trash-2");
146
+ deleteBtn.title = "Delete session";
147
+ deleteBtn.addEventListener("click", (function(id) {
148
+ return function(e) {
149
+ e.stopPropagation();
150
+ if (ws && connected) {
151
+ ws.send(JSON.stringify({ type: "delete_session", id: id }));
152
+ }
153
+ };
154
+ })(s.id));
155
+ el.appendChild(deleteBtn);
138
156
 
139
157
  el.addEventListener("click", (function (id) {
140
158
  return function () {
@@ -147,6 +165,21 @@
147
165
 
148
166
  sessionListEl.appendChild(el);
149
167
  }
168
+ refreshIcons();
169
+ updatePageTitle();
170
+ }
171
+
172
+ function updatePageTitle() {
173
+ var sessionTitle = "";
174
+ var activeItem = sessionListEl.querySelector(".session-item.active .session-item-text");
175
+ if (activeItem) sessionTitle = activeItem.textContent;
176
+ if (projectName && sessionTitle) {
177
+ document.title = projectName + " - " + sessionTitle;
178
+ } else if (projectName) {
179
+ document.title = projectName;
180
+ } else {
181
+ document.title = "Claude Relay";
182
+ }
150
183
  }
151
184
 
152
185
  function openSidebar() {
@@ -200,6 +233,7 @@
200
233
  // --- Pixel character animation ---
201
234
  var pixelAnimTimer = null;
202
235
  var pixelBlocks = [];
236
+ var antennaBlocks = [];
203
237
 
204
238
  (function initPixelAnim() {
205
239
  var canvas = document.getElementById("pixel-canvas");
@@ -207,7 +241,10 @@
207
241
 
208
242
  // Character grid: 1 = body, 2 = eye, 0 = empty
209
243
  // 12 cols x 9 rows
244
+ // 0=empty, 1=body, 2=eye, 3=antenna
210
245
  var grid = [
246
+ [0, 0, 0, 0, 0, 3, 3, 0, 0, 0, 0, 0],
247
+ [0, 0, 0, 0, 0, 3, 3, 0, 0, 0, 0, 0],
211
248
  [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
212
249
  [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
213
250
  [0, 0, 1, 2, 1, 1, 1, 1, 2, 1, 0, 0],
@@ -223,15 +260,18 @@
223
260
  var CELL = 12;
224
261
  var accent = "#DA7756";
225
262
  var eye = "#2F2E2B";
263
+ var antenna = "#E8E5DE";
226
264
 
227
265
  for (var r = 0; r < grid.length; r++) {
228
266
  for (var c = 0; c < grid[r].length; c++) {
229
267
  if (grid[r][c] === 0) continue;
230
268
  var el = document.createElement("div");
231
269
  el.className = "px";
232
- el.style.background = grid[r][c] === 2 ? eye : accent;
270
+ var v = grid[r][c];
271
+ el.style.background = v === 2 ? eye : v === 3 ? antenna : accent;
233
272
  el.style.left = c * CELL + "px";
234
273
  el.style.top = r * CELL + "px";
274
+ if (v === 3) antennaBlocks.push(el);
235
275
  canvas.appendChild(el);
236
276
  pixelBlocks.push(el);
237
277
  }
@@ -239,6 +279,7 @@
239
279
  })();
240
280
 
241
281
  function pixelScatter() {
282
+ stopSpark();
242
283
  for (var i = 0; i < pixelBlocks.length; i++) {
243
284
  var el = pixelBlocks[i];
244
285
  var angle = Math.random() * Math.PI * 2;
@@ -252,6 +293,8 @@
252
293
  }
253
294
  }
254
295
 
296
+ var sparkTimer = null;
297
+
255
298
  function pixelAssemble() {
256
299
  for (var i = 0; i < pixelBlocks.length; i++) {
257
300
  (function (el, delay) {
@@ -262,6 +305,36 @@
262
305
  }, delay);
263
306
  })(pixelBlocks[i], Math.random() * 300);
264
307
  }
308
+ startSpark();
309
+ }
310
+
311
+ function startSpark() {
312
+ stopSpark();
313
+ var count = 0;
314
+ sparkTimer = setInterval(function () {
315
+ for (var i = 0; i < antennaBlocks.length; i++) {
316
+ if (Math.random() < 0.4) {
317
+ antennaBlocks[i].style.background = "#FFF";
318
+ antennaBlocks[i].style.boxShadow = "0 0 6px 2px rgba(255,255,255,0.6)";
319
+ } else {
320
+ antennaBlocks[i].style.background = "#E8E5DE";
321
+ antennaBlocks[i].style.boxShadow = "none";
322
+ }
323
+ }
324
+ count++;
325
+ if (count > 20) stopSpark();
326
+ }, 80);
327
+ }
328
+
329
+ function stopSpark() {
330
+ if (sparkTimer) {
331
+ clearInterval(sparkTimer);
332
+ sparkTimer = null;
333
+ }
334
+ for (var i = 0; i < antennaBlocks.length; i++) {
335
+ antennaBlocks[i].style.background = "#E8E5DE";
336
+ antennaBlocks[i].style.boxShadow = "none";
337
+ }
265
338
  }
266
339
 
267
340
  function startPixelAnim() {
@@ -291,6 +364,23 @@
291
364
  }
292
365
  }
293
366
 
367
+ // --- Dynamic favicon ---
368
+ var faviconSvg = null;
369
+ var faviconLink = document.querySelector('link[rel="icon"]');
370
+
371
+ function updateFavicon(bgColor) {
372
+ if (!faviconLink) return;
373
+ if (!faviconSvg) {
374
+ var xhr = new XMLHttpRequest();
375
+ xhr.open("GET", "/favicon.svg", false);
376
+ xhr.send();
377
+ if (xhr.status === 200) faviconSvg = xhr.responseText;
378
+ else return;
379
+ }
380
+ var svg = faviconSvg.replace(/fill="#57AB5A"/, 'fill="' + bgColor + '"');
381
+ faviconLink.href = "data:image/svg+xml," + encodeURIComponent(svg);
382
+ }
383
+
294
384
  // --- Status & Activity ---
295
385
  function setSendBtnMode(mode) {
296
386
  if (mode === "stop") {
@@ -315,11 +405,13 @@
315
405
  setSendBtnMode("send");
316
406
  connectOverlay.classList.add("hidden");
317
407
  stopVerbCycle();
408
+ updateFavicon("#57AB5A");
318
409
  } else if (status === "processing") {
319
410
  statusDot.classList.add("processing");
320
411
  statusTextEl.textContent = "";
321
412
  processing = true;
322
413
  setSendBtnMode("stop");
414
+ updateFavicon("#E0943A");
323
415
  } else {
324
416
  statusTextEl.textContent = "Disconnected";
325
417
  connected = false;
@@ -328,6 +420,7 @@
328
420
  connectStatusEl.textContent = "Reconnecting...";
329
421
  startVerbCycle();
330
422
  startPixelAnim();
423
+ updateFavicon("#E5534B");
331
424
  }
332
425
  }
333
426
 
@@ -1220,7 +1313,9 @@
1220
1313
 
1221
1314
  switch (msg.type) {
1222
1315
  case "info":
1223
- projectNameEl.textContent = msg.project || msg.cwd;
1316
+ projectName = msg.project || msg.cwd;
1317
+ projectNameEl.textContent = projectName;
1318
+ updatePageTitle();
1224
1319
  break;
1225
1320
 
1226
1321
  case "slash_commands":
@@ -1396,6 +1491,10 @@
1396
1491
  processing = false;
1397
1492
  setStatus("connected");
1398
1493
  tools = {};
1494
+ if (document.hidden && notifPermission === "granted") {
1495
+ showDoneNotification();
1496
+ playDoneSound();
1497
+ }
1399
1498
  break;
1400
1499
 
1401
1500
  case "stderr":
@@ -1409,6 +1508,7 @@
1409
1508
  case "error":
1410
1509
  setActivity(null);
1411
1510
  addSystemMessage(msg.text, true);
1511
+ updateFavicon("#E5534B");
1412
1512
  break;
1413
1513
  }
1414
1514
  };
@@ -1769,6 +1869,68 @@
1769
1869
  }
1770
1870
  });
1771
1871
 
1872
+ // --- Browser notifications ---
1873
+ var notifPermission = ("Notification" in window) ? Notification.permission : "denied";
1874
+
1875
+ function requestNotifPermission() {
1876
+ if (!("Notification" in window)) return;
1877
+ if (Notification.permission === "granted") {
1878
+ notifPermission = "granted";
1879
+ return;
1880
+ }
1881
+ if (Notification.permission !== "denied") {
1882
+ Notification.requestPermission().then(function(p) {
1883
+ notifPermission = p;
1884
+ });
1885
+ }
1886
+ }
1887
+
1888
+ document.addEventListener("click", function requestOnce() {
1889
+ requestNotifPermission();
1890
+ document.removeEventListener("click", requestOnce);
1891
+ }, { once: true });
1892
+
1893
+ function playDoneSound() {
1894
+ try {
1895
+ var ctx = new (window.AudioContext || window.webkitAudioContext)();
1896
+ var osc = ctx.createOscillator();
1897
+ var gain = ctx.createGain();
1898
+ osc.type = "sine";
1899
+ osc.frequency.value = 880;
1900
+ gain.gain.value = 0.1;
1901
+ osc.connect(gain);
1902
+ gain.connect(ctx.destination);
1903
+ osc.start();
1904
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
1905
+ osc.stop(ctx.currentTime + 0.3);
1906
+ } catch(e) {}
1907
+ }
1908
+
1909
+ function showDoneNotification() {
1910
+ var lastAssistant = messagesEl.querySelector(".msg-assistant:last-of-type .md-content");
1911
+ var preview = lastAssistant ? lastAssistant.textContent.substring(0, 100) : "Response ready";
1912
+
1913
+ var sessionTitle = "Claude";
1914
+ var activeItem = sessionListEl.querySelector(".session-item.active");
1915
+ if (activeItem) {
1916
+ var textEl = activeItem.querySelector(".session-item-text");
1917
+ if (textEl) sessionTitle = textEl.textContent || "Claude";
1918
+ else sessionTitle = activeItem.textContent || "Claude";
1919
+ }
1920
+
1921
+ var n = new Notification(sessionTitle, {
1922
+ body: preview,
1923
+ tag: "claude-done",
1924
+ });
1925
+
1926
+ n.onclick = function() {
1927
+ window.focus();
1928
+ n.close();
1929
+ };
1930
+
1931
+ setTimeout(function() { n.close(); }, 5000);
1932
+ }
1933
+
1772
1934
  // --- Init ---
1773
1935
  lucide.createIcons();
1774
1936
  startVerbCycle();
@@ -0,0 +1,26 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="128" height="128">
2
+ <!-- background -->
3
+ <rect x="0" y="0" width="12" height="12" rx="2" fill="#57AB5A"/>
4
+ <!-- antenna -->
5
+ <rect x="5" y="0" width="1" height="1" fill="#E8E5DE"/>
6
+ <rect x="6" y="0" width="1" height="1" fill="#E8E5DE"/>
7
+ <rect x="5" y="1" width="1" height="1" fill="#E8E5DE"/>
8
+ <rect x="6" y="1" width="1" height="1" fill="#E8E5DE"/>
9
+ <!-- head -->
10
+ <rect x="2" y="2" width="8" height="2" fill="#DA7756"/>
11
+ <!-- eyes row -->
12
+ <rect x="2" y="4" width="1" height="2" fill="#DA7756"/>
13
+ <rect x="3" y="4" width="1" height="2" fill="#2F2E2B"/>
14
+ <rect x="4" y="4" width="4" height="2" fill="#DA7756"/>
15
+ <rect x="8" y="4" width="1" height="2" fill="#2F2E2B"/>
16
+ <rect x="9" y="4" width="1" height="2" fill="#DA7756"/>
17
+ <!-- arms -->
18
+ <rect x="0" y="6" width="12" height="2" fill="#DA7756"/>
19
+ <!-- body -->
20
+ <rect x="2" y="8" width="8" height="2" fill="#DA7756"/>
21
+ <!-- feet -->
22
+ <rect x="2" y="10" width="1" height="2" fill="#DA7756"/>
23
+ <rect x="4" y="10" width="1" height="2" fill="#DA7756"/>
24
+ <rect x="7" y="10" width="1" height="2" fill="#DA7756"/>
25
+ <rect x="9" y="10" width="1" height="2" fill="#DA7756"/>
26
+ </svg>
@@ -7,7 +7,8 @@
7
7
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
8
  <link rel="manifest" href="/manifest.json">
9
9
  <meta name="theme-color" content="#2F2E2B">
10
- <title>Claude</title>
10
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
11
+ <title>Claude Relay</title>
11
12
  <link rel="preconnect" href="https://fonts.googleapis.com">
12
13
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
14
  <link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600&family=Styrene+A+Web:wght@400;500&display=swap" rel="stylesheet">
@@ -130,16 +130,24 @@ html, body {
130
130
  }
131
131
 
132
132
  .session-item {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 4px;
133
136
  padding: 10px 12px;
134
137
  border-radius: 10px;
135
138
  cursor: pointer;
136
139
  font-size: 14px;
137
140
  color: var(--text-secondary);
141
+ margin-bottom: 2px;
142
+ transition: background 0.15s, color 0.15s;
143
+ }
144
+
145
+ .session-item-text {
146
+ flex: 1;
138
147
  overflow: hidden;
139
148
  text-overflow: ellipsis;
140
149
  white-space: nowrap;
141
- margin-bottom: 2px;
142
- transition: background 0.15s, color 0.15s;
150
+ min-width: 0;
143
151
  }
144
152
 
145
153
  .session-item:hover {
@@ -163,6 +171,38 @@ html, body {
163
171
  vertical-align: middle;
164
172
  }
165
173
 
174
+ .session-delete-btn {
175
+ display: none;
176
+ width: 24px;
177
+ height: 24px;
178
+ border-radius: 6px;
179
+ border: none;
180
+ background: transparent;
181
+ color: var(--text-muted);
182
+ cursor: pointer;
183
+ flex-shrink: 0;
184
+ align-items: center;
185
+ justify-content: center;
186
+ padding: 0;
187
+ transition: color 0.15s, background 0.15s;
188
+ }
189
+
190
+ .session-item:hover .session-delete-btn { display: flex; }
191
+
192
+ .session-delete-btn:hover {
193
+ color: var(--error);
194
+ background: rgba(238, 85, 85, 0.1);
195
+ }
196
+
197
+ .session-delete-btn .lucide {
198
+ width: 14px;
199
+ height: 14px;
200
+ }
201
+
202
+ @media (hover: none) {
203
+ .session-delete-btn { display: flex; }
204
+ }
205
+
166
206
  #sidebar-footer {
167
207
  padding: 12px 16px;
168
208
  border-top: 1px solid var(--border-subtle);
@@ -296,7 +336,7 @@ html, body {
296
336
  #pixel-canvas {
297
337
  position: relative;
298
338
  width: 144px;
299
- height: 108px;
339
+ height: 144px;
300
340
  }
301
341
 
302
342
  .px {
package/lib/server.js CHANGED
@@ -426,6 +426,35 @@ function createServer(cwd, tlsOptions, caPath, pin, mainPort) {
426
426
  }
427
427
  }
428
428
 
429
+ function deleteSession(localId) {
430
+ var session = sessions.get(localId);
431
+ if (!session) return;
432
+
433
+ if (session.abortController) {
434
+ try { session.abortController.abort(); } catch(e) {}
435
+ }
436
+ if (session.messageQueue) {
437
+ try { session.messageQueue.end(); } catch(e) {}
438
+ }
439
+
440
+ if (session.cliSessionId) {
441
+ try { fs.unlinkSync(sessionFilePath(session.cliSessionId)); } catch(e) {}
442
+ }
443
+
444
+ sessions.delete(localId);
445
+
446
+ if (activeSessionId === localId) {
447
+ var remaining = [...sessions.keys()];
448
+ if (remaining.length > 0) {
449
+ switchSession(remaining[remaining.length - 1]);
450
+ } else {
451
+ createSession();
452
+ }
453
+ } else {
454
+ broadcastSessionList();
455
+ }
456
+ }
457
+
429
458
  // --- SDK message processing ---
430
459
 
431
460
  function processSDKMessage(session, parsed) {
@@ -475,6 +504,7 @@ function createServer(cwd, tlsOptions, caPath, pin, mainPort) {
475
504
  var idx = evt.index;
476
505
 
477
506
  if (evt.delta.type === "text_delta" && typeof evt.delta.text === "string") {
507
+ session.streamedText = true;
478
508
  sendAndRecord(session, { type: "delta", text: evt.delta.text });
479
509
  } else if (evt.delta.type === "input_json_delta" && session.blocks[idx]) {
480
510
  session.blocks[idx].inputJson += evt.delta.partial_json;
@@ -502,6 +532,17 @@ function createServer(cwd, tlsOptions, caPath, pin, mainPort) {
502
532
  } else if ((parsed.type === "assistant" || parsed.type === "user") && parsed.message && parsed.message.content) {
503
533
  var content = parsed.message.content;
504
534
 
535
+ // Fallback: if assistant text wasn't streamed via deltas, send it now
536
+ if (parsed.type === "assistant" && !session.streamedText && Array.isArray(content)) {
537
+ var assistantText = content
538
+ .filter(function(c) { return c.type === "text"; })
539
+ .map(function(c) { return c.text; })
540
+ .join("");
541
+ if (assistantText) {
542
+ sendAndRecord(session, { type: "delta", text: assistantText });
543
+ }
544
+ }
545
+
505
546
  // Check for local slash command output in user messages
506
547
  if (parsed.type === "user") {
507
548
  var fullText = "";
@@ -648,6 +689,7 @@ function createServer(cwd, tlsOptions, caPath, pin, mainPort) {
648
689
  session.messageQueue = createMessageQueue();
649
690
  session.blocks = {};
650
691
  session.sentToolResults = {};
692
+ session.streamedText = false;
651
693
 
652
694
  // Build initial user message
653
695
  var content = [];
@@ -914,6 +956,13 @@ function createServer(cwd, tlsOptions, caPath, pin, mainPort) {
914
956
  return;
915
957
  }
916
958
 
959
+ if (msg.type === "delete_session") {
960
+ if (msg.id && sessions.has(msg.id)) {
961
+ deleteSession(msg.id);
962
+ }
963
+ return;
964
+ }
965
+
917
966
  if (msg.type === "stop") {
918
967
  var session = getActiveSession();
919
968
  if (session && session.abortController && session.isProcessing) {
package/lib/updater.js ADDED
@@ -0,0 +1,96 @@
1
+ const https = require("https");
2
+ const { execSync, spawn } = require("child_process");
3
+
4
+ // ANSI helpers (mirrors cli.js)
5
+ var a = {
6
+ reset: "\x1b[0m",
7
+ bold: "\x1b[1m",
8
+ dim: "\x1b[2m",
9
+ cyan: "\x1b[36m",
10
+ green: "\x1b[32m",
11
+ yellow: "\x1b[33m",
12
+ };
13
+
14
+ var sym = {
15
+ pointer: a.cyan + "\u25C6" + a.reset,
16
+ done: a.green + "\u25C7" + a.reset,
17
+ bar: a.dim + "\u2502" + a.reset,
18
+ warn: a.yellow + "\u25B2" + a.reset,
19
+ };
20
+
21
+ function log(s) { console.log(" " + s); }
22
+
23
+ function fetchLatestVersion() {
24
+ return new Promise(function (resolve) {
25
+ var req = https.get("https://registry.npmjs.org/claude-relay/latest", function (res) {
26
+ var data = "";
27
+ res.on("data", function (chunk) { data += chunk; });
28
+ res.on("end", function () {
29
+ try {
30
+ resolve(JSON.parse(data).version || null);
31
+ } catch (e) {
32
+ resolve(null);
33
+ }
34
+ });
35
+ });
36
+ req.on("error", function () { resolve(null); });
37
+ req.setTimeout(3000, function () {
38
+ req.destroy();
39
+ resolve(null);
40
+ });
41
+ });
42
+ }
43
+
44
+ function isNewer(latest, current) {
45
+ if (!latest || !current) return false;
46
+ var lp = latest.split(".").map(Number);
47
+ var cp = current.split(".").map(Number);
48
+ for (var i = 0; i < 3; i++) {
49
+ var l = lp[i] || 0;
50
+ var c = cp[i] || 0;
51
+ if (l > c) return true;
52
+ if (l < c) return false;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ function performUpdate() {
58
+ try {
59
+ execSync("npm install -g claude-relay@latest", { stdio: "pipe" });
60
+ return true;
61
+ } catch (e) {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function reExec() {
67
+ var args = process.argv.slice(1).concat("--no-update");
68
+ var child = spawn(process.execPath, args, { stdio: "inherit" });
69
+ child.on("exit", function (code) {
70
+ process.exit(code);
71
+ });
72
+ }
73
+
74
+ async function checkAndUpdate(currentVersion, skipUpdate) {
75
+ if (skipUpdate) return false;
76
+
77
+ var latest = await fetchLatestVersion();
78
+ if (!latest || !isNewer(latest, currentVersion)) return false;
79
+
80
+ log(sym.pointer + " " + a.bold + "Update available" + a.reset + " " + a.dim + currentVersion + " -> " + latest + a.reset);
81
+ log(sym.bar + " Installing...");
82
+
83
+ if (performUpdate()) {
84
+ log(sym.done + " Updated to " + a.green + latest + a.reset);
85
+ log("");
86
+ reExec();
87
+ return true;
88
+ }
89
+
90
+ log(sym.warn + " " + a.yellow + "Update failed" + a.reset + a.dim + " (permission denied?)" + a.reset);
91
+ log(sym.bar + " " + a.dim + "Run manually: npm install -g claude-relay@latest" + a.reset);
92
+ log("");
93
+ return false;
94
+ }
95
+
96
+ module.exports = { checkAndUpdate };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-relay",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Access Claude Code on your machine, from anywhere. One command, no config.",
5
5
  "bin": {
6
6
  "claude-relay": "./bin/cli.js"