clauderipple 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.ko.md +48 -4
  3. package/README.md +58 -4
  4. package/dist/cli/src/claude-auth.js +3 -2
  5. package/dist/cli/src/codex.js +20 -1
  6. package/dist/cli/src/hooks/agent-title.js +1 -1
  7. package/dist/cli/src/index.js +4 -4
  8. package/dist/cli/src/schtasks.js +43 -1
  9. package/dist/cli/src/settings.js +73 -6
  10. package/dist/router/src/admin.js +489 -56
  11. package/dist/router/src/agents.js +250 -0
  12. package/dist/router/src/bootstrap.js +24 -8
  13. package/dist/router/src/capabilities.js +214 -0
  14. package/dist/router/src/compat.js +5 -1
  15. package/dist/router/src/config.js +264 -11
  16. package/dist/router/src/index.js +14 -1
  17. package/dist/router/src/ingress/server.js +24 -14
  18. package/dist/router/src/picker.js +14 -6
  19. package/dist/router/src/pool.js +233 -0
  20. package/dist/router/src/presets.js +156 -1
  21. package/dist/router/src/providers/anthropic-account-pool.js +139 -0
  22. package/dist/router/src/providers/anthropic-accounts.js +281 -0
  23. package/dist/router/src/providers/chatgpt/catalog.js +97 -0
  24. package/dist/router/src/providers/chatgpt/index.js +343 -12
  25. package/dist/router/src/providers/chatgpt/sse.js +4 -0
  26. package/dist/router/src/providers/chatgpt/translate.js +156 -14
  27. package/dist/router/src/providers/claude-oauth.js +61 -19
  28. package/dist/router/src/providers/openai/index.js +55 -11
  29. package/dist/router/src/providers/openai/translate.js +82 -14
  30. package/dist/router/src/providers/retry.js +88 -0
  31. package/dist/router/src/proxy.js +697 -82
  32. package/dist/router/src/requestlog.js +5 -2
  33. package/dist/router/src/routing.js +151 -17
  34. package/dist/router/src/version.js +1 -1
  35. package/dist/router/src/websearch.js +307 -0
  36. package/dist/router/src/x509.js +7 -2
  37. package/dist/ui/app.js +740 -160
  38. package/dist/ui/i18n.js +14 -6
  39. package/dist/ui/index.html +18 -5
  40. package/dist/ui/presets-fallback.js +2 -0
  41. package/dist/ui/style.css +133 -9
  42. package/docs/ARCHITECTURE.md +381 -20
  43. package/package.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-09-23
4
+
5
+ Everything on `main` since 0.2.0. The account rotation that issue #8 asked for
6
+ was on `main` from 2026-09-20 while the README already described it, but no
7
+ release carried it. A user looking for the setting in 0.2.0 could not find it.
8
+
9
+ ### Added
10
+
11
+ - **Claude subscription account rotation.** A native Anthropic provider can opt in
12
+ with `accountPool: true` and use the current Claude Code/Desktop login followed by
13
+ every OAuth account added through ClaudeRipple. Conversations stay on the account
14
+ that answered to preserve the prompt cache; before any response reaches the client,
15
+ 429, 401 and transient upstream failures can move the same turn to the next account.
16
+ Stored grants refresh independently with single-flight and compare-and-swap safety,
17
+ rejected accounts are isolated until re-login, and the dashboard can rename or
18
+ remove them without exposing a token or upstream account id. Existing single-account
19
+ OAuth grants migrate to the private `~/.clauderipple/claude-accounts.json` file on
20
+ the first pool write. This applies to native Claude Desktop/Claude Code Messages;
21
+ the translated OpenAI ingress for Codex picks one highest-priority available login
22
+ per request but deliberately does not rotate accounts.
23
+ - **ChatGPT subscription web search.** When `webSearch` explicitly names a
24
+ ChatGPT provider, Claude Code's separate search request uses the subscription's
25
+ Codex hosted-search tool instead of silently consuming Anthropic quota. The
26
+ adapter requires a reported search and URL citations, preserves allowed-domain
27
+ filters, and refuses unsupported blocked-domain filters.
28
+ - **New ChatGPT models appear without an update.** The model list is read from
29
+ the Codex catalogue the subscription serves, not from a list in the code, so
30
+ GPT-6 Sol and GPT-6 Luna showed up the day they shipped. If the catalogue
31
+ cannot be read, a built-in list is used instead. Newly found models are
32
+ offered unticked. Claude Opus 5.5 is in the Claude list.
33
+ - **Credential pools and route fallbacks.** A provider can hold several keys. A
34
+ rate-limited or refused key rests until its cooldown ends, and the turn moves
35
+ to the next key before the client sees the failure. A model slot can name
36
+ fallback providers for when the primary has nothing usable left. The
37
+ dashboard shows which credential is resting, and until when.
38
+ - **OpenCode Go and OpenCode Zen presets.** One OpenCode Go subscription is one
39
+ preset covering all three of its endpoints, each with the session header its
40
+ prompt cache needs. Each model's request format is taken from the vendor's
41
+ published endpoint table. After a provider is saved, its models are measured
42
+ for request format and effort levels instead of being assumed.
43
+ - **A ticked model is a subagent.** Every model ticked in the GUI gets a
44
+ generated agent file. A model that only one provider carries routes there
45
+ without a hand-written rule. A model no provider carries is refused with a
46
+ message naming it, instead of being sent to Anthropic and coming back 404.
47
+ - **Web search in a routed session.** It reaches a provider that runs the
48
+ search tool itself, uses ChatGPT's hosted search when configured, and is
49
+ refused with a reason where no provider can run it.
50
+ - **Claude Code's own model slots as a setting.**
51
+
52
+ ### Fixed
53
+
54
+ - **A cut-off stream is retried, not taken as the answer.** An upstream that
55
+ closed the stream without finishing used to become an empty, successful
56
+ turn. A subagent then stopped silently, which is how long Muse runs appeared
57
+ to stall and die. The turn now fails as overloaded, and Claude Code retries it.
58
+ - **The Codex prompt cache holds across turns again.** The conversation is
59
+ named to the Codex backend, which now keys its cache on that name.
60
+ - **The provider form no longer wipes settings it does not show.** Saving a
61
+ ChatGPT provider in the GUI dropped fields such as `instructionsAppend`.
62
+ - **The request log shows a request's whole input.** A fully cached Anthropic
63
+ turn used to read as a 2-token request. The model column no longer shows
64
+ the agent file's default effort next to the effort that was actually sent.
65
+ - **Routed models keep their own context window,** including across a model
66
+ switch, and tool names the wire rejects are rewritten and restored.
67
+ - **Screenshots in tool results survive translation.**
68
+
69
+ - **Remote Control works through the forward proxy.** Claude Code sends its
70
+ registration, polling and heartbeat requests in HTTPS absolute-form rather
71
+ than CONNECT. ClaudeRipple now rewrites only the request target, strips proxy
72
+ headers and relays it over TLS outside model routing. The connection closes
73
+ after one request so another proxy target cannot leak to the first origin.
74
+ - **Windows reinstall is idempotent without elevation.** An existing scheduled
75
+ task is reused only when its action, principal, trigger, restart policy,
76
+ execution limit, instance policy and battery settings all match the intended
77
+ per-user task. A foreign or stale task is never trusted.
78
+
3
79
  ## 0.2.0 — 2026-09-16
4
80
 
5
81
  The first release published to npm, and the reason the number moves to 0.2:
package/README.ko.md CHANGED
@@ -150,7 +150,11 @@ ClaudeRipple은 Claude Code 프로세스만 신뢰하는 작은 HTTPS 프록시
150
150
  GPT-6 Astra를, 아니면 DeepSeek·Kimi·GLM·MiniMax·Qwen·Grok·Mistral·Groq·Together·Fireworks·OpenRouter(400+ 모델)·
151
151
  로컬 Ollama / LM Studio를. 피커에 실제 이름으로 띄우거나, Claude 이름에 매핑해서.
152
152
  - **Codex 앱과 Codex CLI에서 Claude를.** Codex가 프로바이더로 인식하는 로컬 OpenAI 호환 엔드포인트. Claude 모델이
153
- Codex 자체 모델 목록에 뜹니다. Claude Code 로그인이나 Anthropic API 키를 씁니다.
153
+ Codex 자체 모델 목록에 뜹니다. 현재 Claude Code 로그인이나 Anthropic API 키를 씁니다.
154
+ - **프롬프트 캐시를 깨지 않는 Claude 다계정.** 네이티브 Claude 프로바이더에서 계정 자동 전환을 켜고 대시보드로
155
+ 구독 계정을 추가하면, 대화마다 답한 계정에 고정됩니다. 응답이 시작되기 전 한도·인증 거부가 오면 같은 요청을
156
+ 다음 계정으로 넘깁니다. 이 Claude Desktop/Code 원형 경로는 번역을 거치는 Codex 입구와 별개이며, Codex 쪽은
157
+ 사용 가능한 로그인 하나만 골라 쓰고 계정을 자동 전환하지 않습니다.
154
158
  - **Claude Code 하네스는 손대지 않습니다.** 스킬, 훅, MCP, `CLAUDE.md`, 서브에이전트, 플랜 모드, 휴대폰 Remote Control.
155
159
  아무것도 꺼지지 않습니다.
156
160
  - **구조적으로 올바르게.** 프롬프트 캐시 보존(Anthropic 캐시 브레이크포인트, 고정된 OpenAI 프리픽스), Claude Code의
@@ -249,6 +253,45 @@ node packages/cli/src/index.ts ui # 브라우저에서 로컬 GUI 열기
249
253
  같은 라우터, 같은 매핑. `/model gpt-5.6-terra`에 추가한 모델이 나열됩니다. 서브에이전트도 같은 라우팅을 따르고,
250
254
  프롬프트에 `[[gpt: sol@xhigh]]` 표식을 넣으면 그 호출만 모델을 바꿉니다. 작업 패널에는 실제 모델 이름이 보입니다.
251
255
 
256
+ **이걸 쓰려고 하네스를 따로 짤 필요는 없습니다.** 서브에이전트 슬롯을 라우팅된 모델로 지정하면
257
+ 모든 서브에이전트가 그 모델로 돕니다. 에이전트 파일도, 다른 것도 필요 없습니다.
258
+
259
+ ```jsonc
260
+ // 설정 → CLI 모델, 또는 설정 파일의 "cli": { "models": { … } }
261
+ { "subagent": "gpt-5.6-terra" } // → CLAUDE_CODE_SUBAGENT_MODEL
262
+ ```
263
+
264
+ **체크한 모델은 곧 이름 붙은 서브에이전트입니다.** 프로바이더 하나만 들고 있는 모델마다 라우터가
265
+ `~/.claude/agents/<이름>.md`를 씁니다(`gpt-5.6-terra` → `gpt-5-6-terra`, `deepseek-v4.1-flash` →
266
+ `deepseek-v4-1-flash`). 그래서 "딥시크한테 시켜"에 필요한 건 모델 체크뿐입니다 — Agent 도구 목록에 뜨고,
267
+ `subagent_type: "deepseek-v4-1-flash"`로 돌고, 작업 패널에 그 이름이 보입니다. 체크를 빼면 파일도 사라집니다.
268
+ 라우터는 자기가 쓴 파일만 건드리고(`generated-agents.json`에 기록), 같은 이름으로 직접 쓴 파일이 있으면
269
+ 그쪽이 이기므로, 지시를 따로 주고 싶으면 Claude Code 에이전트 파일에 라우터가 아는 `model:`만 적으면 됩니다:
270
+
271
+ ```markdown
272
+ ---
273
+ name: reviewer
274
+ description: GPT-5.6 Sol로 독립 리뷰.
275
+ model: gpt-5.6-sol@medium
276
+ ---
277
+ 너는 이 세션의 리뷰어다. 변경을 직접 검증해서 결론만 보고한다.
278
+ ```
279
+
280
+ 호출마다 강도를 바꾸려면 서브에이전트 프롬프트 **첫 줄**에 `[[ripple: gpt-5-6-terra@high]]`처럼 적습니다 —
281
+ 에이전트 이름이든 모델 id든 됩니다. 첫 줄이 아닌 곳의 표식은 그냥 글이라 무시합니다(압축 요약에 인용된
282
+ 표식이 세션을 엉뚱한 곳으로 보내면 안 되니까요). 라우터가 보낼 곳을 모르는 모델은 어디론가 흘려보내
283
+ 알 수 없는 에러로 죽게 두지 않고 이름을 대고 거부합니다(`400 ClaudeRipple: no provider declares "…"`).
284
+ Claude 모델은 언제나 그대로 통과합니다. 생성을 끄려면 `"cli": { "agentFiles": false }`.
285
+
286
+ Claude Code의 `WebSearch`는 작은 모델에 별도 요청을 보냅니다. ChatGPT로 라우팅한 구성에서 그 요청만
287
+ Anthropic 한도를 쓰지 않게 하려면 `config.json`에서 같은 ChatGPT 프로바이더를 검색 백엔드로 지정합니다:
288
+
289
+ ```jsonc
290
+ { "webSearch": { "provider": "chatgpt", "model": "gpt-5.6-terra" } }
291
+ ```
292
+
293
+ 명시해야만 켜집니다. `webSearch` 설정이 없으면 Claude Code의 기존 검색 경로는 바뀌지 않습니다.
294
+
252
295
  ### Codex 앱과 Codex CLI
253
296
 
254
297
  ```bash
@@ -275,7 +318,7 @@ Anthropic API 키로 갑니다. 구독 로그인 재사용은 Anthropic 약관
275
318
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen(국제/중국) | Anthropic 호환 | API 키 | 프리셋 | 벤더 공식 문서로 확인 |
276
319
  | xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI 호환 | API 키 | 자동 검색 | 번역(Chat Completions / Responses) |
277
320
  | Ollama, LM Studio | OpenAI 호환, 로컬 | 없음 | 자동 검색 | |
278
- | Anthropic | 네이티브 | Claude Code 로그인 또는 API 키 | Claude 모델 | Codex 쪽에서 사용 |
321
+ | Anthropic | 네이티브 | Claude 로그인(복수 가능) 또는 API 키 | Claude 모델 | Claude Desktop/Code는 선택적 대화 고정 자동 전환, Codex 로그인 하나 선택 |
279
322
  | 그 밖의 무엇이든 | 직접 입력 | 자유 | 자동 검색 | Anthropic·OpenAI 호환 엔드포인트라면 무엇이든 |
280
323
 
281
324
  호환 프로바이더로 가는 요청에서는 Anthropic 전용 필드(서버 측 스레드, 지연 로딩 도구, 컨텍스트 관리, thinking 바인딩)를
@@ -303,8 +346,9 @@ Codex 앱 / CLI ──/v1/responses──▶ ClaudeRipple 입구 ──▶ Claud
303
346
 
304
347
  ## 개인정보
305
348
 
306
- 전부 127.0.0.1에서 돕니다. API 키는 `~/.clauderipple/config.json`(0600)에만 있습니다. 네트워크 목적지는 직접 설정한
307
- 프로바이더뿐입니다. 텔레메트리는 없습니다.
349
+ 전부 127.0.0.1에서 돕니다. API 키는 `~/.clauderipple/config.json`, 추가한 Claude OAuth 그랜트는
350
+ `~/.clauderipple/claude-accounts.json`에만 있고 둘 다 권한은 0600입니다. 관리 API와 로그에는 토큰이나 업스트림
351
+ 계정 ID를 내보내지 않습니다. 네트워크 목적지는 직접 설정한 프로바이더뿐이며 텔레메트리는 없습니다.
308
352
 
309
353
  ## 상태: 알파
310
354
 
package/README.md CHANGED
@@ -171,7 +171,13 @@ you map go to your provider, everything else goes to Anthropic byte for byte.
171
171
  a Claude name.
172
172
  - **Claude in the Codex app and Codex CLI.** A local OpenAI-compatible endpoint that
173
173
  Codex treats as a provider; Claude models appear in Codex's own model list. Uses
174
- your Claude Code login or an Anthropic API key.
174
+ your current Claude Code login or an Anthropic API key.
175
+ - **Multiple Claude accounts without breaking the prompt cache.** Opt a native Claude
176
+ provider into account rotation, add subscriptions from the dashboard, and each
177
+ conversation stays on the account that answered. Before the response starts, a
178
+ quota or authentication refusal moves that turn to the next account. This native
179
+ Claude Desktop/Code path is separate from the translated Codex ingress, which picks
180
+ one available login and does not rotate accounts.
175
181
  - **The full Claude Code harness, untouched.** Skills, hooks, MCP, `CLAUDE.md`,
176
182
  subagents, plan mode, Remote Control on your phone: nothing is turned off.
177
183
  - **Correct by construction.** Prompt caching preserved (Anthropic cache breakpoints
@@ -282,6 +288,53 @@ Same router, same mapping. `/model gpt-5.6-terra` lists the models you added.
282
288
  Subagents follow the routing; a `[[gpt: sol@xhigh]]` marker in a subagent prompt
283
289
  overrides the model for that call, and the task panel shows the real model name.
284
290
 
291
+ **You do not have to build an agent harness for this.** Point the subagent slot at
292
+ a routed model and every subagent runs on it — no agent file, nothing else:
293
+
294
+ ```jsonc
295
+ // Settings → CLI models, or "cli": { "models": { … } } in the config
296
+ { "subagent": "gpt-5.6-terra" } // → CLAUDE_CODE_SUBAGENT_MODEL
297
+ ```
298
+
299
+ **Every model you tick is also a named subagent.** The router writes
300
+ `~/.claude/agents/<name>.md` for each model exactly one provider carries
301
+ (`gpt-5.6-terra` → `gpt-5-6-terra`, `deepseek-v4.1-flash` → `deepseek-v4-1-flash`),
302
+ so "have DeepSeek do it" needs nothing beyond ticking the model: the Agent tool
303
+ lists it, `subagent_type: "deepseek-v4-1-flash"` runs it, and the task panel
304
+ names it. Untick the model and its file goes away. The router only ever touches
305
+ the files it wrote (recorded in `generated-agents.json`); an agent file you wrote
306
+ yourself under the same name wins and is left alone, so a custom brief is still
307
+ just Claude Code's own agent file with a `model:` the router knows:
308
+
309
+ ```markdown
310
+ ---
311
+ name: reviewer
312
+ description: Independent review on GPT-5.6 Sol.
313
+ model: gpt-5.6-sol@medium
314
+ ---
315
+ You are the reviewer for this session. Verify the change yourself and report
316
+ the conclusion only.
317
+ ```
318
+
319
+ Per-call effort goes on the **first line** of the subagent prompt:
320
+ `[[ripple: gpt-5-6-terra@high]]` — any agent name or model id works there. A
321
+ marker anywhere else is prose and is ignored (a compaction summary that quotes
322
+ one must not re-route the session). A model the router cannot place is refused
323
+ by name (`400 ClaudeRipple: no provider declares "…"`) instead of being sent on
324
+ to fail somewhere less legible; native Claude models always pass through.
325
+ Set `"cli": { "agentFiles": false }` to turn generation off.
326
+
327
+ Claude Code runs `WebSearch` as a separate small-model request. To keep a
328
+ ChatGPT-routed setup from spending Anthropic quota for that request, select the
329
+ same ChatGPT provider as the search backend in `config.json`:
330
+
331
+ ```jsonc
332
+ { "webSearch": { "provider": "chatgpt", "model": "gpt-5.6-terra" } }
333
+ ```
334
+
335
+ This is opt-in. With no `webSearch` setting, Claude Code's existing search path
336
+ is unchanged.
337
+
285
338
  ### Codex app and Codex CLI
286
339
 
287
340
  ```bash
@@ -311,7 +364,7 @@ provider you configured is available the same way.
311
364
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen (intl / cn) | Anthropic-compatible | API key | preset | verified against vendor docs |
312
365
  | xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI-compatible | API key | discovered | translated (Chat Completions / Responses) |
313
366
  | Ollama, LM Studio | OpenAI-compatible, local | none | discovered | |
314
- | Anthropic | native | Claude Code login or API key | Claude models | for the Codex side |
367
+ | Anthropic | native | Claude login(s) or API key | Claude models | optional sticky rotation for Claude Desktop/Code; Codex picks one login |
315
368
  | Anything else | custom | your choice | discovered | any Anthropic- or OpenAI-compatible endpoint |
316
369
 
317
370
  Requests to compatible providers are cleaned of Anthropic-only fields
@@ -343,8 +396,9 @@ Details with sources: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
343
396
  ## Privacy
344
397
 
345
398
  Everything runs on 127.0.0.1. API keys live in `~/.clauderipple/config.json`
346
- (0600). The only network destinations are the providers you configure. There is no
347
- telemetry.
399
+ and added Claude OAuth grants in `~/.clauderipple/claude-accounts.json` (both
400
+ 0600). Admin APIs and logs expose neither tokens nor upstream account ids. The only
401
+ network destinations are the providers you configure. There is no telemetry.
348
402
 
349
403
  ## Status: alpha
350
404
 
@@ -4,7 +4,8 @@ import { execFileSync } from "node:child_process";
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { removeClaudeAuthFile, saveClaudeAuthFile } from "../../router/src/providers/anthropic-token-file.js";
7
+ import { saveClaudeAuthFile } from "../../router/src/providers/anthropic-token-file.js";
8
+ import { removeAllClaudeAccounts } from "../../router/src/providers/anthropic-accounts.js";
8
9
  function isSetupToken(value) {
9
10
  return /^sk-ant-oat01-[A-Za-z0-9._~-]{16,}$/.test(value);
10
11
  }
@@ -108,5 +109,5 @@ export function claudeLogin(home, options = {}) {
108
109
  saveClaudeAuthFile(home, token);
109
110
  }
110
111
  export function claudeLogout(home) {
111
- return removeClaudeAuthFile(home);
112
+ return removeAllClaudeAccounts(home);
112
113
  }
@@ -119,6 +119,25 @@ function profileBlock() {
119
119
  model_provider = "clauderipple"
120
120
  ${PROFILE_END}`;
121
121
  }
122
+ /** The head of a TOML document, before its first table header — where Codex keeps the default selection. */
123
+ function beforeFirstTable(content) {
124
+ const at = content.search(/^[ \t]*\[/m);
125
+ return at < 0 ? [content, ""] : [content.slice(0, at), content.slice(at)];
126
+ }
127
+ /**
128
+ * Codex writes the model picked from our catalog into config.toml's own head, outside our markers:
129
+ * `model_provider = "clauderipple"` and the model id. Removing the provider block while that
130
+ * reference stays makes Codex refuse to start at all — "Model provider `clauderipple` not found",
131
+ * and no `codex` command runs until it is edited by hand — so `off` drops the dangling pair. The
132
+ * `model` line is ours to remove only while it names our provider; standing alone it is the user's.
133
+ */
134
+ function withoutDanglingSelection(content) {
135
+ const [head, rest] = beforeFirstTable(content);
136
+ const selectsUs = /^[ \t]*model_provider[ \t]*=[ \t]*(["'])clauderipple\1[ \t]*\r?\n?/m;
137
+ if (!selectsUs.test(head))
138
+ return content;
139
+ return head.replace(selectsUs, "").replace(/^[ \t]*model[ \t]*=[ \t]*(["'])[^\n]*\1[ \t]*\r?\n?/m, "") + rest;
140
+ }
122
141
  /** Install provider in config.toml plus selection in the dedicated `clauderipple` profile. */
123
142
  export function codexOn(port, home = codexHome(), models = []) {
124
143
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
@@ -143,7 +162,7 @@ export function codexOff(home = codexHome()) {
143
162
  const profile = profileFile(home);
144
163
  const beforeConfig = fs.existsSync(config) ? fs.readFileSync(config, "utf8") : "";
145
164
  const beforeProfile = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : "";
146
- const nextConfig = withoutOwnedBlock(withoutOwnedBlock(beforeConfig, START, END), CATALOG_START, CATALOG_END);
165
+ const nextConfig = withoutDanglingSelection(withoutOwnedBlock(withoutOwnedBlock(beforeConfig, START, END), CATALOG_START, CATALOG_END));
147
166
  const nextProfile = withoutOwnedBlock(beforeProfile, PROFILE_START, PROFILE_END);
148
167
  fs.rmSync(catalogFile(home), { force: true });
149
168
  if (nextConfig === beforeConfig && nextProfile === beforeProfile)
@@ -19,7 +19,7 @@ const OUR_PREFIX = /^[^\s·]+(?:·[a-z]+)? · /;
19
19
  const CLAUDE_ID = /^claude-(opus|sonnet|haiku|fable)-(\d+(?:-\d+)*?)(?:-\d{8})?$/;
20
20
  /** Display names: config's picker names (e.g. "GPT-5.6 Terra" → "Terra"), then a built-in table. */
21
21
  function prettyNames() {
22
- const out = { "gpt-5.6-terra": "Terra", "gpt-5.6-sol": "Sol", "gpt-5.6-luna": "Luna", "gpt-6-astra": "Astra" };
22
+ const out = { "gpt-5.6-terra": "Terra", "gpt-5.6-sol": "Sol", "gpt-5.6-luna": "Luna", "gpt-6-astra": "Astra", "gpt-6-sol": "Sol", "gpt-6-luna": "Luna" };
23
23
  const home = process.env.CLAUDERIPPLE_HOME ?? path.join(os.homedir(), ".clauderipple");
24
24
  try {
25
25
  const cfg = JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8"));
@@ -137,7 +137,7 @@ async function install() {
137
137
  const proxyUrl = proxyUrlFor(cfg.listen.port);
138
138
  const caPath = certPaths(home).caPem;
139
139
  const maxCtx = opt("max-context-tokens");
140
- const edit = applyProxyEnv({ proxyUrl, caPath, force: flag("force"), ...(maxCtx ? { maxContextTokens: Number(maxCtx) } : {}) });
140
+ const edit = applyProxyEnv({ proxyUrl, caPath, force: flag("force"), ...(maxCtx ? { maxContextTokens: Number(maxCtx) } : {}), models: cfg.cli.models ?? {} });
141
141
  console.log(edit.changed ? `✓ ${settingsPath()} updated (backup: ${edit.backup ?? "none"})` : `✓ ${settingsPath()} already correct`);
142
142
  for (const n of edit.notes)
143
143
  console.log(` note: ${n}`);
@@ -343,7 +343,7 @@ function help() {
343
343
  login sign in to ChatGPT (opens your browser; tokens stay in the home dir)
344
344
  logout forget the ChatGPT login made with "login"
345
345
  claude-login connect a Claude subscription in the browser (--setup-token: via \`claude setup-token\`; --manual: paste the code)
346
- claude-logout remove ClaudeRipple's own Claude subscription credential
346
+ claude-logout remove every Claude subscription added to ClaudeRipple
347
347
  picker on|off show your mapped models by name in the Claude Desktop picker (trusts the CA in your login keychain, routes the app through ClaudeRipple)
348
348
  codex on|off add/remove ClaudeRipple's local OpenAI provider and selection profile for Codex CLI
349
349
  agent-title on|off|status
@@ -463,11 +463,11 @@ try {
463
463
  await session.submitCode(code);
464
464
  }
465
465
  await session.result;
466
- console.log(`✓ Claude subscription connected. Stored in ${homeDir()}/claude-auth.json; refreshed automatically.`);
466
+ console.log(`✓ Claude subscription added. Stored privately in ${homeDir()}/claude-accounts.json; refreshed automatically.`);
467
467
  break;
468
468
  }
469
469
  case "claude-logout":
470
- console.log(claudeLogout(homeDir()) ? "✓ Claude subscription credential removed" : "no Claude subscription credential stored");
470
+ console.log(claudeLogout(homeDir()) ? "✓ ClaudeRipple Claude accounts removed" : "no ClaudeRipple Claude accounts stored");
471
471
  break;
472
472
  case "ui":
473
473
  ui();
@@ -37,6 +37,26 @@ function run(script) {
37
37
  return { ok: false, out: `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim() };
38
38
  }
39
39
  }
40
+ export function taskDefinitionMatches(value, expected) {
41
+ if (!value || typeof value !== "object")
42
+ return false;
43
+ const task = value;
44
+ return (task.actionCount === 1 &&
45
+ task.execute?.toLowerCase() === "powershell.exe" &&
46
+ task.arguments === expected.arguments &&
47
+ task.userId?.toLowerCase() === expected.userId.toLowerCase() &&
48
+ task.runLevel === 0 &&
49
+ task.logonType === 3 &&
50
+ task.triggerCount === 1 &&
51
+ task.triggerType === "MSFT_TaskLogonTrigger" &&
52
+ task.triggerUserId?.toLowerCase() === expected.userId.toLowerCase() &&
53
+ task.restartCount === 99 &&
54
+ task.restartInterval === "PT1M" &&
55
+ task.executionTimeLimit === "PT0S" &&
56
+ task.multipleInstances === 2 &&
57
+ task.allowStartIfOnBatteries === true &&
58
+ task.dontStopIfGoingOnBatteries === true);
59
+ }
40
60
  /**
41
61
  * The task runs a PowerShell launcher rather than node directly, for three reasons.
42
62
  *
@@ -79,9 +99,31 @@ function writeLauncher(opts) {
79
99
  export function installAgent(opts) {
80
100
  const launcher = writeLauncher({ program: opts.program, args: opts.args ?? [], home: opts.home, ...(opts.env ? { env: opts.env } : {}) });
81
101
  const user = `${os.userInfo().username}`;
102
+ const actionArgs = `-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`;
103
+ // Updating the generated launcher does not require re-registering an otherwise correct task.
104
+ // Some Windows installations protect an existing per-user task's security descriptor so that
105
+ // even the same unelevated user gets E_ACCESSDENIED from Register-ScheduledTask -Force. Treat the
106
+ // verified existing definition as success; never silently accept a same-named foreign task.
107
+ const existing = run([
108
+ `$t = Get-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction SilentlyContinue`,
109
+ `if ($null -eq $t) { 'null'; exit 0 }`,
110
+ `$a = @($t.Actions); $p = $t.Principal; $tr = @($t.Triggers); $s = $t.Settings`,
111
+ `[pscustomobject]@{ actionCount = $a.Count; execute = $a[0].Execute; arguments = $a[0].Arguments; userId = $p.UserId; runLevel = [int]$p.RunLevel; logonType = [int]$p.LogonType; triggerCount = $tr.Count; triggerType = $tr[0].CimClass.CimClassName; triggerUserId = $tr[0].UserId; restartCount = $s.RestartCount; restartInterval = [string]$s.RestartInterval; executionTimeLimit = [string]$s.ExecutionTimeLimit; multipleInstances = [int]$s.MultipleInstances; allowStartIfOnBatteries = $s.AllowStartIfOnBatteries; dontStopIfGoingOnBatteries = $s.DontStopIfGoingOnBatteries } | ConvertTo-Json -Compress`,
112
+ ].join("; "));
113
+ let definition = null;
114
+ if (existing.ok) {
115
+ try {
116
+ definition = JSON.parse(existing.out);
117
+ }
118
+ catch {
119
+ // A malformed inspection result is not trusted; fall through to registration.
120
+ }
121
+ }
122
+ if (taskDefinitionMatches(definition, { arguments: actionArgs, userId: user }))
123
+ return launcher;
82
124
  const script = [
83
125
  `$ErrorActionPreference = 'Stop'`,
84
- `$a = New-ScheduledTaskAction -Execute ${ps("powershell.exe")} -Argument ${ps(`-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`)}`,
126
+ `$a = New-ScheduledTaskAction -Execute ${ps("powershell.exe")} -Argument ${ps(actionArgs)}`,
85
127
  `$t = New-ScheduledTaskTrigger -AtLogOn -User ${ps(user)}`,
86
128
  // Limited: the router needs no elevation, and asking for it would put a UAC prompt at every logon.
87
129
  `$p = New-ScheduledTaskPrincipal -UserId ${ps(user)} -LogonType Interactive -RunLevel Limited`,
@@ -26,6 +26,16 @@ function write(file, obj) {
26
26
  fs.mkdirSync(path.dirname(file), { recursive: true });
27
27
  fs.writeFileSync(file, JSON.stringify(obj, null, 2) + "\n");
28
28
  }
29
+ /**
30
+ * Claude Code's own model slots, as environment names. These are decided inside the CLI before a
31
+ * request exists, so routing cannot reach them: `smallFast` in particular is what a `WebSearch`
32
+ * side request runs on, which is why a routed session searches on Claude quota until it is set.
33
+ */
34
+ export const MODEL_SLOT_ENV = {
35
+ main: "ANTHROPIC_MODEL",
36
+ smallFast: "ANTHROPIC_SMALL_FAST_MODEL",
37
+ subagent: "CLAUDE_CODE_SUBAGENT_MODEL",
38
+ };
29
39
  export function applyProxyEnv(opts) {
30
40
  const file = settingsPath();
31
41
  const s = readSettings(file);
@@ -40,18 +50,67 @@ export function applyProxyEnv(opts) {
40
50
  const want = { HTTPS_PROXY: opts.proxyUrl, NODE_EXTRA_CA_CERTS: opts.caPath };
41
51
  if (opts.maxContextTokens)
42
52
  want.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(opts.maxContextTokens);
53
+ for (const [slot, name] of Object.entries(MODEL_SLOT_ENV)) {
54
+ const model = opts.models?.[slot];
55
+ if (model)
56
+ want[name] = model;
57
+ }
58
+ const wroteSlots = [];
43
59
  let changed = false;
44
60
  for (const [k, v] of Object.entries(want)) {
45
61
  if (env[k] !== v) {
46
62
  env[k] = v;
47
63
  changed = true;
64
+ const slot = Object.keys(MODEL_SLOT_ENV).find((name) => MODEL_SLOT_ENV[name] === k);
65
+ if (slot)
66
+ wroteSlots.push(slot);
67
+ }
68
+ }
69
+ // A slot that was set and is now cleared must go, or the old model keeps answering with nothing
70
+ // in the config to explain why.
71
+ //
72
+ // Which slots count as "now cleared" depends on the caller. `install` owns all three, so anything
73
+ // it was not given is cleared. The GUI names only the slots it shows a choice for and must leave
74
+ // the rest as they are — including a value someone put there by hand — so it passes
75
+ // `preserveUnnamedSlots` and a slot it never mentioned is simply not touched.
76
+ if (opts.models !== undefined) {
77
+ const named = new Set(Object.keys(opts.models));
78
+ for (const [slot, name] of Object.entries(MODEL_SLOT_ENV)) {
79
+ if (opts.preserveUnnamedSlots && !named.has(slot))
80
+ continue;
81
+ if (!opts.models[slot] && env[name] !== undefined) {
82
+ delete env[name];
83
+ changed = true;
84
+ wroteSlots.push(slot);
85
+ notes.push(`cleared ${name}`);
86
+ }
48
87
  }
49
88
  }
50
89
  if (!changed)
51
- return { changed: false, backup: null, notes };
90
+ return { changed: false, backup: null, notes, wroteSlots: [] };
52
91
  const b = backup(file);
53
92
  write(file, { ...s, env });
54
- return { changed: true, backup: b, notes };
93
+ return { changed: true, backup: b, notes, wroteSlots };
94
+ }
95
+ /**
96
+ * Bring `~/.claude/settings.json` in line with `cli.models` after a GUI save.
97
+ *
98
+ * The GUI writes only config.json, and that is by design — the router hot-reloads it on mtime. But
99
+ * these slots are read by Claude Code *before* a request exists, so they live in the env block and
100
+ * nothing else writes them. Without this, choosing a slot on the Clients screen saved a value that
101
+ * did nothing until the next `install`: the screen said DeepSeek while every search, title and
102
+ * subagent went on running on Haiku (found this way, 2026-09-21).
103
+ *
104
+ * The GUI sends an empty string to mean "back to Claude" (`saveModelSlots` records every select,
105
+ * so a slot set back to the default arrives as `""`). That is a key *present* with a falsy value,
106
+ * which `applyProxyEnv` reads as "clear this one" and writes no env entry for — not the same thing
107
+ * as `ANTHROPIC_MODEL: ""`, which would still be a key to Claude Code.
108
+ *
109
+ * A slot the GUI does not name at all is left alone, so a save here cannot undo a value someone
110
+ * set by hand or through `install`.
111
+ */
112
+ export function syncModelSlots(opts) {
113
+ return applyProxyEnv({ ...opts, preserveUnnamedSlots: true });
55
114
  }
56
115
  export function removeProxyEnv(opts) {
57
116
  const file = settingsPath();
@@ -71,8 +130,16 @@ export function removeProxyEnv(opts) {
71
130
  }
72
131
  else if (env.NODE_EXTRA_CA_CERTS)
73
132
  notes.push(`left NODE_EXTRA_CA_CERTS=${env.NODE_EXTRA_CA_CERTS} (not ours)`);
133
+ // Uninstalling must hand the CLI back to Anthropic completely: a model slot still pointing at a
134
+ // routed model would send every search and subagent somewhere the router no longer serves.
135
+ for (const name of Object.values(MODEL_SLOT_ENV)) {
136
+ if (env[name] !== undefined) {
137
+ delete env[name];
138
+ changed = true;
139
+ }
140
+ }
74
141
  if (!changed)
75
- return { changed: false, backup: null, notes };
142
+ return { changed: false, backup: null, notes, wroteSlots: [] };
76
143
  const b = backup(file);
77
144
  const next = { ...s };
78
145
  if (Object.keys(env).length === 0)
@@ -80,7 +147,7 @@ export function removeProxyEnv(opts) {
80
147
  else
81
148
  next.env = env;
82
149
  write(file, next);
83
- return { changed: true, backup: b, notes };
150
+ return { changed: true, backup: b, notes, wroteSlots: [] };
84
151
  }
85
152
  export function currentProxyEnv() {
86
153
  const env = readSettings(settingsPath()).env ?? {};
@@ -123,12 +190,12 @@ export function setAgentTitleHook(enabled, cmd) {
123
190
  }
124
191
  const changed = JSON.stringify(kept) !== JSON.stringify(list);
125
192
  if (!changed)
126
- return { changed: false, backup: null, notes };
193
+ return { changed: false, backup: null, notes, wroteSlots: [] };
127
194
  const b = backup(file);
128
195
  hooks.PreToolUse = kept;
129
196
  s.hooks = hooks;
130
197
  write(file, s);
131
- return { changed: true, backup: b, notes };
198
+ return { changed: true, backup: b, notes, wroteSlots: [] };
132
199
  }
133
200
  export function agentTitleHookEnabled() {
134
201
  try {