clauderipple 0.12.5 → 0.12.7

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,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.7 — 2026-10-22
4
+
5
+ ### Fixed
6
+
7
+ - **A GPT main session knows it can make images.** How to call `/api/image` was written only into
8
+ generated worker files, so a session running GPT as its own model said it had no image tool and
9
+ drew an SVG. The same lines now end its instructions; subagent turns are unchanged.
10
+ - **A relay's 402 for a downed model no longer parks the whole account.** OpenCode Go answered 402
11
+ while one model's endpoint was down, which read as billing exhaustion and rested the account for
12
+ 30 minutes, DeepSeek included. A refusal whose body says the upstream failed is now transient.
13
+ - **A failed hotspot join is tried again within seconds** (10 s, 20 s, 30 s … up to a minute)
14
+ instead of waiting out the once-a-minute switch rule.
15
+ - **`ripple:auto` asks for a bare task description,** so the Claude app's running-agent row no
16
+ longer shows another worker's model name copied into the title.
17
+
18
+ ## 0.12.6 — 2026-10-22
19
+
20
+ ### Fixed
21
+
22
+ - **The phone can pick a folder on another Windows drive.** Browsing starts in `C:\Users\<name>`,
23
+ and a drive root had nowhere to go up to, so a project on `E:` was out of reach. A drive root now
24
+ lists the other drives first.
25
+ - **The phone's model picker always offers Claude.** It listed only what the router's providers
26
+ declare, so a router that passes Claude straight through showed no Claude, and with no other
27
+ provider the picker was empty. Claude Code's own Opus, Sonnet and Haiku are now offered when no
28
+ Claude provider is configured.
29
+ - **A running worker's row names its model** as soon as the worker's first answer (or, for a Claude
30
+ app session, its meta file) says which model ran it, instead of only when it finishes.
31
+ - **A quiet computer reads as a wait, not a lost connection.** The phone's red "connection lost"
32
+ card came after 30 seconds of silence, which a phone that slept or switched networks often
33
+ reaches. It now comes after a minute, in the warning tone, as "waiting for your computer", and
34
+ time the phone app spent in the background no longer counts toward it.
35
+ - **Jev gets 5 seconds to answer** before Haiku judges instead. It answers in about 2.1–2.4
36
+ seconds, so 3 seconds timed out whenever OpenCode slowed down.
37
+ - **`clauderipple install` removes npm's `clauderipple.ps1` on Windows.** PowerShell runs it before
38
+ `clauderipple.cmd`, and its default execution policy refuses it, so an `npm install -g` user could
39
+ not run `clauderipple` in PowerShell. The README now says to start with `clauderipple.cmd` there.
40
+ - **The phone says when the Mac is asking for folder access.** Opening a folder macOS guards
41
+ (Desktop, Documents, Downloads, iCloud Drive, another volume) for the first time puts up the
42
+ Mac's "would like to access" dialog, and the phone waited in silence, then said the computer did
43
+ not answer. It now says the dialog is up within two seconds, and where to allow access if it was
44
+ refused.
45
+ - **The band's limits row comes back at the next check after a failed read**, not a minute later.
46
+
3
47
  ## 0.12.5 — 2026-10-22
4
48
 
5
49
  ### 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.7",
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> {
@@ -7,7 +7,11 @@ import { kindOf, parseEffortVerdict, verdictObject } from './judge'
7
7
  import { claudeChoices, rungAbove, rungOf, tickedChoices } from './ladder'
8
8
 
9
9
  /** The one agent the main chat delegates to; the judge picks the worker (docs/specs/2026-10-12-worker-tiers.md). */
10
+ // The description line: a chat that had seen other workers' "Sol·high · " titles wrote one here
11
+ // too, and the app's running-agent row showed "Sol·high" while Sonnet ran (2026-10-22). The row
12
+ // shows the call as written; only the task list gets the mod's corrected title.
10
13
  export const AUTO_DESCRIPTION = 'Delegate any task here; ClaudeRipple picks the model. If a worker fails or returns nothing usable, delegate again here once and say the earlier attempt failed. ' +
14
+ 'Write the Agent description as the task alone, with no model or effort in front: ClaudeRipple puts the model it picks there. ' +
11
15
  'If you know how hard the task is, put [[ripple: light]], [[ripple: standard]], [[ripple: hard]] or [[ripple: max]] on the prompt\'s first line: ' +
12
16
  'light = lookups, mechanical edits, one small fix; standard = carry out a designed change (even across several files), research, summarize, read-only review; ' +
13
17
  'hard = subtle debugging, security review, or work where a wrong call is costly; max = planning, design or architecture where the deliverable is the decision itself. ' +
@@ -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.
@@ -45,9 +45,10 @@ const BODY = [
45
45
  /**
46
46
  * Added to the body when a ChatGPT provider is configured: how a worker makes an image. A worker has
47
47
  * no image tool, and `clauderipple` is not on PATH for everyone (a checkout runs from source), so
48
- * the instruction is the admin endpoint itself, which curl reaches on every platform.
48
+ * the instruction is the admin endpoint itself, which curl reaches on every platform. A GPT main
49
+ * session gets the same lines in its instructions (proxy.ts).
49
50
  */
50
- function imageLine(port) {
51
+ export function imageLine(port) {
51
52
  return [
52
53
  "이미지가 필요하면 ChatGPT 구독으로 만든다(한 장 30초~수 분):",
53
54
  `\`curl -sS --fail-with-body -o <절대경로>.png -H 'content-type: application/json' -d '{"prompt":"…"}' http://127.0.0.1:${port}/api/image\``,
@@ -12,6 +12,7 @@
12
12
  // A check that cannot run (no curl) exits as before: the documented fix stays the default.
13
13
  import path from "node:path";
14
14
  import { PROBE_URLS } from "./tether.js";
15
+ import { RELAY_UPSTREAM_FAILED } from "./pool.js";
15
16
  /** Only definite billing/quota exhaustion, never a bare rate limit or RESOURCE_EXHAUSTED.
16
17
  * 402 is already `exhausted` in pool.ts; Codex's usage_limit_reached is named in
17
18
  * providers/chatgpt/translate.ts. Other vendors share the explicit billing vocabulary.
@@ -20,6 +21,9 @@ import { PROBE_URLS } from "./tether.js";
20
21
  export function providerExhaustion(status, body = "", retryMs, now = Date.now()) {
21
22
  if (status !== 402 && status !== 429)
22
23
  return undefined;
24
+ // A relay's 402 for its upstream being down is not about money (pool.ts, RELAY_UPSTREAM_FAILED).
25
+ if (RELAY_UPSTREAM_FAILED.test(body))
26
+ return undefined;
23
27
  const balance = /\b(?:insufficient[_ -]+(?:balance|credits?|funds)|(?:balance|credits?)[_ -]+(?:insufficient|exhausted|depleted))\b/i.test(body);
24
28
  const billing = /\bbilling[_ -]+(?:hard[_ -]+)?(?:limit|exhausted|quota)|\b(?:exhausted|exceeded|insufficient)[_ -]+billing\b/i.test(body);
25
29
  const quota = /\b(?:insufficient_quota|usage_limit_reached|quota[_ -]+exhausted|exhausted[_ -]+quota)\b/i.test(body);
@@ -26,6 +26,8 @@
26
26
  const DEFAULT_RATE_LIMIT_MS = 60_000;
27
27
  const TRANSIENT_MS = 10_000;
28
28
  const EXHAUSTED_MS = 30 * 60_000;
29
+ /** A relay passing on its upstream's failure; whatever status it chose, the credential did nothing. */
30
+ export const RELAY_UPSTREAM_FAILED = /upstream (request )?failed/i;
29
31
  /** A `Retry-After` beyond this is treated as "not within this session"; the credential is parked. */
30
32
  const MAX_COOLDOWN_MS = 6 * 60 * 60_000;
31
33
  /**
@@ -48,13 +50,19 @@ export function classify(status, retryAfterMs, body = "") {
48
50
  // held for no time at all rather than for a shorter time — any cooldown at all would move the
49
51
  // conversation to another credential and take the prompt cache with it (§4). `bounded` is
50
52
  // bypassed deliberately: its one-second floor is exactly the cache cost this avoids.
51
- if (/upstream (request )?failed/i.test(body)) {
53
+ if (RELAY_UPSTREAM_FAILED.test(body)) {
52
54
  return { retryable: true, kind: "transient", cooldownMs: 0, quarantine: false };
53
55
  }
54
56
  return { retryable: true, kind: "auth", cooldownMs: bounded(retryAfterMs ?? DEFAULT_RATE_LIMIT_MS), quarantine: false };
55
57
  }
56
58
  if (status === 429)
57
59
  return { retryable: true, kind: "rate-limit", cooldownMs: bounded(retryAfterMs ?? DEFAULT_RATE_LIMIT_MS), quarantine: false };
60
+ // The same relay answers 402 for a model whose own endpoint is down ("server_error … Upstream
61
+ // request failed: Endpoint is unavailable", OpenCode Go for minimax-m3.1-flash-free, 2026-10-22).
62
+ // That is no statement about money: read as exhaustion it parked the whole account for half an
63
+ // hour and its other models with it.
64
+ if (status === 402 && RELAY_UPSTREAM_FAILED.test(body))
65
+ return { retryable: true, kind: "transient", cooldownMs: 0, quarantine: false };
58
66
  if (status === 402)
59
67
  return { retryable: true, kind: "exhausted", cooldownMs: bounded(retryAfterMs ?? EXHAUSTED_MS), quarantine: false };
60
68
  // 408 and 425 are the server saying "ask again"; 5xx is the server being broken. Both may work
@@ -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;
@@ -984,12 +984,13 @@ export class ChatGptAdapter {
984
984
  * `subagent` is the one bit about the caller the tier needs: Claude Code names the agent in the
985
985
  * `x-claude-code-agent-id` header, and only a subagent's request carries it.
986
986
  */
987
- responsesOptions(carried, model, effort, replay = true, subagent = false) {
987
+ responsesOptions(carried, model, effort, replay = true, subagent = false, imageHint) {
988
988
  return {
989
989
  model,
990
990
  effort: effort ?? this.cfg.defaultEffort ?? "high",
991
991
  identity: this.cfg.identity ?? true,
992
992
  ...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
993
+ ...(imageHint ? { imageHint } : {}),
993
994
  replay,
994
995
  // The envelope names this provider and this model, and the request builder refuses anything
995
996
  // else: reasoning outlives the turn that made it, and a conversation moved to another model
@@ -1006,7 +1007,7 @@ export class ChatGptAdapter {
1006
1007
  * Handle a fully-read Messages request. `model`/`effort` already resolved by routing.
1007
1008
  *
1008
1009
  * `options.subagent` says the turn was made inside a subagent, which is what the provider's
1009
- * `fast.subagent` switch is about.
1010
+ * `fast.subagent` switch is about. `options.imageHint` is how the session makes an image (translate.ts).
1010
1011
  */
1011
1012
  async handle(req, res, path, json, model, effort, options) {
1012
1013
  if (path.startsWith("/v1/messages/count_tokens")) {
@@ -1021,7 +1022,7 @@ export class ChatGptAdapter {
1021
1022
  this.log.warn(`chatgpt ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
1022
1023
  const carried = { replayed: 0, undecodable: 0 };
1023
1024
  const subagent = options?.subagent === true;
1024
- let upstreamReq = toResponsesRequest(json, this.responsesOptions(carried, model, effort, true, subagent));
1025
+ let upstreamReq = toResponsesRequest(json, this.responsesOptions(carried, model, effort, true, subagent, options?.imageHint));
1025
1026
  let body = JSON.stringify(upstreamReq);
1026
1027
  const cacheKey = upstreamReq.prompt_cache_key;
1027
1028
  // Input estimate for message_start: a conversation only grows, so the last measured total is a floor.
@@ -1062,7 +1063,7 @@ export class ChatGptAdapter {
1062
1063
  // must never be the reason a user's turn fails (reasoning.ts).
1063
1064
  if (carried.replayed > 0 && sent.kind === "refused" && refusesReplayedReasoning(sent.status, sent.text)) {
1064
1065
  this.log.warn(`chatgpt ${this.name}: upstream ${sent.status} refused ${carried.replayed} replayed reasoning item(s) for ${model}; asking again without them: ${sent.text.slice(0, 200)}`);
1065
- upstreamReq = toResponsesRequest(json, this.responsesOptions({ replayed: 0, undecodable: 0 }, model, effort, false, subagent));
1066
+ upstreamReq = toResponsesRequest(json, this.responsesOptions({ replayed: 0, undecodable: 0 }, model, effort, false, subagent, options?.imageHint));
1066
1067
  body = JSON.stringify(upstreamReq);
1067
1068
  carried.replayed = 0;
1068
1069
  sent = await ask();
@@ -233,6 +233,8 @@ export function toResponsesRequest(req, opts) {
233
233
  parts.push(sys);
234
234
  if (opts.instructionsAppend)
235
235
  parts.push(opts.instructionsAppend);
236
+ if (opts.imageHint && opts.subagent !== true)
237
+ parts.push(opts.imageHint);
236
238
  const input = [];
237
239
  // Claude Code sends side queries whose history starts with a bare tool_result (e.g. summarising a
238
240
  // large tool output). Anthropic tolerates the orphan; the Responses API rejects a function_call_output
@@ -21,7 +21,7 @@ import zlib from "node:zlib";
21
21
  import { ProviderHealth, providerExhaustion, UpstreamHealth } from "./health.js";
22
22
  import { BOOTSTRAP_PATH, injectBootstrap } from "./bootstrap.js";
23
23
  import { THREAD_UNSUPPORTED, effortOf, isAutoModeClassifier, resolve, rewriteBody, stripThreadFields, threadDecision, unroutableReason } from "./routing.js";
24
- import { defaultAgentDir, withAgentAliases } from "./agents.js";
24
+ import { defaultAgentDir, imageLine, withAgentAliases } from "./agents.js";
25
25
  import { forwardCompatibleHeader, resolveCompatibleCaps, sanitizeForCompatible } from "./compat.js";
26
26
  import { applyIdentityToAnthropicBody } from "./identity.js";
27
27
  import { anthropicServerToolBackend, webPluginBackend, webSearchBlocks, webSearchErrorBlocks, webSearchMessage, webSearchQuery, webSearchSse } from "./websearch.js";
@@ -33,7 +33,7 @@ import { GoogleAdapter } from "./providers/google/index.js";
33
33
  import { OpenCodeUsage, isOpenCodeGo } from "./providers/opencode-usage.js";
34
34
  import { GrokAdapter } from "./providers/grok/index.js";
35
35
  import { conversationKey } from "./providers/chatgpt/translate.js";
36
- import { providerFor, searchProviderFor, terminateHosts } from "./config.js";
36
+ import { adminPort, providerFor, searchProviderFor, terminateHosts } from "./config.js";
37
37
  import { chatPickerModels, injectPickerModels, isBootstrapPath } from "./picker.js";
38
38
  import { effortLevels, effortLevelsForModel } from "./admin.js";
39
39
  import { isWebFetchRequest } from "./webfetch.js";
@@ -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,
@@ -988,7 +989,7 @@ export class Proxy {
988
989
  try {
989
990
  // The adapter reports each account's outcome to the shared pool itself, so a slot pointing
990
991
  // here fails over once every account is out (chooseTarget asks the adapter).
991
- const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json), { subagent });
992
+ const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json), { subagent, imageHint: imageLine(adminPort(cfg)) });
992
993
  if (!path.startsWith("/v1/messages/count_tokens"))
993
994
  this.providerHealth.observe(route.provider, o.status, o.exhaustion);
994
995
  finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
@@ -66,6 +66,13 @@ export const PROBE_URLS = {
66
66
  };
67
67
  export const PROBE_TIMEOUT_MS = 4_000;
68
68
  export const MIN_SWITCH_INTERVAL_MS = 60_000;
69
+ /**
70
+ * After a join that failed, the next try comes this much later per failure in a row (10, 20, 30 s…)
71
+ * up to MIN_SWITCH_INTERVAL_MS. A failed join switched nothing, so the once-a-minute rule against
72
+ * flapping does not apply: walking out of the office, the first join failed as the hotspot was only
73
+ * just coming into range, and the Mac sat offline for the rest of the minute (2026-10-22).
74
+ */
75
+ export const JOIN_RETRY_MS = 10_000;
69
76
  /** How long a scan that saw a known Wi-Fi stands for a return still waiting on busy calls. */
70
77
  export const KNOWN_VISIBLE_TRUST_MS = 60_000;
71
78
  export const RETURN_PROBE_MS = 20_000;
@@ -96,6 +103,8 @@ export const RETURN_HOLD_FAILSAFE_MS = RETURN_DRAIN_WAIT_MS + 3 * 60_000;
96
103
  const JOIN_SETTLE_MS = 15_000;
97
104
  /** States that say why the last join failed; they stay (with their hint) until the next attempt. */
98
105
  const JOIN_FAILURES = new Set(["hotspot-not-visible", "join-failed", "needs-setup", "no-hotspot-profile", "needs-location"]);
106
+ /** Failures a moment later can cure (the phone coming into range); setup problems wait the full minute. */
107
+ const RETRIABLE_JOIN = new Set(["hotspot-not-visible", "join-failed"]);
99
108
  // ---- Pure helpers -------------------------------------------------------------------------------
100
109
  /** The Wi-Fi device from `networksetup -listallhardwareports` ("Hardware Port: Wi-Fi\nDevice: en0"). */
101
110
  export function parseWifiDevice(text) {
@@ -336,6 +345,8 @@ export class TetherController {
336
345
  state = "off";
337
346
  pinned = false;
338
347
  lastSwitchAt = 0;
348
+ /** Hotspot joins that failed in a row; the retry gap grows with it (JOIN_RETRY_MS). */
349
+ joinFailures = 0;
339
350
  /** When a scan last saw a known Wi-Fi other than the hotspot. */
340
351
  knownVisibleAt = -Infinity;
341
352
  lastSwitch = null;
@@ -486,6 +497,7 @@ export class TetherController {
486
497
  }
487
498
  if (this.network === "hotspot") {
488
499
  // Landed here by our own failover or by hand: either way, plan the way back from here.
500
+ this.joinFailures = 0;
489
501
  if (this.landedOnHotspotAt === null) {
490
502
  this.landedOnHotspotAt = this.now();
491
503
  this.nextReturnAt = nextReturnPoint(this.landedOnHotspotAt, this.now());
@@ -500,6 +512,8 @@ export class TetherController {
500
512
  // On Wi-Fi (or none): the way back is no longer pending, and landing again starts the schedule over.
501
513
  this.landedOnHotspotAt = null;
502
514
  if (!this.window.outage()) {
515
+ if (this.network === "wifi")
516
+ this.joinFailures = 0;
503
517
  this.state = this.network === "wifi" ? "watching" : "no-wifi";
504
518
  if (this.network === "wifi")
505
519
  this.decide(`watching Wi-Fi (${5 - this.window.failures()}/5 recent probes ok)`);
@@ -517,11 +531,13 @@ export class TetherController {
517
531
  return;
518
532
  }
519
533
  const since = this.now() - this.lastSwitchAt;
520
- if (since < MIN_SWITCH_INTERVAL_MS) {
534
+ const retrying = this.joinFailures > 0 && RETRIABLE_JOIN.has(this.state);
535
+ const gap = retrying ? Math.min(MIN_SWITCH_INTERVAL_MS, JOIN_RETRY_MS * this.joinFailures) : MIN_SWITCH_INTERVAL_MS;
536
+ if (since < gap) {
521
537
  // A failed join keeps saying why (and its hint) until the next attempt.
522
538
  if (!JOIN_FAILURES.has(this.state))
523
539
  this.state = "outage";
524
- this.decide(`${why}; waiting ${Math.ceil((MIN_SWITCH_INTERVAL_MS - since) / 1000)}s (at most one switch per minute)`);
540
+ this.decide(`${why}; waiting ${Math.ceil((gap - since) / 1000)}s (${retrying ? "trying the hotspot again" : "at most one switch per minute"})`);
525
541
  return;
526
542
  }
527
543
  // A call in flight on a dead network cannot finish anyway; give it a short chance, no more.
@@ -533,6 +549,7 @@ export class TetherController {
533
549
  this.decide(`${why}: joining hotspot "${settings.hotspotSsid}"`);
534
550
  const outcome = await this.join(settings.hotspotSsid);
535
551
  this.recordSwitch("hotspot", outcome === "joined", outcome === "joined" ? why : outcome);
552
+ this.joinFailures = outcome === "joined" ? 0 : this.joinFailures + 1;
536
553
  if (outcome === "joined" && !this.onHotspot(this.currentIp())) {
537
554
  // Joined, but on a network that could not be remembered (see learn()): it reads as Wi-Fi.
538
555
  this.window.reset();
@@ -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.7";
@@ -427,7 +427,10 @@ must preserve **Data: Artificial Analysis (artificialanalysis.ai)** attribution.
427
427
  reason. With a chatgpt provider configured the body also tells a worker how to
428
428
  make an image: `POST /api/image` on the admin port, which runs one Codex
429
429
  Responses turn with the hosted `image_generation` tool required and answers
430
- with the image bytes (`clauderipple image` is the same call). Measured
430
+ with the image bytes (`clauderipple image` is the same call). A GPT main session
431
+ gets the same lines at the end of its instructions (not a subagent's turns, whose
432
+ agent file has them): without them a GPT session told the operator it could not
433
+ make images and drew an SVG (2026-10-22). Measured
431
434
  2026-09-29 on a subscription: the image is the `image_generation_call` output
432
435
  item's base64 `result`, about 30 s; `output_format` and `background` are
433
436
  honoured, `size` and `quality` are accepted and ignored (even "999x1" and
@@ -1647,6 +1650,7 @@ plan unless marked otherwise.
1647
1650
  | Blind returns repeatedly cut the only connection while travelling with the lid closed (2026-10-21 18:40–19:08 KST, owner-reported router.log): at each point, "trying to go back to Wi-Fi: leaving the hotspot so macOS joins the best known network", then "no network was joined", "networksetup said: Failed to join network PBJ" → `join-failed`. No known Wi-Fi was in range, and an idle iPhone stops advertising; with the lid closed macOS did not reliably rejoin it. The owner pinned the network as a stopgap. | Never leave the hotspot speculatively. Before either quiet return or the hold gate, a read-only backend check must prove another saved Wi-Fi is visible (on Windows, also an automatic profile). No match → "staying on the hotspot: no known Wi-Fi in range", once per point, then the next point on the unchanged schedule. Scan/list failure, unsupported response or Location refusal → stay and log why; never hold calls or disconnect on unknown visibility. macOS: preferred SSIDs + directed CoreWLAN JXA scans, Windows: saved profile SSIDs intersected with `netsh wlan show networks` (§8, measurements 2026-10-21). |
1648
1651
  | Android hotspot never recognised (2026-10-20, found by reading the code for an owner with Windows laptops and Android phones in mind): "on the hotspot" was the iPhone range 172.20.10.0/28 alone, on macOS and Windows. An Android hotspot hands out 192.168.43.x (≤ Android 10) or a random /24 (Android 11+; 192.168/16, 172.16/12, 10/8 by Android 14), so after our own failover `join()` saw no hotspot address and reported `join-failed`/`hotspot-not-visible`, while on the phone the controller read Wi-Fi and never planned the way back, and a by-hand join was never recognised | The product does not assume an iPhone. After our own join of the configured SSID is accepted by the OS, a new, non-link-local address within the settle time is the join, and its subnet (address + netmask from `os.networkInterfaces()`: no Location, no elevation) is kept in `<home>/tether.json` for that hotspot name; "on the hotspot" = iPhone range OR that network (§8). Only our own join replaces it, and a subnet equal to the Wi-Fi watched before is not remembered (logged as ambiguous) so home Wi-Fi is never read as the hotspot. Hints name both phones where the step differs. Not yet run with a real Android phone. |
1649
1652
  | An expired Claude login on the Mac wrote `[desktop state] …로그인이 만료되었습니다…` every few seconds per Claude app session and read the keychain (a synchronous child process) for every request, while phones learned of it only by a failed send (2026-10-15) | A login problem is one host state: logged when it starts and ends, sent to phones as `HostInfo.desktopProblem` (a banner), and the keychain is reread at most every 30 s while expired or after a 401. Per-session Remote Control errors log once per change. The host never refreshes the login itself. |
1653
+ | OpenCode Go answered **402** for `minimax-m3.1-flash-free` while that model's endpoint was down (`server_error … Upstream request failed: Endpoint is unavailable`); read as billing exhaustion, it parked the whole `opencode-go` account for 30 minutes, DeepSeek with it, and delegated work fell back to Opus while the account had credit (2026-10-22) | A relay's refusal whose body says its upstream failed (`RELAY_UPSTREAM_FAILED`, `pool.ts`) is transient whatever status it carries — 402 as well as 403 — and never a provider exhaustion (`health.ts`). A plain 402 or a body naming balance, billing or quota still is. |
1650
1654
 
1651
1655
  ### Router-owned automatic updates (2026-10-21)
1652
1656
 
@@ -1802,7 +1806,11 @@ before; a Wi-Fi elsewhere that happens to use the learned /24 reads as the hotsp
1802
1806
  can drop it only when another saved SSID is visible (visibility guard, 2026-10-21, below);
1803
1807
  `tether.pin` stops switching entirely. Failover (on Wi-Fi or no
1804
1808
  address, outage): wait at most 30s for an in-flight call, then `sudo -n networksetup
1805
- -setairportnetwork <dev> <NAME>` (macOS takes the password from the System keychain). The probe window
1809
+ -setairportnetwork <dev> <NAME>` (macOS takes the password from the System keychain). Switches are at
1810
+ least a minute apart, but a join that failed ("Failed to join network", or not visible) switched
1811
+ nothing, so it is tried again 10 s, 20 s, 30 s … later up to that minute: walking out of the office
1812
+ the first join came as the hotspot was only just in range, failed, and the Mac stayed offline for the
1813
+ rest of the minute (2026-10-22). Setup failures (sudo, profile, Location) still wait the minute. The probe window
1806
1814
  lives in the process, so it only works if the router is not restarted every few seconds while the
1807
1815
  machine is offline (§5, health self-exit refined 2026-10-20). networksetup
1808
1816
  reports failure on stdout and may exit 0, so its text decides: "Could not find network" →
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.12.5",
3
+ "version": "0.12.7",
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": [