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 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
- npm install -g clauderipple@latest
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
- npm install -g clauderipple@latest
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
@@ -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
- if (!downNotified && Date.now() - downSince >= DOWN_NOTIFY_AFTER_MS) {
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
- (0, node_child_process_1.execFile)(node, [cli, ...args], { env: { ...process.env, ...env, CLAUDERIPPLE_HOME: home } }, (err, stdout, stderr) => {
396
- resolve(`${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim());
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.about, click: () => void electron_1.dialog.showMessageBox({ title: "ClaudeRipple", message: "ClaudeRipple", detail: L.aboutDetail }) },
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", () => {
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "private": true,
3
+ "version": "0.6.0",
3
4
  "type": "commonjs"
4
5
  }
@@ -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);
@@ -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
- /** The Electron executable installed alongside us, or null when the optional dependency is absent. */
16
- export function electronPath() {
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 require = createRequire(import.meta.url);
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 = runtime().repo;
91
- const result = spawnSync(process.execPath, [cli, "install", "--no-save", "--loglevel", "error", `electron@${ELECTRON_RANGE}`], {
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
+ }
@@ -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
  }
@@ -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
+ }
@@ -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.5.1";
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); }
@@ -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.5.1",
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": [