claude-relay 2.4.0 → 2.4.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.
package/bin/cli.js CHANGED
@@ -41,6 +41,7 @@ var removePath = null;
41
41
  var listMode = false;
42
42
  var dangerouslySkipPermissions = false;
43
43
  var headlessMode = false;
44
+ var watchMode = false;
44
45
 
45
46
  for (var i = 0; i < args.length; i++) {
46
47
  if (args[i] === "-p" || args[i] === "--port") {
@@ -56,6 +57,8 @@ for (var i = 0; i < args.length; i++) {
56
57
  skipUpdate = true;
57
58
  } else if (args[i] === "--dev") {
58
59
  // Already handled above for CLAUDE_RELAY_HOME, just skip
60
+ } else if (args[i] === "--watch" || args[i] === "-w") {
61
+ watchMode = true;
59
62
  } else if (args[i] === "--debug") {
60
63
  debugMode = true;
61
64
  } else if (args[i] === "-y" || args[i] === "--yes") {
@@ -406,9 +409,13 @@ async function restartDaemonFromConfig() {
406
409
  newConfig.pid = child.pid;
407
410
  saveConfig(newConfig);
408
411
 
409
- // Wait and verify
410
- await new Promise(function (resolve) { setTimeout(resolve, 1200); });
411
- var alive = await isDaemonAliveAsync(newConfig);
412
+ // Wait and verify (retry up to 5 seconds)
413
+ var alive = false;
414
+ for (var rc = 0; rc < 10; rc++) {
415
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
416
+ alive = await isDaemonAliveAsync(newConfig);
417
+ if (alive) break;
418
+ }
412
419
  if (!alive) {
413
420
  log(a.red + " Restart failed. Check logs: " + a.reset + logFile);
414
421
  process.exit(1);
@@ -1266,11 +1273,13 @@ async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
1266
1273
  config.pid = child.pid;
1267
1274
  saveConfig(config);
1268
1275
 
1269
- // Wait for daemon to start
1270
- await new Promise(function (resolve) { setTimeout(resolve, 800); });
1271
-
1272
- // Verify daemon is alive
1273
- var alive = await isDaemonAliveAsync(config);
1276
+ // Wait for daemon to start (retry up to 5 seconds)
1277
+ var alive = false;
1278
+ for (var attempt = 0; attempt < 10; attempt++) {
1279
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1280
+ alive = await isDaemonAliveAsync(config);
1281
+ if (alive) break;
1282
+ }
1274
1283
  if (!alive) {
1275
1284
  log(a.red + "Failed to start daemon. Check logs:" + a.reset);
1276
1285
  log(a.dim + logFile + a.reset);
@@ -1363,6 +1372,13 @@ async function devMode(pin, keepAwake, existingPinHash) {
1363
1372
  intentionalKill = false;
1364
1373
  return;
1365
1374
  }
1375
+ // Exit code 120 = update restart — respawn daemon with current dev code
1376
+ if (code === 120) {
1377
+ console.log("\x1b[36m[dev]\x1b[0m Update restart — respawning daemon...");
1378
+ console.log("");
1379
+ setTimeout(spawnDaemon, 500);
1380
+ return;
1381
+ }
1366
1382
  // Unexpected exit — auto restart
1367
1383
  console.log("\x1b[33m[dev] Daemon exited (code " + code + "), restarting...\x1b[0m");
1368
1384
  setTimeout(spawnDaemon, 500);
@@ -1382,37 +1398,46 @@ async function devMode(pin, keepAwake, existingPinHash) {
1382
1398
  }
1383
1399
 
1384
1400
  console.log("\x1b[36m[dev]\x1b[0m Starting relay on port " + port + "...");
1385
- console.log("\x1b[36m[dev]\x1b[0m Watching lib/ for changes (excluding lib/public/)");
1401
+ if (watchMode) {
1402
+ console.log("\x1b[36m[dev]\x1b[0m Watching lib/ for changes (excluding lib/public/)");
1403
+ }
1386
1404
  console.log("");
1387
1405
 
1388
1406
  spawnDaemon();
1389
1407
 
1390
1408
  // Wait for daemon to be ready, then show CLI menu
1391
- await new Promise(function (resolve) { setTimeout(resolve, 1000); });
1392
1409
  config.pid = child ? child.pid : null;
1393
1410
  saveConfig(config);
1394
1411
 
1395
- var daemonReady = await isDaemonAliveAsync(config);
1412
+ var daemonReady = false;
1413
+ for (var da = 0; da < 10; da++) {
1414
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1415
+ daemonReady = await isDaemonAliveAsync(config);
1416
+ if (daemonReady) break;
1417
+ }
1396
1418
  if (daemonReady) {
1397
1419
  showServerStarted(config, ip);
1398
1420
  }
1399
1421
 
1400
- // Watch lib/ for server-side file changes
1401
- var watcher = fs.watch(libDir, { recursive: true }, function (eventType, filename) {
1402
- if (!filename) return;
1403
- // Skip client-side files they're served from disk
1404
- if (filename.startsWith("public" + path.sep) || filename.startsWith("public/")) return;
1405
- // Skip non-JS files
1406
- if (!filename.endsWith(".js")) return;
1407
-
1408
- if (debounceTimer) clearTimeout(debounceTimer);
1409
- debounceTimer = setTimeout(function () {
1410
- console.log("\x1b[36m[dev]\x1b[0m File changed: lib/" + filename);
1411
- console.log("\x1b[36m[dev]\x1b[0m Restarting...");
1412
- console.log("");
1413
- restartDaemon();
1414
- }, 300);
1415
- });
1422
+ // Watch lib/ for server-side file changes (only with --watch)
1423
+ var watcher = null;
1424
+ if (watchMode) {
1425
+ watcher = fs.watch(libDir, { recursive: true }, function (eventType, filename) {
1426
+ if (!filename) return;
1427
+ // Skip client-side files — they're served from disk
1428
+ if (filename.startsWith("public" + path.sep) || filename.startsWith("public/")) return;
1429
+ // Skip non-JS files
1430
+ if (!filename.endsWith(".js")) return;
1431
+
1432
+ if (debounceTimer) clearTimeout(debounceTimer);
1433
+ debounceTimer = setTimeout(function () {
1434
+ console.log("\x1b[36m[dev]\x1b[0m File changed: lib/" + filename);
1435
+ console.log("\x1b[36m[dev]\x1b[0m Restarting...");
1436
+ console.log("");
1437
+ restartDaemon();
1438
+ }, 300);
1439
+ });
1440
+ }
1416
1441
 
1417
1442
  // Clean exit on Ctrl+C
1418
1443
  var shuttingDown = false;
@@ -1420,7 +1445,7 @@ async function devMode(pin, keepAwake, existingPinHash) {
1420
1445
  if (shuttingDown) return;
1421
1446
  shuttingDown = true;
1422
1447
  console.log("\n\x1b[36m[dev]\x1b[0m Shutting down...");
1423
- watcher.close();
1448
+ if (watcher) watcher.close();
1424
1449
  if (debounceTimer) clearTimeout(debounceTimer);
1425
1450
  intentionalKill = true;
1426
1451
  if (child) {
@@ -1498,9 +1523,12 @@ async function restartDaemonWithTLS(config, callback) {
1498
1523
  newConfig.pid = child.pid;
1499
1524
  saveConfig(newConfig);
1500
1525
 
1501
- await new Promise(function (resolve) { setTimeout(resolve, 800); });
1502
-
1503
- var alive = await isDaemonAliveAsync(newConfig);
1526
+ var alive = false;
1527
+ for (var ra = 0; ra < 10; ra++) {
1528
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1529
+ alive = await isDaemonAliveAsync(newConfig);
1530
+ if (alive) break;
1531
+ }
1504
1532
  if (!alive) {
1505
1533
  log(sym.warn + " " + a.yellow + "Failed to restart with HTTPS, falling back to HTTP..." + a.reset);
1506
1534
  // Re-fork without TLS so the server is at least running
@@ -1519,7 +1547,11 @@ async function restartDaemonWithTLS(config, callback) {
1519
1547
  fs.closeSync(logFd2);
1520
1548
  newConfig.pid = child2.pid;
1521
1549
  saveConfig(newConfig);
1522
- await new Promise(function (resolve) { setTimeout(resolve, 800); });
1550
+ for (var rb = 0; rb < 10; rb++) {
1551
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1552
+ var retryAlive = await isDaemonAliveAsync(newConfig);
1553
+ if (retryAlive) break;
1554
+ }
1523
1555
  startDaemonWatcher();
1524
1556
  callback(newConfig);
1525
1557
  return;
@@ -2204,6 +2236,17 @@ var currentVersion = require("../package.json").version;
2204
2236
  }
2205
2237
 
2206
2238
  if (alive) {
2239
+ // Headless mode — daemon already running, just report and exit
2240
+ if (headlessMode) {
2241
+ var protocol = config.tls ? "https" : "http";
2242
+ var ip = getLocalIP();
2243
+ var url = protocol + "://" + ip + ":" + config.port;
2244
+ console.log(" " + sym.done + " Daemon already running (PID " + config.pid + ")");
2245
+ console.log(" " + sym.done + " " + url);
2246
+ process.exit(0);
2247
+ return;
2248
+ }
2249
+
2207
2250
  // Daemon is running — auto-add cwd if needed, then show menu
2208
2251
  var ip = getLocalIP();
2209
2252
 
package/lib/daemon.js CHANGED
@@ -234,13 +234,103 @@ var ipc = createIPCServer(socketPath(), function (msg) {
234
234
  gracefulShutdown();
235
235
  return { ok: true };
236
236
 
237
+ case "update": {
238
+ console.log("[daemon] Update & restart requested via IPC");
239
+
240
+ // Dev mode (config.debug): just exit with code 120, cli.js dev watcher respawns daemon
241
+ if (config.debug) {
242
+ console.log("[daemon] Dev mode — restarting via dev watcher");
243
+ updateHandoff = true;
244
+ setTimeout(function () { gracefulShutdown(); }, 100);
245
+ return { ok: true };
246
+ }
247
+
248
+ // Production: fetch latest via npx, then spawn updated daemon
249
+ var { execSync: execSyncUpd, spawn: spawnUpd } = require("child_process");
250
+ var updDaemonScript;
251
+ try {
252
+ // npx downloads the package and puts a bin symlink; `which` prints its path
253
+ var binPath = execSyncUpd(
254
+ "npx --yes --package=claude-relay@latest -- which claude-relay",
255
+ { stdio: ["ignore", "pipe", "pipe"], timeout: 120000, encoding: "utf8" }
256
+ ).trim();
257
+ // Resolve symlink to get the actual package directory
258
+ var realBin = fs.realpathSync(binPath);
259
+ updDaemonScript = path.join(path.dirname(realBin), "..", "lib", "daemon.js");
260
+ updDaemonScript = path.resolve(updDaemonScript);
261
+ console.log("[daemon] Resolved updated daemon:", updDaemonScript);
262
+ } catch (updErr) {
263
+ console.log("[daemon] npx resolve failed:", updErr.message);
264
+ // Fallback: restart with current code
265
+ updDaemonScript = path.join(__dirname, "daemon.js");
266
+ }
267
+ // Spawn new daemon process — it will retry if port is still in use
268
+ var { logPath: updLogPath, configPath: updConfigPath } = require("./config");
269
+ var updLogFd = fs.openSync(updLogPath(), "a");
270
+ var updChild = spawnUpd(process.execPath, [updDaemonScript], {
271
+ detached: true,
272
+ windowsHide: true,
273
+ stdio: ["ignore", updLogFd, updLogFd],
274
+ env: Object.assign({}, process.env, {
275
+ CLAUDE_RELAY_CONFIG: updConfigPath(),
276
+ }),
277
+ });
278
+ updChild.unref();
279
+ fs.closeSync(updLogFd);
280
+ config.pid = updChild.pid;
281
+ saveConfig(config);
282
+ console.log("[daemon] Spawned new daemon (PID " + updChild.pid + "), shutting down...");
283
+ updateHandoff = true;
284
+ setTimeout(function () { gracefulShutdown(); }, 100);
285
+ return { ok: true };
286
+ }
287
+
237
288
  default:
238
289
  return { ok: false, error: "unknown command: " + msg.cmd };
239
290
  }
240
291
  });
241
292
 
242
- // --- Start listening ---
293
+ // --- Start listening (with retry for port-in-use during update handoff) ---
294
+ var listenRetries = 0;
295
+ var MAX_LISTEN_RETRIES = 15;
296
+
297
+ function startListening() {
298
+ relay.server.listen(config.port, function () {
299
+ var protocol = tlsOptions ? "https" : "http";
300
+ console.log("[daemon] Listening on " + protocol + "://0.0.0.0:" + config.port);
301
+ console.log("[daemon] PID:", process.pid);
302
+ console.log("[daemon] Projects:", config.projects.length);
303
+
304
+ // Update PID in config
305
+ config.pid = process.pid;
306
+ saveConfig(config);
307
+
308
+ // Check for crash info from a previous crash and notify clients
309
+ var crashInfo = readCrashInfo();
310
+ if (crashInfo) {
311
+ console.log("[daemon] Recovered from crash at", new Date(crashInfo.time).toISOString());
312
+ console.log("[daemon] Crash reason:", crashInfo.reason);
313
+ // Delay notification so clients have time to reconnect
314
+ setTimeout(function () {
315
+ relay.broadcastAll({
316
+ type: "toast",
317
+ level: "warn",
318
+ message: "Server recovered from a crash and was automatically restarted.",
319
+ detail: crashInfo.reason || null,
320
+ });
321
+ }, 3000);
322
+ clearCrashInfo();
323
+ }
324
+ });
325
+ }
326
+
243
327
  relay.server.on("error", function (err) {
328
+ if (err.code === "EADDRINUSE" && listenRetries < MAX_LISTEN_RETRIES) {
329
+ listenRetries++;
330
+ console.log("[daemon] Port " + config.port + " in use, retrying (" + listenRetries + "/" + MAX_LISTEN_RETRIES + ")...");
331
+ setTimeout(startListening, 1000);
332
+ return;
333
+ }
244
334
  console.error("[daemon] Server error:", err.message);
245
335
  writeCrashInfo({
246
336
  reason: "Server error: " + err.message,
@@ -250,33 +340,7 @@ relay.server.on("error", function (err) {
250
340
  process.exit(1);
251
341
  });
252
342
 
253
- relay.server.listen(config.port, function () {
254
- var protocol = tlsOptions ? "https" : "http";
255
- console.log("[daemon] Listening on " + protocol + "://0.0.0.0:" + config.port);
256
- console.log("[daemon] PID:", process.pid);
257
- console.log("[daemon] Projects:", config.projects.length);
258
-
259
- // Update PID in config
260
- config.pid = process.pid;
261
- saveConfig(config);
262
-
263
- // Check for crash info from a previous crash and notify clients
264
- var crashInfo = readCrashInfo();
265
- if (crashInfo) {
266
- console.log("[daemon] Recovered from crash at", new Date(crashInfo.time).toISOString());
267
- console.log("[daemon] Crash reason:", crashInfo.reason);
268
- // Delay notification so clients have time to reconnect
269
- setTimeout(function () {
270
- relay.broadcastAll({
271
- type: "toast",
272
- level: "warn",
273
- message: "Server recovered from a crash and was automatically restarted.",
274
- detail: crashInfo.reason || null,
275
- });
276
- }, 3000);
277
- clearCrashInfo();
278
- }
279
- });
343
+ startListening();
280
344
 
281
345
  // --- HTTP onboarding server (only when TLS is active) ---
282
346
  if (relay.onboardingServer) {
@@ -300,8 +364,11 @@ if (config.keepAwake && process.platform === "darwin") {
300
364
  }
301
365
 
302
366
  // --- Graceful shutdown ---
367
+ var updateHandoff = false; // true when shutting down for update (new daemon already spawned)
368
+
303
369
  function gracefulShutdown() {
304
370
  console.log("[daemon] Shutting down...");
371
+ var exitCode = updateHandoff ? 120 : 0; // 120 = update handoff, don't auto-restart
305
372
 
306
373
  if (caffeinateProc) {
307
374
  try { caffeinateProc.kill(); } catch (e) {}
@@ -309,14 +376,16 @@ function gracefulShutdown() {
309
376
 
310
377
  ipc.close();
311
378
 
312
- // Remove PID from config
313
- try {
314
- var c = loadConfig();
315
- if (c && c.pid === process.pid) {
316
- delete c.pid;
317
- saveConfig(c);
318
- }
319
- } catch (e) {}
379
+ // Remove PID from config (skip if update handoff — new daemon PID is already saved)
380
+ if (!updateHandoff) {
381
+ try {
382
+ var c = loadConfig();
383
+ if (c && c.pid === process.pid) {
384
+ delete c.pid;
385
+ saveConfig(c);
386
+ }
387
+ } catch (e) {}
388
+ }
320
389
 
321
390
  relay.destroyAll();
322
391
 
@@ -326,7 +395,7 @@ function gracefulShutdown() {
326
395
 
327
396
  relay.server.close(function () {
328
397
  console.log("[daemon] Server closed");
329
- process.exit(0);
398
+ process.exit(exitCode);
330
399
  });
331
400
 
332
401
  // Force exit after 5 seconds
package/lib/project.js CHANGED
@@ -346,7 +346,7 @@ function createProjectContext(opts) {
346
346
  if (s.cliSessionId) relayIds[s.cliSessionId] = true;
347
347
  });
348
348
  try {
349
- var sessDir = _path.join(cwd, ".claude-relay", "sessions");
349
+ var sessDir = sm.sessionsDir;
350
350
  var diskFiles = _fs.readdirSync(sessDir);
351
351
  for (var fi = 0; fi < diskFiles.length; fi++) {
352
352
  if (diskFiles[fi].endsWith(".jsonl")) {
@@ -406,6 +406,14 @@ function createProjectContext(opts) {
406
406
  return;
407
407
  }
408
408
 
409
+ if (msg.type === "update_now") {
410
+ send({ type: "update_started", version: latestVersion || "" });
411
+ var _ipc = require("./ipc");
412
+ var _config = require("./config");
413
+ _ipc.sendIPCCommand(_config.socketPath(), { cmd: "update" });
414
+ return;
415
+ }
416
+
409
417
  if (msg.type === "process_stats") {
410
418
  var sessionCount = sm.sessions.size;
411
419
  var processingCount = 0;
package/lib/public/app.js CHANGED
@@ -1355,6 +1355,12 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
1355
1355
  if (updateBanner && updateVersion && msg.version) {
1356
1356
  updateVersion.textContent = "v" + msg.version;
1357
1357
  updateBanner.classList.remove("hidden");
1358
+ // Reset button state (may be stuck on "Updating..." after restart)
1359
+ var updResetBtn = $("update-now");
1360
+ if (updResetBtn) {
1361
+ updResetBtn.textContent = "Update now";
1362
+ updResetBtn.disabled = false;
1363
+ }
1358
1364
  refreshIcons();
1359
1365
  }
1360
1366
  // Show badge on footer update check item
@@ -1373,6 +1379,18 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
1373
1379
  }
1374
1380
  break;
1375
1381
 
1382
+ case "update_started":
1383
+ var updNowBtn = $("update-now");
1384
+ if (updNowBtn) {
1385
+ updNowBtn.textContent = "Updating...";
1386
+ updNowBtn.disabled = true;
1387
+ }
1388
+ // Block the entire screen with the connect overlay
1389
+ connectStatusEl.textContent = "Updating" + (msg.version ? " to v" + msg.version : "") + "...";
1390
+ connectOverlay.classList.remove("hidden");
1391
+ startPixelAnim();
1392
+ break;
1393
+
1376
1394
  case "slash_commands":
1377
1395
  var reserved = new Set(builtinCommands.map(function (c) { return c.name; }));
1378
1396
  slashCommands = (msg.commands || []).filter(function (name) {
@@ -74,9 +74,14 @@
74
74
  align-items: center;
75
75
  justify-content: center;
76
76
  width: 16px;
77
+ height: 16px;
77
78
  flex-shrink: 0;
78
- font-size: 14px;
79
- line-height: 1;
79
+ line-height: 0;
80
+ }
81
+
82
+ .file-tree-icon svg {
83
+ width: 16px;
84
+ height: 16px;
80
85
  }
81
86
 
82
87
  .file-tree-chevron {
@@ -91,7 +91,7 @@
91
91
  .update-banner-text .lucide { width: 13px; height: 13px; color: var(--success); }
92
92
  .update-banner-text strong { color: var(--text); font-weight: 600; }
93
93
 
94
- #update-how {
94
+ #update-now {
95
95
  background: var(--success-15);
96
96
  border: 1px solid var(--success-25);
97
97
  border-radius: 6px;
@@ -105,7 +105,24 @@
105
105
  transition: background 0.15s;
106
106
  }
107
107
 
108
- #update-how:hover { background: var(--success-25); }
108
+ #update-now:hover { background: var(--success-25); }
109
+ #update-now:disabled { opacity: 0.6; cursor: default; }
110
+
111
+ #update-how {
112
+ background: transparent;
113
+ border: 1px solid var(--text-dimmer);
114
+ border-radius: 6px;
115
+ color: var(--text-dimmer);
116
+ cursor: pointer;
117
+ font-family: inherit;
118
+ font-size: 11px;
119
+ font-weight: 600;
120
+ padding: 3px 7px;
121
+ flex-shrink: 0;
122
+ transition: background 0.15s, color 0.15s;
123
+ }
124
+
125
+ #update-how:hover { background: rgba(var(--overlay-rgb), 0.05); color: var(--text-secondary); }
109
126
 
110
127
  #update-popover {
111
128
  display: none;
@@ -432,13 +432,15 @@
432
432
  display: flex;
433
433
  align-items: center;
434
434
  gap: 4px;
435
- padding: 7px 12px;
435
+ padding: 0 36px 0 12px;
436
+ height: 34px;
436
437
  border-radius: 10px;
437
438
  cursor: pointer;
438
439
  font-size: 13px;
439
440
  color: var(--text-secondary);
440
441
  margin-bottom: 0;
441
- transition: background 0.15s, color 0.15s;
442
+ transition: background 0.35s ease, color 0.35s ease;
443
+ contain: size layout;
442
444
  }
443
445
 
444
446
  .session-item-text {
@@ -471,7 +473,11 @@
471
473
  }
472
474
 
473
475
  .session-more-btn {
474
- display: none;
476
+ position: absolute;
477
+ right: 8px;
478
+ top: 50%;
479
+ transform: translateY(-50%);
480
+ display: flex;
475
481
  width: 24px;
476
482
  height: 24px;
477
483
  border-radius: 6px;
@@ -479,15 +485,16 @@
479
485
  background: transparent;
480
486
  color: var(--text-muted);
481
487
  cursor: pointer;
482
- flex-shrink: 0;
483
488
  align-items: center;
484
489
  justify-content: center;
485
490
  padding: 0;
486
- transition: color 0.15s, background 0.15s;
491
+ opacity: 0;
492
+ transition: opacity 0.35s ease, color 0.15s, background 0.15s;
487
493
  }
488
494
 
489
- .session-more-btn .lucide { width: 14px; height: 14px; }
490
- .session-item:hover .session-more-btn { display: flex; }
495
+ .session-more-btn .lucide,
496
+ .session-more-btn svg { width: 14px; height: 14px; display: block; }
497
+ .session-item:hover .session-more-btn { opacity: 1; }
491
498
  .session-more-btn:hover { color: var(--text); background: rgba(var(--overlay-rgb), 0.06); }
492
499
 
493
500
  /* --- Session context menu --- */
@@ -540,7 +547,7 @@
540
547
  }
541
548
 
542
549
  @media (hover: none) {
543
- .session-more-btn { display: flex; }
550
+ .session-more-btn { opacity: 1; }
544
551
  .msg-copy-hint { opacity: 1; }
545
552
  .msg-user[data-uuid] .bubble::after { display: none; }
546
553
  .mermaid-diagram::after { display: none; }
@@ -13,7 +13,6 @@
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com">
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
15
15
  <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">
16
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/file-icons-js@1/css/style.css">
17
16
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.min.css">
18
17
  <script>
19
18
  (function(){try{var k="claude-relay-theme-vars",v=localStorage.getItem(k);if(!v)return;var o=JSON.parse(v),r=document.documentElement,p;for(p in o)r.style.setProperty(p,o[p]);var vt=localStorage.getItem(k.replace("-vars","-variant"));if(vt==="light"){r.classList.add("light-theme");r.classList.remove("dark-theme")}else{r.classList.add("dark-theme");r.classList.remove("light-theme")}var m=document.querySelector('meta[name="theme-color"]');if(m&&o["--bg"])m.setAttribute("content",o["--bg"])}catch(e){}})();
@@ -107,7 +106,8 @@
107
106
  <div id="app">
108
107
  <div id="update-banner" class="hidden">
109
108
  <span class="update-banner-text"><i data-lucide="arrow-up-circle"></i> A new version of Claude Relay is available: <strong id="update-version"></strong></span>
110
- <button id="update-how" title="How to update">How to update</button>
109
+ <button id="update-now">Update now</button>
110
+ <button id="update-how" title="Manual update instructions">?</button>
111
111
  <button id="update-banner-close" aria-label="Dismiss"><i data-lucide="x"></i></button>
112
112
  </div>
113
113
  <div id="onboarding-banner" class="hidden">
@@ -453,7 +453,6 @@
453
453
  <script src="https://cdn.jsdelivr.net/npm/lucide@0.468.0/dist/umd/lucide.min.js"></script>
454
454
  <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
455
455
  <script src="https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js"></script>
456
- <script src="https://cdn.jsdelivr.net/npm/file-icons-js@1/index.js"></script>
457
456
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/lib/xterm.min.js"></script>
458
457
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0/lib/addon-fit.min.js"></script>
459
458
  <script type="module" src="app.js"></script>
@@ -3,6 +3,7 @@ import { escapeHtml, copyToClipboard } from './utils.js';
3
3
  import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks } from './markdown.js';
4
4
  import { closeSidebar } from './sidebar.js';
5
5
  import { renderUnifiedDiff, renderSplitDiff } from './diff.js';
6
+ import { initFileIcons, getFileIconSvg, getFolderIconSvg } from './fileicons.js';
6
7
 
7
8
  var ctx;
8
9
  var treeData = {}; // path -> { loaded, children }
@@ -24,6 +25,14 @@ var pendingFileAt = null; // callback for pending file-at
24
25
  export function initFileBrowser(_ctx) {
25
26
  ctx = _ctx;
26
27
 
28
+ // Load material file icons in background
29
+ initFileIcons().then(function () {
30
+ // Re-render tree if already loaded, so file icons appear
31
+ if (treeData["."] && treeData["."].loaded) {
32
+ renderTree();
33
+ }
34
+ });
35
+
27
36
  // Close button
28
37
  document.getElementById("file-viewer-close").addEventListener("click", function () {
29
38
  closeFileViewer();
@@ -329,14 +338,21 @@ function renderEntries(container, entries, depth) {
329
338
  if (entry.type === "dir") {
330
339
  row.innerHTML =
331
340
  '<span class="file-tree-chevron">' + iconHtml("chevron-right") + '</span>' +
332
- iconHtml("folder") +
341
+ '<span class="file-tree-icon file-tree-folder-icon"></span>' +
333
342
  '<span class="file-tree-name">' + escapeHtml(entry.name) + '</span>';
334
343
 
344
+ // Async-load folder icon SVG
345
+ (function (iconEl, name) {
346
+ getFolderIconSvg(name, false, function (svg) {
347
+ iconEl.innerHTML = svg;
348
+ });
349
+ })(row.querySelector(".file-tree-folder-icon"), entry.name);
350
+
335
351
  var childContainer = document.createElement("div");
336
352
  childContainer.className = "file-tree-children hidden";
337
353
  childContainer.dataset.parentPath = entry.path;
338
354
 
339
- (function (dirPath, childEl, rowEl) {
355
+ (function (dirPath, childEl, rowEl, folderName) {
340
356
  rowEl.addEventListener("click", function (e) {
341
357
  e.stopPropagation();
342
358
  var isExpanded = rowEl.classList.contains("expanded");
@@ -356,16 +372,23 @@ function renderEntries(container, entries, depth) {
356
372
  refreshIcons();
357
373
  }
358
374
  }
375
+ // Swap folder icon open/closed
376
+ var folderIconEl = rowEl.querySelector(".file-tree-folder-icon");
377
+ if (folderIconEl) {
378
+ getFolderIconSvg(folderName, !isExpanded, function (svg) {
379
+ folderIconEl.innerHTML = svg;
380
+ });
381
+ }
359
382
  });
360
- })(entry.path, childContainer, row);
383
+ })(entry.path, childContainer, row, entry.name);
361
384
 
362
385
  container.appendChild(row);
363
386
  container.appendChild(childContainer);
364
387
  } else {
365
- var iconClass = getFileIconClass(entry.name);
388
+ var fileSvg = getFileIconSvg(entry.name);
366
389
  row.innerHTML =
367
390
  '<span class="file-tree-spacer"></span>' +
368
- '<span class="file-tree-icon ' + iconClass + '"></span>' +
391
+ '<span class="file-tree-icon">' + fileSvg + '</span>' +
369
392
  '<span class="file-tree-name">' + escapeHtml(entry.name) + '</span>';
370
393
 
371
394
  (function (filePath, rowEl) {
@@ -388,12 +411,6 @@ function renderEntries(container, entries, depth) {
388
411
  }
389
412
  }
390
413
 
391
- function getFileIconClass(name) {
392
- if (typeof FileIcons !== "undefined") {
393
- return FileIcons.getClassWithColor(name) || "default-icon";
394
- }
395
- return "default-icon";
396
- }
397
414
 
398
415
  // --- File viewer ---
399
416
 
@@ -0,0 +1,172 @@
1
+ // Material Icon Theme file & folder icons
2
+ // Files: material-file-icons via esm.sh (dynamic import, ~475KB lazy-loaded)
3
+ // Folders: material-icon-theme SVGs via jsdelivr CDN (fetched & cached)
4
+
5
+ var materialIcons = null;
6
+ var folderSvgCache = {};
7
+ var loadPromise = null;
8
+
9
+ var FOLDER_CDN = "https://cdn.jsdelivr.net/npm/material-icon-theme@5/icons/";
10
+
11
+ var FOLDER_MAP = {
12
+ "src": "folder-src",
13
+ "lib": "folder-lib",
14
+ "dist": "folder-dist",
15
+ "build": "folder-dist",
16
+ "out": "folder-dist",
17
+ "output": "folder-dist",
18
+ "node_modules": "folder-node",
19
+ "test": "folder-test",
20
+ "tests": "folder-test",
21
+ "__tests__": "folder-test",
22
+ "spec": "folder-test",
23
+ "docs": "folder-docs",
24
+ "doc": "folder-docs",
25
+ "config": "folder-config",
26
+ ".config": "folder-config",
27
+ "public": "folder-public",
28
+ "static": "folder-public",
29
+ "assets": "folder-images",
30
+ "images": "folder-images",
31
+ "img": "folder-images",
32
+ "icons": "folder-images",
33
+ "media": "folder-images",
34
+ "components": "folder-components",
35
+ "hooks": "folder-hook",
36
+ "utils": "folder-utils",
37
+ "util": "folder-utils",
38
+ "helpers": "folder-helper",
39
+ "helper": "folder-helper",
40
+ "api": "folder-api",
41
+ "routes": "folder-routes",
42
+ "router": "folder-routes",
43
+ "pages": "folder-layout",
44
+ "views": "folder-views",
45
+ "styles": "folder-css",
46
+ "css": "folder-css",
47
+ "scss": "folder-sass",
48
+ "sass": "folder-sass",
49
+ "scripts": "folder-scripts",
50
+ "bin": "folder-scripts",
51
+ ".git": "folder-git",
52
+ ".github": "folder-github",
53
+ ".vscode": "folder-vscode",
54
+ "docker": "folder-docker",
55
+ ".docker": "folder-docker",
56
+ "database": "folder-database",
57
+ "db": "folder-database",
58
+ "server": "folder-server",
59
+ "client": "folder-client",
60
+ "types": "folder-typescript",
61
+ "typings": "folder-typescript",
62
+ "@types": "folder-typescript",
63
+ "modules": "folder-node",
64
+ "packages": "folder-packages",
65
+ "vendor": "folder-lib",
66
+ "env": "folder-environment",
67
+ ".env": "folder-environment",
68
+ "mock": "folder-mock",
69
+ "mocks": "folder-mock",
70
+ "__mocks__": "folder-mock",
71
+ "middleware": "folder-middleware",
72
+ "i18n": "folder-i18n",
73
+ "locale": "folder-i18n",
74
+ "locales": "folder-i18n",
75
+ "fonts": "folder-font",
76
+ "font": "folder-font",
77
+ "logs": "folder-log",
78
+ "log": "folder-log",
79
+ "tmp": "folder-temp",
80
+ "temp": "folder-temp",
81
+ "templates": "folder-template",
82
+ "template": "folder-template",
83
+ "ref": "folder-resource",
84
+ "resources": "folder-resource",
85
+ "res": "folder-resource",
86
+ "themes": "folder-theme",
87
+ "theme": "folder-theme",
88
+ "plugins": "folder-plugin",
89
+ "plugin": "folder-plugin",
90
+ "context": "folder-context",
91
+ "contexts": "folder-context",
92
+ "redux": "folder-redux-store",
93
+ "store": "folder-redux-store",
94
+ "stores": "folder-redux-store",
95
+ "controllers": "folder-controller",
96
+ "controller": "folder-controller",
97
+ "models": "folder-database",
98
+ "model": "folder-database",
99
+ "services": "folder-server",
100
+ "service": "folder-server",
101
+ ".claude": "folder-config"
102
+ };
103
+
104
+ // --- Public API ---
105
+
106
+ export function initFileIcons() {
107
+ if (loadPromise) return loadPromise;
108
+ loadPromise = import("https://esm.sh/material-file-icons@2").then(function (mod) {
109
+ materialIcons = mod;
110
+ }).catch(function (err) {
111
+ console.warn("[fileicons] Failed to load material-file-icons:", err);
112
+ });
113
+ // Pre-fetch default folder icons
114
+ fetchFolderSvg("folder.svg");
115
+ fetchFolderSvg("folder-open.svg");
116
+ return loadPromise;
117
+ }
118
+
119
+ export function getFileIconSvg(filename) {
120
+ if (!materialIcons) return "";
121
+ try {
122
+ var icon = materialIcons.getIcon(filename);
123
+ return icon && icon.svg ? icon.svg : "";
124
+ } catch (e) {
125
+ return "";
126
+ }
127
+ }
128
+
129
+ export function getFolderIconSvg(folderName, isOpen, callback) {
130
+ var lower = folderName.toLowerCase();
131
+ var base = FOLDER_MAP[lower] || "folder";
132
+ var svgName = isOpen ? (base + "-open.svg") : (base + ".svg");
133
+
134
+ // Cache hit
135
+ if (folderSvgCache[svgName]) {
136
+ callback(folderSvgCache[svgName]);
137
+ return;
138
+ }
139
+
140
+ fetchFolderSvg(svgName).then(function (svg) {
141
+ if (svg) {
142
+ callback(svg);
143
+ } else {
144
+ // Fallback to default folder icon
145
+ var fallback = isOpen ? "folder-open.svg" : "folder.svg";
146
+ if (folderSvgCache[fallback]) {
147
+ callback(folderSvgCache[fallback]);
148
+ } else {
149
+ fetchFolderSvg(fallback).then(function (fb) {
150
+ callback(fb || "");
151
+ });
152
+ }
153
+ }
154
+ });
155
+ }
156
+
157
+ // --- Internal ---
158
+
159
+ function fetchFolderSvg(svgName) {
160
+ if (folderSvgCache[svgName]) {
161
+ return Promise.resolve(folderSvgCache[svgName]);
162
+ }
163
+ return fetch(FOLDER_CDN + svgName).then(function (res) {
164
+ if (!res.ok) return null;
165
+ return res.text();
166
+ }).then(function (text) {
167
+ if (text) folderSvgCache[svgName] = text;
168
+ return text;
169
+ }).catch(function () {
170
+ return null;
171
+ });
172
+ }
@@ -85,9 +85,10 @@ export function initNotifications(_ctx) {
85
85
  var banner = $("update-banner");
86
86
  var closeBtn = $("update-banner-close");
87
87
  var howBtn = $("update-how");
88
+ var updateNowBtn = $("update-now");
88
89
  if (!banner) return;
89
90
 
90
- // Build popover
91
+ // Build popover (manual update instructions)
91
92
  var popover = document.createElement("div");
92
93
  popover.id = "update-popover";
93
94
  popover.innerHTML =
@@ -113,6 +114,18 @@ export function initNotifications(_ctx) {
113
114
  });
114
115
  });
115
116
 
117
+ // "Update now" button — trigger server-side update + restart
118
+ if (updateNowBtn) {
119
+ updateNowBtn.addEventListener("click", function () {
120
+ if (ctx.ws && ctx.connected) {
121
+ ctx.ws.send(JSON.stringify({ type: "update_now" }));
122
+ updateNowBtn.textContent = "Updating...";
123
+ updateNowBtn.disabled = true;
124
+ }
125
+ });
126
+ }
127
+
128
+ // "?" button — toggle manual instructions popover
116
129
  howBtn.addEventListener("click", function (e) {
117
130
  e.stopPropagation();
118
131
  popover.classList.toggle("visible");
@@ -151,8 +151,8 @@ function computeVars(theme) {
151
151
  "--success": b.base0B,
152
152
  "--warning": b.base0A,
153
153
  "--sidebar-bg": isLight ? darken(b.base00, 0.02) : darken(b.base00, 0.10),
154
- "--sidebar-hover": mixColors(b.base00, b.base01, 0.5),
155
- "--sidebar-active": mixColors(b.base01, b.base02, 0.5),
154
+ "--sidebar-hover": isLight ? darken(b.base00, 0.06) : mixColors(b.base00, b.base01, 0.5),
155
+ "--sidebar-active": isLight ? darken(b.base01, 0.05) : mixColors(b.base01, b.base02, 0.5),
156
156
  "--accent-8": hexToRgba(b.base09, 0.08),
157
157
  "--accent-12": hexToRgba(b.base09, 0.12),
158
158
  "--accent-15": hexToRgba(b.base09, 0.15),
package/lib/sdk-bridge.js CHANGED
@@ -1,4 +1,7 @@
1
1
  const crypto = require("crypto");
2
+ var fs = require("fs");
3
+ var path = require("path");
4
+ var os = require("os");
2
5
 
3
6
  // Async message queue for streaming input to SDK
4
7
  function createMessageQueue() {
@@ -50,6 +53,53 @@ function createSDKBridge(opts) {
50
53
  var getSDK = opts.getSDK;
51
54
  var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
52
55
 
56
+ // --- Skill discovery helpers ---
57
+
58
+ function discoverSkillDirs() {
59
+ var skills = {};
60
+ var dirs = [
61
+ path.join(os.homedir(), ".claude", "skills"),
62
+ path.join(cwd, ".claude", "skills"),
63
+ ];
64
+ for (var d = 0; d < dirs.length; d++) {
65
+ var base = dirs[d];
66
+ var entries;
67
+ try {
68
+ entries = fs.readdirSync(base, { withFileTypes: true });
69
+ } catch (e) {
70
+ continue; // directory doesn't exist
71
+ }
72
+ for (var i = 0; i < entries.length; i++) {
73
+ var entry = entries[i];
74
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
75
+ var skillDir = path.join(base, entry.name);
76
+ var skillMd = path.join(skillDir, "SKILL.md");
77
+ try {
78
+ fs.accessSync(skillMd, fs.constants.R_OK);
79
+ // project skills override global skills with same name
80
+ skills[entry.name] = skillDir;
81
+ } catch (e) {
82
+ // no SKILL.md, skip
83
+ }
84
+ }
85
+ }
86
+ return skills;
87
+ }
88
+
89
+ function mergeSkills(sdkSkills, fsSkills) {
90
+ var merged = new Set();
91
+ if (Array.isArray(sdkSkills)) {
92
+ for (var i = 0; i < sdkSkills.length; i++) {
93
+ merged.add(sdkSkills[i]);
94
+ }
95
+ }
96
+ var fsNames = Object.keys(fsSkills);
97
+ for (var i = 0; i < fsNames.length; i++) {
98
+ merged.add(fsNames[i]);
99
+ }
100
+ return merged;
101
+ }
102
+
53
103
  function sendAndRecord(session, obj) {
54
104
  sm.sendAndRecord(session, obj);
55
105
  }
@@ -79,13 +129,20 @@ function createSDKBridge(opts) {
79
129
 
80
130
  // Cache slash_commands and model from CLI init message
81
131
  if (parsed.type === "system" && parsed.subtype === "init") {
82
- if (parsed.skills) {
83
- sm.skillNames = new Set(parsed.skills);
84
- }
132
+ var fsSkills = discoverSkillDirs();
133
+ sm.skillNames = mergeSkills(parsed.skills, fsSkills);
85
134
  if (parsed.slash_commands) {
86
- sm.slashCommands = parsed.slash_commands.filter(function(name) {
87
- return !sm.skillNames || !sm.skillNames.has(name);
88
- });
135
+ // Union: SDK slash_commands + merged skills (deduplicated)
136
+ var seen = new Set();
137
+ var combined = [];
138
+ var all = parsed.slash_commands.concat(Array.from(sm.skillNames));
139
+ for (var k = 0; k < all.length; k++) {
140
+ if (!seen.has(all[k])) {
141
+ seen.add(all[k]);
142
+ combined.push(all[k]);
143
+ }
144
+ }
145
+ sm.slashCommands = combined;
89
146
  send({ type: "slash_commands", commands: sm.slashCommands });
90
147
  }
91
148
  if (parsed.model) {
@@ -649,13 +706,20 @@ function createSDKBridge(opts) {
649
706
  });
650
707
  for await (var msg of stream) {
651
708
  if (msg.type === "system" && msg.subtype === "init") {
652
- if (msg.skills) {
653
- sm.skillNames = new Set(msg.skills);
654
- }
709
+ var fsSkills = discoverSkillDirs();
710
+ sm.skillNames = mergeSkills(msg.skills, fsSkills);
655
711
  if (msg.slash_commands) {
656
- sm.slashCommands = msg.slash_commands.filter(function(name) {
657
- return !sm.skillNames || !sm.skillNames.has(name);
658
- });
712
+ // Union: SDK slash_commands + merged skills (deduplicated)
713
+ var seen = new Set();
714
+ var combined = [];
715
+ var all = msg.slash_commands.concat(Array.from(sm.skillNames));
716
+ for (var k = 0; k < all.length; k++) {
717
+ if (!seen.has(all[k])) {
718
+ seen.add(all[k]);
719
+ combined.push(all[k]);
720
+ }
721
+ }
722
+ sm.slashCommands = combined;
659
723
  send({ type: "slash_commands", commands: sm.slashCommands });
660
724
  }
661
725
  if (msg.model) {
package/lib/server.js CHANGED
@@ -90,7 +90,7 @@ function serveStatic(urlPath, res) {
90
90
  var content = fs.readFileSync(filePath);
91
91
  var ext = path.extname(filePath);
92
92
  var mime = MIME_TYPES[ext] || "application/octet-stream";
93
- res.writeHead(200, { "Content-Type": mime + "; charset=utf-8" });
93
+ res.writeHead(200, { "Content-Type": mime + "; charset=utf-8", "Cache-Control": "no-cache" });
94
94
  res.end(content);
95
95
  return true;
96
96
  } catch (e) {
package/lib/sessions.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
+ const config = require("./config");
3
4
 
4
5
  function createSessionManager(opts) {
5
6
  var cwd = opts.cwd;
@@ -13,10 +14,47 @@ function createSessionManager(opts) {
13
14
  var slashCommands = null; // shared across sessions
14
15
  var skillNames = null; // Claude-only skills to filter from slash menu
15
16
 
16
- // --- Session persistence ---
17
- var sessionsDir = path.join(cwd, ".claude-relay", "sessions");
17
+ // --- Session persistence (centralized in ~/.claude-relay/sessions/{encoded-cwd}/) ---
18
+ var encodedCwd = cwd.replace(/\//g, "-");
19
+ var sessionsDir = path.join(config.CONFIG_DIR, "sessions", encodedCwd);
18
20
  fs.mkdirSync(sessionsDir, { recursive: true });
19
21
 
22
+ // Auto-migrate from old per-project location ({cwd}/.claude-relay/sessions/)
23
+ var oldSessionsDir = path.join(cwd, ".claude-relay", "sessions");
24
+ try {
25
+ var oldFiles = fs.readdirSync(oldSessionsDir);
26
+ var migrated = 0;
27
+ for (var mi = 0; mi < oldFiles.length; mi++) {
28
+ if (!oldFiles[mi].endsWith(".jsonl")) continue;
29
+ var oldFilePath = path.join(oldSessionsDir, oldFiles[mi]);
30
+ var newFilePath = path.join(sessionsDir, oldFiles[mi]);
31
+ if (fs.existsSync(newFilePath)) continue;
32
+ try {
33
+ fs.renameSync(oldFilePath, newFilePath);
34
+ migrated++;
35
+ } catch (renameErr) {
36
+ try {
37
+ fs.copyFileSync(oldFilePath, newFilePath);
38
+ fs.unlinkSync(oldFilePath);
39
+ migrated++;
40
+ } catch (copyErr) {}
41
+ }
42
+ }
43
+ if (migrated > 0) {
44
+ console.log("[sessions] Migrated " + migrated + " session(s) to " + sessionsDir);
45
+ }
46
+ // Clean up old directory if empty
47
+ try {
48
+ if (fs.readdirSync(oldSessionsDir).length === 0) {
49
+ fs.rmdirSync(oldSessionsDir);
50
+ var parentDir = path.join(cwd, ".claude-relay");
51
+ if (fs.readdirSync(parentDir).length === 0) fs.rmdirSync(parentDir);
52
+ }
53
+ } catch (e) {}
54
+ } catch (e) {
55
+ // Old directory doesn't exist — that's fine
56
+ }
57
+
20
58
  function sessionFilePath(cliSessionId) {
21
59
  return path.join(sessionsDir, cliSessionId + ".jsonl");
22
60
  }
@@ -344,6 +382,7 @@ function createSessionManager(opts) {
344
382
  get skillNames() { return skillNames; },
345
383
  set skillNames(v) { skillNames = v; },
346
384
  sessions: sessions,
385
+ sessionsDir: sessionsDir,
347
386
  HISTORY_PAGE_SIZE: HISTORY_PAGE_SIZE,
348
387
  getActiveSession: getActiveSession,
349
388
  createSession: createSession,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-relay",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "claude-relay": "./bin/cli.js"