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 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** es opcional. Muestra el estado del router y abre
436
- el panel con un clic:
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
- Funciona con Electron, que ocupa unos 270 MB y por eso no se instala por defecto.
443
- `clauderipple tray --install` lo descarga una sola vez; todo lo demás funciona sin él.
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, deja allí la dirección de Gmail que usas en Google Play.
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で動作し、約270MBあるため、デフォルトではインストールされません。`clauderipple tray --install`で一度だけ取得します。それ以外の機能はすべて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 アドレスを Discord に書き込んでください。
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
- Electron으로 도는데 용량이 270MB라 기본 설치에는 넣지 않았습니다. `clauderipple tray --install`이 한 번만
360
- 받아 옵니다. 나머지 기능은 Electron 없이도 다 돕니다.
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** is optional. It shows the router's state and opens the
417
- dashboard in a click:
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
- It runs on Electron, which is about 270MB and is therefore not installed by default.
424
- `clauderipple tray --install` fetches it once; everything else works without it.
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, leave the Gmail address you use on Google Play there.
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** é opcional. Ele mostra o estado do roteador e abre o
428
- painel com um clique:
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
- Ele roda em Electron, que tem cerca de 270 MB e, por isso, não é instalado por padrão.
435
- `clauderipple tray --install` baixa o Electron uma única vez; todo o resto funciona sem ele.
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, deixe lá o endereço do Gmail que você usa no Google Play.
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
- 它基于 Electron,体积约 270MB,因此默认不安装。`clauderipple tray --install` 会下载一次;除此之外的一切不需要它也能正常工作。
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 测试版,请在那里留下你在 Google Play 上使用的 Gmail 地址。
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
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "type": "commonjs"
5
5
  }
@@ -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() {
@@ -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,
@@ -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
- $.clock.sleep(3000).then(() => null),
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 (items && !same(items, await read($, limits))) await update($, limits, () => items)
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
- return scanLines(fd, size, line => {
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 segment = { fd: openSync(file, "a+", 0o600), bytes: data.length, number: Number(name.slice(7, -6)) };
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
- this.index(event, segment, offset, end + 1 - offset);
137
+ entries.push({ event, offset, length: end + 1 - offset });
139
138
  offset = end + 1;
140
139
  }
141
- if (offset !== data.length) {
142
- ftruncateSync(segment.fd, offset);
143
- segment.bytes = offset;
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
- if (!(await isDirectory(s.path)))
58
- continue;
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
- export async function browse(target, hidden = false, home = os.homedir()) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
- if (current.dev !== stat.dev || current.ino !== stat.ino)
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
  }
@@ -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 = specFor({ script: fileURLToPath(import.meta.url), claudePath, ...(process.env.RIPPLE_REMOTE_HOME ? { remoteHome: process.env.RIPPLE_REMOTE_HOME } : {}) });
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
- if (model.startsWith("claude-"))
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 = (p) => (p && path.isAbsolute(p) && (p === cwd || p.startsWith(cwd + "/")) ? path.relative(cwd, p) || "." : p);
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
- sub.steps++;
1713
- if (message.model)
1714
- sub.model = message.model;
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) {
@@ -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.nudge(); void this.refreshPush(); } });
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".
@@ -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; }
@@ -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": "Can't reach your {host}",
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": "What should it do in this folder?",
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",
@@ -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
- if (link.downSince !== null && now - link.downSince > 30_000) return "lost";
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 bad">${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>`;
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>`;
@@ -840,7 +840,11 @@ function codexState() {
840
840
  }
841
841
  }
842
842
  function claudeAuthStore(deps) {
843
- return new ClaudeCodeAuthStore(undefined, { home: homeDir(), ...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}) });
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
- const keychain = readClaudeCodeKeychain();
40
- return parseClaudeCodeCredentials(keychain) ? keychain : readClaudeCodeCredentialsFile();
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 = new ClaudeCodeCredentialStore(), options = {}) {
93
- this.credentials = credentials;
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(readClaudeCodeKeychain());
165
+ const keychain = parseClaudeCodeCredentials(this.credentialReaders.keychain());
158
166
  if (keychain && this.now() < keychain.expiresAt)
159
167
  return "keychain";
160
- const credentialsFile = parseClaudeCodeCredentials(readClaudeCodeCredentialsFile());
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;
@@ -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.4";
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.4",
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": [