clauderipple 0.12.5 → 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,34 @@
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
+
3
32
  ## 0.12.5 — 2026-10-22
4
33
 
5
34
  ### Changed
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
@@ -711,7 +716,7 @@ El autor lo usa a diario, pero es un proyecto joven. Cuenta con algunas aspereza
711
716
 
712
717
  ## Comunidad
713
718
 
714
- 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.
715
720
 
716
721
  ## Sin afiliación
717
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
@@ -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
@@ -570,7 +575,7 @@ Codex 앱 / CLI ──/v1/responses──▶ ClaudeRipple 입구 ──▶ Claud
570
575
 
571
576
  ## 커뮤니티
572
577
 
573
- 질문, 버그 제보, 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으로 보내 주세요. 모두가 읽는 채널에는 올리지 마세요.
574
579
 
575
580
  ## 비제휴
576
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
@@ -699,7 +703,7 @@ In daily use by the author, but young. Expect rough edges:
699
703
 
700
704
  ## Community
701
705
 
702
- 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.
703
707
 
704
708
  ## Not affiliated
705
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
@@ -694,7 +699,7 @@ Em uso diário pelo autor, mas ainda jovem. Espere algumas arestas:
694
699
 
695
700
  ## Comunidade
696
701
 
697
- 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.
698
703
 
699
704
  ## Sem afiliação
700
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
@@ -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.5",
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.
@@ -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
  }
@@ -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
  }
@@ -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.5";
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.5",
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": [