claude-relay 2.2.4 → 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.
@@ -1,13 +1,21 @@
1
- export function showToast(message) {
1
+ export function showToast(message, level, detail) {
2
2
  var el = document.createElement("div");
3
3
  el.className = "toast";
4
+ if (level) el.classList.add("toast-" + level);
4
5
  el.textContent = message;
6
+ if (detail) {
7
+ var detailEl = document.createElement("div");
8
+ detailEl.style.cssText = "font-size:11px;opacity:0.7;margin-top:4px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
9
+ detailEl.textContent = detail.split("\n")[0];
10
+ el.appendChild(detailEl);
11
+ }
5
12
  document.body.appendChild(el);
6
13
  requestAnimationFrame(function () { el.classList.add("visible"); });
14
+ var duration = level === "warn" ? 5000 : 1500;
7
15
  setTimeout(function () {
8
16
  el.classList.remove("visible");
9
17
  setTimeout(function () { el.remove(); }, 300);
10
- }, 1500);
18
+ }, duration);
11
19
  }
12
20
 
13
21
  export function copyToClipboard(text) {
@@ -6,3 +6,4 @@
6
6
  @import url("css/rewind.css");
7
7
  @import url("css/input.css");
8
8
  @import url("css/filebrowser.css");
9
+ @import url("css/diff.css");
package/lib/public/sw.js CHANGED
@@ -10,6 +10,9 @@ self.addEventListener("push", function (event) {
10
10
  var data = {};
11
11
  try { data = event.data.json(); } catch (e) { return; }
12
12
 
13
+ // Silent validation push, do not show notification
14
+ if (data.type === "test") return;
15
+
13
16
  var options = {
14
17
  body: data.body || "",
15
18
  tag: data.tag || "claude-relay",
@@ -31,9 +34,12 @@ self.addEventListener("push", function (event) {
31
34
 
32
35
  event.waitUntil(
33
36
  self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clientList) {
34
- // Skip notification if app is focused (user is already looking at it)
35
- for (var i = 0; i < clientList.length; i++) {
36
- if (clientList[i].focused || clientList[i].visibilityState === "visible") return;
37
+ // Always show permission requests, questions, and errors
38
+ // Only suppress "done" notifications when app is in foreground
39
+ if (data.type !== "permission_request" && data.type !== "ask_user" && data.type !== "error") {
40
+ for (var i = 0; i < clientList.length; i++) {
41
+ if (clientList[i].focused || clientList[i].visibilityState === "visible") return;
42
+ }
37
43
  }
38
44
  return self.registration.showNotification(data.title || "Claude Relay", options);
39
45
  }).catch(function () {})
@@ -44,18 +50,26 @@ self.addEventListener("notificationclick", function (event) {
44
50
  var data = event.notification.data || {};
45
51
  event.notification.close();
46
52
 
47
- // Default click: focus existing window or open new one
48
- // Use the service worker's scope as the base URL for this project
49
- var scopeUrl = self.registration.scope || "/";
53
+ // Build target URL from slug so we open the correct project
54
+ var baseUrl = self.registration.scope || "/";
55
+ var targetUrl = data.slug ? baseUrl + "p/" + data.slug + "/" : baseUrl;
56
+
50
57
  event.waitUntil(
51
58
  self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clientList) {
59
+ // Prefer a client already on the correct project
60
+ for (var i = 0; i < clientList.length; i++) {
61
+ if (clientList[i].url.indexOf(targetUrl) !== -1) {
62
+ return clientList[i].focus();
63
+ }
64
+ }
65
+ // Fall back to any visible client
52
66
  for (var i = 0; i < clientList.length; i++) {
53
67
  if (clientList[i].visibilityState !== "hidden") {
54
68
  return clientList[i].focus();
55
69
  }
56
70
  }
57
71
  if (clientList.length > 0) return clientList[0].focus();
58
- return self.clients.openWindow(scopeUrl);
72
+ return self.clients.openWindow(targetUrl);
59
73
  })
60
74
  );
61
75
  });
package/lib/push.js CHANGED
@@ -75,8 +75,12 @@ function initPush() {
75
75
  })(startupEndpoints[si]);
76
76
  }
77
77
 
78
- function addSubscription(sub) {
78
+ function addSubscription(sub, replaceEndpoint) {
79
79
  if (!sub || !sub.endpoint) return;
80
+ // Remove previous subscription from the same client if endpoint changed
81
+ if (replaceEndpoint && replaceEndpoint !== sub.endpoint) {
82
+ subscriptions.delete(replaceEndpoint);
83
+ }
80
84
  // Store immediately, then validate async. Invalid subs get cleaned on first sendPush.
81
85
  subscriptions.set(sub.endpoint, sub);
82
86
  save();
package/lib/sdk-bridge.js CHANGED
@@ -43,10 +43,12 @@ function createMessageQueue() {
43
43
 
44
44
  function createSDKBridge(opts) {
45
45
  var cwd = opts.cwd;
46
+ var slug = opts.slug || "";
46
47
  var sm = opts.sessionManager; // session manager instance
47
48
  var send = opts.send; // broadcast to all clients
48
49
  var pushModule = opts.pushModule;
49
50
  var getSDK = opts.getSDK;
51
+ var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
50
52
 
51
53
  function sendAndRecord(session, obj) {
52
54
  sm.sendAndRecord(session, obj);
@@ -139,6 +141,7 @@ function createSDKBridge(opts) {
139
141
  var q = input.questions[0];
140
142
  pushModule.sendPush({
141
143
  type: "ask_user",
144
+ slug: slug,
142
145
  title: "Claude has a question",
143
146
  body: q ? q.question : "Waiting for your response",
144
147
  tag: "claude-ask",
@@ -219,6 +222,7 @@ function createSDKBridge(opts) {
219
222
  cost: parsed.total_cost_usd,
220
223
  duration: parsed.duration_ms,
221
224
  usage: parsed.usage || null,
225
+ modelUsage: parsed.modelUsage || null,
222
226
  sessionId: parsed.session_id,
223
227
  });
224
228
  sendAndRecord(session, { type: "done", code: 0 });
@@ -227,6 +231,7 @@ function createSDKBridge(opts) {
227
231
  if (preview.length > 140) preview = preview.substring(0, 140) + "...";
228
232
  pushModule.sendPush({
229
233
  type: "done",
234
+ slug: slug,
230
235
  title: session.title || "Claude",
231
236
  body: preview || "Response ready",
232
237
  tag: "claude-done",
@@ -298,6 +303,7 @@ function createSDKBridge(opts) {
298
303
  if (pushModule) {
299
304
  pushModule.sendPush({
300
305
  type: "permission_request",
306
+ slug: slug,
301
307
  requestId: requestId,
302
308
  title: permissionPushTitle(toolName, input),
303
309
  body: permissionPushBody(toolName, input),
@@ -331,6 +337,7 @@ function createSDKBridge(opts) {
331
337
  if (pushModule) {
332
338
  pushModule.sendPush({
333
339
  type: "error",
340
+ slug: slug,
334
341
  title: "Connection Lost",
335
342
  body: "Claude process disconnected: " + (err.message || "unknown error"),
336
343
  tag: "claude-error",
@@ -343,6 +350,8 @@ function createSDKBridge(opts) {
343
350
  session.queryInstance = null;
344
351
  session.messageQueue = null;
345
352
  session.abortController = null;
353
+ session.pendingPermissions = {};
354
+ session.pendingAskUser = {};
346
355
  }
347
356
  }
348
357
 
@@ -385,7 +394,10 @@ function createSDKBridge(opts) {
385
394
  try {
386
395
  sdk = await getSDK();
387
396
  } catch (e) {
397
+ session.isProcessing = false;
388
398
  send({ type: "error", text: "Failed to load Claude SDK: " + (e.message || e) });
399
+ sendAndRecord(session, { type: "done", code: 1 });
400
+ sm.broadcastSessionList();
389
401
  return;
390
402
  }
391
403
 
@@ -428,6 +440,11 @@ function createSDKBridge(opts) {
428
440
  },
429
441
  };
430
442
 
443
+ if (dangerouslySkipPermissions) {
444
+ queryOptions.permissionMode = "bypassPermissions";
445
+ queryOptions.allowDangerouslySkipPermissions = true;
446
+ }
447
+
431
448
  if (session.cliSessionId) {
432
449
  queryOptions.resume = session.cliSessionId;
433
450
  if (session.lastRewindUuid) {
@@ -436,10 +453,21 @@ function createSDKBridge(opts) {
436
453
  }
437
454
  }
438
455
 
439
- session.queryInstance = sdk.query({
440
- prompt: session.messageQueue,
441
- options: queryOptions,
442
- });
456
+ try {
457
+ session.queryInstance = sdk.query({
458
+ prompt: session.messageQueue,
459
+ options: queryOptions,
460
+ });
461
+ } catch (e) {
462
+ session.isProcessing = false;
463
+ session.queryInstance = null;
464
+ session.messageQueue = null;
465
+ session.abortController = null;
466
+ send({ type: "error", text: "Failed to start query: " + (e.message || e) });
467
+ sendAndRecord(session, { type: "done", code: 1 });
468
+ sm.broadcastSessionList();
469
+ return;
470
+ }
443
471
 
444
472
  processQueryStream(session).catch(function(err) {
445
473
  });
@@ -515,9 +543,14 @@ function createSDKBridge(opts) {
515
543
  var mq = createMessageQueue();
516
544
  mq.push({ type: "user", message: { role: "user", content: [{ type: "text", text: "hi" }] } });
517
545
  mq.end();
546
+ var warmupOptions = { cwd: cwd, settingSources: ["user", "project", "local"], abortController: ac };
547
+ if (dangerouslySkipPermissions) {
548
+ warmupOptions.permissionMode = "bypassPermissions";
549
+ warmupOptions.allowDangerouslySkipPermissions = true;
550
+ }
518
551
  var stream = sdk.query({
519
552
  prompt: mq,
520
- options: { cwd: cwd, settingSources: ["user", "project", "local"], abortController: ac },
553
+ options: warmupOptions,
521
554
  });
522
555
  for await (var msg of stream) {
523
556
  if (msg.type === "system" && msg.subtype === "init") {
package/lib/server.js CHANGED
@@ -114,7 +114,7 @@ function stripPrefix(urlPath, slug) {
114
114
 
115
115
  /**
116
116
  * Create a multi-project server.
117
- * opts: { tlsOptions, caPath, pinHash, port, debug }
117
+ * opts: { tlsOptions, caPath, pinHash, port, debug, dangerouslySkipPermissions }
118
118
  */
119
119
  function createServer(opts) {
120
120
  var tlsOptions = opts.tlsOptions || null;
@@ -122,6 +122,8 @@ function createServer(opts) {
122
122
  var pinHash = opts.pinHash || null;
123
123
  var portNum = opts.port || 2633;
124
124
  var debug = opts.debug || false;
125
+ var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
126
+ var lanHost = opts.lanHost || null;
125
127
 
126
128
  var authToken = pinHash || null;
127
129
  var realVersion = require("../package.json").version;
@@ -228,8 +230,9 @@ function createServer(opts) {
228
230
  req.on("data", function (chunk) { body += chunk; });
229
231
  req.on("end", function () {
230
232
  try {
231
- var sub = JSON.parse(body);
232
- pushModule.addSubscription(sub);
233
+ var parsed = JSON.parse(body);
234
+ var sub = parsed.subscription || parsed;
235
+ pushModule.addSubscription(sub, parsed.replaceEndpoint);
233
236
  res.writeHead(200, { "Content-Type": "application/json" });
234
237
  res.end('{"ok":true}');
235
238
  } catch (e) {
@@ -247,7 +250,8 @@ function createServer(opts) {
247
250
  res.end(pinPage);
248
251
  return;
249
252
  }
250
- if (projects.size === 1) {
253
+ var hasGoneParam = req.url.indexOf("gone=") !== -1;
254
+ if (projects.size === 1 && !hasGoneParam) {
251
255
  var slug = projects.keys().next().value;
252
256
  res.writeHead(302, { "Location": "/p/" + slug + "/" });
253
257
  res.end();
@@ -294,8 +298,8 @@ function createServer(opts) {
294
298
 
295
299
  var ctx = projects.get(slug);
296
300
  if (!ctx) {
297
- res.writeHead(404);
298
- res.end("Project not found: " + slug);
301
+ res.writeHead(302, { "Location": "/?gone=" + encodeURIComponent(slug) });
302
+ res.end();
299
303
  return;
300
304
  }
301
305
 
@@ -402,7 +406,19 @@ function createServer(opts) {
402
406
  if (origin) {
403
407
  try {
404
408
  var originUrl = new URL(origin);
405
- if (String(originUrl.port || (originUrl.protocol === "https:" ? "443" : "80")) !== String(portNum)) {
409
+ var originPort = String(originUrl.port || (originUrl.protocol === "https:" ? "443" : "80"));
410
+ // Extract port from Host header for reverse proxy support.
411
+ // Use URL parser to correctly handle IPv6 addresses (e.g. [::1])
412
+ // and infer default port from origin protocol (not backend tlsOptions)
413
+ // so TLS-terminating proxies on :443 with HTTP backends work.
414
+ var hostPort;
415
+ try {
416
+ var hostUrl = new URL(originUrl.protocol + "//" + (req.headers.host || ""));
417
+ hostPort = String(hostUrl.port || (originUrl.protocol === "https:" ? "443" : "80"));
418
+ } catch (e2) {
419
+ hostPort = String(portNum);
420
+ }
421
+ if (originPort !== String(portNum) && originPort !== hostPort) {
406
422
  socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
407
423
  socket.destroy();
408
424
  return;
@@ -447,7 +463,9 @@ function createServer(opts) {
447
463
  title: title || null,
448
464
  pushModule: pushModule,
449
465
  debug: debug,
466
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
450
467
  currentVersion: currentVersion,
468
+ lanHost: lanHost,
451
469
  getProjectCount: function () { return projects.size; },
452
470
  getProjectList: function () {
453
471
  var list = [];
@@ -487,6 +505,20 @@ function createServer(opts) {
487
505
  authToken = hash;
488
506
  }
489
507
 
508
+ function broadcastAll(msg) {
509
+ projects.forEach(function (ctx) {
510
+ ctx.send(msg);
511
+ });
512
+ }
513
+
514
+ function destroyAll() {
515
+ projects.forEach(function (ctx, slug) {
516
+ console.log("[server] Destroying project:", slug);
517
+ ctx.destroy();
518
+ });
519
+ projects.clear();
520
+ }
521
+
490
522
  return {
491
523
  server: server,
492
524
  onboardingServer: onboardingServer,
@@ -496,6 +528,8 @@ function createServer(opts) {
496
528
  getProjects: getProjects,
497
529
  setProjectTitle: setProjectTitle,
498
530
  setAuthToken: setAuthToken,
531
+ broadcastAll: broadcastAll,
532
+ destroyAll: destroyAll,
499
533
  };
500
534
  }
501
535
 
package/lib/sessions.js CHANGED
@@ -25,13 +25,15 @@ function createSessionManager(opts) {
25
25
  if (!session.cliSessionId) return;
26
26
  session.lastActivity = Date.now();
27
27
  try {
28
- var meta = JSON.stringify({
28
+ var metaObj = {
29
29
  type: "meta",
30
30
  localId: session.localId,
31
31
  cliSessionId: session.cliSessionId,
32
32
  title: session.title,
33
33
  createdAt: session.createdAt,
34
- });
34
+ };
35
+ if (session.lastRewindUuid) metaObj.lastRewindUuid = session.lastRewindUuid;
36
+ var meta = JSON.stringify(metaObj);
35
37
  var lines = [meta];
36
38
  for (var i = 0; i < session.history.length; i++) {
37
39
  lines.push(JSON.stringify(session.history[i]));
@@ -105,6 +107,7 @@ function createSessionManager(opts) {
105
107
  lastActivity: loaded[i].mtime || m.createdAt || Date.now(),
106
108
  history: loaded[i].history,
107
109
  messageUUIDs: messageUUIDs,
110
+ lastRewindUuid: m.lastRewindUuid || null,
108
111
  };
109
112
  sessions.set(localId, session);
110
113
  }
@@ -278,9 +281,15 @@ function createSessionManager(opts) {
278
281
  if (sessions.size === 0) {
279
282
  createSession();
280
283
  } else {
281
- // Activate the most recent session
282
- var lastSession = [...sessions.values()].pop();
283
- activeSessionId = lastSession.localId;
284
+ // Activate the most recently used session
285
+ var allSessions = [...sessions.values()];
286
+ var mostRecent = allSessions[0];
287
+ for (var i = 1; i < allSessions.length; i++) {
288
+ if ((allSessions[i].lastActivity || 0) > (mostRecent.lastActivity || 0)) {
289
+ mostRecent = allSessions[i];
290
+ }
291
+ }
292
+ activeSessionId = mostRecent.localId;
284
293
  }
285
294
 
286
295
  function searchSessions(query) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-relay",
3
- "version": "2.2.4",
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"