clauderipple 0.12.4 → 0.12.6
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 +50 -0
- package/README.es.md +12 -5
- package/README.ja.md +8 -3
- package/README.ko.md +10 -4
- package/README.md +10 -5
- package/README.pt-BR.md +12 -5
- package/README.zh-CN.md +7 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +12 -1
- package/dist/cli/src/schtasks.js +7 -1
- package/dist/cli/src/update.js +21 -0
- package/dist/mod/hooks/register.tsx +10 -5
- package/dist/mod/tests/register.test.tsx +19 -0
- package/dist/remote/src/desktop.js +13 -1
- package/dist/remote/src/eventlog.js +11 -9
- package/dist/remote/src/folders.js +79 -5
- package/dist/remote/src/host-service.js +26 -0
- package/dist/remote/src/images.js +6 -4
- package/dist/remote/src/local-control.js +1 -0
- package/dist/remote/src/main.js +30 -2
- package/dist/remote/src/router.js +13 -0
- package/dist/remote/src/server.js +2 -2
- package/dist/remote/src/sessions.js +25 -7
- package/dist/remote/web/app.ts +1 -1
- package/dist/remote/web/conn.ts +10 -0
- package/dist/remote/web/i18n.ts +5 -5
- package/dist/remote/web/views.ts +4 -2
- package/dist/router/src/admin.js +5 -1
- package/dist/router/src/providers/anthropic-account-pool.js +1 -0
- package/dist/router/src/providers/anthropic.js +15 -7
- package/dist/router/src/proxy.js +1 -0
- package/dist/router/src/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.6 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **The phone can pick a folder on another Windows drive.** Browsing starts in `C:\Users\<name>`,
|
|
8
|
+
and a drive root had nowhere to go up to, so a project on `E:` was out of reach. A drive root now
|
|
9
|
+
lists the other drives first.
|
|
10
|
+
- **The phone's model picker always offers Claude.** It listed only what the router's providers
|
|
11
|
+
declare, so a router that passes Claude straight through showed no Claude, and with no other
|
|
12
|
+
provider the picker was empty. Claude Code's own Opus, Sonnet and Haiku are now offered when no
|
|
13
|
+
Claude provider is configured.
|
|
14
|
+
- **A running worker's row names its model** as soon as the worker's first answer (or, for a Claude
|
|
15
|
+
app session, its meta file) says which model ran it, instead of only when it finishes.
|
|
16
|
+
- **A quiet computer reads as a wait, not a lost connection.** The phone's red "connection lost"
|
|
17
|
+
card came after 30 seconds of silence, which a phone that slept or switched networks often
|
|
18
|
+
reaches. It now comes after a minute, in the warning tone, as "waiting for your computer", and
|
|
19
|
+
time the phone app spent in the background no longer counts toward it.
|
|
20
|
+
- **Jev gets 5 seconds to answer** before Haiku judges instead. It answers in about 2.1–2.4
|
|
21
|
+
seconds, so 3 seconds timed out whenever OpenCode slowed down.
|
|
22
|
+
- **`clauderipple install` removes npm's `clauderipple.ps1` on Windows.** PowerShell runs it before
|
|
23
|
+
`clauderipple.cmd`, and its default execution policy refuses it, so an `npm install -g` user could
|
|
24
|
+
not run `clauderipple` in PowerShell. The README now says to start with `clauderipple.cmd` there.
|
|
25
|
+
- **The phone says when the Mac is asking for folder access.** Opening a folder macOS guards
|
|
26
|
+
(Desktop, Documents, Downloads, iCloud Drive, another volume) for the first time puts up the
|
|
27
|
+
Mac's "would like to access" dialog, and the phone waited in silence, then said the computer did
|
|
28
|
+
not answer. It now says the dialog is up within two seconds, and where to allow access if it was
|
|
29
|
+
refused.
|
|
30
|
+
- **The band's limits row comes back at the next check after a failed read**, not a minute later.
|
|
31
|
+
|
|
32
|
+
## 0.12.5 — 2026-10-22
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **The install script adds the menu-bar app on macOS and Windows.** It carries the repair
|
|
37
|
+
screen that opens while the router is down, which someone who does not use a terminal needs.
|
|
38
|
+
Electron is about 270MB and fetched once; `CLAUDERIPPLE_NO_TRAY=1` skips it, and a failed
|
|
39
|
+
fetch leaves the router installed with a hint to add the app later. Linux keeps it opt-in.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **Updating restarts the phone host too.** An update replaced the files but left the running
|
|
44
|
+
host on the old code until the machine restarted, so a fix like 0.12.4's Windows sending did
|
|
45
|
+
not arrive. `install` (which every update ends with) now restarts a phone host this
|
|
46
|
+
installation registered, unless a turn is in progress; then it says to run
|
|
47
|
+
`clauderipple remote install` afterwards. A host run from a source checkout is left alone.
|
|
48
|
+
- **Windows with a non-ASCII account name no longer re-registers the router on every install.**
|
|
49
|
+
The scheduled task was read back in the console's code page, so it never matched (#74, #75 by
|
|
50
|
+
@115dkk). Tests that assumed "/" paths or POSIX permission bits now run only where those hold
|
|
51
|
+
(#76).
|
|
52
|
+
|
|
3
53
|
## 0.12.4 — 2026-10-22
|
|
4
54
|
|
|
5
55
|
### Added
|
package/README.es.md
CHANGED
|
@@ -424,6 +424,11 @@ npm install -g clauderipple
|
|
|
424
424
|
clauderipple install
|
|
425
425
|
```
|
|
426
426
|
|
|
427
|
+
En Windows, la política de ejecución predeterminada de PowerShell rechaza el `clauderipple.ps1` que
|
|
428
|
+
crea npm, así que ahí ejecuta `clauderipple.cmd install` (o usa el Símbolo del sistema). `install`
|
|
429
|
+
borra ese archivo y desde entonces basta con `clauderipple` también en PowerShell. El script de
|
|
430
|
+
instalación nunca lo deja.
|
|
431
|
+
|
|
427
432
|
Luego abre el panel y añade un proveedor:
|
|
428
433
|
|
|
429
434
|
```sh
|
|
@@ -432,15 +437,17 @@ clauderipple ui
|
|
|
432
437
|
|
|
433
438
|
**Proveedores** → añade ChatGPT o pega una clave de API → **Asignación de modelos**.
|
|
434
439
|
|
|
435
|
-
**La app de la barra de menús / bandeja**
|
|
436
|
-
el
|
|
440
|
+
**La app de la barra de menús / bandeja** muestra el estado del router, abre el panel con un
|
|
441
|
+
clic y ofrece una pantalla de reparación que funciona aunque el router esté caído:
|
|
437
442
|
|
|
438
443
|
```sh
|
|
439
444
|
clauderipple tray
|
|
440
445
|
```
|
|
441
446
|
|
|
442
|
-
|
|
443
|
-
|
|
447
|
+
En macOS y Windows el script de instalación la añade por defecto. Funciona con Electron, unos
|
|
448
|
+
270 MB que se descargan una sola vez; define `CLAUDERIPPLE_NO_TRAY=1` antes del comando de
|
|
449
|
+
instalación para omitirla. En Linux, o tras instalar solo con npm, `clauderipple tray --install`
|
|
450
|
+
la añade. Todo lo demás funciona sin ella.
|
|
444
451
|
|
|
445
452
|
**Actualización.** La bandeja ofrece la nueva versión cuando la hay (**Buscar actualizaciones…**), o bien:
|
|
446
453
|
|
|
@@ -709,7 +716,7 @@ El autor lo usa a diario, pero es un proyecto joven. Cuenta con algunas aspereza
|
|
|
709
716
|
|
|
710
717
|
## Comunidad
|
|
711
718
|
|
|
712
|
-
Las preguntas, los informes de errores y la beta de Ripple Remote para Android (una prueba cerrada en Google Play) están en Discord: [https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw). Para unirte a la beta de Android,
|
|
719
|
+
Las preguntas, los informes de errores y la beta de Ripple Remote para Android (una prueba cerrada en Google Play) están en Discord: [https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw). Para unirte a la beta de Android, envía la dirección de Gmail que usas en Google Play a @pbj_ripple por mensaje directo, no en un canal donde todos pueden leerla.
|
|
713
720
|
|
|
714
721
|
## Sin afiliación
|
|
715
722
|
|
package/README.ja.md
CHANGED
|
@@ -250,6 +250,11 @@ npm install -g clauderipple
|
|
|
250
250
|
clauderipple install
|
|
251
251
|
```
|
|
252
252
|
|
|
253
|
+
Windows の PowerShell は既定の実行ポリシーにより、npm が作る `clauderipple.ps1` を実行しません。
|
|
254
|
+
PowerShell では `clauderipple.cmd install` と入力してください(コマンドプロンプトならそのまま動きます)。
|
|
255
|
+
`install` がこのファイルを削除するので、以後は PowerShell でも `clauderipple` だけで動きます。
|
|
256
|
+
インストールスクリプトではこのファイルは残りません。
|
|
257
|
+
|
|
253
258
|
続いてダッシュボードを開き、プロバイダーを追加します。
|
|
254
259
|
|
|
255
260
|
```sh
|
|
@@ -258,13 +263,13 @@ clauderipple ui
|
|
|
258
263
|
|
|
259
264
|
**プロバイダー** → ChatGPTを追加するかAPIキーを貼り付け → **モデルマッピング**。
|
|
260
265
|
|
|
261
|
-
**メニューバー /
|
|
266
|
+
**メニューバー / トレイアプリ**はルーターの状態を表示し、ワンクリックでダッシュボードを開き、ルーターが止まっていても開ける修復画面を備えています。
|
|
262
267
|
|
|
263
268
|
```sh
|
|
264
269
|
clauderipple tray
|
|
265
270
|
```
|
|
266
271
|
|
|
267
|
-
Electron
|
|
272
|
+
macOSとWindowsではインストールスクリプトが標準で追加します。Electronで動作し約270MBで、取得は一度だけです。不要ならインストールコマンドの前に`CLAUDERIPPLE_NO_TRAY=1`を付けてください。Linux、またはnpmだけでインストールした場合は`clauderipple tray --install`で追加します。それ以外の機能はすべてアプリなしで動きます。
|
|
268
273
|
|
|
269
274
|
**アップデート**。新しいバージョンがあればトレイから案内されます(**アップデートを確認…**)。または次のコマンドを実行します。
|
|
270
275
|
|
|
@@ -420,7 +425,7 @@ Codex app / CLI ──/v1/responses──▶ ClaudeRipple ingress ──▶ Clau
|
|
|
420
425
|
|
|
421
426
|
## コミュニティ
|
|
422
427
|
|
|
423
|
-
質問、不具合の報告、Ripple Remote Android 版ベータ(Google Play のクローズドテスト)への参加受付は Discord で行っています:[https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw)。Android 版ベータに参加するには、Google Play で使っている Gmail アドレスを
|
|
428
|
+
質問、不具合の報告、Ripple Remote Android 版ベータ(Google Play のクローズドテスト)への参加受付は Discord で行っています:[https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw)。Android 版ベータに参加するには、Google Play で使っている Gmail アドレスを @pbj_ripple にダイレクトメッセージで送ってください。誰でも読めるチャンネルには書き込まないでください。
|
|
424
429
|
|
|
425
430
|
## 提携関係について
|
|
426
431
|
|
package/README.ko.md
CHANGED
|
@@ -342,6 +342,11 @@ npm install -g clauderipple
|
|
|
342
342
|
clauderipple install
|
|
343
343
|
```
|
|
344
344
|
|
|
345
|
+
Windows PowerShell은 기본 실행 정책 때문에 npm이 만든 `clauderipple.ps1`을 실행하지 않습니다. 그래서
|
|
346
|
+
PowerShell에서는 `clauderipple.cmd install`로 실행하십시오(명령 프롬프트에서는 그대로 됩니다). `install`이
|
|
347
|
+
그 파일을 지우므로, 그다음부터는 PowerShell에서도 `clauderipple`만 입력하면 됩니다. 설치 스크립트로 설치하면
|
|
348
|
+
이 파일이 남지 않습니다.
|
|
349
|
+
|
|
345
350
|
그다음 대시보드를 열어 프로바이더를 추가합니다.
|
|
346
351
|
|
|
347
352
|
```sh
|
|
@@ -350,14 +355,15 @@ clauderipple ui
|
|
|
350
355
|
|
|
351
356
|
**프로바이더** → ChatGPT 추가 또는 API 키 붙여 넣기 → **모델 매핑**.
|
|
352
357
|
|
|
353
|
-
**메뉴 막대·트레이
|
|
358
|
+
**메뉴 막대·트레이 앱**은 라우터 상태를 보여주고, 한 번에 대시보드를 열고, 라우터가 죽어도 열리는 복구 화면을 갖고 있습니다.
|
|
354
359
|
|
|
355
360
|
```sh
|
|
356
361
|
clauderipple tray
|
|
357
362
|
```
|
|
358
363
|
|
|
359
|
-
|
|
360
|
-
|
|
364
|
+
macOS와 Windows에서는 설치 스크립트가 기본으로 함께 설치합니다. Electron으로 돌아 약 270MB이고 한 번만 받습니다.
|
|
365
|
+
빼고 싶으면 설치 명령 앞에 `CLAUDERIPPLE_NO_TRAY=1`을 붙이세요. Linux나 npm으로만 설치했다면
|
|
366
|
+
`clauderipple tray --install`로 추가합니다. 나머지 기능은 앱 없이도 다 돕니다.
|
|
361
367
|
|
|
362
368
|
**업데이트.** 새 버전이 나오면 트레이가 알려 줍니다(**업데이트 확인…**). 터미널에서는:
|
|
363
369
|
|
|
@@ -569,7 +575,7 @@ Codex 앱 / CLI ──/v1/responses──▶ ClaudeRipple 입구 ──▶ Claud
|
|
|
569
575
|
|
|
570
576
|
## 커뮤니티
|
|
571
577
|
|
|
572
|
-
질문, 버그 제보, Ripple Remote 안드로이드 베타(Google Play 비공개 테스트) 신청은 디스코드에서 받습니다: [https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw). 안드로이드 베타에 참여하려면 Google Play에서 쓰는 Gmail 주소를
|
|
578
|
+
질문, 버그 제보, Ripple Remote 안드로이드 베타(Google Play 비공개 테스트) 신청은 디스코드에서 받습니다: [https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw). 안드로이드 베타에 참여하려면 Google Play에서 쓰는 Gmail 주소를 @pbj_ripple에게 DM으로 보내 주세요. 모두가 읽는 채널에는 올리지 마세요.
|
|
573
579
|
|
|
574
580
|
## 비제휴
|
|
575
581
|
|
package/README.md
CHANGED
|
@@ -405,6 +405,10 @@ npm install -g clauderipple
|
|
|
405
405
|
clauderipple install
|
|
406
406
|
```
|
|
407
407
|
|
|
408
|
+
On Windows, PowerShell's default execution policy refuses the `clauderipple.ps1` npm adds, so
|
|
409
|
+
run `clauderipple.cmd install` there (or use Command Prompt). `install` removes that file, and
|
|
410
|
+
plain `clauderipple` works in PowerShell from then on. The install script never leaves one.
|
|
411
|
+
|
|
408
412
|
Then open the dashboard and add a provider:
|
|
409
413
|
|
|
410
414
|
```sh
|
|
@@ -413,15 +417,16 @@ clauderipple ui
|
|
|
413
417
|
|
|
414
418
|
**Providers** → add ChatGPT or paste an API key → **Model mapping**.
|
|
415
419
|
|
|
416
|
-
**The menu-bar / tray app**
|
|
417
|
-
|
|
420
|
+
**The menu-bar / tray app** shows the router's state, opens the dashboard in a click, and
|
|
421
|
+
keeps a repair screen that works while the router is down:
|
|
418
422
|
|
|
419
423
|
```sh
|
|
420
424
|
clauderipple tray
|
|
421
425
|
```
|
|
422
426
|
|
|
423
|
-
|
|
424
|
-
|
|
427
|
+
On macOS and Windows the install script adds it by default. It runs on Electron, about 270MB,
|
|
428
|
+
fetched once; set `CLAUDERIPPLE_NO_TRAY=1` before the install command to skip it. On Linux, or
|
|
429
|
+
after an npm-only install, `clauderipple tray --install` adds it. Everything else works without it.
|
|
425
430
|
|
|
426
431
|
**Updating.** The tray offers a new version when there is one (**Check for Updates…**), or:
|
|
427
432
|
|
|
@@ -698,7 +703,7 @@ In daily use by the author, but young. Expect rough edges:
|
|
|
698
703
|
|
|
699
704
|
## Community
|
|
700
705
|
|
|
701
|
-
Questions, bug reports and the Ripple Remote Android beta (a closed test on Google Play) are on Discord: [https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw). To join the Android beta,
|
|
706
|
+
Questions, bug reports and the Ripple Remote Android beta (a closed test on Google Play) are on Discord: [https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw). To join the Android beta, send the Gmail address you use on Google Play to @pbj_ripple as a direct message, not in a channel where everyone can read it.
|
|
702
707
|
|
|
703
708
|
## Not affiliated
|
|
704
709
|
|
package/README.pt-BR.md
CHANGED
|
@@ -416,6 +416,11 @@ npm install -g clauderipple
|
|
|
416
416
|
clauderipple install
|
|
417
417
|
```
|
|
418
418
|
|
|
419
|
+
No Windows, a política de execução padrão do PowerShell recusa o `clauderipple.ps1` que o npm cria,
|
|
420
|
+
então ali rode `clauderipple.cmd install` (ou use o Prompt de Comando). O `install` apaga esse
|
|
421
|
+
arquivo e, a partir daí, basta `clauderipple` também no PowerShell. O script de instalação nunca o
|
|
422
|
+
deixa.
|
|
423
|
+
|
|
419
424
|
Depois, abra o painel e adicione um provedor:
|
|
420
425
|
|
|
421
426
|
```sh
|
|
@@ -424,15 +429,17 @@ clauderipple ui
|
|
|
424
429
|
|
|
425
430
|
**Provedores** → adicione o ChatGPT ou cole uma chave de API → **Mapeamento de modelos**.
|
|
426
431
|
|
|
427
|
-
**O app da barra de menus / bandeja**
|
|
428
|
-
|
|
432
|
+
**O app da barra de menus / bandeja** mostra o estado do roteador, abre o painel com um
|
|
433
|
+
clique e traz uma tela de reparo que funciona mesmo com o roteador fora do ar:
|
|
429
434
|
|
|
430
435
|
```sh
|
|
431
436
|
clauderipple tray
|
|
432
437
|
```
|
|
433
438
|
|
|
434
|
-
|
|
435
|
-
|
|
439
|
+
No macOS e no Windows o script de instalação o adiciona por padrão. Ele roda em Electron, cerca
|
|
440
|
+
de 270 MB baixados uma única vez; defina `CLAUDERIPPLE_NO_TRAY=1` antes do comando de instalação
|
|
441
|
+
para pular. No Linux, ou depois de instalar só pelo npm, `clauderipple tray --install` o adiciona.
|
|
442
|
+
Todo o resto funciona sem ele.
|
|
436
443
|
|
|
437
444
|
**Atualização.** A bandeja oferece a nova versão quando houver uma (**Verificar atualizações…**), ou:
|
|
438
445
|
|
|
@@ -692,7 +699,7 @@ Em uso diário pelo autor, mas ainda jovem. Espere algumas arestas:
|
|
|
692
699
|
|
|
693
700
|
## Comunidade
|
|
694
701
|
|
|
695
|
-
Perguntas, relatos de bugs e o beta do Ripple Remote para Android (um teste fechado no Google Play) ficam no Discord: [https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw). Para entrar no beta do Android,
|
|
702
|
+
Perguntas, relatos de bugs e o beta do Ripple Remote para Android (um teste fechado no Google Play) ficam no Discord: [https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw). Para entrar no beta do Android, envie o endereço do Gmail que você usa no Google Play para @pbj_ripple por mensagem direta, não em um canal onde todos podem lê-lo.
|
|
696
703
|
|
|
697
704
|
## Sem afiliação
|
|
698
705
|
|
package/README.zh-CN.md
CHANGED
|
@@ -249,6 +249,10 @@ npm install -g clauderipple
|
|
|
249
249
|
clauderipple install
|
|
250
250
|
```
|
|
251
251
|
|
|
252
|
+
Windows PowerShell 的默认执行策略会拒绝运行 npm 生成的 `clauderipple.ps1`,所以在 PowerShell 中请输入
|
|
253
|
+
`clauderipple.cmd install`(命令提示符中可直接运行)。`install` 会删除该文件,之后在 PowerShell 中
|
|
254
|
+
直接输入 `clauderipple` 即可。用安装脚本安装不会留下这个文件。
|
|
255
|
+
|
|
252
256
|
然后打开控制面板并添加供应商:
|
|
253
257
|
|
|
254
258
|
```sh
|
|
@@ -257,13 +261,13 @@ clauderipple ui
|
|
|
257
261
|
|
|
258
262
|
**供应商** → 添加 ChatGPT 或粘贴 API 密钥 → **模型映射**。
|
|
259
263
|
|
|
260
|
-
**菜单栏 /
|
|
264
|
+
**菜单栏 / 托盘应用**显示路由服务的状态,点一下就能打开控制面板,并带有路由服务停止时也能打开的修复界面:
|
|
261
265
|
|
|
262
266
|
```sh
|
|
263
267
|
clauderipple tray
|
|
264
268
|
```
|
|
265
269
|
|
|
266
|
-
|
|
270
|
+
在 macOS 和 Windows 上,安装脚本默认会一并安装它。它基于 Electron,约 270MB,只下载一次;如不需要,在安装命令前加上 `CLAUDERIPPLE_NO_TRAY=1` 即可跳过。在 Linux 上或仅通过 npm 安装时,用 `clauderipple tray --install` 添加。除此之外的一切不需要它也能正常工作。
|
|
267
271
|
|
|
268
272
|
**更新。** 有新版本时,托盘应用会提示(**检查更新…**),也可以运行:
|
|
269
273
|
|
|
@@ -419,7 +423,7 @@ Codex app / CLI ──/v1/responses──▶ ClaudeRipple ingress ──▶ Clau
|
|
|
419
423
|
|
|
420
424
|
## 社区
|
|
421
425
|
|
|
422
|
-
提问、报告问题以及报名 Ripple Remote Android 测试版(Google Play 封闭测试)都在 Discord:[https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw)。想参加 Android
|
|
426
|
+
提问、报告问题以及报名 Ripple Remote Android 测试版(Google Play 封闭测试)都在 Discord:[https://discord.gg/R8ZgGSNHjw](https://discord.gg/R8ZgGSNHjw)。想参加 Android 测试版,请把你在 Google Play 上使用的 Gmail 地址私信发给 @pbj_ripple,不要发在所有人都能看到的频道里。
|
|
423
427
|
|
|
424
428
|
## 无关联声明
|
|
425
429
|
|
package/dist/app/package.json
CHANGED
package/dist/cli/src/index.js
CHANGED
|
@@ -26,7 +26,7 @@ import { GROK_DEFAULT_URL } from "../../router/src/providers/grok/catalog.js";
|
|
|
26
26
|
import { claudeLogin, claudeLogout, desktopClaudeCodeDirs } from "./claude-auth.js";
|
|
27
27
|
import { openBrowser } from "./browser.js";
|
|
28
28
|
import { electronPath, installTrayRuntime, startTray } from "./tray.js";
|
|
29
|
-
import { installKind, isNewer, latestVersion, PACKAGE, runUpdate } from "./update.js";
|
|
29
|
+
import { dropPowerShellShim, installKind, isNewer, latestVersion, PACKAGE, runUpdate } from "./update.js";
|
|
30
30
|
import { ClaudeOAuthSession } from "../../router/src/providers/claude-oauth.js";
|
|
31
31
|
function setPickerEnabled(enabled) {
|
|
32
32
|
const file = configPath();
|
|
@@ -208,6 +208,9 @@ async function install() {
|
|
|
208
208
|
// Persist the single execution contract so the GUI, admin API, hooks, and supervisor all use
|
|
209
209
|
// this installation's runtime rather than any Node installation on the user's PATH.
|
|
210
210
|
writePaths(installedRuntime, home);
|
|
211
|
+
const shim = dropPowerShellShim(installKind(installedRuntime));
|
|
212
|
+
if (shim)
|
|
213
|
+
console.log(`✓ removed ${shim}: PowerShell's default policy refuses it, and clauderipple.cmd runs instead`);
|
|
211
214
|
// Windows and Linux register node + the router script directly (schtasks.ts writes its own .cmd
|
|
212
215
|
// launcher); a macOS source checkout gets a small .app so the agent shows a real name in Login Items.
|
|
213
216
|
// registration.ts holds the mapping, so `repair` registers exactly the same thing.
|
|
@@ -255,6 +258,14 @@ async function install() {
|
|
|
255
258
|
catch (e) {
|
|
256
259
|
console.log(` menu-bar app launcher not written: ${e.message}`);
|
|
257
260
|
}
|
|
261
|
+
// The phone host has the same problem as the router above: new files, old process. The host
|
|
262
|
+
// decides whether it is ours and idle (remote host-service refreshPlan). Never fatal.
|
|
263
|
+
try {
|
|
264
|
+
execFileSync(installedRuntime.node, [installedRuntime.cli, "remote", "refresh"], { env: { ...process.env, ...installedRuntime.env }, stdio: "inherit" });
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
console.log(" the phone host was not restarted; if you use it, run: clauderipple remote install");
|
|
268
|
+
}
|
|
258
269
|
console.log("\nDone. New Claude Desktop Code sessions go through ClaudeRipple. Existing sessions pick up settings.json env changes on their next request.");
|
|
259
270
|
}
|
|
260
271
|
async function uninstall() {
|
package/dist/cli/src/schtasks.js
CHANGED
|
@@ -25,9 +25,15 @@ export function taskName() {
|
|
|
25
25
|
function ps(value) {
|
|
26
26
|
return `'${value.replace(/'/g, "''")}'`;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Windows PowerShell writes to a pipe in the console's code page, which we would read as UTF-8: on
|
|
30
|
+
* a Korean Windows an account named in Hangul came back garbled, so the registered task never
|
|
31
|
+
* matched and every install tried to register it again (measured 2026-10-13). The script sets
|
|
32
|
+
* UTF-8 first.
|
|
33
|
+
*/
|
|
28
34
|
function run(script) {
|
|
29
35
|
try {
|
|
30
|
-
const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
36
|
+
const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ${script}`], {
|
|
31
37
|
env: windowsPowerShellEnv(),
|
|
32
38
|
stdio: ["ignore", "pipe", "pipe"],
|
|
33
39
|
windowsHide: true,
|
package/dist/cli/src/update.js
CHANGED
|
@@ -54,6 +54,27 @@ export function installKind(rt, opts = {}) {
|
|
|
54
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
55
|
return same(prefix, scriptPrefix(opts.env, opts.home)) ? { kind: "script", prefix } : { kind: "npm", prefix };
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* npm puts a clauderipple.ps1 next to clauderipple.cmd, and PowerShell picks it first — but the
|
|
59
|
+
* default execution policy runs no .ps1, so `clauderipple` failed with PSSecurityException (#38 for
|
|
60
|
+
* a script install, #62 for `npm install -g`). Without it PowerShell falls through to the .cmd,
|
|
61
|
+
* which runs under any policy. Every npm install writes it again, so `install`, which every update
|
|
62
|
+
* ends with, removes it. Returns the file removed.
|
|
63
|
+
*/
|
|
64
|
+
export function dropPowerShellShim(kind, platform = process.platform) {
|
|
65
|
+
if (platform !== "win32" || (kind.kind !== "npm" && kind.kind !== "script"))
|
|
66
|
+
return null;
|
|
67
|
+
const ps1 = path.join(kind.prefix, `${PACKAGE}.ps1`);
|
|
68
|
+
if (!fs.existsSync(path.join(kind.prefix, `${PACKAGE}.cmd`)) || !fs.existsSync(ps1))
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
fs.rmSync(ps1);
|
|
72
|
+
return ps1;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
57
78
|
/** The version npm would install now. */
|
|
58
79
|
export async function latestVersion(fetchImpl = fetch) {
|
|
59
80
|
const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE}/latest`, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
|
|
@@ -642,7 +642,9 @@ async function judge($: EngineInterface, s: Settings, input: JudgeInput | { desc
|
|
|
642
642
|
const response = await Promise.race([
|
|
643
643
|
$.http.fetch(source === 'zen' ? 'https://opencode.ai/zen/v1/systemone' : 'https://api.typesafe.ai/v1/systemone', { method: 'POST', headers: { 'content-type': 'application/json', authorization: source === 'zen' ? key : `Bearer ${key}` }, body: JSON.stringify({ ...body, model }) })
|
|
644
644
|
.catch(() => 'network' as const),
|
|
645
|
-
|
|
645
|
+
// Jev answers in about 2.1–2.4 s even with a full context (measured 2026-10-22), so 3 s timed
|
|
646
|
+
// out whenever OpenCode slowed down — and the Haiku fallback then cost more than waiting.
|
|
647
|
+
$.clock.sleep(5000).then(() => null),
|
|
646
648
|
])
|
|
647
649
|
// A dropped connection or a slow answer is often one bad moment, not a broken judge.
|
|
648
650
|
if (!response || response === 'network') {
|
|
@@ -1092,21 +1094,24 @@ async function fallbackLimits($: EngineInterface): Promise<LimitItem[]> {
|
|
|
1092
1094
|
* predates the endpoint (404) the older per-provider routes answer instead, so the row shows before
|
|
1093
1095
|
* the router is restarted. A failure keeps the last good row rather than blanking it, and the state
|
|
1094
1096
|
* is written only when it moved: a redraw that shows nothing new is wasted (each answer is drawn
|
|
1095
|
-
* afresh).
|
|
1097
|
+
* afresh). Only a good read holds off the next minute, and only while there is a row: a failed read
|
|
1098
|
+
* stamped the minute too, and a session whose first read failed (the router restarting) showed no
|
|
1099
|
+
* limits row for that minute (2026-10-22).
|
|
1096
1100
|
*/
|
|
1097
1101
|
let limitsAt = 0
|
|
1098
1102
|
async function refreshLimits($: EngineInterface): Promise<void> {
|
|
1099
1103
|
if (!(await currentSettings($)).extras.includes('limits')) return
|
|
1100
1104
|
const now = await $.clock.now()
|
|
1101
|
-
if (now - limitsAt < 60_000) return
|
|
1102
|
-
limitsAt = now
|
|
1105
|
+
if (now - limitsAt < 60_000 && await read($, limits) !== null) return
|
|
1103
1106
|
try {
|
|
1104
1107
|
const admin = await adminUrl($)
|
|
1105
1108
|
const response = await $.http.fetch(`${admin}/api/limits`)
|
|
1106
1109
|
let items: LimitItem[] | null = null
|
|
1107
1110
|
if (response.ok) items = routerLimitItems(JSON.parse(response.text), now)
|
|
1108
1111
|
else if (response.status === 404) items = await fallbackLimits($)
|
|
1109
|
-
if (
|
|
1112
|
+
if (!items) return
|
|
1113
|
+
limitsAt = now
|
|
1114
|
+
if (!same(items, await read($, limits))) await update($, limits, () => items)
|
|
1110
1115
|
} catch (error) { await report($, 'limits', error) }
|
|
1111
1116
|
}
|
|
1112
1117
|
async function refreshUsage($: EngineInterface): Promise<void> {
|
|
@@ -768,6 +768,25 @@ test('the limits row shows Claude\'s plan on a chat that runs on another provide
|
|
|
768
768
|
await ui.unmount()
|
|
769
769
|
})
|
|
770
770
|
|
|
771
|
+
test('a failed first limits read is tried again at the next tick, not a minute later', async ($, on) => {
|
|
772
|
+
const b = boundaries(on, { extras: ['limits'] })
|
|
773
|
+
const limits = { providers: [{ provider: 'claude', type: 'anthropic', title: 'Claude', accounts: [{ account: 0, at: NOW, windows: [
|
|
774
|
+
{ kind: 'seven_day', used_percent: 88, window_minutes: 10080, reset_at: (NOW + 7_200_000) / 1000 },
|
|
775
|
+
] }] }] }
|
|
776
|
+
let reads = 0
|
|
777
|
+
on('http.fetch', ($, e, next) => {
|
|
778
|
+
if (e.url.endsWith('/api/mod-settings')) return next(e)
|
|
779
|
+
if (e.url.endsWith('/api/limits') && ++reads === 1) return { value: { ok: false, status: 503, headers: {}, text: '' } }
|
|
780
|
+
return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify(e.url.endsWith('/api/limits') ? limits : { requests: [] }) } }
|
|
781
|
+
})
|
|
782
|
+
await start($)
|
|
783
|
+
await b.clock.advance(15000)
|
|
784
|
+
expect(reads).toBeGreaterThan(1)
|
|
785
|
+
const ui = await band($, 'desktop')
|
|
786
|
+
expect(await ui.find({ type: 'Text', text: /^88%$/ })).toBeDefined()
|
|
787
|
+
await ui.unmount()
|
|
788
|
+
})
|
|
789
|
+
|
|
771
790
|
test('a tier marker on a worker other than ripple:auto is dropped, not routed as a model', async ($, on) => {
|
|
772
791
|
boundaries(on)
|
|
773
792
|
http(on)
|
|
@@ -488,10 +488,22 @@ export class DesktopSessions {
|
|
|
488
488
|
this.tailSubs.set(id, state);
|
|
489
489
|
}
|
|
490
490
|
const resolve = (toolUseId, input) => this.subInfo(id, toolUseId, input);
|
|
491
|
-
|
|
491
|
+
const next = scanLines(fd, size, line => {
|
|
492
492
|
for (const body of transcriptEvents(line, source.summary.cwd, this.opts.summarize, sink, { state, resolve }).bodies)
|
|
493
493
|
visit(body);
|
|
494
494
|
}, offset);
|
|
495
|
+
// A worker's meta file, which names its model, is often written just after the line that
|
|
496
|
+
// started it: say the model once it is known instead of only when the worker ends.
|
|
497
|
+
for (const [toolUseId, running] of state) {
|
|
498
|
+
if (running.model)
|
|
499
|
+
continue;
|
|
500
|
+
const info = resolve(toolUseId, running.input);
|
|
501
|
+
if (!info.model || info.ended)
|
|
502
|
+
continue;
|
|
503
|
+
running.model = info.model;
|
|
504
|
+
visit({ kind: "subagent", toolUseId, sub: `sub-${toolUseId}`, description: running.description, model: running.model, status: "running", steps: info.steps });
|
|
505
|
+
}
|
|
506
|
+
return next;
|
|
495
507
|
}
|
|
496
508
|
finally {
|
|
497
509
|
fs.closeSync(fd);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { closeSync, ftruncateSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { closeSync, ftruncateSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, truncateSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { readJson, writeFileAtomic } from "./fsutil.js";
|
|
5
5
|
import { FRAME_CAP, INLINE_CAP } from "./protocol.js";
|
|
@@ -113,8 +113,7 @@ export class EventLog {
|
|
|
113
113
|
scan(name, last) {
|
|
114
114
|
const file = join(this.dir, name);
|
|
115
115
|
const data = readFileSync(file);
|
|
116
|
-
const
|
|
117
|
-
this.segments.push(segment);
|
|
116
|
+
const entries = [];
|
|
118
117
|
let offset = 0;
|
|
119
118
|
while (offset < data.length) {
|
|
120
119
|
const end = data.indexOf(10, offset);
|
|
@@ -132,16 +131,19 @@ export class EventLog {
|
|
|
132
131
|
throw error;
|
|
133
132
|
break;
|
|
134
133
|
}
|
|
135
|
-
if (event === null || event.seq !== this.lastSeq + 1 || typeof event.at !== "number" || typeof event.kind !== "string") {
|
|
134
|
+
if (event === null || event.seq !== this.lastSeq + entries.length + 1 || typeof event.at !== "number" || typeof event.kind !== "string") {
|
|
136
135
|
throw new Error(`Invalid event at ${name}:${offset}`);
|
|
137
136
|
}
|
|
138
|
-
|
|
137
|
+
entries.push({ event, offset, length: end + 1 - offset });
|
|
139
138
|
offset = end + 1;
|
|
140
139
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
140
|
+
// Windows cannot truncate through an append-only handle, so repair before opening that handle.
|
|
141
|
+
if (offset !== data.length)
|
|
142
|
+
truncateSync(file, offset);
|
|
143
|
+
const segment = { fd: openSync(file, "a+", 0o600), bytes: offset, number: Number(name.slice(7, -6)) };
|
|
144
|
+
this.segments.push(segment);
|
|
145
|
+
for (const entry of entries)
|
|
146
|
+
this.index(entry.event, segment, entry.offset, entry.length);
|
|
145
147
|
}
|
|
146
148
|
index(event, segment, offset, length) {
|
|
147
149
|
const entry = { seq: event.seq, segment, offset, length, kind: event.kind };
|
|
@@ -20,6 +20,52 @@ export async function isDirectory(p) {
|
|
|
20
20
|
return false;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
export const MAC_CONSENT_PENDING = "Mac에 폴더 접근 허용 창이 떠 있습니다. 컴퓨터에서 '허용'을 누른 뒤 다시 시도해 주세요.";
|
|
24
|
+
export const MAC_CONSENT_DENIED = "macOS가 이 폴더 접근을 막았습니다. Mac의 시스템 설정 → 개인정보 보호 및 보안 → 파일 및 폴더에서 허용해 주세요.";
|
|
25
|
+
/** Where macOS asks the person before a process first reads: Desktop, Documents, Downloads, iCloud Drive, other volumes. */
|
|
26
|
+
export function macProtected(p, home = os.homedir()) {
|
|
27
|
+
const abs = path.resolve(p);
|
|
28
|
+
const under = (root) => abs === root || abs.startsWith(root + path.sep);
|
|
29
|
+
return ["Desktop", "Documents", "Downloads", path.join("Library", "Mobile Documents")].some(d => under(path.join(home, d))) || under("/Volumes");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A read of a macOS-protected folder waits on the Mac's "would like to access" dialog, so from the
|
|
33
|
+
* phone a folder never opened before looked like nothing happening, then "did not answer in time"
|
|
34
|
+
* (2026-10-22). One that has not answered in `wait` ms is reported as that dialog; one refused
|
|
35
|
+
* (EPERM) says where to allow it. The read itself goes on, so the dialog stays up to be answered.
|
|
36
|
+
*/
|
|
37
|
+
export async function macConsent(p, work, o = {}) {
|
|
38
|
+
if ((o.platform ?? process.platform) !== "darwin" || !macProtected(p, o.home))
|
|
39
|
+
return work();
|
|
40
|
+
const running = work();
|
|
41
|
+
running.catch(() => { });
|
|
42
|
+
let timer;
|
|
43
|
+
const asking = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(MAC_CONSENT_PENDING)), o.wait ?? 2000); });
|
|
44
|
+
try {
|
|
45
|
+
return await Promise.race([running, asking]);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error.code === "EPERM")
|
|
49
|
+
throw new Error(MAC_CONSENT_DENIED);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** A folder this process can list: false when it is missing or not a folder; a refusal is thrown. */
|
|
57
|
+
export async function readableFolder(p) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.readdir(p);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const code = error.code;
|
|
64
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
65
|
+
return false;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
23
69
|
/** One path segment: no separators, control characters or the reserved names "." and "..". */
|
|
24
70
|
const FOLDER_NAME = /^[^/\\\u0000-\u001f]{1,100}$/;
|
|
25
71
|
/** Make a folder directly inside `parent`, returning its path. The name is one segment and must
|
|
@@ -51,18 +97,43 @@ export async function recentFolders(sources, limit = 12) {
|
|
|
51
97
|
}
|
|
52
98
|
const sorted = Array.from(best.values()).sort((a, b) => b.lastUsed - a.lastUsed);
|
|
53
99
|
const out = [];
|
|
100
|
+
// A recent folder whose check waits on the Mac's access dialog stays listed, and the protected ones
|
|
101
|
+
// after it go unchecked rather than each holding the list; picking one reports the dialog (Sessions.createNew).
|
|
102
|
+
let asking = false;
|
|
54
103
|
for (const s of sorted) {
|
|
55
104
|
if (out.length >= limit)
|
|
56
105
|
break;
|
|
57
|
-
|
|
58
|
-
|
|
106
|
+
const protectedHere = process.platform === "darwin" && macProtected(s.path);
|
|
107
|
+
if (!(asking && protectedHere)) {
|
|
108
|
+
try {
|
|
109
|
+
if (!(await macConsent(s.path, () => isDirectory(s.path), { wait: 500 })))
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
asking = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
59
116
|
out.push({ path: s.path, name: path.basename(s.path) || s.path, lastUsed: s.lastUsed, ...(s.model ? { model: s.model } : {}) });
|
|
60
117
|
}
|
|
61
118
|
return out;
|
|
62
119
|
}
|
|
63
|
-
|
|
120
|
+
/**
|
|
121
|
+
* The drive roots this Windows machine answers for (`C:\`, `E:\`, …). A: and B: are floppy letters
|
|
122
|
+
* that can stall; a mapped drive that does not answer within a second is left out.
|
|
123
|
+
*/
|
|
124
|
+
export async function windowsDrives(platform = process.platform) {
|
|
125
|
+
if (platform !== "win32")
|
|
126
|
+
return [];
|
|
127
|
+
const roots = "CDEFGHIJKLMNOPQRSTUVWXYZ".split("").map(letter => `${letter}:\\`);
|
|
128
|
+
const found = await Promise.all(roots.map(root => Promise.race([
|
|
129
|
+
isDirectory(root).then(ok => (ok ? root : null)),
|
|
130
|
+
new Promise(resolve => setTimeout(resolve, 1000, null).unref()),
|
|
131
|
+
])));
|
|
132
|
+
return found.filter((root) => root !== null);
|
|
133
|
+
}
|
|
134
|
+
export async function browse(target, hidden = false, home = os.homedir(), drives = windowsDrives) {
|
|
64
135
|
const dir = path.resolve(target && target.trim() ? target : home);
|
|
65
|
-
const dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
136
|
+
const dirents = await macConsent(dir, () => fs.readdir(dir, { withFileTypes: true }), { home });
|
|
66
137
|
const names = [];
|
|
67
138
|
for (const d of dirents) {
|
|
68
139
|
if (!hidden && d.name.startsWith("."))
|
|
@@ -73,7 +144,10 @@ export async function browse(target, hidden = false, home = os.homedir()) {
|
|
|
73
144
|
names.push(d.name);
|
|
74
145
|
}
|
|
75
146
|
names.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }));
|
|
76
|
-
const entries = names.slice(0, BROWSE_CAP).map((name) => ({ path: path.join(dir, name), name }));
|
|
77
147
|
const parent = path.dirname(dir);
|
|
148
|
+
// A drive root has no parent to go up to, so on Windows the other drives are listed first: a
|
|
149
|
+
// project on E: was out of reach from a browse that starts in C:\Users (Discord, 2026-10-22).
|
|
150
|
+
const others = parent === dir ? (await drives()).filter(root => root.toLowerCase() !== dir.toLowerCase()) : [];
|
|
151
|
+
const entries = [...others.map(root => ({ path: root, name: root })), ...names.slice(0, BROWSE_CAP).map((name) => ({ path: path.join(dir, name), name }))];
|
|
78
152
|
return { path: dir, parent: parent === dir ? null : parent, home, entries, truncated: names.length > BROWSE_CAP };
|
|
79
153
|
}
|
|
@@ -104,6 +104,32 @@ async function waitForHost(dir = remoteHome()) {
|
|
|
104
104
|
}
|
|
105
105
|
throw new Error("host service was registered but did not answer its authenticated status; check its logs");
|
|
106
106
|
}
|
|
107
|
+
export function refreshPlan(o) {
|
|
108
|
+
if (!o.installed || o.definition === null)
|
|
109
|
+
return "absent";
|
|
110
|
+
const xml = o.script.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
111
|
+
if (![o.script, xml, o.script.replace(/'/g, "''")].some(form => o.definition.includes(form)))
|
|
112
|
+
return "foreign";
|
|
113
|
+
// An older host does not report it; its update came while the router sat idle, so most likely idle too.
|
|
114
|
+
return o.busy ? "busy" : "restart";
|
|
115
|
+
}
|
|
116
|
+
/** The running host's own answer; undefined when it does not answer or predates the field. */
|
|
117
|
+
export async function hostBusy(dir = remoteHome()) {
|
|
118
|
+
try {
|
|
119
|
+
const cfg = loadConfig(dir, false);
|
|
120
|
+
const secret = fs.readFileSync(path.join(dir, "local.secret"), "utf8").trim();
|
|
121
|
+
const response = await fetch(`http://127.0.0.1:${cfg.port}/local/status`, { headers: { "x-ripple-local": secret }, redirect: "error", signal: AbortSignal.timeout(2000) });
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
await response.body?.cancel();
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const body = await response.json();
|
|
127
|
+
return typeof body.busy === "boolean" ? body.busy : undefined;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
107
133
|
export async function installService(spec) {
|
|
108
134
|
const platform = process.platform;
|
|
109
135
|
if (platform === "darwin")
|
|
@@ -115,14 +115,16 @@ function fileImage(file, sink) {
|
|
|
115
115
|
throw error;
|
|
116
116
|
}
|
|
117
117
|
try {
|
|
118
|
-
const stat = fs.fstatSync(fd);
|
|
118
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
119
119
|
const checked = imageCachePath(file);
|
|
120
120
|
if (checked !== real)
|
|
121
121
|
return unavailable;
|
|
122
|
-
const current = fs.statSync(checked);
|
|
123
|
-
|
|
122
|
+
const current = fs.statSync(checked, { bigint: true });
|
|
123
|
+
// Windows path stats report dev as zero while handle stats do not; its ino, size and birth time agree.
|
|
124
|
+
const sameDevice = current.dev === 0n || stat.dev === 0n || current.dev === stat.dev;
|
|
125
|
+
if (!sameDevice || current.ino !== stat.ino || current.size !== stat.size || current.birthtimeNs !== stat.birthtimeNs)
|
|
124
126
|
return unavailable;
|
|
125
|
-
if (!stat.isFile() || stat.size > MAX_BYTES || !MIME.has(mediaType))
|
|
127
|
+
if (!stat.isFile() || stat.size > BigInt(MAX_BYTES) || !MIME.has(mediaType))
|
|
126
128
|
return unavailable;
|
|
127
129
|
return { ...unavailable, blob: sink(fs.readFileSync(fd).toString("base64")) };
|
|
128
130
|
}
|
|
@@ -66,6 +66,7 @@ export class LocalControl {
|
|
|
66
66
|
reachableFromPhone: !!cfg.relay || this.where.reachableFromPhone, pushReady: !!cfg.relay,
|
|
67
67
|
phones: this.opts.phones(), settings: { bind: saved.bind, ...(saved.relay ? { relay: saved.relay } : {}) }, defaultRelay: DEFAULT_RELAY,
|
|
68
68
|
restartRequired: saved.bind !== cfg.bind || saved.relay !== cfg.relay,
|
|
69
|
+
busy: this.opts.busy?.() ?? false,
|
|
69
70
|
});
|
|
70
71
|
return;
|
|
71
72
|
}
|
package/dist/remote/src/main.js
CHANGED
|
@@ -30,7 +30,7 @@ import { RouterClient } from "./router.js";
|
|
|
30
30
|
import { HostServer } from "./server.js";
|
|
31
31
|
import { LocalControl } from "./local-control.js";
|
|
32
32
|
import { findClaude, specFor, cliLoginWarning } from "./service.js";
|
|
33
|
-
import { installService, uninstallService, serviceStatus, servicePaths, renderService, supervisorName, controlShutdown } from "./host-service.js";
|
|
33
|
+
import { installService, uninstallService, serviceStatus, servicePaths, renderService, supervisorName, controlShutdown, refreshPlan, hostBusy } from "./host-service.js";
|
|
34
34
|
import { Sessions } from "./sessions.js";
|
|
35
35
|
import { desktopHandoff } from "./handoff.js";
|
|
36
36
|
import { claudeBinary, claudeCodeLogin } from "../../cli/src/claude-auth.js";
|
|
@@ -179,6 +179,7 @@ async function runHost() {
|
|
|
179
179
|
phones: () => server?.phones() ?? [],
|
|
180
180
|
rotate: token => { server?.rotateToken(token); },
|
|
181
181
|
shutdown: () => stop(),
|
|
182
|
+
busy: () => live.busy(),
|
|
182
183
|
}),
|
|
183
184
|
});
|
|
184
185
|
server = host;
|
|
@@ -234,7 +235,7 @@ async function installCmd() {
|
|
|
234
235
|
const cfg = process.argv.includes("--print") ? loadConfig(dir, false) : configFromArgs(dir);
|
|
235
236
|
// The CLI the host will start; also what a person is told to run when it has no login.
|
|
236
237
|
const claudePath = findClaude(cfg.claudePath);
|
|
237
|
-
const spec =
|
|
238
|
+
const spec = hostSpec(claudePath);
|
|
238
239
|
if (process.argv.includes("--print")) {
|
|
239
240
|
out(renderService(spec));
|
|
240
241
|
return;
|
|
@@ -249,11 +250,38 @@ async function installCmd() {
|
|
|
249
250
|
if (warning)
|
|
250
251
|
out(`⚠ ${warning}\n`);
|
|
251
252
|
}
|
|
253
|
+
function hostSpec(claudePath) {
|
|
254
|
+
return specFor({ script: fileURLToPath(import.meta.url), claudePath, ...(process.env.RIPPLE_REMOTE_HOME ? { remoteHome: process.env.RIPPLE_REMOTE_HOME } : {}) });
|
|
255
|
+
}
|
|
256
|
+
/** Run by `clauderipple update` from the new files: restarts a host this installation registered (refreshPlan). */
|
|
257
|
+
async function refreshCmd() {
|
|
258
|
+
const p = servicePaths();
|
|
259
|
+
let definition = null;
|
|
260
|
+
try {
|
|
261
|
+
definition = fs.readFileSync(p.definition, "utf8");
|
|
262
|
+
}
|
|
263
|
+
catch { /* not registered */ }
|
|
264
|
+
const plan = refreshPlan({ installed: serviceStatus().installed, definition, script: fileURLToPath(import.meta.url), busy: definition === null ? undefined : await hostBusy() });
|
|
265
|
+
if (plan === "absent")
|
|
266
|
+
return;
|
|
267
|
+
if (plan === "foreign") {
|
|
268
|
+
out(`phone host: runs from other files (${p.definition}); left as it is\n`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (plan === "busy") {
|
|
272
|
+
out("phone host: a turn is in progress, so it keeps the old code; restart it afterwards with: clauderipple remote install\n");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const spec = hostSpec(findClaude(loadConfig(remoteHome(), false).claudePath));
|
|
276
|
+
for (const line of await installService(spec))
|
|
277
|
+
out(`phone host: ${line}\n`);
|
|
278
|
+
}
|
|
252
279
|
async function main() {
|
|
253
280
|
const sub = process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : null;
|
|
254
281
|
switch (sub) {
|
|
255
282
|
case null: return runHost();
|
|
256
283
|
case "install": return installCmd();
|
|
284
|
+
case "refresh": return refreshCmd();
|
|
257
285
|
case "uninstall":
|
|
258
286
|
for (const line of await uninstallService())
|
|
259
287
|
out(`${line}\n`);
|
|
@@ -3,6 +3,11 @@ const LABELS = {
|
|
|
3
3
|
chatgpt: "ChatGPT", anthropic: "Claude", openrouter: "OpenRouter", deepseek: "DeepSeek",
|
|
4
4
|
"opencode-go": "OpenCode Go", "opencode-zen": "OpenCode Zen", "google-gemini": "Google Gemini",
|
|
5
5
|
};
|
|
6
|
+
/**
|
|
7
|
+
* Claude Code's own aliases for the current Opus, Sonnet and Haiku: the CLI resolves them, so this
|
|
8
|
+
* list never names a model version that goes stale.
|
|
9
|
+
*/
|
|
10
|
+
const CLAUDE_ALIASES = [{ model: "opus", name: "Opus" }, { model: "sonnet", name: "Sonnet" }, { model: "haiku", name: "Haiku" }];
|
|
6
11
|
const toEffort = (s) => (s && EFFORTS.includes(s) ? s : null);
|
|
7
12
|
const base = (id) => id.split("@")[0].replace(/-\d{8}$/, "");
|
|
8
13
|
export class RouterClient {
|
|
@@ -97,6 +102,14 @@ export function groupModels(opts, levels) {
|
|
|
97
102
|
}
|
|
98
103
|
g.models.push({ model: m.model, name: m.name, provider: m.provider, efforts, usable: ok });
|
|
99
104
|
}
|
|
105
|
+
// A router that passes Claude straight through declares no anthropic provider, so Claude was
|
|
106
|
+
// missing from the phone's picker, and with no other provider the picker came up empty
|
|
107
|
+
// (Discord, 2026-10-22). Claude Code runs its own models with its own login either way.
|
|
108
|
+
if (!opts.providers.some((p) => p.type === "anthropic")) {
|
|
109
|
+
const efforts = (levels.providers.anthropic?.default ?? opts.claudeEfforts ?? []).map(toEffort).filter((e) => e !== null && e !== "none" && e !== "minimal");
|
|
110
|
+
const models = CLAUDE_ALIASES.map(({ model, name }) => ({ model, name, provider: "claude-code", efforts, usable: true }));
|
|
111
|
+
return [{ provider: "claude-code", label: "Claude", usable: true, models }, ...groups.values()];
|
|
112
|
+
}
|
|
100
113
|
return Array.from(groups.values());
|
|
101
114
|
}
|
|
102
115
|
/**
|
|
@@ -7,7 +7,7 @@ import module from "node:module";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { WebSocketServer } from "ws";
|
|
9
9
|
import { tokenMatches } from "./config.js";
|
|
10
|
-
import { browse, makeFolder, recentFolders } from "./folders.js";
|
|
10
|
+
import { browse, macConsent, makeFolder, recentFolders } from "./folders.js";
|
|
11
11
|
import { gitInfo } from "./worktree.js";
|
|
12
12
|
import { ATTACHMENT_CAP, FRAME_CAP, PING_MS, STALE_MS } from "./protocol.js";
|
|
13
13
|
import { SEEN_MS, VISIBLE_MS } from "./push.js";
|
|
@@ -511,7 +511,7 @@ export class HostServer {
|
|
|
511
511
|
}
|
|
512
512
|
case "browse": return browse(op.path, op.hidden ?? false);
|
|
513
513
|
case "mkdir": return { path: await makeFolder(op.parent, op.name) };
|
|
514
|
-
case "git": return gitInfo(op.path);
|
|
514
|
+
case "git": return macConsent(op.path, () => gitInfo(op.path));
|
|
515
515
|
case "commands": return s.commands(op.session);
|
|
516
516
|
case "blob": {
|
|
517
517
|
if (!s.has(op.session))
|
|
@@ -12,7 +12,7 @@ import { ATTACHMENT_CAP, ATTACHMENT_COUNT } from "./protocol.js";
|
|
|
12
12
|
import { DesktopSessions } from "./desktop.js";
|
|
13
13
|
import { DesktopSidebar, PHONE_GROUP } from "./sidebar.js";
|
|
14
14
|
import { BridgeAuthError, BridgeClient, CLI_NOT_LOGGED_IN, DESKTOP_BLOCKED, isCliNotLoggedIn, object, slashCommands } from "./bridge-client.js";
|
|
15
|
-
import { isDirectory } from "./folders.js";
|
|
15
|
+
import { isDirectory, macConsent, readableFolder } from "./folders.js";
|
|
16
16
|
import { readJson, writeFileAtomic } from "./fsutil.js";
|
|
17
17
|
import { createWorktree } from "./worktree.js";
|
|
18
18
|
import { MessageReceipts, OutcomeUnknownError } from "./receipts.js";
|
|
@@ -57,15 +57,25 @@ class Inbox {
|
|
|
57
57
|
}
|
|
58
58
|
/** Routed models take their effort as `model@level`, which the router reads (ARCHITECTURE §3a); Claude models take the SDK's own option. */
|
|
59
59
|
export function modelArg(model, effort) {
|
|
60
|
-
|
|
60
|
+
// Claude Code's own aliases (router.ts offers them when no anthropic provider is declared) are
|
|
61
|
+
// Claude models too: `opus@high` would reach Anthropic as a model nobody has.
|
|
62
|
+
if (model.startsWith("claude-") || /^(opus|sonnet|haiku)(\[1m\])?$/.test(model))
|
|
61
63
|
return effort && ["low", "medium", "high", "xhigh", "max"].includes(effort) ? { model, effort } : { model };
|
|
62
64
|
return { model: effort ? `${model}@${effort}` : model };
|
|
63
65
|
}
|
|
64
66
|
const str = (v) => (typeof v === "string" ? v : "");
|
|
65
67
|
const oneLine = (s, n = 200) => { const l = s.split("\n").find((x) => x.trim()) ?? ""; return l.length > n ? l.slice(0, n - 1) + "…" : l; };
|
|
68
|
+
function relativeToCwd(file, cwd) {
|
|
69
|
+
if (!file || !path.isAbsolute(file))
|
|
70
|
+
return file;
|
|
71
|
+
const relative = path.relative(cwd, file);
|
|
72
|
+
if (!relative)
|
|
73
|
+
return ".";
|
|
74
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? relative : file;
|
|
75
|
+
}
|
|
66
76
|
/** One line a person reads to know what a tool call does. */
|
|
67
77
|
export function summarize(tool, input, cwd) {
|
|
68
|
-
const rel = (
|
|
78
|
+
const rel = (file) => relativeToCwd(file, cwd);
|
|
69
79
|
switch (tool) {
|
|
70
80
|
case "Bash": return oneLine(str(input.command));
|
|
71
81
|
case "Read":
|
|
@@ -759,6 +769,8 @@ export class Sessions {
|
|
|
759
769
|
const isolation = req.isolation ?? "local";
|
|
760
770
|
if (isolation !== "local" && isolation !== "worktree")
|
|
761
771
|
throw new Error("invalid isolation");
|
|
772
|
+
// Read first, so a folder macOS guards says so now rather than holding the session at "starting".
|
|
773
|
+
await macConsent(path.resolve(req.cwd), () => readableFolder(path.resolve(req.cwd)));
|
|
762
774
|
// A worktree session runs in a fresh branch under the repo's parent; the picked folder itself is
|
|
763
775
|
// never modified. The host validates everything (worktree.ts), refusing a folder that is not a repo.
|
|
764
776
|
const cwd = isolation === "worktree"
|
|
@@ -1708,10 +1720,16 @@ export class Sessions {
|
|
|
1708
1720
|
const tools = (message.content ?? []).filter((b) => b.type === "tool_use" && b.id && b.name).map((b) => ({ id: b.id, name: b.name, summary: summarize(b.name, b.input ?? {}, r.cwd), input: JSON.stringify(b.input ?? {}, null, 2) }));
|
|
1709
1721
|
log.append({ kind: "assistant", msg: message.id ?? randomUUID(), model: message.model ?? "", ...converted, tools });
|
|
1710
1722
|
const sub = live.subs.get(parent);
|
|
1711
|
-
if (sub)
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1723
|
+
if (!sub)
|
|
1724
|
+
return;
|
|
1725
|
+
sub.steps++;
|
|
1726
|
+
// A named worker is spawned without a `model` argument, so its row had no model until it
|
|
1727
|
+
// finished; its first answer says which model ran it.
|
|
1728
|
+
if (message.model && sub.model !== message.model) {
|
|
1729
|
+
const first = sub.model === null;
|
|
1730
|
+
sub.model = message.model;
|
|
1731
|
+
if (first)
|
|
1732
|
+
this.append(r.id, { kind: "subagent", toolUseId: parent, sub: `sub-${parent}`, description: sub.description, model: sub.model, status: "running", steps: sub.steps });
|
|
1715
1733
|
}
|
|
1716
1734
|
}
|
|
1717
1735
|
flush(id, live) {
|
package/dist/remote/web/app.ts
CHANGED
|
@@ -140,7 +140,7 @@ export class App {
|
|
|
140
140
|
link.onReady = () => this.onReady();
|
|
141
141
|
window.addEventListener("online", () => link.nudge());
|
|
142
142
|
window.addEventListener("offline", () => link.nudge());
|
|
143
|
-
document.addEventListener("visibilitychange", () => { this.sendPresence(); if (document.visibilityState === "visible") { link.
|
|
143
|
+
document.addEventListener("visibilitychange", () => { this.sendPresence(); if (document.visibilityState === "visible") { link.foreground(); void this.refreshPush(); } });
|
|
144
144
|
setInterval(() => { this.ui.now = Date.now(); this.renderChrome(); }, 1000);
|
|
145
145
|
// While in front, say so every PING_MS: the host pushes nothing to a phone that is looking, and
|
|
146
146
|
// stops counting one whose app froze in the background without saying "hidden".
|
package/dist/remote/web/conn.ts
CHANGED
|
@@ -84,6 +84,16 @@ export class Link {
|
|
|
84
84
|
this.connect();
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The app came back to the front. The time it spent in the background was the phone's, not the
|
|
89
|
+
* computer's silence: counting it put "no answer for 4 minutes" on screen the moment someone
|
|
90
|
+
* returned from another app (Discord, 2026-10-22). The count starts again from now.
|
|
91
|
+
*/
|
|
92
|
+
foreground(): void {
|
|
93
|
+
if (this.status.downSince !== null) this.status = { ...this.status, downSince: this.deps.now() };
|
|
94
|
+
this.nudge();
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
send(msg: ClientMsg): boolean {
|
|
88
98
|
if (this.status.state !== "open" || !this.sock) return false;
|
|
89
99
|
try { this.sock.send(JSON.stringify(msg)); return true; } catch { return false; }
|
package/dist/remote/web/i18n.ts
CHANGED
|
@@ -14,8 +14,8 @@ const ko = {
|
|
|
14
14
|
"conn.retryIn": "{s}초 후 재연결",
|
|
15
15
|
"conn.retrying": "재연결 중…",
|
|
16
16
|
"conn.offline": "오프라인 상태",
|
|
17
|
-
"conn.lost.title": "{host}
|
|
18
|
-
"conn.lost.body": "{ago} 동안 응답이 없습니다. {host}에서
|
|
17
|
+
"conn.lost.title": "{host} 응답을 기다리는 중",
|
|
18
|
+
"conn.lost.body": "{ago} 동안 응답이 없습니다. 세션은 {host}에서 계속 실행 중일 수 있고, 다시 연결되면 놓친 내용을 모두 받아옵니다.",
|
|
19
19
|
"conn.offline.title": "기기 오프라인",
|
|
20
20
|
"conn.offline.body": "네트워크가 복구되면 자동으로 다시 연결합니다. 대기 중인 메시지는 보관 후 전송합니다.",
|
|
21
21
|
"conn.retryNow": "지금 다시 시도",
|
|
@@ -62,7 +62,7 @@ const ko = {
|
|
|
62
62
|
"isolation.local": "로컬",
|
|
63
63
|
"isolation.worktree": "새 worktree",
|
|
64
64
|
"isolation.base": "분기 기준",
|
|
65
|
-
"composer.new": "
|
|
65
|
+
"composer.new": "예: 실패하는 테스트를 찾아 고쳐 주세요",
|
|
66
66
|
"composer.reply": "이어서 입력",
|
|
67
67
|
"composer.send": "보내기",
|
|
68
68
|
"composer.stop": "중지",
|
|
@@ -187,7 +187,7 @@ const en: Record<Key, string> = {
|
|
|
187
187
|
"conn.retryIn": "Reconnecting in {s}s",
|
|
188
188
|
"conn.retrying": "Reconnecting…",
|
|
189
189
|
"conn.offline": "Phone offline",
|
|
190
|
-
"conn.lost.title": "
|
|
190
|
+
"conn.lost.title": "Waiting for your {host}",
|
|
191
191
|
"conn.lost.body": "No answer for {ago}. Sessions may still be running on your {host}; everything you missed arrives once it's back.",
|
|
192
192
|
"conn.offline.title": "This phone is offline",
|
|
193
193
|
"conn.offline.body": "It reconnects as soon as the network is back. Messages you write meanwhile are kept and sent then.",
|
|
@@ -235,7 +235,7 @@ const en: Record<Key, string> = {
|
|
|
235
235
|
"isolation.local": "Local",
|
|
236
236
|
"isolation.worktree": "New worktree",
|
|
237
237
|
"isolation.base": "Branch from",
|
|
238
|
-
"composer.new": "
|
|
238
|
+
"composer.new": "e.g. Find the failing test and fix it",
|
|
239
239
|
"composer.reply": "Reply",
|
|
240
240
|
"composer.send": "Send",
|
|
241
241
|
"composer.stop": "Stop",
|
package/dist/remote/web/views.ts
CHANGED
|
@@ -58,7 +58,9 @@ function textOf(v: Text): string {
|
|
|
58
58
|
/** A link is "lost" (not merely reconnecting) once it has been down for 30 s: say so plainly. */
|
|
59
59
|
export function linkKind(link: LinkStatus, now: number): "open" | "connecting" | "waiting" | "offline" | "denied" | "lost" {
|
|
60
60
|
if (link.state === "open" || link.state === "denied" || link.state === "offline") return link.state;
|
|
61
|
-
|
|
61
|
+
// A phone that slept or switched networks is often silent for half a minute; saying "lost" then
|
|
62
|
+
// alarmed people over nothing (Discord, 2026-10-22). It is a calm wait, not an error, either way.
|
|
63
|
+
if (link.downSince !== null && now - link.downSince > 60_000) return "lost";
|
|
62
64
|
return link.state;
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -96,7 +98,7 @@ export function banner(ui: UI): string {
|
|
|
96
98
|
}
|
|
97
99
|
if (kind === "lost") {
|
|
98
100
|
const ago = duration(ui.now - (ui.link.downSince ?? ui.now));
|
|
99
|
-
return `<div class="banner
|
|
101
|
+
return `<div class="banner warn">${ripple("sleepy", 40)}<div class="txt"><b>${esc(t("conn.lost.title", { host: hostName(ui) }))}</b>${esc(t("conn.lost.body", { ago, host: hostName(ui) }))}${seen ? `<small>${esc(seen)}</small>` : ""}<div class="act"><button class="btn primary" data-act="retry">${esc(t("conn.retryNow"))}</button></div></div></div>`;
|
|
100
102
|
}
|
|
101
103
|
if (ui.link.attempt === 0) return "";
|
|
102
104
|
return `<div class="banner slim">${icon.clock(16)}<span>${esc(ui.link.lastRx ? t("conn.stale", { time: clock(ui.link.lastRx) }) : t("conn.connecting"))}</span></div>`;
|
package/dist/router/src/admin.js
CHANGED
|
@@ -840,7 +840,11 @@ function codexState() {
|
|
|
840
840
|
}
|
|
841
841
|
}
|
|
842
842
|
function claudeAuthStore(deps) {
|
|
843
|
-
return new ClaudeCodeAuthStore(undefined, {
|
|
843
|
+
return new ClaudeCodeAuthStore(undefined, {
|
|
844
|
+
home: homeDir(),
|
|
845
|
+
...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}),
|
|
846
|
+
...(deps.claudeCodeCredentialReaders ? { credentialReaders: deps.claudeCodeCredentialReaders } : {}),
|
|
847
|
+
});
|
|
844
848
|
}
|
|
845
849
|
async function probeAnthropicApiKey(apiKey, probeFetch = fetchWithTimeout) {
|
|
846
850
|
// The key's own model list where it answers; a key that cannot list is still tested below.
|
|
@@ -36,6 +36,7 @@ export class ClaudeAccountAuthPool {
|
|
|
36
36
|
...(options.observed ? { observed: options.observed } : {}),
|
|
37
37
|
...(options.env ? { env: options.env } : {}),
|
|
38
38
|
...(options.now ? { now: options.now } : {}),
|
|
39
|
+
...(options.credentialReaders ? { credentialReaders: options.credentialReaders } : {}),
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
42
|
/** Refresh all due stored accounts, with one network request per account in this process. */
|
|
@@ -35,9 +35,12 @@ export function readClaudeCodeCredentialsFile() {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
/** Keychain first on macOS, then Claude Code's credential-file fallback. */
|
|
38
|
-
export function readClaudeCodeCredentials(
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
export function readClaudeCodeCredentials(readers = {
|
|
39
|
+
keychain: readClaudeCodeKeychain,
|
|
40
|
+
credentialsFile: readClaudeCodeCredentialsFile,
|
|
41
|
+
}) {
|
|
42
|
+
const keychain = readers.keychain();
|
|
43
|
+
return parseClaudeCodeCredentials(keychain) ? keychain : readers.credentialsFile();
|
|
41
44
|
}
|
|
42
45
|
export function parseClaudeCodeCredentials(raw) {
|
|
43
46
|
if (!raw)
|
|
@@ -82,6 +85,7 @@ export class ClaudeCodeCredentialStore {
|
|
|
82
85
|
*/
|
|
83
86
|
export class ClaudeCodeAuthStore {
|
|
84
87
|
credentials;
|
|
88
|
+
credentialReaders;
|
|
85
89
|
observed;
|
|
86
90
|
home;
|
|
87
91
|
env;
|
|
@@ -89,8 +93,12 @@ export class ClaudeCodeAuthStore {
|
|
|
89
93
|
fetchImpl;
|
|
90
94
|
log;
|
|
91
95
|
refreshing = null;
|
|
92
|
-
constructor(credentials =
|
|
93
|
-
this.
|
|
96
|
+
constructor(credentials = undefined, options = {}) {
|
|
97
|
+
this.credentialReaders = options.credentialReaders ?? {
|
|
98
|
+
keychain: readClaudeCodeKeychain,
|
|
99
|
+
credentialsFile: readClaudeCodeCredentialsFile,
|
|
100
|
+
};
|
|
101
|
+
this.credentials = credentials ?? new ClaudeCodeCredentialStore(() => readClaudeCodeCredentials(this.credentialReaders));
|
|
94
102
|
this.observed = options.observed;
|
|
95
103
|
this.home = options.home ?? (process.env.CLAUDERIPPLE_HOME?.trim() || path.join(os.homedir(), ".clauderipple"));
|
|
96
104
|
this.env = options.env ?? process.env;
|
|
@@ -154,10 +162,10 @@ export class ClaudeCodeAuthStore {
|
|
|
154
162
|
return "observed";
|
|
155
163
|
if (this.env.CLAUDE_CODE_OAUTH_TOKEN?.trim())
|
|
156
164
|
return "env";
|
|
157
|
-
const keychain = parseClaudeCodeCredentials(
|
|
165
|
+
const keychain = parseClaudeCodeCredentials(this.credentialReaders.keychain());
|
|
158
166
|
if (keychain && this.now() < keychain.expiresAt)
|
|
159
167
|
return "keychain";
|
|
160
|
-
const credentialsFile = parseClaudeCodeCredentials(
|
|
168
|
+
const credentialsFile = parseClaudeCodeCredentials(this.credentialReaders.credentialsFile());
|
|
161
169
|
if (credentialsFile && this.now() < credentialsFile.expiresAt)
|
|
162
170
|
return "credentials-file";
|
|
163
171
|
return readClaudeAuthFile(this.home) ? "token-file" : null;
|
package/dist/router/src/proxy.js
CHANGED
|
@@ -434,6 +434,7 @@ export class Proxy {
|
|
|
434
434
|
home: deps.home,
|
|
435
435
|
log: deps.log,
|
|
436
436
|
...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}),
|
|
437
|
+
...(deps.claudeCodeCredentialReaders ? { credentialReaders: deps.claudeCodeCredentialReaders } : {}),
|
|
437
438
|
});
|
|
438
439
|
this.claudeUsage = new ClaudeUsage(this.claudeAccounts, {
|
|
439
440
|
upstream: () => this.deps.config().upstream,
|
|
@@ -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.12.
|
|
8
|
+
export const VERSION = "0.12.6";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauderipple",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.6",
|
|
4
4
|
"description": "Use GPT (any ChatGPT plan with Codex), 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": [
|