claude-relay 2.2.1 → 2.2.3
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 +54 -26
- package/lib/pages.js +24 -6
- package/lib/project.js +0 -9
- package/lib/public/app.js +1 -146
- package/lib/public/css/menus.css +18 -102
- package/lib/public/index.html +5 -32
- package/lib/public/modules/notifications.js +0 -9
- package/lib/server.js +5 -3
- package/package.json +1 -1
- package/lib/usage.js +0 -90
package/bin/cli.js
CHANGED
|
@@ -312,6 +312,33 @@ function getLocalIP() {
|
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
// --- Certs ---
|
|
315
|
+
function isRoutableIP(addr) {
|
|
316
|
+
if (addr.startsWith("10.")) return true;
|
|
317
|
+
if (addr.startsWith("192.168.")) return true;
|
|
318
|
+
if (addr.startsWith("100.")) {
|
|
319
|
+
var second = parseInt(addr.split(".")[1], 10);
|
|
320
|
+
return second >= 64 && second <= 127; // CGNAT (Tailscale)
|
|
321
|
+
}
|
|
322
|
+
if (addr.startsWith("172.")) {
|
|
323
|
+
var second = parseInt(addr.split(".")[1], 10);
|
|
324
|
+
return second >= 16 && second <= 31;
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function getAllIPs() {
|
|
330
|
+
var ips = [];
|
|
331
|
+
var ifaces = os.networkInterfaces();
|
|
332
|
+
for (var addrs of Object.values(ifaces)) {
|
|
333
|
+
for (var j = 0; j < addrs.length; j++) {
|
|
334
|
+
if (addrs[j].family === "IPv4" && !addrs[j].internal && isRoutableIP(addrs[j].address)) {
|
|
335
|
+
ips.push(addrs[j].address);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return ips;
|
|
340
|
+
}
|
|
341
|
+
|
|
315
342
|
function ensureCerts(ip) {
|
|
316
343
|
var homeDir = os.homedir();
|
|
317
344
|
var certDir = path.join(homeDir, ".claude-relay", "certs");
|
|
@@ -336,21 +363,29 @@ function ensureCerts(ip) {
|
|
|
336
363
|
if (!fs.existsSync(caRoot)) caRoot = null;
|
|
337
364
|
} catch (e) {}
|
|
338
365
|
|
|
366
|
+
// Collect all IPv4 addresses (Tailscale + LAN)
|
|
367
|
+
var allIPs = getAllIPs();
|
|
368
|
+
|
|
339
369
|
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
|
340
370
|
var needRegen = false;
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (certText.indexOf(
|
|
345
|
-
|
|
346
|
-
|
|
371
|
+
try {
|
|
372
|
+
var certText = execFileSync("openssl", ["x509", "-in", certPath, "-text", "-noout"], { encoding: "utf8" });
|
|
373
|
+
for (var i = 0; i < allIPs.length; i++) {
|
|
374
|
+
if (certText.indexOf(allIPs[i]) === -1) {
|
|
375
|
+
needRegen = true;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} catch (e) { needRegen = true; }
|
|
347
380
|
if (!needRegen) return { key: keyPath, cert: certPath, caRoot: caRoot };
|
|
348
381
|
}
|
|
349
382
|
|
|
350
383
|
fs.mkdirSync(certDir, { recursive: true });
|
|
351
384
|
|
|
352
385
|
var domains = ["localhost", "127.0.0.1", "::1"];
|
|
353
|
-
|
|
386
|
+
for (var i = 0; i < allIPs.length; i++) {
|
|
387
|
+
if (domains.indexOf(allIPs[i]) === -1) domains.push(allIPs[i]);
|
|
388
|
+
}
|
|
354
389
|
|
|
355
390
|
try {
|
|
356
391
|
execSync(
|
|
@@ -1435,22 +1470,6 @@ function showSetupGuide(config, ip, goBack) {
|
|
|
1435
1470
|
var wantRemote = false;
|
|
1436
1471
|
var wantPush = false;
|
|
1437
1472
|
|
|
1438
|
-
// If everything is already set up, skip straight to QR
|
|
1439
|
-
var tsReady = getTailscaleIP() !== null;
|
|
1440
|
-
var mcReady = hasMkcert();
|
|
1441
|
-
if (tsReady && mcReady && config.tls) {
|
|
1442
|
-
console.clear();
|
|
1443
|
-
printLogo();
|
|
1444
|
-
log("");
|
|
1445
|
-
log(sym.pointer + " " + a.bold + "Setup Notifications" + a.reset);
|
|
1446
|
-
log(sym.bar);
|
|
1447
|
-
log(sym.done + " " + a.green + "Tailscale" + a.reset + a.dim + " · " + getTailscaleIP() + a.reset);
|
|
1448
|
-
log(sym.done + " " + a.green + "HTTPS" + a.reset + a.dim + " · mkcert installed" + a.reset);
|
|
1449
|
-
log(sym.bar);
|
|
1450
|
-
showSetupQR();
|
|
1451
|
-
return;
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
1473
|
console.clear();
|
|
1455
1474
|
printLogo();
|
|
1456
1475
|
log("");
|
|
@@ -1564,10 +1583,19 @@ function showSetupGuide(config, ip, goBack) {
|
|
|
1564
1583
|
|
|
1565
1584
|
function showSetupQR() {
|
|
1566
1585
|
var tsIP = getTailscaleIP();
|
|
1586
|
+
var lanIP = null;
|
|
1587
|
+
if (!wantRemote) {
|
|
1588
|
+
var allIPs = getAllIPs();
|
|
1589
|
+
for (var j = 0; j < allIPs.length; j++) {
|
|
1590
|
+
if (!allIPs[j].startsWith("100.")) { lanIP = allIPs[j]; break; }
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
var setupIP = wantRemote ? (tsIP || ip) : (lanIP || ip);
|
|
1594
|
+
var setupQuery = wantRemote ? "" : "?mode=lan";
|
|
1567
1595
|
// Always use HTTP onboarding URL for QR/setup when TLS is active
|
|
1568
1596
|
var setupUrl = config.tls
|
|
1569
|
-
? "http://" +
|
|
1570
|
-
: "http://" +
|
|
1597
|
+
? "http://" + setupIP + ":" + (config.port + 1) + "/setup" + setupQuery
|
|
1598
|
+
: "http://" + setupIP + ":" + config.port + "/setup" + setupQuery;
|
|
1571
1599
|
log(sym.pointer + " " + a.bold + "Continue on your device" + a.reset);
|
|
1572
1600
|
log(sym.bar + " " + a.dim + "Scan the QR code or open:" + a.reset);
|
|
1573
1601
|
log(sym.bar + " " + a.bold + setupUrl + a.reset);
|
|
@@ -1576,7 +1604,7 @@ function showSetupGuide(config, ip, goBack) {
|
|
|
1576
1604
|
var lines = code.split("\n").map(function (l) { return " " + sym.bar + " " + l; }).join("\n");
|
|
1577
1605
|
console.log(lines);
|
|
1578
1606
|
log(sym.bar);
|
|
1579
|
-
if (
|
|
1607
|
+
if (wantRemote) {
|
|
1580
1608
|
log(sym.bar + " " + a.dim + "Can't connect? Make sure Tailscale is installed on your phone too." + a.reset);
|
|
1581
1609
|
} else {
|
|
1582
1610
|
log(sym.bar + " " + a.dim + "Can't connect? Your phone must be on the same Wi-Fi network." + a.reset);
|
package/lib/pages.js
CHANGED
|
@@ -40,7 +40,7 @@ function pinPageHtml() {
|
|
|
40
40
|
'</script></div></body></html>';
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function setupPageHtml(httpsUrl, httpUrl, hasCert) {
|
|
43
|
+
function setupPageHtml(httpsUrl, httpUrl, hasCert, lanMode) {
|
|
44
44
|
return `<!DOCTYPE html><html lang="en"><head>
|
|
45
45
|
<meta charset="UTF-8">
|
|
46
46
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
@@ -195,6 +195,7 @@ h1{color:#DA7756;font-size:22px;margin:0 0 4px;text-align:center}
|
|
|
195
195
|
<div class="step-desc">Install Claude Relay as an app for quick access and a full-screen experience.</div>
|
|
196
196
|
|
|
197
197
|
<div class="platform-ios">
|
|
198
|
+
<div class="check-status warn">On iOS, push notifications only work from the installed app. This step is required.</div>
|
|
198
199
|
<div id="ios-not-safari" class="check-status warn" style="display:none">You must use <b>Safari</b> to install. Open this page in Safari first.</div>
|
|
199
200
|
<div id="ios-safari-steps">
|
|
200
201
|
<div class="instruction"><div class="inst-num">1</div>
|
|
@@ -237,6 +238,7 @@ h1{color:#DA7756;font-size:22px;margin:0 0 4px;text-align:center}
|
|
|
237
238
|
</div>
|
|
238
239
|
|
|
239
240
|
<div id="pwa-status" class="check-status pending">After installing, open Claude Relay from your home screen to continue setup.</div>
|
|
241
|
+
<button class="skip-link" id="pwa-skip" onclick="nextStep()" style="display:none">Skip for now</button>
|
|
240
242
|
</div>
|
|
241
243
|
|
|
242
244
|
<!-- Step 3: Push Notifications -->
|
|
@@ -270,6 +272,7 @@ h1{color:#DA7756;font-size:22px;margin:0 0 4px;text-align:center}
|
|
|
270
272
|
var httpsUrl = ${JSON.stringify(httpsUrl)};
|
|
271
273
|
var httpUrl = ${JSON.stringify(httpUrl)};
|
|
272
274
|
var hasCert = ${hasCert ? 'true' : 'false'};
|
|
275
|
+
var lanMode = ${lanMode ? 'true' : 'false'};
|
|
273
276
|
var isHttps = location.protocol === "https:";
|
|
274
277
|
var ua = navigator.userAgent;
|
|
275
278
|
var isIOS = /iPhone|iPad|iPod/.test(ua);
|
|
@@ -326,10 +329,17 @@ if (isStandalone && localStorage.getItem("setup-pending")) {
|
|
|
326
329
|
|
|
327
330
|
function buildSteps(hasPushSub) {
|
|
328
331
|
steps = [];
|
|
329
|
-
if (!isTailscale && !isLocal) steps.push("tailscale");
|
|
332
|
+
if (!isTailscale && !isLocal && !lanMode) steps.push("tailscale");
|
|
330
333
|
if (hasCert && !isHttps) steps.push("cert");
|
|
331
|
-
if (
|
|
332
|
-
|
|
334
|
+
if (isAndroid) {
|
|
335
|
+
// Android: push first (works in browser), then PWA as optional
|
|
336
|
+
if ((isHttps || isLocal) && !hasPushSub) steps.push("push");
|
|
337
|
+
if (!isStandalone) steps.push("pwa");
|
|
338
|
+
} else {
|
|
339
|
+
// iOS: PWA required for push, so install first
|
|
340
|
+
if (!isStandalone) steps.push("pwa");
|
|
341
|
+
if ((isHttps || isLocal) && !hasPushSub) steps.push("push");
|
|
342
|
+
}
|
|
333
343
|
steps.push("done");
|
|
334
344
|
|
|
335
345
|
// Trigger HTTPS check now that steps are built
|
|
@@ -350,6 +360,14 @@ function buildSteps(hasPushSub) {
|
|
|
350
360
|
localStorage.setItem("setup-pending", String(stepsBeforePwa + 1));
|
|
351
361
|
}
|
|
352
362
|
|
|
363
|
+
// Android: PWA is optional, show skip button and update text
|
|
364
|
+
if (isAndroid && steps.indexOf("pwa") !== -1) {
|
|
365
|
+
var pwaSkip = document.getElementById("pwa-skip");
|
|
366
|
+
var pwaStatus = document.getElementById("pwa-status");
|
|
367
|
+
if (pwaSkip) pwaSkip.style.display = "block";
|
|
368
|
+
if (pwaStatus) pwaStatus.textContent = "Optional: install for quick access and full-screen experience.";
|
|
369
|
+
}
|
|
370
|
+
|
|
353
371
|
// Push: show warning if not on HTTPS
|
|
354
372
|
if (!isHttps && !isLocal) {
|
|
355
373
|
pushBtn.style.display = "none";
|
|
@@ -393,7 +411,7 @@ function showStep(idx) {
|
|
|
393
411
|
function nextStep() {
|
|
394
412
|
// After cert step on HTTP, redirect to HTTPS for remaining steps
|
|
395
413
|
if (!isHttps && steps[currentStep] === "cert") {
|
|
396
|
-
location.replace(httpsUrl + "/setup");
|
|
414
|
+
location.replace(httpsUrl + "/setup" + (lanMode ? "?mode=lan" : ""));
|
|
397
415
|
return;
|
|
398
416
|
}
|
|
399
417
|
if (currentStep < steps.length - 1) showStep(currentStep + 1);
|
|
@@ -616,7 +634,7 @@ if (!isHttps && !isLocal) {
|
|
|
616
634
|
var ac = new AbortController();
|
|
617
635
|
setTimeout(function() { ac.abort(); }, 3000);
|
|
618
636
|
fetch(info.httpsUrl + "/info", { signal: ac.signal, mode: "no-cors" })
|
|
619
|
-
.then(function() { location.replace(info.httpsUrl + "/setup"); })
|
|
637
|
+
.then(function() { location.replace(info.httpsUrl + "/setup" + (lanMode ? "?mode=lan" : "")); })
|
|
620
638
|
.catch(function() { init(); });
|
|
621
639
|
}).catch(function() { init(); });
|
|
622
640
|
} else {
|
package/lib/project.js
CHANGED
|
@@ -4,7 +4,6 @@ var { createSessionManager } = require("./sessions");
|
|
|
4
4
|
var { createSDKBridge } = require("./sdk-bridge");
|
|
5
5
|
var { createTerminalManager } = require("./terminal-manager");
|
|
6
6
|
var { fetchLatestVersion, isNewer } = require("./updater");
|
|
7
|
-
var { fetchUsageData } = require("./usage");
|
|
8
7
|
var { execFileSync } = require("child_process");
|
|
9
8
|
|
|
10
9
|
// SDK loaded dynamically (ESM module)
|
|
@@ -317,14 +316,6 @@ function createProjectContext(opts) {
|
|
|
317
316
|
return;
|
|
318
317
|
}
|
|
319
318
|
|
|
320
|
-
if (msg.type === "get_usage") {
|
|
321
|
-
fetchUsageData().then(function (data) {
|
|
322
|
-
sendTo(ws, { type: "usage_data", data: data });
|
|
323
|
-
}).catch(function (err) {
|
|
324
|
-
sendTo(ws, { type: "usage_data", error: err.message || "Failed to fetch usage data" });
|
|
325
|
-
});
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
319
|
|
|
329
320
|
if (msg.type === "set_model" && msg.model) {
|
|
330
321
|
var session = sm.getActiveSession();
|
package/lib/public/app.js
CHANGED
|
@@ -502,8 +502,6 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
|
|
|
502
502
|
connectOverlay.classList.add("hidden");
|
|
503
503
|
stopVerbCycle();
|
|
504
504
|
updateFavicon("#57AB5A");
|
|
505
|
-
if (usageFab) usageFab.classList.remove("hidden");
|
|
506
|
-
if (usageHeaderBtn) usageHeaderBtn.classList.remove("hidden");
|
|
507
505
|
} else if (status === "processing") {
|
|
508
506
|
statusDot.classList.add("processing");
|
|
509
507
|
processing = true;
|
|
@@ -605,8 +603,6 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
|
|
|
605
603
|
// --- Usage panel ---
|
|
606
604
|
var usagePanel = $("usage-panel");
|
|
607
605
|
var usagePanelClose = $("usage-panel-close");
|
|
608
|
-
var usageFab = $("usage-fab");
|
|
609
|
-
var usageHeaderBtn = $("usage-header-btn");
|
|
610
606
|
var usageCostEl = $("usage-cost");
|
|
611
607
|
var usageInputEl = $("usage-input");
|
|
612
608
|
var usageOutputEl = $("usage-output");
|
|
@@ -615,126 +611,12 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
|
|
|
615
611
|
var usageTurnsEl = $("usage-turns");
|
|
616
612
|
var sessionUsage = { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 };
|
|
617
613
|
|
|
618
|
-
// Rate limit bar elements
|
|
619
|
-
var usageLoading = $("usage-loading");
|
|
620
|
-
var usageError = $("usage-error");
|
|
621
|
-
var usageBars = $("usage-bars");
|
|
622
|
-
var usageRateLimitFetched = false;
|
|
623
|
-
|
|
624
614
|
function formatTokens(n) {
|
|
625
615
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
|
626
616
|
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
|
|
627
617
|
return String(n);
|
|
628
618
|
}
|
|
629
619
|
|
|
630
|
-
function formatTimeUntil(isoStr) {
|
|
631
|
-
if (!isoStr) return "";
|
|
632
|
-
var d = new Date(isoStr);
|
|
633
|
-
var now = Date.now();
|
|
634
|
-
var diff = d.getTime() - now;
|
|
635
|
-
if (diff <= 0) return "Resets soon";
|
|
636
|
-
var hours = Math.floor(diff / 3600000);
|
|
637
|
-
var minutes = Math.floor((diff % 3600000) / 60000);
|
|
638
|
-
var relative;
|
|
639
|
-
if (hours > 24) {
|
|
640
|
-
var days = Math.floor(hours / 24);
|
|
641
|
-
relative = days + "d " + (hours % 24) + "h";
|
|
642
|
-
} else if (hours > 0) {
|
|
643
|
-
relative = hours + "h " + minutes + "m";
|
|
644
|
-
} else {
|
|
645
|
-
relative = minutes + "m";
|
|
646
|
-
}
|
|
647
|
-
// Absolute time: "Mon 2/17 15:00" or "15:00" if today
|
|
648
|
-
var nowDate = new Date(now);
|
|
649
|
-
var month = d.getMonth() + 1;
|
|
650
|
-
var day = d.getDate();
|
|
651
|
-
var hh = String(d.getHours()).padStart(2, "0");
|
|
652
|
-
var mm = String(d.getMinutes()).padStart(2, "0");
|
|
653
|
-
var sameDay = d.getFullYear() === nowDate.getFullYear() && d.getMonth() === nowDate.getMonth() && d.getDate() === nowDate.getDate();
|
|
654
|
-
var abs = sameDay ? hh + ":" + mm : month + "/" + day + " " + hh + ":" + mm;
|
|
655
|
-
return "Resets in " + relative + " (" + abs + ")";
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
function updateRateLimitBar(prefix, utilization, resetsAt) {
|
|
659
|
-
var pctEl = $("usage-pct-" + prefix);
|
|
660
|
-
var fillEl = $("usage-fill-" + prefix);
|
|
661
|
-
var resetEl = $("usage-reset-" + prefix);
|
|
662
|
-
var groupEl = $("usage-bar-" + prefix);
|
|
663
|
-
if (!pctEl || !fillEl || !resetEl) return;
|
|
664
|
-
|
|
665
|
-
if (utilization == null) {
|
|
666
|
-
if (groupEl) groupEl.classList.add("hidden");
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
if (groupEl) groupEl.classList.remove("hidden");
|
|
670
|
-
|
|
671
|
-
var pct = Math.max(0, Math.min(100, Math.round(utilization)));
|
|
672
|
-
pctEl.textContent = pct + "%";
|
|
673
|
-
fillEl.style.width = pct + "%";
|
|
674
|
-
fillEl.className = "usage-bar-fill";
|
|
675
|
-
if (prefix === "extra") fillEl.classList.add("usage-bar-fill-extra");
|
|
676
|
-
if (pct >= 90) fillEl.classList.add("critical");
|
|
677
|
-
else if (pct >= 70) fillEl.classList.add("warn");
|
|
678
|
-
|
|
679
|
-
resetEl.textContent = formatTimeUntil(resetsAt);
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function handleUsageData(msg) {
|
|
683
|
-
if (!usageLoading || !usageError || !usageBars) return;
|
|
684
|
-
usageLoading.style.display = "none";
|
|
685
|
-
|
|
686
|
-
if (msg.error) {
|
|
687
|
-
usageError.textContent = msg.error;
|
|
688
|
-
usageError.classList.remove("hidden");
|
|
689
|
-
usageBars.classList.add("hidden");
|
|
690
|
-
return;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
usageError.classList.add("hidden");
|
|
694
|
-
usageBars.classList.remove("hidden");
|
|
695
|
-
var data = msg.data || {};
|
|
696
|
-
|
|
697
|
-
// Session (five_hour)
|
|
698
|
-
var session = data.five_hour || {};
|
|
699
|
-
updateRateLimitBar("session", session.utilization, session.resets_at);
|
|
700
|
-
|
|
701
|
-
// Weekly all models (seven_day)
|
|
702
|
-
var weekly = data.seven_day || {};
|
|
703
|
-
updateRateLimitBar("weekly", weekly.utilization, weekly.resets_at);
|
|
704
|
-
|
|
705
|
-
// Weekly Sonnet only (seven_day_sonnet)
|
|
706
|
-
var sonnet = data.seven_day_sonnet || {};
|
|
707
|
-
updateRateLimitBar("sonnet", sonnet.utilization, sonnet.resets_at);
|
|
708
|
-
|
|
709
|
-
// Extra usage
|
|
710
|
-
var extra = data.extra_usage || {};
|
|
711
|
-
var extraGroup = $("usage-bar-extra");
|
|
712
|
-
if (extra.is_enabled) {
|
|
713
|
-
if (extraGroup) extraGroup.classList.remove("hidden");
|
|
714
|
-
// Compute reset time as first of next month
|
|
715
|
-
var now = new Date();
|
|
716
|
-
var nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
717
|
-
updateRateLimitBar("extra", extra.utilization, nextMonth.toISOString());
|
|
718
|
-
var extraResetEl = $("usage-reset-extra");
|
|
719
|
-
if (extraResetEl && extra.monthly_limit != null) {
|
|
720
|
-
var usedDollars = (extra.used_credits / 100).toFixed(2);
|
|
721
|
-
var limitDollars = (extra.monthly_limit / 100).toFixed(2);
|
|
722
|
-
extraResetEl.textContent = "$" + usedDollars + " / $" + limitDollars;
|
|
723
|
-
}
|
|
724
|
-
} else {
|
|
725
|
-
if (extraGroup) extraGroup.classList.add("hidden");
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
usageRateLimitFetched = true;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
function requestUsageData() {
|
|
732
|
-
if (!ws || ws.readyState !== 1) return;
|
|
733
|
-
if (usageLoading) usageLoading.style.display = "";
|
|
734
|
-
if (usageError) usageError.classList.add("hidden");
|
|
735
|
-
ws.send(JSON.stringify({ type: "get_usage" }));
|
|
736
|
-
}
|
|
737
|
-
|
|
738
620
|
function updateUsagePanel() {
|
|
739
621
|
if (!usageCostEl) return;
|
|
740
622
|
usageCostEl.textContent = "$" + sessionUsage.cost.toFixed(4);
|
|
@@ -761,40 +643,17 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
|
|
|
761
643
|
sessionUsage = { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 };
|
|
762
644
|
updateUsagePanel();
|
|
763
645
|
if (usagePanel) usagePanel.classList.add("hidden");
|
|
764
|
-
if (usageFab) usageFab.classList.remove("active");
|
|
765
|
-
if (usageHeaderBtn) usageHeaderBtn.classList.remove("active");
|
|
766
|
-
usageRateLimitFetched = false;
|
|
767
646
|
}
|
|
768
647
|
|
|
769
648
|
function toggleUsagePanel() {
|
|
770
649
|
if (!usagePanel) return;
|
|
771
|
-
|
|
772
|
-
if (usageFab) usageFab.classList.toggle("active", !isHidden);
|
|
773
|
-
if (usageHeaderBtn) usageHeaderBtn.classList.toggle("active", !isHidden);
|
|
774
|
-
// Fetch rate limit data when opening
|
|
775
|
-
if (!isHidden) {
|
|
776
|
-
requestUsageData();
|
|
777
|
-
}
|
|
650
|
+
usagePanel.classList.toggle("hidden");
|
|
778
651
|
refreshIcons();
|
|
779
652
|
}
|
|
780
653
|
|
|
781
654
|
if (usagePanelClose) {
|
|
782
655
|
usagePanelClose.addEventListener("click", function () {
|
|
783
656
|
usagePanel.classList.add("hidden");
|
|
784
|
-
if (usageFab) usageFab.classList.remove("active");
|
|
785
|
-
if (usageHeaderBtn) usageHeaderBtn.classList.remove("active");
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
if (usageFab) {
|
|
790
|
-
usageFab.addEventListener("click", function () {
|
|
791
|
-
toggleUsagePanel();
|
|
792
|
-
});
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
if (usageHeaderBtn) {
|
|
796
|
-
usageHeaderBtn.addEventListener("click", function () {
|
|
797
|
-
toggleUsagePanel();
|
|
798
657
|
});
|
|
799
658
|
}
|
|
800
659
|
|
|
@@ -1195,10 +1054,6 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
|
|
|
1195
1054
|
updateModelSelector(msg.model, msg.models || []);
|
|
1196
1055
|
break;
|
|
1197
1056
|
|
|
1198
|
-
case "usage_data":
|
|
1199
|
-
handleUsageData(msg);
|
|
1200
|
-
break;
|
|
1201
|
-
|
|
1202
1057
|
case "client_count":
|
|
1203
1058
|
var countEl = document.getElementById("client-count");
|
|
1204
1059
|
if (countEl) {
|
package/lib/public/css/menus.css
CHANGED
|
@@ -85,24 +85,6 @@
|
|
|
85
85
|
flex-shrink: 0;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/* --- Usage header button (mobile only) --- */
|
|
89
|
-
#usage-header-btn {
|
|
90
|
-
display: none;
|
|
91
|
-
align-items: center;
|
|
92
|
-
justify-content: center;
|
|
93
|
-
background: none;
|
|
94
|
-
border: 1px solid transparent;
|
|
95
|
-
border-radius: 8px;
|
|
96
|
-
color: var(--text-dimmer);
|
|
97
|
-
cursor: pointer;
|
|
98
|
-
padding: 4px;
|
|
99
|
-
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
|
100
|
-
}
|
|
101
|
-
#usage-header-btn .lucide { width: 15px; height: 15px; }
|
|
102
|
-
#usage-header-btn:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); border-color: var(--border); }
|
|
103
|
-
#usage-header-btn.active { color: var(--accent); border-color: var(--accent); }
|
|
104
|
-
#usage-header-btn.hidden { display: none !important; }
|
|
105
|
-
|
|
106
88
|
/* --- Terminal toggle button --- */
|
|
107
89
|
#terminal-toggle-btn {
|
|
108
90
|
display: flex;
|
|
@@ -485,68 +467,29 @@
|
|
|
485
467
|
padding: 8px 14px 12px;
|
|
486
468
|
}
|
|
487
469
|
|
|
488
|
-
/* ---
|
|
489
|
-
.usage-
|
|
490
|
-
color: var(--text-muted);
|
|
491
|
-
padding: 8px 0;
|
|
492
|
-
text-align: center;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
.usage-error {
|
|
496
|
-
color: var(--error);
|
|
497
|
-
padding: 6px 0;
|
|
498
|
-
font-size: 11px;
|
|
499
|
-
line-height: 1.4;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
.usage-error.hidden { display: none; }
|
|
503
|
-
|
|
504
|
-
.usage-bar-group {
|
|
505
|
-
margin-bottom: 10px;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
.usage-bar-group:last-child { margin-bottom: 0; }
|
|
509
|
-
.usage-bar-group.hidden { display: none; }
|
|
510
|
-
|
|
511
|
-
.usage-bar-label {
|
|
470
|
+
/* --- Plan usage link --- */
|
|
471
|
+
.usage-external-link {
|
|
512
472
|
display: flex;
|
|
513
|
-
justify-content: space-between;
|
|
514
473
|
align-items: center;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
.usage-bar-pct {
|
|
521
|
-
font-family: "SF Mono", Menlo, Monaco, monospace;
|
|
522
|
-
font-weight: 600;
|
|
523
|
-
font-size: 11px;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
.usage-bar-track {
|
|
527
|
-
height: 6px;
|
|
528
|
-
background: var(--input-bg);
|
|
474
|
+
justify-content: center;
|
|
475
|
+
gap: 6px;
|
|
476
|
+
padding: 8px 12px;
|
|
477
|
+
border-radius: 6px;
|
|
529
478
|
border: 1px solid var(--border-subtle);
|
|
530
|
-
|
|
531
|
-
|
|
479
|
+
background: var(--input-bg);
|
|
480
|
+
color: var(--text-secondary);
|
|
481
|
+
font-size: 12px;
|
|
482
|
+
text-decoration: none;
|
|
483
|
+
transition: background 0.15s, color 0.15s;
|
|
532
484
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
border-radius: 3px;
|
|
537
|
-
background: var(--accent);
|
|
538
|
-
transition: width 0.4s ease;
|
|
539
|
-
min-width: 0;
|
|
485
|
+
.usage-external-link:hover {
|
|
486
|
+
background: var(--hover-bg);
|
|
487
|
+
color: var(--text-primary);
|
|
540
488
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
.usage-bar-reset {
|
|
547
|
-
font-size: 10px;
|
|
548
|
-
color: var(--text-muted);
|
|
549
|
-
margin-top: 2px;
|
|
489
|
+
.usage-external-link svg {
|
|
490
|
+
width: 13px;
|
|
491
|
+
height: 13px;
|
|
492
|
+
flex-shrink: 0;
|
|
550
493
|
}
|
|
551
494
|
|
|
552
495
|
.usage-divider {
|
|
@@ -580,31 +523,6 @@
|
|
|
580
523
|
font-weight: 500;
|
|
581
524
|
}
|
|
582
525
|
|
|
583
|
-
/* --- Usage FAB --- */
|
|
584
|
-
#usage-fab {
|
|
585
|
-
position: fixed;
|
|
586
|
-
bottom: calc(var(--safe-bottom, 0px) + 16px);
|
|
587
|
-
right: 16px;
|
|
588
|
-
width: 40px;
|
|
589
|
-
height: 40px;
|
|
590
|
-
border-radius: 50%;
|
|
591
|
-
border: 1px solid var(--border);
|
|
592
|
-
background: var(--bg-alt);
|
|
593
|
-
color: var(--text-muted);
|
|
594
|
-
cursor: pointer;
|
|
595
|
-
display: flex;
|
|
596
|
-
align-items: center;
|
|
597
|
-
justify-content: center;
|
|
598
|
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
599
|
-
z-index: 90;
|
|
600
|
-
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
#usage-fab.hidden { display: none; }
|
|
604
|
-
#usage-fab .lucide { width: 18px; height: 18px; }
|
|
605
|
-
#usage-fab:hover { color: var(--text); border-color: var(--text-dimmer); }
|
|
606
|
-
#usage-fab.active { color: var(--accent); border-color: var(--accent); }
|
|
607
|
-
|
|
608
526
|
@media (max-width: 768px) {
|
|
609
527
|
#usage-panel {
|
|
610
528
|
top: auto;
|
|
@@ -614,8 +532,6 @@
|
|
|
614
532
|
width: auto;
|
|
615
533
|
max-height: 60vh;
|
|
616
534
|
}
|
|
617
|
-
#usage-fab { display: none !important; }
|
|
618
|
-
#usage-header-btn:not(.hidden) { display: flex; }
|
|
619
535
|
}
|
|
620
536
|
|
|
621
537
|
.status-dot.connected { background: var(--success); }
|
package/lib/public/index.html
CHANGED
|
@@ -80,9 +80,6 @@
|
|
|
80
80
|
<i data-lucide="ellipsis" style="width:14px;height:14px"></i>
|
|
81
81
|
</button>
|
|
82
82
|
<div id="sidebar-footer-menu" class="hidden">
|
|
83
|
-
<button class="sidebar-menu-item" id="footer-usage">
|
|
84
|
-
<i data-lucide="gauge"></i> <span>Usage</span>
|
|
85
|
-
</button>
|
|
86
83
|
<a class="sidebar-menu-item" href="https://github.com/chadbyte/claude-relay" target="_blank" rel="noopener">
|
|
87
84
|
<i data-lucide="github"></i> <span>GitHub</span>
|
|
88
85
|
</a>
|
|
@@ -125,7 +122,6 @@
|
|
|
125
122
|
</label>
|
|
126
123
|
</div>
|
|
127
124
|
</div>
|
|
128
|
-
<button id="usage-header-btn" class="hidden" title="Usage"><i data-lucide="dollar-sign"></i></button>
|
|
129
125
|
<button id="terminal-toggle-btn" title="Terminal"><i data-lucide="square-terminal"></i><span id="terminal-count" class="hidden"></span></button>
|
|
130
126
|
<button id="qr-btn" title="Share"><i data-lucide="share"></i></button>
|
|
131
127
|
<div id="qr-overlay" class="hidden">
|
|
@@ -173,33 +169,11 @@
|
|
|
173
169
|
<button id="usage-panel-close" aria-label="Close"><i data-lucide="x"></i></button>
|
|
174
170
|
</div>
|
|
175
171
|
<div class="usage-panel-body">
|
|
176
|
-
<
|
|
177
|
-
<
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
<div class="usage-bar-label"><span>Current session</span><span class="usage-bar-pct" id="usage-pct-session"></span></div>
|
|
182
|
-
<div class="usage-bar-track"><div class="usage-bar-fill" id="usage-fill-session"></div></div>
|
|
183
|
-
<div class="usage-bar-reset" id="usage-reset-session"></div>
|
|
184
|
-
</div>
|
|
185
|
-
<div class="usage-bar-group" id="usage-bar-weekly">
|
|
186
|
-
<div class="usage-bar-label"><span>Current week (all models)</span><span class="usage-bar-pct" id="usage-pct-weekly"></span></div>
|
|
187
|
-
<div class="usage-bar-track"><div class="usage-bar-fill" id="usage-fill-weekly"></div></div>
|
|
188
|
-
<div class="usage-bar-reset" id="usage-reset-weekly"></div>
|
|
189
|
-
</div>
|
|
190
|
-
<div class="usage-bar-group" id="usage-bar-sonnet">
|
|
191
|
-
<div class="usage-bar-label"><span>Current week (Sonnet only)</span><span class="usage-bar-pct" id="usage-pct-sonnet"></span></div>
|
|
192
|
-
<div class="usage-bar-track"><div class="usage-bar-fill" id="usage-fill-sonnet"></div></div>
|
|
193
|
-
<div class="usage-bar-reset" id="usage-reset-sonnet"></div>
|
|
194
|
-
</div>
|
|
195
|
-
<div class="usage-bar-group hidden" id="usage-bar-extra">
|
|
196
|
-
<div class="usage-bar-label"><span>Extra usage</span><span class="usage-bar-pct" id="usage-pct-extra"></span></div>
|
|
197
|
-
<div class="usage-bar-track"><div class="usage-bar-fill usage-bar-fill-extra" id="usage-fill-extra"></div></div>
|
|
198
|
-
<div class="usage-bar-reset" id="usage-reset-extra"></div>
|
|
199
|
-
</div>
|
|
200
|
-
</div>
|
|
201
|
-
</div>
|
|
202
|
-
<div class="usage-divider" id="usage-session-divider"></div>
|
|
172
|
+
<a href="https://claude.ai/settings/usage" target="_blank" rel="noopener" class="usage-external-link">
|
|
173
|
+
<span>Check plan usage on claude.ai</span>
|
|
174
|
+
<i data-lucide="external-link"></i>
|
|
175
|
+
</a>
|
|
176
|
+
<div class="usage-divider"></div>
|
|
203
177
|
<div class="usage-section-label">Session</div>
|
|
204
178
|
<div class="usage-row"><span class="usage-label">Cost</span><span id="usage-cost" class="usage-value">$0.0000</span></div>
|
|
205
179
|
<div class="usage-row"><span class="usage-label">Input tokens</span><span id="usage-input" class="usage-value">0</span></div>
|
|
@@ -211,7 +185,6 @@
|
|
|
211
185
|
</div>
|
|
212
186
|
<div id="todo-sticky" class="hidden"></div>
|
|
213
187
|
<button id="new-msg-btn" class="hidden" aria-label="Scroll to bottom"></button>
|
|
214
|
-
<button id="usage-fab" class="hidden" aria-label="Usage"><i data-lucide="gauge"></i></button>
|
|
215
188
|
<div id="input-area">
|
|
216
189
|
<div id="input-wrapper">
|
|
217
190
|
<div id="slash-menu"></div>
|
|
@@ -137,7 +137,6 @@ export function initNotifications(_ctx) {
|
|
|
137
137
|
var footerBtn = $("sidebar-footer-btn");
|
|
138
138
|
var footerMenu = $("sidebar-footer-menu");
|
|
139
139
|
var footerUpdateCheck = $("footer-update-check");
|
|
140
|
-
var footerUsage = $("footer-usage");
|
|
141
140
|
if (!footerBtn || !footerMenu) return;
|
|
142
141
|
|
|
143
142
|
footerBtn.addEventListener("click", function (e) {
|
|
@@ -151,14 +150,6 @@ export function initNotifications(_ctx) {
|
|
|
151
150
|
}
|
|
152
151
|
});
|
|
153
152
|
|
|
154
|
-
if (footerUsage && ctx.toggleUsagePanel) {
|
|
155
|
-
footerUsage.addEventListener("click", function (e) {
|
|
156
|
-
e.stopPropagation();
|
|
157
|
-
footerMenu.classList.add("hidden");
|
|
158
|
-
ctx.toggleUsagePanel();
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
|
|
162
153
|
function setUpdateIcon(name, spin) {
|
|
163
154
|
var el = footerUpdateCheck.querySelector(".lucide, [data-lucide]");
|
|
164
155
|
if (!el) return;
|
package/lib/server.js
CHANGED
|
@@ -202,16 +202,17 @@ function createServer(opts) {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
// Setup page
|
|
205
|
-
if (
|
|
205
|
+
if (fullUrl === "/setup" && req.method === "GET") {
|
|
206
206
|
var host = req.headers.host || "localhost";
|
|
207
207
|
var hostname = host.split(":")[0];
|
|
208
208
|
var protocol = tlsOptions ? "https" : "http";
|
|
209
209
|
var setupUrl = protocol + "://" + hostname + ":" + portNum;
|
|
210
|
+
var lanMode = /[?&]mode=lan/.test(req.url);
|
|
210
211
|
res.writeHead(200, {
|
|
211
212
|
"Content-Type": "text/html; charset=utf-8",
|
|
212
213
|
"Access-Control-Allow-Origin": "*",
|
|
213
214
|
});
|
|
214
|
-
res.end(setupPageHtml(setupUrl, setupUrl, !!caContent));
|
|
215
|
+
res.end(setupPageHtml(setupUrl, setupUrl, !!caContent, lanMode));
|
|
215
216
|
return;
|
|
216
217
|
}
|
|
217
218
|
|
|
@@ -364,8 +365,9 @@ function createServer(opts) {
|
|
|
364
365
|
var hostname = host.split(":")[0];
|
|
365
366
|
var httpsSetupUrl = "https://" + hostname + ":" + portNum;
|
|
366
367
|
var httpSetupUrl = "http://" + hostname + ":" + (portNum + 1);
|
|
368
|
+
var lanMode = /[?&]mode=lan/.test(req.url);
|
|
367
369
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
368
|
-
res.end(setupPageHtml(httpsSetupUrl, httpSetupUrl, !!caContent));
|
|
370
|
+
res.end(setupPageHtml(httpsSetupUrl, httpSetupUrl, !!caContent, lanMode));
|
|
369
371
|
return;
|
|
370
372
|
}
|
|
371
373
|
|
package/package.json
CHANGED
package/lib/usage.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
var os = require("os");
|
|
2
|
-
var path = require("path");
|
|
3
|
-
var fs = require("fs");
|
|
4
|
-
var { execFileSync } = require("child_process");
|
|
5
|
-
var https = require("https");
|
|
6
|
-
|
|
7
|
-
var BASE_API_URL = "https://api.anthropic.com";
|
|
8
|
-
|
|
9
|
-
function readOAuthToken() {
|
|
10
|
-
// Priority 1: env var override
|
|
11
|
-
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
12
|
-
return process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// Priority 2: macOS keychain
|
|
16
|
-
if (process.platform === "darwin") {
|
|
17
|
-
try {
|
|
18
|
-
var user = os.userInfo().username;
|
|
19
|
-
var data = execFileSync("security", [
|
|
20
|
-
"find-generic-password", "-a", user, "-w", "-s", "Claude Code-credentials"
|
|
21
|
-
], { encoding: "utf8", timeout: 5000 }).trim();
|
|
22
|
-
if (data) {
|
|
23
|
-
var parsed = JSON.parse(data);
|
|
24
|
-
if (parsed.claudeAiOauth && parsed.claudeAiOauth.accessToken) {
|
|
25
|
-
return parsed.claudeAiOauth.accessToken;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
} catch (e) { /* fall through */ }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Priority 3: plaintext credentials file
|
|
32
|
-
var configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
33
|
-
var credFile = path.join(configDir, ".credentials.json");
|
|
34
|
-
try {
|
|
35
|
-
var content = fs.readFileSync(credFile, "utf8");
|
|
36
|
-
var parsed = JSON.parse(content);
|
|
37
|
-
if (parsed.claudeAiOauth && parsed.claudeAiOauth.accessToken) {
|
|
38
|
-
return parsed.claudeAiOauth.accessToken;
|
|
39
|
-
}
|
|
40
|
-
} catch (e) { /* fall through */ }
|
|
41
|
-
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function fetchUsageData() {
|
|
46
|
-
return new Promise(function (resolve, reject) {
|
|
47
|
-
var token = readOAuthToken();
|
|
48
|
-
if (!token) {
|
|
49
|
-
reject(new Error("No OAuth token available"));
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
var url = new URL(BASE_API_URL + "/api/oauth/usage");
|
|
54
|
-
var options = {
|
|
55
|
-
hostname: url.hostname,
|
|
56
|
-
port: url.port || 443,
|
|
57
|
-
path: url.pathname,
|
|
58
|
-
method: "GET",
|
|
59
|
-
headers: {
|
|
60
|
-
"Content-Type": "application/json",
|
|
61
|
-
"Authorization": "Bearer " + token,
|
|
62
|
-
"anthropic-beta": "oauth-2025-04-20",
|
|
63
|
-
"User-Agent": "claude-code/2.0.0",
|
|
64
|
-
},
|
|
65
|
-
timeout: 5000,
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
var req = https.request(options, function (res) {
|
|
69
|
-
var body = "";
|
|
70
|
-
res.on("data", function (chunk) { body += chunk; });
|
|
71
|
-
res.on("end", function () {
|
|
72
|
-
if (res.statusCode !== 200) {
|
|
73
|
-
reject(new Error("Usage API returned " + res.statusCode));
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
try {
|
|
77
|
-
resolve(JSON.parse(body));
|
|
78
|
-
} catch (e) {
|
|
79
|
-
reject(new Error("Invalid JSON from usage API"));
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
req.on("error", function (err) { reject(err); });
|
|
85
|
-
req.on("timeout", function () { req.destroy(); reject(new Error("Usage API timeout")); });
|
|
86
|
-
req.end();
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
module.exports = { fetchUsageData: fetchUsageData };
|