clauderipple 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.ko.md +5 -3
- package/README.md +5 -3
- package/dist/app/dist/main.js +124 -4
- package/dist/app/package.json +1 -0
- package/dist/cli/src/index.js +67 -1
- package/dist/cli/src/tray.js +21 -7
- package/dist/cli/src/update.js +104 -0
- package/dist/router/src/admin.js +32 -2
- package/dist/router/src/index.js +1 -0
- package/dist/router/src/providers/anthropic-usage.js +98 -0
- package/dist/router/src/proxy.js +7 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +23 -2
- package/dist/ui/style.css +1 -0
- package/docs/ARCHITECTURE.md +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 — 2026-09-28
|
|
4
|
+
|
|
5
|
+
Both suggested in #8 by @artisthanbohee-del.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Updates from the tray and the CLI** (#39). The tray checks for a new version at start and
|
|
10
|
+
twice a day and offers it in its menu; **Check for Updates…** asks on demand. In a terminal:
|
|
11
|
+
`clauderipple update` (`--check` only reports). It updates the way ClaudeRipple was
|
|
12
|
+
installed — the install script again, or npm into the same prefix — and restarts the router
|
|
13
|
+
on the new version. The standalone app points to the releases page instead.
|
|
14
|
+
- **Usage of each Claude subscription account** (#40): the five-hour and weekly limits of the
|
|
15
|
+
current Claude login and of every account added in ClaudeRipple, on the Health screen, in the
|
|
16
|
+
Claude provider's Accounts tab and in the tray menu. Read from the same endpoint Claude
|
|
17
|
+
Code's `/usage` uses, so an account that has not answered anything yet still has a number.
|
|
18
|
+
- **The version in the tray's About box.**
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **Re-running the installer now restarts a router still running the old version.** Before,
|
|
23
|
+
the files changed but the router kept the old code until the next login — on Windows and
|
|
24
|
+
Linux always, since an unchanged task or service is not registered again.
|
|
25
|
+
- **The tray survives updates.** Its Electron (~270MB) was fetched into the package folder, and
|
|
26
|
+
every update deleted it; on Windows a running tray could make the update fail. It now lives in
|
|
27
|
+
`~/.clauderipple/tray-runtime`, and the install scripts move an existing copy there instead of
|
|
28
|
+
fetching it again. An install made with plain `npm install -g` fetches it once more with
|
|
29
|
+
`clauderipple tray --install`.
|
|
30
|
+
- **The tray installed from npm reports its own version**, not Electron's: its package manifest
|
|
31
|
+
carried none.
|
|
32
|
+
|
|
3
33
|
## 0.5.1 — 2026-09-28
|
|
4
34
|
|
|
5
35
|
### Fixed
|
package/README.ko.md
CHANGED
|
@@ -280,13 +280,15 @@ clauderipple tray
|
|
|
280
280
|
Electron으로 도는데 용량이 270MB라 기본 설치에는 넣지 않았습니다. `clauderipple tray --install`이 한 번만
|
|
281
281
|
받아 옵니다. 나머지 기능은 Electron 없이도 다 돕니다.
|
|
282
282
|
|
|
283
|
-
**업데이트.**
|
|
283
|
+
**업데이트.** 새 버전이 나오면 트레이가 알려 줍니다(**업데이트 확인…**). 터미널에서는:
|
|
284
284
|
|
|
285
285
|
```sh
|
|
286
|
-
|
|
287
|
-
clauderipple restart
|
|
286
|
+
clauderipple update
|
|
288
287
|
```
|
|
289
288
|
|
|
289
|
+
설치한 방식 그대로(설치 스크립트 또는 npm) 업데이트하고, 라우터를 새 버전으로 다시 시작합니다. 트레이용
|
|
290
|
+
Electron은 업데이트해도 그대로 남습니다.
|
|
291
|
+
|
|
290
292
|
선택 사항, Desktop 피커에 실제 이름을 띄우려면: **클라이언트 → Claude Desktop → 모델 피커 → 켜기**. 그다음 Claude
|
|
291
293
|
Desktop을 완전히 종료했다가 다시 엽니다. 로컬 인증서를 사용자 범위로만 신뢰시키는데, 이때 macOS는 로그인 암호를
|
|
292
294
|
묻고 Windows는 지문이 적힌 확인 창을 띄웁니다. Linux는 묻지 않고 NSS 데이터베이스에 등록합니다. ClaudeRipple은
|
package/README.md
CHANGED
|
@@ -328,13 +328,15 @@ clauderipple tray
|
|
|
328
328
|
It runs on Electron, which is about 270MB and is therefore not installed by default.
|
|
329
329
|
`clauderipple tray --install` fetches it once; everything else works without it.
|
|
330
330
|
|
|
331
|
-
**Updating.**
|
|
331
|
+
**Updating.** The tray offers a new version when there is one (**Check for Updates…**), or:
|
|
332
332
|
|
|
333
333
|
```sh
|
|
334
|
-
|
|
335
|
-
clauderipple restart
|
|
334
|
+
clauderipple update
|
|
336
335
|
```
|
|
337
336
|
|
|
337
|
+
It updates the way you installed — the install script again, or npm — and restarts the router
|
|
338
|
+
on the new version. The tray's Electron is kept across updates.
|
|
339
|
+
|
|
338
340
|
Optional, for real names in the Desktop picker: **Clients → Claude Desktop → Model
|
|
339
341
|
picker → on**, then quit and reopen Claude Desktop. To trust the local certificate
|
|
340
342
|
for your user only, macOS asks for your login password and Windows shows a
|
package/dist/app/dist/main.js
CHANGED
|
@@ -73,6 +73,23 @@ const STRINGS = {
|
|
|
73
73
|
setupNeeded: "setup not finished",
|
|
74
74
|
setupNeededDetail: "ClaudeRipple is installed but not set up yet",
|
|
75
75
|
runSetup: "Finish setting up ClaudeRipple…",
|
|
76
|
+
claudeCurrent: "current login",
|
|
77
|
+
claudeUsage: (label, windows) => `Claude ${label} · ${windows}`,
|
|
78
|
+
window5h: "5h",
|
|
79
|
+
windowWeek: "weekly",
|
|
80
|
+
checkUpdates: "Check for Updates…",
|
|
81
|
+
updateAvailableItem: (v) => `Update to ClaudeRipple ${v}…`,
|
|
82
|
+
upToDate: (v) => `ClaudeRipple ${v} is the latest version.`,
|
|
83
|
+
updateAvailable: (latest, current) => `ClaudeRipple ${latest} is available (you have ${current}).`,
|
|
84
|
+
updateDetail: "The router restarts on the new version once requests in flight have finished. The tray reopens by itself.",
|
|
85
|
+
updateNow: "Update",
|
|
86
|
+
later: "Later",
|
|
87
|
+
updating: (v) => `Updating to ClaudeRipple ${v}…`,
|
|
88
|
+
updateDone: (v) => `Updated to ClaudeRipple ${v}`,
|
|
89
|
+
updateFailed: "The update did not finish",
|
|
90
|
+
updateCheckFailed: "Could not check for updates",
|
|
91
|
+
updatePackaged: "This is the standalone app. Download the new version from the releases page.",
|
|
92
|
+
updateCheckout: "This is a source checkout. Update it with git pull.",
|
|
76
93
|
},
|
|
77
94
|
ko: {
|
|
78
95
|
healthy: "정상",
|
|
@@ -132,6 +149,23 @@ const STRINGS = {
|
|
|
132
149
|
setupNeeded: "설정이 끝나지 않았습니다",
|
|
133
150
|
setupNeededDetail: "설치는 됐지만 아직 설정하지 않았습니다",
|
|
134
151
|
runSetup: "ClaudeRipple 설정 마치기…",
|
|
152
|
+
claudeCurrent: "현재 로그인",
|
|
153
|
+
claudeUsage: (label, windows) => `Claude ${label} · ${windows}`,
|
|
154
|
+
window5h: "5시간",
|
|
155
|
+
windowWeek: "주간",
|
|
156
|
+
checkUpdates: "업데이트 확인…",
|
|
157
|
+
updateAvailableItem: (v) => `새 버전 업데이트: ClaudeRipple ${v}…`,
|
|
158
|
+
upToDate: (v) => `최신 버전입니다: ClaudeRipple ${v}`,
|
|
159
|
+
updateAvailable: (latest, current) => `새 버전이 나왔습니다: ClaudeRipple ${latest} (지금 ${current})`,
|
|
160
|
+
updateDetail: "진행 중인 요청이 끝나면 라우터가 새 버전으로 다시 시작합니다. 트레이는 저절로 다시 열립니다.",
|
|
161
|
+
updateNow: "업데이트",
|
|
162
|
+
later: "나중에",
|
|
163
|
+
updating: (v) => `ClaudeRipple ${v} 업데이트 중…`,
|
|
164
|
+
updateDone: (v) => `ClaudeRipple ${v} 업데이트 완료`,
|
|
165
|
+
updateFailed: "업데이트를 마치지 못했습니다",
|
|
166
|
+
updateCheckFailed: "업데이트를 확인하지 못했습니다",
|
|
167
|
+
updatePackaged: "독립 실행 앱입니다. 릴리스 페이지에서 새 버전을 받으세요.",
|
|
168
|
+
updateCheckout: "소스 체크아웃입니다. git pull로 업데이트하세요.",
|
|
135
169
|
},
|
|
136
170
|
};
|
|
137
171
|
let L = STRINGS.en;
|
|
@@ -223,7 +257,8 @@ async function poll() {
|
|
|
223
257
|
last = null;
|
|
224
258
|
lastError = e.message;
|
|
225
259
|
downSince ??= Date.now();
|
|
226
|
-
|
|
260
|
+
// An update restarts the router on purpose; that is not the outage the notification is for.
|
|
261
|
+
if (!downNotified && !updating && Date.now() - downSince >= DOWN_NOTIFY_AFTER_MS) {
|
|
227
262
|
downNotified = true;
|
|
228
263
|
notifyDown();
|
|
229
264
|
}
|
|
@@ -390,13 +425,77 @@ function cliPaths() {
|
|
|
390
425
|
return { node: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1" }, cli };
|
|
391
426
|
}
|
|
392
427
|
function runCli(args) {
|
|
428
|
+
return runCliResult(args).then((r) => r.output);
|
|
429
|
+
}
|
|
430
|
+
function runCliResult(args) {
|
|
393
431
|
const { node, env, cli } = cliPaths();
|
|
394
432
|
return new Promise((resolve) => {
|
|
395
|
-
|
|
396
|
-
|
|
433
|
+
// An update prints npm's and the installer's own lines; the default 1MB buffer is not for that.
|
|
434
|
+
(0, node_child_process_1.execFile)(node, [cli, ...args], { env: { ...process.env, ...env, CLAUDERIPPLE_HOME: home }, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
435
|
+
resolve({ ok: !err, output: `${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim() });
|
|
397
436
|
});
|
|
398
437
|
});
|
|
399
438
|
}
|
|
439
|
+
let availableUpdate = null;
|
|
440
|
+
let updating = false;
|
|
441
|
+
const UPDATE_CHECK_MS = 12 * 60 * 60 * 1000;
|
|
442
|
+
async function checkForUpdate() {
|
|
443
|
+
const r = await runCliResult(["update", "--check", "--json"]);
|
|
444
|
+
try {
|
|
445
|
+
const check = JSON.parse(r.output.split("\n").pop() ?? "");
|
|
446
|
+
availableUpdate = check.newer ? check : null;
|
|
447
|
+
render();
|
|
448
|
+
return check;
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function checkUpdatesInteractive() {
|
|
455
|
+
const check = await checkForUpdate();
|
|
456
|
+
if (!check) {
|
|
457
|
+
await electron_1.dialog.showMessageBox({ type: "warning", message: L.updateCheckFailed });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (!check.newer) {
|
|
461
|
+
await electron_1.dialog.showMessageBox({ message: L.upToDate(check.current) });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
await offerUpdate(check);
|
|
465
|
+
}
|
|
466
|
+
async function offerUpdate(check) {
|
|
467
|
+
if (check.kind === "packaged") {
|
|
468
|
+
const choice = await electron_1.dialog.showMessageBox({ message: L.updateAvailable(check.latest ?? "?", check.current), detail: L.updatePackaged, buttons: [L.updateNow, L.later], defaultId: 0, cancelId: 1 });
|
|
469
|
+
if (choice.response === 0)
|
|
470
|
+
void electron_1.shell.openExternal("https://github.com/PBJ-2/clauderipple/releases/latest");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (check.kind === "checkout") {
|
|
474
|
+
await electron_1.dialog.showMessageBox({ message: L.updateAvailable(check.latest ?? "?", check.current), detail: L.updateCheckout });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const choice = await electron_1.dialog.showMessageBox({ message: L.updateAvailable(check.latest ?? "?", check.current), detail: L.updateDetail, buttons: [L.updateNow, L.later], defaultId: 0, cancelId: 1 });
|
|
478
|
+
if (choice.response !== 0 || updating)
|
|
479
|
+
return;
|
|
480
|
+
updating = true;
|
|
481
|
+
render();
|
|
482
|
+
const version = check.latest ?? "";
|
|
483
|
+
if (electron_1.Notification.isSupported())
|
|
484
|
+
new electron_1.Notification({ title: "ClaudeRipple", body: L.updating(version) }).show();
|
|
485
|
+
const r = await runCliResult(["update"]);
|
|
486
|
+
updating = false;
|
|
487
|
+
if (!r.ok) {
|
|
488
|
+
render();
|
|
489
|
+
await electron_1.dialog.showMessageBox({ type: "warning", message: L.updateFailed, detail: r.output.slice(-4000) });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
availableUpdate = null;
|
|
493
|
+
if (electron_1.Notification.isSupported())
|
|
494
|
+
new electron_1.Notification({ title: "ClaudeRipple", body: L.updateDone(version) }).show();
|
|
495
|
+
// A new tray from the new files, then this one goes.
|
|
496
|
+
await runCliResult(["tray"]);
|
|
497
|
+
electron_1.app.quit();
|
|
498
|
+
}
|
|
400
499
|
// Offer setup only when there is no working installation at all. A developer who runs the router
|
|
401
500
|
// from a source checkout keeps that; switching to the app's bundled runtime is a menu action.
|
|
402
501
|
function needsSetup() {
|
|
@@ -436,6 +535,14 @@ async function promptForSetup() {
|
|
|
436
535
|
if (choice.response === 0)
|
|
437
536
|
await setup();
|
|
438
537
|
}
|
|
538
|
+
/** "5h 14% · weekly 10%", the same reading the dashboard gives. */
|
|
539
|
+
function windowsText(quota) {
|
|
540
|
+
const limits = quota?.rate_limits;
|
|
541
|
+
return [limits?.primary, limits?.secondary]
|
|
542
|
+
.filter((w) => !!w && typeof w.used_percent === "number")
|
|
543
|
+
.map((w) => `${w.window_minutes === 300 ? L.window5h : L.windowWeek} ${Math.round(w.used_percent)}%`)
|
|
544
|
+
.join(" · ");
|
|
545
|
+
}
|
|
439
546
|
function render() {
|
|
440
547
|
if (!tray)
|
|
441
548
|
return;
|
|
@@ -462,9 +569,18 @@ function render() {
|
|
|
462
569
|
: unconfigured
|
|
463
570
|
? L.setupNeededDetail
|
|
464
571
|
: L.downNotifyTitle;
|
|
572
|
+
// One line per Claude subscription account, current login first (#40).
|
|
573
|
+
const claudeLines = (s?.claude?.accounts ?? [])
|
|
574
|
+
.map((account) => ({ account, windows: windowsText(account.quota) }))
|
|
575
|
+
.filter(({ windows }) => windows)
|
|
576
|
+
.map(({ account, windows }) => ({ label: L.claudeUsage(account.id === "current" ? L.claudeCurrent : account.label, windows), click: () => void openDashboard() }));
|
|
465
577
|
const template = [
|
|
466
578
|
{ label: headline, click: () => void openDashboard() },
|
|
467
579
|
...(detail ? [{ label: detail, click: () => void openDashboard() }] : []),
|
|
580
|
+
...claudeLines,
|
|
581
|
+
...(availableUpdate
|
|
582
|
+
? [{ label: updating ? L.updating(availableUpdate.latest ?? "") : L.updateAvailableItem(availableUpdate.latest ?? ""), enabled: !updating, click: () => void offerUpdate(availableUpdate) }]
|
|
583
|
+
: []),
|
|
468
584
|
{ type: "separator" },
|
|
469
585
|
// Enabled even when the router is down: it is started first, then the page is opened.
|
|
470
586
|
{ label: L.openDashboard, click: () => void openDashboard() },
|
|
@@ -485,7 +601,8 @@ function render() {
|
|
|
485
601
|
: []),
|
|
486
602
|
{ label: L.connectClaudeSubscription, click: async () => void electron_1.dialog.showMessageBox({ message: await runCli(["claude-login"]) }) },
|
|
487
603
|
{ type: "separator" },
|
|
488
|
-
{ label: L.
|
|
604
|
+
{ label: L.checkUpdates, enabled: !updating, click: () => void checkUpdatesInteractive() },
|
|
605
|
+
{ label: L.about, click: () => void electron_1.dialog.showMessageBox({ title: "ClaudeRipple", message: `ClaudeRipple ${electron_1.app.getVersion()}`, detail: L.aboutDetail }) },
|
|
489
606
|
{ label: L.quit, role: "quit" },
|
|
490
607
|
];
|
|
491
608
|
tray.setContextMenu(electron_1.Menu.buildFromTemplate(template));
|
|
@@ -511,6 +628,9 @@ electron_1.app.whenReady().then(() => {
|
|
|
511
628
|
setInterval(() => void poll(), POLL_MS);
|
|
512
629
|
if (needsSetup())
|
|
513
630
|
void promptForSetup();
|
|
631
|
+
// Not at the very start: a tray opened at login shares the first minute with everything else.
|
|
632
|
+
setTimeout(() => void checkForUpdate(), 60_000);
|
|
633
|
+
setInterval(() => void checkForUpdate(), UPDATE_CHECK_MS);
|
|
514
634
|
});
|
|
515
635
|
// A tray-only app owns no windows; without this Electron would quit the moment a dialog closes.
|
|
516
636
|
electron_1.app.on("window-all-closed", () => {
|
package/dist/app/package.json
CHANGED
package/dist/cli/src/index.js
CHANGED
|
@@ -22,7 +22,8 @@ import { codexOff, codexOn } from "./codex.js";
|
|
|
22
22
|
import { ingressModels } from "../../router/src/ingress/models.js";
|
|
23
23
|
import { claudeLogin, claudeLogout, desktopClaudeCodeDirs } from "./claude-auth.js";
|
|
24
24
|
import { openBrowser } from "./browser.js";
|
|
25
|
-
import { installTrayRuntime, startTray } from "./tray.js";
|
|
25
|
+
import { electronPath, installTrayRuntime, startTray } from "./tray.js";
|
|
26
|
+
import { installKind, isNewer, latestVersion, PACKAGE, runUpdate } from "./update.js";
|
|
26
27
|
import { ClaudeOAuthSession } from "../../router/src/providers/claude-oauth.js";
|
|
27
28
|
function setPickerEnabled(enabled) {
|
|
28
29
|
const file = configPath();
|
|
@@ -119,6 +120,17 @@ function starterConfig(port) {
|
|
|
119
120
|
log: DEFAULTS.log,
|
|
120
121
|
}, null, 2) + "\n";
|
|
121
122
|
}
|
|
123
|
+
/** The version the router on this admin port reports, or null when it does not answer. */
|
|
124
|
+
async function runningVersion(port) {
|
|
125
|
+
try {
|
|
126
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/status`, { signal: AbortSignal.timeout(10_000) });
|
|
127
|
+
const version = res.ok ? (await res.json()).version : null;
|
|
128
|
+
return typeof version === "string" ? version : null;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
122
134
|
function proxyUrlFor(port) {
|
|
123
135
|
return `http://127.0.0.1:${port}`;
|
|
124
136
|
}
|
|
@@ -185,6 +197,16 @@ async function install() {
|
|
|
185
197
|
// its logon trigger, so the router would only appear after the next sign-in. Start it either way.
|
|
186
198
|
const started = startAgent();
|
|
187
199
|
console.log(started === "failed" ? `✗ could not start the router (${supervisorName()})` : `✓ router ${started === "already-running" ? "already running" : "started"}`);
|
|
200
|
+
// Running the installer again is how an update arrives, and it replaces files, not the process:
|
|
201
|
+
// a router left alone keeps serving the old code until the next login. Restart one that
|
|
202
|
+
// answers with another version.
|
|
203
|
+
if (started === "already-running") {
|
|
204
|
+
const running = await runningVersion(adminPort(cfg));
|
|
205
|
+
if (running && running !== VERSION) {
|
|
206
|
+
console.log(` the running router is ${running}; restarting it on ${VERSION}`);
|
|
207
|
+
console.log(describeRestart(restartAgent({ onProgress: (m) => console.log(` ${m}`) })));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
188
210
|
const p = await probeWithRetry({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
|
|
189
211
|
console.log(p.ok ? `✓ end-to-end probe passed (${p.detail}, ${p.ms}ms)` : `✗ probe failed: ${p.detail}`);
|
|
190
212
|
if (!p.ok) {
|
|
@@ -342,6 +364,46 @@ function logs() {
|
|
|
342
364
|
offset = size;
|
|
343
365
|
});
|
|
344
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* `update --check [--json]` reports; `update` installs the latest version the way this copy was
|
|
369
|
+
* installed (update.ts) and fetches the tray's Electron again if the old copy had it.
|
|
370
|
+
*/
|
|
371
|
+
async function update() {
|
|
372
|
+
const kind = installKind(installedRuntime);
|
|
373
|
+
// CLAUDERIPPLE_PACKAGE names what to install instead of the registry's latest (a tarball, say).
|
|
374
|
+
const override = process.env.CLAUDERIPPLE_PACKAGE;
|
|
375
|
+
const latest = override ? null : await latestVersion();
|
|
376
|
+
const newer = latest ? isNewer(latest, VERSION) : true;
|
|
377
|
+
if (flag("check")) {
|
|
378
|
+
if (flag("json"))
|
|
379
|
+
console.log(JSON.stringify({ current: VERSION, latest, newer, kind: kind.kind }));
|
|
380
|
+
else
|
|
381
|
+
console.log(newer ? `ClaudeRipple ${latest} is available (this is ${VERSION}). Update with: clauderipple update` : `ClaudeRipple ${VERSION} is the latest version`);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (kind.kind !== "script" && kind.kind !== "npm") {
|
|
385
|
+
process.exitCode = 1;
|
|
386
|
+
console.log(kind.kind === "packaged" ? "This is the standalone app. Download the new version from https://github.com/PBJ-2/clauderipple/releases"
|
|
387
|
+
: kind.kind === "checkout" ? "This is a source checkout. Update it with git pull."
|
|
388
|
+
: `ClaudeRipple is installed at ${kind.root}, which is not a global npm install. Update it the way you installed it.`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (!newer && !flag("force")) {
|
|
392
|
+
console.log(`✓ ClaudeRipple ${VERSION} is the latest version`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const hadTray = electronPath() !== null;
|
|
396
|
+
const spec = override ?? `${PACKAGE}@${latest}`;
|
|
397
|
+
console.log(`Updating ClaudeRipple ${VERSION} → ${latest ?? spec} (${kind.kind === "script" ? "install script" : "npm"}, ${kind.prefix})…`);
|
|
398
|
+
const result = runUpdate(kind, installedRuntime, spec);
|
|
399
|
+
if (result.ok && hadTray) {
|
|
400
|
+
// The new files decide where Electron lives; ask them, not this process's copy.
|
|
401
|
+
execFileSync(installedRuntime.node, [installedRuntime.cli, "tray", "--install"], { env: { ...process.env, ...installedRuntime.env }, stdio: "inherit" });
|
|
402
|
+
}
|
|
403
|
+
console.log(result.message);
|
|
404
|
+
if (!result.ok)
|
|
405
|
+
process.exitCode = 1;
|
|
406
|
+
}
|
|
345
407
|
function help() {
|
|
346
408
|
console.log(`clauderipple <command>
|
|
347
409
|
|
|
@@ -353,6 +415,7 @@ function help() {
|
|
|
353
415
|
config print the config file path
|
|
354
416
|
ui open the dashboard in your browser
|
|
355
417
|
tray [--install] start the menu-bar / tray app (--install fetches Electron, ~270MB, once)
|
|
418
|
+
update [--check] install the latest version the way this one was installed (--check only reports)
|
|
356
419
|
login add a ChatGPT account (opens your browser; run again to add another — they take turns when one runs out)
|
|
357
420
|
logout forget every ChatGPT account added with "login" (the Codex CLI's own login is left alone)
|
|
358
421
|
claude-login connect a Claude subscription in the browser (--setup-token: via \`claude setup-token\`; --manual: paste the code)
|
|
@@ -492,6 +555,9 @@ try {
|
|
|
492
555
|
case "ui":
|
|
493
556
|
ui();
|
|
494
557
|
break;
|
|
558
|
+
case "update":
|
|
559
|
+
await update();
|
|
560
|
+
break;
|
|
495
561
|
case "tray": {
|
|
496
562
|
const result = args.includes("--install") ? installTrayRuntime() : startTray();
|
|
497
563
|
console.log(result.message);
|
package/dist/cli/src/tray.js
CHANGED
|
@@ -9,21 +9,34 @@ import { createRequire } from "node:module";
|
|
|
9
9
|
import { spawn, spawnSync } from "node:child_process";
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
import { homeDir } from "../../router/src/config.js";
|
|
12
13
|
import { runtime } from "./runtime.js";
|
|
13
14
|
/** Electron version to fetch on request. Kept in step with the one the app is developed against. */
|
|
14
15
|
const ELECTRON_RANGE = "^38.1.0";
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Where Electron is fetched to: ClaudeRipple's home, not the package. Until 0.6 it went into the
|
|
18
|
+
* installed package's own node_modules, and every update replaced that folder — measured
|
|
19
|
+
* 2026-09-28, `npm install -g` of another version left no trace of it, so the tray was gone after
|
|
20
|
+
* each update, and on Windows a running tray held electron.exe inside the folder being replaced.
|
|
21
|
+
*/
|
|
22
|
+
export function trayRuntimeDir() {
|
|
23
|
+
return path.join(homeDir(), "tray-runtime");
|
|
24
|
+
}
|
|
25
|
+
/** The electron package exports the path to its binary as its module value. */
|
|
26
|
+
function resolveElectron(from) {
|
|
17
27
|
try {
|
|
18
|
-
const
|
|
19
|
-
// The electron package exports the path to its binary as its module value.
|
|
20
|
-
const value = require("electron");
|
|
28
|
+
const value = createRequire(from)("electron");
|
|
21
29
|
return typeof value === "string" && fs.existsSync(value) ? value : null;
|
|
22
30
|
}
|
|
23
31
|
catch {
|
|
24
32
|
return null;
|
|
25
33
|
}
|
|
26
34
|
}
|
|
35
|
+
/** The Electron executable to run the tray with, or null when none has been fetched. */
|
|
36
|
+
export function electronPath() {
|
|
37
|
+
// A checkout or an install from before 0.6 resolves it next to us.
|
|
38
|
+
return resolveElectron(path.join(trayRuntimeDir(), "package.json")) ?? resolveElectron(import.meta.url);
|
|
39
|
+
}
|
|
27
40
|
/**
|
|
28
41
|
* Our environment without ELECTRON_RUN_AS_NODE, so Electron starts as the tray rather than as a
|
|
29
42
|
* plain Node process. The variable has to be removed, not emptied: macOS Electron reads an empty
|
|
@@ -87,8 +100,9 @@ export function installTrayRuntime() {
|
|
|
87
100
|
const cli = npmCli();
|
|
88
101
|
if (!cli)
|
|
89
102
|
return { ok: false, message: `found no npm next to ${process.execPath}; install Electron yourself: npm install electron` };
|
|
90
|
-
const target =
|
|
91
|
-
|
|
103
|
+
const target = trayRuntimeDir();
|
|
104
|
+
fs.mkdirSync(target, { recursive: true });
|
|
105
|
+
const result = spawnSync(process.execPath, [cli, "install", "--prefix", target, "--no-package-lock", "--loglevel", "error", `electron@${ELECTRON_RANGE}`], {
|
|
92
106
|
cwd: target,
|
|
93
107
|
stdio: "inherit",
|
|
94
108
|
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// `clauderipple update`: bring this installation to the latest published version, the way it
|
|
2
|
+
// was installed.
|
|
3
|
+
//
|
|
4
|
+
// Until now an update meant running the install command again by hand, and the tray could not
|
|
5
|
+
// offer it (#39). What "again" means depends on how ClaudeRipple arrived:
|
|
6
|
+
// * The install scripts put it under their own prefix (~/.clauderipple by default) with their own
|
|
7
|
+
// command shims — a wrapper naming the Node they downloaded, and on Windows no .ps1. A plain
|
|
8
|
+
// `npm install -g` would put npm's default shims back, which find no Node on a machine where
|
|
9
|
+
// the script's Node is the only one. So a script install is updated by running its script.
|
|
10
|
+
// * An `npm install -g` is updated by npm, into the same prefix.
|
|
11
|
+
// * A packaged app or a source checkout is not updated from here.
|
|
12
|
+
// Either way `install` runs afterwards from the new files; it restarts a router still running
|
|
13
|
+
// the old version, which re-running the installer never did before.
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { windowsPowerShellEnv } from "./windows-powershell.js";
|
|
19
|
+
export const PACKAGE = "clauderipple";
|
|
20
|
+
const SCRIPT_BASE = "https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts";
|
|
21
|
+
/** Numeric x.y.z comparison; a pre-release suffix is ignored. */
|
|
22
|
+
export function isNewer(latest, current) {
|
|
23
|
+
const parse = (v) => v.replace(/^v/, "").split("-")[0].split(".").map((n) => Number(n) || 0);
|
|
24
|
+
const a = parse(latest);
|
|
25
|
+
const b = parse(current);
|
|
26
|
+
for (let i = 0; i < 3; i++) {
|
|
27
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
28
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/** The default prefix of both install scripts, or the one they were told to use. */
|
|
33
|
+
export function scriptPrefix(env = process.env, home = os.homedir()) {
|
|
34
|
+
return env.CLAUDERIPPLE_PREFIX || path.join(home, ".clauderipple");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* How the running copy was installed, read from where its files are. A global npm package lives
|
|
38
|
+
* at <prefix>/lib/node_modules/<name> on macOS and Linux and <prefix>\node_modules\<name> on Windows.
|
|
39
|
+
*/
|
|
40
|
+
export function installKind(rt, opts = {}) {
|
|
41
|
+
if (rt.packaged)
|
|
42
|
+
return { kind: "packaged" };
|
|
43
|
+
if (rt.cli.endsWith(".ts"))
|
|
44
|
+
return { kind: "checkout" };
|
|
45
|
+
const p = (opts.platform ?? process.platform) === "win32" ? path.win32 : path.posix;
|
|
46
|
+
const root = rt.repo;
|
|
47
|
+
const modules = p.dirname(root);
|
|
48
|
+
if (p.basename(modules) !== "node_modules")
|
|
49
|
+
return { kind: "unknown", root };
|
|
50
|
+
const parent = p.dirname(modules);
|
|
51
|
+
const prefix = (opts.platform ?? process.platform) === "win32" ? parent : p.basename(parent) === "lib" ? p.dirname(parent) : null;
|
|
52
|
+
if (!prefix)
|
|
53
|
+
return { kind: "unknown", root };
|
|
54
|
+
const same = (a, b) => (opts.platform ?? process.platform) === "win32" ? p.resolve(a).toLowerCase() === p.resolve(b).toLowerCase() : p.resolve(a) === p.resolve(b);
|
|
55
|
+
return same(prefix, scriptPrefix(opts.env, opts.home)) ? { kind: "script", prefix } : { kind: "npm", prefix };
|
|
56
|
+
}
|
|
57
|
+
/** The version npm would install now. */
|
|
58
|
+
export async function latestVersion(fetchImpl = fetch) {
|
|
59
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE}/latest`, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
|
|
60
|
+
if (!res.ok)
|
|
61
|
+
throw new Error(`npm registry answered HTTP ${res.status}`);
|
|
62
|
+
const version = (await res.json()).version;
|
|
63
|
+
if (typeof version !== "string")
|
|
64
|
+
throw new Error("npm registry answered without a version");
|
|
65
|
+
return version;
|
|
66
|
+
}
|
|
67
|
+
/** The npm that came with the Node running us, as a script that same Node can run. */
|
|
68
|
+
function npmCli() {
|
|
69
|
+
const candidates = [
|
|
70
|
+
path.resolve(path.dirname(process.execPath), "../lib/node_modules/npm/bin/npm-cli.js"),
|
|
71
|
+
path.resolve(path.dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"),
|
|
72
|
+
];
|
|
73
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Installs `spec` over this installation and runs `install` from the new files. Output goes to
|
|
77
|
+
* our stdout as it happens, so the tray's dialog and a terminal show the installer's own lines.
|
|
78
|
+
*/
|
|
79
|
+
export function runUpdate(target, rt, spec) {
|
|
80
|
+
const env = { ...process.env, CLAUDERIPPLE_PREFIX: target.prefix, CLAUDERIPPLE_PACKAGE: spec };
|
|
81
|
+
if (target.kind === "script") {
|
|
82
|
+
// A local path stands in for the published script when checking an update before release.
|
|
83
|
+
const script = process.env.CLAUDERIPPLE_INSTALLER;
|
|
84
|
+
const result = process.platform === "win32"
|
|
85
|
+
? spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script ? `& ${psQuote(script)}` : `irm ${SCRIPT_BASE}/install.ps1 | iex`], { env: { ...windowsPowerShellEnv(), ...env }, stdio: "inherit", windowsHide: true })
|
|
86
|
+
: spawnSync("sh", ["-c", script ? `sh ${shQuote(script)}` : `curl -fsSL ${SCRIPT_BASE}/install.sh | sh`], { env, stdio: "inherit" });
|
|
87
|
+
// The script ends by running `install` itself.
|
|
88
|
+
return result.status === 0 ? { ok: true, message: "✓ updated" } : { ok: false, message: `the installer exited with ${result.status ?? result.signal}` };
|
|
89
|
+
}
|
|
90
|
+
const npm = npmCli();
|
|
91
|
+
if (!npm)
|
|
92
|
+
return { ok: false, message: `found no npm next to ${process.execPath}; update with: npm install -g ${PACKAGE}@latest` };
|
|
93
|
+
const installed = spawnSync(process.execPath, [npm, "install", "--global", "--prefix", target.prefix, "--loglevel", "error", spec], { env, stdio: "inherit" });
|
|
94
|
+
if (installed.status !== 0)
|
|
95
|
+
return { ok: false, message: `npm could not install ${spec}` };
|
|
96
|
+
const setup = spawnSync(rt.node, [rt.cli, "install"], { env: { ...process.env, ...rt.env }, stdio: "inherit" });
|
|
97
|
+
return setup.status === 0 ? { ok: true, message: "✓ updated" } : { ok: false, message: "the new version is installed, but `clauderipple install` did not finish" };
|
|
98
|
+
}
|
|
99
|
+
function psQuote(value) {
|
|
100
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
101
|
+
}
|
|
102
|
+
function shQuote(value) {
|
|
103
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
104
|
+
}
|
package/dist/router/src/admin.js
CHANGED
|
@@ -48,6 +48,32 @@ const MIME = {
|
|
|
48
48
|
".png": "image/png",
|
|
49
49
|
".ico": "image/x-icon",
|
|
50
50
|
};
|
|
51
|
+
/** Claude usage is looked up only where a Claude subscription provider is configured. */
|
|
52
|
+
function hasClaudeSubscription(cfg) {
|
|
53
|
+
return Object.values(cfg.providers).some((p) => p.type === "anthropic" && p.auth === "claude-code");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* One row per Claude account with a known usage, current login first. Labels only: the status is
|
|
57
|
+
* polled every few seconds and has no use for an email address.
|
|
58
|
+
*/
|
|
59
|
+
async function claudeAccountUsage(deps) {
|
|
60
|
+
let usage;
|
|
61
|
+
try {
|
|
62
|
+
usage = (await deps.claudeUsage?.()) ?? {};
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const rows = [];
|
|
68
|
+
if (usage.current)
|
|
69
|
+
rows.push({ id: "current", label: "Current Claude login", quota: usage.current });
|
|
70
|
+
for (const account of listClaudeAccounts(homeDir())) {
|
|
71
|
+
const quota = usage[account.id];
|
|
72
|
+
if (quota)
|
|
73
|
+
rows.push({ id: account.id, label: account.label, quota });
|
|
74
|
+
}
|
|
75
|
+
return rows;
|
|
76
|
+
}
|
|
51
77
|
const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "max"];
|
|
52
78
|
const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
53
79
|
let chatgptLogin = { running: false };
|
|
@@ -317,6 +343,7 @@ async function buildStatus(deps, opts = {}) {
|
|
|
317
343
|
// `quota` stays one snapshot per provider (the account in use) for the readers that want one
|
|
318
344
|
// number; `accounts` is the per-account view, read after the refresh above so it is as fresh.
|
|
319
345
|
chatgpt: { ...chatgpt, signedIn, accounts: chatgpt.accounts?.() ?? {}, ...(stale ? { stale: true, staleReason: staleReasons } : {}) },
|
|
346
|
+
...(deps.claudeUsage && hasClaudeSubscription(cfg) ? { claude: { accounts: await claudeAccountUsage(deps) } } : {}),
|
|
320
347
|
// Only present for providers that declare a pool; a provider with one credential has nothing
|
|
321
348
|
// to report and would only add a row that never changes.
|
|
322
349
|
credentials: deps.credentials?.() ?? {},
|
|
@@ -1236,9 +1263,12 @@ export function startAdmin(deps) {
|
|
|
1236
1263
|
// ClaudeRipple accounts can be renamed or removed by their local opaque id.
|
|
1237
1264
|
if (pathname === "/api/claude-accounts" && method === "GET") {
|
|
1238
1265
|
const source = claudeAuthStore(deps).describeSource();
|
|
1266
|
+
let usage = {};
|
|
1267
|
+
if (hasClaudeSubscription(deps.config()))
|
|
1268
|
+
usage = (await deps.claudeUsage?.().catch(() => null)) ?? {};
|
|
1239
1269
|
sendJson(res, 200, {
|
|
1240
|
-
current: source ? { id: "current", label: "Current Claude login", source, external: true } : null,
|
|
1241
|
-
accounts: listClaudeAccounts(homeDir()),
|
|
1270
|
+
current: source ? { id: "current", label: "Current Claude login", source, external: true, ...(usage.current ? { quota: usage.current } : {}) } : null,
|
|
1271
|
+
accounts: listClaudeAccounts(homeDir()).map((account) => (usage[account.id] ? { ...account, quota: usage[account.id] } : account)),
|
|
1242
1272
|
});
|
|
1243
1273
|
return;
|
|
1244
1274
|
}
|
package/dist/router/src/index.js
CHANGED
|
@@ -153,6 +153,7 @@ proxy
|
|
|
153
153
|
credentials: () => proxy.credentialHealth(),
|
|
154
154
|
picker: () => ({ enabled: !!store.get().picker?.enabled, hosts: terminateHosts(store.get()).slice(1), last: proxy.lastPickerInjection }),
|
|
155
155
|
observedClaudeCodeAuth,
|
|
156
|
+
claudeUsage: () => proxy.claudeUsage.snapshot(),
|
|
156
157
|
shutdown: () => beginDrain("shutdown requested"),
|
|
157
158
|
});
|
|
158
159
|
log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// How much of each Claude subscription account's limits is used, for the dashboard and the tray.
|
|
2
|
+
//
|
|
3
|
+
// Response headers would only describe the account that last answered, so an idle second account
|
|
4
|
+
// would never show a number. Claude Code's own `/usage` asks `GET /api/oauth/usage` with the
|
|
5
|
+
// account's OAuth headers instead, and so does this. Shape measured 2026-09-28 (CLI 2.1.281):
|
|
6
|
+
// {"five_hour": {"utilization": 14.0, "resets_at": "2026-09-28T08:30:00.581181+00:00", …},
|
|
7
|
+
// "seven_day": {"utilization": 10.0, "resets_at": "…"}, "seven_day_opus": null, …}
|
|
8
|
+
// `utilization` is a percentage. It is mapped onto the Codex snapshot shape
|
|
9
|
+
// (`rate_limits.primary/secondary` with `used_percent`, `window_minutes`, `reset_at` in seconds)
|
|
10
|
+
// so the GUI and the tray read one format for every account.
|
|
11
|
+
const FIVE_HOURS = 5 * 60;
|
|
12
|
+
const WEEK = 7 * 24 * 60;
|
|
13
|
+
export function claudeUsageFromBody(body) {
|
|
14
|
+
const b = body;
|
|
15
|
+
const win = (w, minutes) => {
|
|
16
|
+
if (!w || typeof w.utilization !== "number" || !Number.isFinite(w.utilization))
|
|
17
|
+
return null;
|
|
18
|
+
const out = { used_percent: w.utilization, window_minutes: minutes };
|
|
19
|
+
const at = typeof w.resets_at === "string" ? Date.parse(w.resets_at) : NaN;
|
|
20
|
+
if (Number.isFinite(at))
|
|
21
|
+
out.reset_at = Math.round(at / 1000);
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
const primary = win(b?.five_hour, FIVE_HOURS);
|
|
25
|
+
const secondary = win(b?.seven_day, WEEK);
|
|
26
|
+
if (!primary && !secondary)
|
|
27
|
+
return null;
|
|
28
|
+
return { type: "claude.usage", rate_limits: { primary: primary ?? secondary, secondary: primary ? secondary : null }, at: Date.now() };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Per-account snapshots, fetched at most once per `ttlMs` per account. The GUI polls the status
|
|
32
|
+
* every five seconds and the tray as often; a limit that moves by a percent in minutes does not
|
|
33
|
+
* need more. A failed lookup keeps the last good numbers rather than blanking them.
|
|
34
|
+
*/
|
|
35
|
+
export class ClaudeUsage {
|
|
36
|
+
last = new Map();
|
|
37
|
+
inFlight = null;
|
|
38
|
+
fetchedAt = Number.NEGATIVE_INFINITY;
|
|
39
|
+
source;
|
|
40
|
+
opts;
|
|
41
|
+
constructor(source, opts) {
|
|
42
|
+
this.source = source;
|
|
43
|
+
this.opts = opts;
|
|
44
|
+
}
|
|
45
|
+
/** Snapshots by account owner id ("current" for the Claude Code login). */
|
|
46
|
+
snapshot() {
|
|
47
|
+
const now = (this.opts.now ?? Date.now)();
|
|
48
|
+
if (now - this.fetchedAt < (this.opts.ttlMs ?? 5 * 60_000))
|
|
49
|
+
return Promise.resolve(Object.fromEntries(this.last));
|
|
50
|
+
this.inFlight ??= this.fetchAll().finally(() => {
|
|
51
|
+
this.fetchedAt = (this.opts.now ?? Date.now)();
|
|
52
|
+
this.inFlight = null;
|
|
53
|
+
});
|
|
54
|
+
return this.inFlight;
|
|
55
|
+
}
|
|
56
|
+
async fetchAll() {
|
|
57
|
+
let credentials;
|
|
58
|
+
try {
|
|
59
|
+
credentials = await this.source.credentials();
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
this.opts.warn?.(`claude usage: no credentials: ${e.message}`);
|
|
63
|
+
return Object.fromEntries(this.last);
|
|
64
|
+
}
|
|
65
|
+
await Promise.all(credentials.map(async (credential) => {
|
|
66
|
+
const owner = credential.ownerId ?? credential.id;
|
|
67
|
+
const snapshot = await this.fetchOne(credential);
|
|
68
|
+
if (snapshot)
|
|
69
|
+
this.last.set(owner, snapshot);
|
|
70
|
+
}));
|
|
71
|
+
// An account that was removed must not keep its old numbers on the screen.
|
|
72
|
+
const owners = new Set(credentials.map((c) => c.ownerId ?? c.id));
|
|
73
|
+
for (const owner of [...this.last.keys()])
|
|
74
|
+
if (!owners.has(owner))
|
|
75
|
+
this.last.delete(owner);
|
|
76
|
+
return Object.fromEntries(this.last);
|
|
77
|
+
}
|
|
78
|
+
async fetchOne(credential) {
|
|
79
|
+
const owner = (credential.ownerId ?? credential.id).slice(0, 8);
|
|
80
|
+
try {
|
|
81
|
+
const res = await (this.opts.fetch ?? fetch)(`https://${this.opts.upstream()}/api/oauth/usage`, {
|
|
82
|
+
method: "GET",
|
|
83
|
+
headers: { ...credential.headers, accept: "application/json" },
|
|
84
|
+
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5000),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
// The status alone: the body of an auth failure is not worth the risk of echoing.
|
|
88
|
+
this.opts.warn?.(`claude usage ${owner}: HTTP ${res.status}`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return claudeUsageFromBody(await res.json());
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
this.opts.warn?.(`claude usage ${owner}: ${e.message}`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/router/src/proxy.js
CHANGED
|
@@ -35,6 +35,7 @@ import { injectPickerModels, isBootstrapPath } from "./picker.js";
|
|
|
35
35
|
import { ResponseUsageTap } from "./requestlog.js";
|
|
36
36
|
import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
|
|
37
37
|
import { ClaudeAccountAuthPool } from "./providers/anthropic-account-pool.js";
|
|
38
|
+
import { ClaudeUsage } from "./providers/anthropic-usage.js";
|
|
38
39
|
import { dropForeignThinking } from "./thinking.js";
|
|
39
40
|
const MAX_BODY = 64 * 1024 * 1024;
|
|
40
41
|
const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host", "content-length"]);
|
|
@@ -177,6 +178,8 @@ export class Proxy {
|
|
|
177
178
|
chatgptAdapters = new Map();
|
|
178
179
|
openaiAdapters = new Map();
|
|
179
180
|
claudeAccounts;
|
|
181
|
+
/** Each Claude subscription account's usage, for the admin status (never a token). */
|
|
182
|
+
claudeUsage;
|
|
180
183
|
deps;
|
|
181
184
|
/**
|
|
182
185
|
* Cooldowns, quarantines and conversation stickiness for provider credentials. In memory: a
|
|
@@ -282,6 +285,10 @@ export class Proxy {
|
|
|
282
285
|
log: deps.log,
|
|
283
286
|
...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}),
|
|
284
287
|
});
|
|
288
|
+
this.claudeUsage = new ClaudeUsage(this.claudeAccounts, {
|
|
289
|
+
upstream: () => this.deps.config().upstream,
|
|
290
|
+
warn: (message) => deps.log.warn(message),
|
|
291
|
+
});
|
|
285
292
|
this.httpServer = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => {
|
|
286
293
|
void this.handle(req, res);
|
|
287
294
|
});
|
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
// from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
|
|
6
6
|
// still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
|
|
7
7
|
// running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
|
|
8
|
-
export const VERSION = "0.
|
|
8
|
+
export const VERSION = "0.6.0";
|
package/dist/ui/app.js
CHANGED
|
@@ -335,6 +335,16 @@ function renderHealthProviders() {
|
|
|
335
335
|
].filter(Boolean));
|
|
336
336
|
const quota = quotaLine(name);
|
|
337
337
|
if (quota) line.appendChild(el("div", { class: "small", text: quota }));
|
|
338
|
+
// Claude subscription accounts, one line each: the current login and every account added here.
|
|
339
|
+
// Before the config has loaded the rows come from the status, which names the sign-in source
|
|
340
|
+
// rather than the auth mode.
|
|
341
|
+
if (provider.type === "anthropic" && (provider.auth === "claude-code" || (live && live.authSource))) {
|
|
342
|
+
const usage = ((status && status.claude && status.claude.accounts) || [])
|
|
343
|
+
.map((account) => ({ account, text: chatgptQuotaText(account.quota) }))
|
|
344
|
+
.filter(({ text }) => text)
|
|
345
|
+
.map(({ account, text }) => el("div", { text: `${claudeAccountLabel(account)} · ${text}` }));
|
|
346
|
+
if (usage.length) line.appendChild(el("div", { class: "small account-usage" }, usage));
|
|
347
|
+
}
|
|
338
348
|
const pool = credentialLine(name);
|
|
339
349
|
if (pool) line.appendChild(pool);
|
|
340
350
|
return line;
|
|
@@ -1002,11 +1012,22 @@ async function saveAnthropicRotation(name, provider, enabled, control, message)
|
|
|
1002
1012
|
} finally { control.disabled = false; }
|
|
1003
1013
|
}
|
|
1004
1014
|
|
|
1015
|
+
/** The current login is named by the router in English; everything else carries the user's own label. */
|
|
1016
|
+
function claudeAccountLabel(account) {
|
|
1017
|
+
return account.id === "current" ? t("providers.anthropicCurrent") : account.label;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** "5h 14% · weekly 10%" under a Claude account, or nothing while its usage is unknown. */
|
|
1021
|
+
function claudeQuotaNode(account) {
|
|
1022
|
+
const text = chatgptQuotaText(account && account.quota);
|
|
1023
|
+
return text ? el("p", { class: "small account-quota", text }) : null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1005
1026
|
function renderClaudeAccountRows(target, data, name, generation) {
|
|
1006
1027
|
if (generation !== providerDetailGeneration) return;
|
|
1007
1028
|
const rows = [];
|
|
1008
1029
|
if (data.current) rows.push(el("article", { class: "account-card current" }, [
|
|
1009
|
-
el("div", { class: "account-card-copy" }, [el("strong", { text: data.current.label }), el("span", { class: "small", text: anthropicSourceText(data.current.source) }), hint(t("providers.currentAccountHelp"))]),
|
|
1030
|
+
el("div", { class: "account-card-copy" }, [el("strong", { text: data.current.label }), el("span", { class: "small", text: anthropicSourceText(data.current.source) }), claudeQuotaNode(data.current), hint(t("providers.currentAccountHelp"))].filter(Boolean)),
|
|
1010
1031
|
el("span", { class: "badge ok", text: t("providers.anthropicCurrent") }),
|
|
1011
1032
|
]));
|
|
1012
1033
|
for (const account of Array.isArray(data.accounts) ? data.accounts : []) {
|
|
@@ -1031,7 +1052,7 @@ function renderClaudeAccountRows(target, data, name, generation) {
|
|
|
1031
1052
|
actions.unshift(reauth);
|
|
1032
1053
|
}
|
|
1033
1054
|
rows.push(el("article", { class: "account-card" }, [
|
|
1034
|
-
el("div", { class: "account-card-copy" }, [el("strong", { text: account.label }), account.email && account.email !== account.label ? el("span", { class: "small", text: account.email }) : null, hint(t("providers.addedAccountHelp"))]),
|
|
1055
|
+
el("div", { class: "account-card-copy" }, [el("strong", { text: account.label }), account.email && account.email !== account.label ? el("span", { class: "small", text: account.email }) : null, claudeQuotaNode(account), hint(t("providers.addedAccountHelp"))].filter(Boolean)),
|
|
1035
1056
|
el("span", { class: `badge ${unavailable ? "bad" : "ok"}`, text: unavailable ? t("providers.anthropicReauth") : t("pool.ready") }),
|
|
1036
1057
|
el("div", { class: "account-card-actions" }, actions),
|
|
1037
1058
|
]));
|
package/dist/ui/style.css
CHANGED
|
@@ -355,6 +355,7 @@ label.check input { width: auto; }
|
|
|
355
355
|
button.btn.compact { padding: 4px 9px; font-size: 14px; }
|
|
356
356
|
.provider-status { display: grid; grid-template-columns: minmax(120px, 1fr) auto; gap: 8px; align-items: center; padding: 7px 0; border-bottom: 1px solid var(--border); }
|
|
357
357
|
.provider-status:last-child { border-bottom: none; }
|
|
358
|
+
.provider-status .account-usage { grid-column: 1 / -1; display: grid; gap: 2px; }
|
|
358
359
|
.empty-card { color: var(--muted); padding: 28px 16px; border: 1px dashed var(--border); border-radius: 8px; text-align: center; }
|
|
359
360
|
.toast-raw { margin-top: 5px; color: var(--muted); font: 10.5px/1.35 var(--mono); word-break: break-word; }
|
|
360
361
|
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 18px; background: color-mix(in srgb, #000 38%, transparent); }
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -836,6 +836,8 @@ what we do not have yet — so that adding a provider does not start with readin
|
|
|
836
836
|
| The same relay 403 kept reaching the user after that fix, because it was the wrong layer: DeepSeek runs on `openai-compatible`, whose adapter was **never** retried at all — the in-turn retry lives only in the anthropic-compatible wire path (`nextCredential`), and `credentialsOf` gives a translated provider exactly one credential and no fallback. So a relay hiccup had nowhere to go but the user (2026-09-21, root cause of the day's repeated "authentication failed") | Every translated adapter retries the `fetch` **before** any status or byte is written, so the turn is still replaceable (§4 "the first byte commits the turn"). `fetchWithRetry` retries only `transient` verdicts — connect failures, 5xx, and a 403 reporting a broken upstream — to the ceiling of 10 attempts the CLI itself uses, with a 250ms→2s backoff. A 401/429 is **not** retried: the adapter holds one credential, so asking the key that just refused the identical request again only delays the error. `classify` is reused for the judgement, with `kind === "transient"` narrowing a pool's answer to an adapter's. |
|
|
837
837
|
| Workers on muse-spark-1.3 "stalled for minutes, then died": 84 of 1,815 turns (2026-09-18..22) waited a median 58s — five exactly 300s — and then the upstream closed the stream with **no** `response.completed`, no usage, no text. Both translated adapters finished such a stream as `end_turn` with whatever had arrived (the ChatGPT one by a comment saying so), so Claude Code received an empty, successful turn, did not retry, and the subagent ended with nothing. Same shape on `chatgpt` (gpt-6-astra, 12 turns, median 105s) and DeepSeek. A non-streaming failure was likewise answered 200 with the fragment | A turn is finished only when the vendor says so: `response.completed`/`response.incomplete` on Responses, a `finish_reason` or `[DONE]` on Chat. A stream that closes without one, or breaks mid-read, ends in an SSE `overloaded_error` — the one mid-stream error Claude Code retries on its own (CLI 2.1.278 matches `"type":"overloaded_error"` in the message; `api_error` is final). Non-streaming turns answer 529 with the same error, and the request record says the turn failed. The upstream stall itself is the vendor's; what must not happen here is turning it into an answer. |
|
|
838
838
|
| A session switched from gpt-6-astra to claude-opus-5-5 failed every turn with `400 messages.2.content.0.thinking: each thinking block must contain thinking`: the translated adapters hand the CLI reasoning as `thinking` blocks with no signature (and no text when the reasoning item had no summary), the CLI keeps them, and passthrough replayed them to Anthropic (2026-09-28) | Before a request reaches Anthropic (`PASS` and the account-pool provider), assistant `thinking` blocks without a signature are dropped (`thinking.ts`) — Anthropic's own always carry one — and a turn left empty goes with them. Passthrough stays byte-exact when there is nothing to drop. |
|
|
839
|
+
| Re-running the installer — the documented update — replaced the files and left the router running the old code: `install` treats a running router as done, and on Windows and Linux an unchanged supervisor definition is not re-registered, so nothing restarted it until the next login (found 2026-09-28 while building `update`; the tray's own restart covered only the packaged app) | `install` asks a router that is already running for its version and restarts one that differs. Measured in the Windows 11 VM: 0.5.1 running, installer run with 0.6.0 → "restarting it on 0.6.0", status 0.6.0. |
|
|
840
|
+
| The tray's Electron (~270MB) was fetched into the installed package's own `node_modules`, and `npm install -g` of any other version deleted it with the old files (measured 2026-09-28): every update silently took the tray away, and on Windows a running tray held `electron.exe` inside the folder npm had to replace | Electron lives in `<home>/tray-runtime`, outside anything an update replaces. Both install scripts move one found in the old place there before installing (Windows first stops a tray running from it, and starts it again after setup) — checked on macOS and in the VM with a locked `electron.exe`. |
|
|
839
841
|
|
|
840
842
|
## 6. Blocked paths (measured, do not retry)
|
|
841
843
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauderipple",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Use GPT (ChatGPT Plus/Pro), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"workspaces": [
|