clauderipple 0.7.0 → 0.8.1

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 (46) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.ko.md +18 -2
  3. package/README.md +27 -5
  4. package/dist/app/package.json +1 -1
  5. package/dist/cli/src/claude-auth.js +35 -1
  6. package/dist/cli/src/codex.js +23 -0
  7. package/dist/cli/src/hooks/agent-title.js +58 -29
  8. package/dist/cli/src/index.js +46 -1
  9. package/dist/cli/src/picker.js +19 -11
  10. package/dist/cli/src/runtime.js +3 -0
  11. package/dist/cli/src/settings.js +49 -0
  12. package/dist/mod/.claude-plugin/plugin.json +1 -0
  13. package/dist/mod/hooks/hooks.json +1 -0
  14. package/dist/mod/hooks/register.tsx +309 -0
  15. package/dist/mod/hooks/worker.ts +49 -0
  16. package/dist/mod/tsconfig.json +3 -0
  17. package/dist/mod/types/index.d.ts +30 -0
  18. package/dist/router/src/admin.js +411 -32
  19. package/dist/router/src/agents.js +24 -8
  20. package/dist/router/src/catalog.js +80 -0
  21. package/dist/router/src/compat.js +27 -1
  22. package/dist/router/src/config.js +31 -0
  23. package/dist/router/src/index.js +12 -1
  24. package/dist/router/src/ingress/server.js +3 -2
  25. package/dist/router/src/picker.js +33 -2
  26. package/dist/router/src/presets.js +2 -1
  27. package/dist/router/src/providers/chatgpt/index.js +77 -18
  28. package/dist/router/src/providers/chatgpt/translate.js +34 -6
  29. package/dist/router/src/providers/google/accounts.js +345 -0
  30. package/dist/router/src/providers/google/antigravity.js +392 -0
  31. package/dist/router/src/providers/google/index.js +617 -0
  32. package/dist/router/src/providers/google/login.js +249 -0
  33. package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
  34. package/dist/router/src/providers/google/translate.js +539 -0
  35. package/dist/router/src/providers/google/transport.js +118 -0
  36. package/dist/router/src/providers/openai/translate.js +3 -3
  37. package/dist/router/src/proxy.js +116 -9
  38. package/dist/router/src/requestlog.js +2 -0
  39. package/dist/router/src/routing.js +22 -0
  40. package/dist/router/src/version.js +1 -1
  41. package/dist/ui/app.js +420 -45
  42. package/dist/ui/i18n.js +76 -4
  43. package/dist/ui/index.html +5 -0
  44. package/dist/ui/style.css +4 -0
  45. package/docs/ARCHITECTURE.md +307 -4
  46. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1 — 2026-10-06
4
+
5
+ ### Fixed
6
+
7
+ - **Codex credits are spent instead of refused.** Once a ChatGPT plan's window read 100%, the
8
+ router rested the account until the reset, even though OpenAI serves an account with bought
9
+ credits past its window. An account with credits (or an unlimited balance) now stays in
10
+ rotation; a real usage-limit refusal still rests it.
11
+ - **Fewer failed Claude app requests in picker mode.** claude.ai closes idle kept-alive
12
+ connections, and a request written on one as it closed came back as a 502 (about 65 a day
13
+ here, mostly the app's start-up requests). A GET, HEAD or OPTIONS that meets such a socket is
14
+ now sent again; a POST is not.
15
+ - **Picker mode on a new Mac.** The ClaudeRipple CA counted as trusted when it was merely in the
16
+ login keychain, so `picker on` could skip the trust step and leave Claude Desktop unable to
17
+ load claude.ai. Trust is now read from the user trust settings, and `picker off` removes the
18
+ certificate either way.
19
+ - **The route band said "router not answering"** on Claude Code 2.1.288, which sends plain
20
+ `http://` requests through the proxy too and got 405. They are now relayed.
21
+
22
+ ### Docs
23
+
24
+ - Any ChatGPT plan that includes Codex works (Plus, Pro, Business; Enterprise/Edu with Codex
25
+ turned on), not just Plus and Pro.
26
+ - `ultra` effort is sent as `max`: the Codex backend refuses `ultra` (measured), and Codex's own
27
+ ultra is max reasoning plus its sub-agents, which in Claude Code are Claude Code's subagents.
28
+
29
+ ## 0.8.0 — 2026-10-05
30
+
31
+ ### Added
32
+
33
+ - **The route, where you type** (Claude Code mods; a switch on the Clients screen, or
34
+ `clauderipple mod on`). A line above the Claude Code prompt says where your last request
35
+ went: model, provider, effort, cache hit and time, colored by provider and result. `Log` or
36
+ `/ripple-log` opens the request log in a side pane, all sessions or just this one; `×` or
37
+ `/ripple-bar` puts the line away. The router now records which Claude Code session sent each
38
+ request (`X-Claude-Code-Session-Id`), and `/api/requests?session=` filters by it.
39
+ - **Workers stay on their own model.** The same mod catches a `model` argument on an Agent call
40
+ that would have put a ClaudeRipple worker on a Claude alias (its output cut to 64 tokens) and
41
+ runs it on its own model, and reads a `[[ripple: name@level]]` marker wherever it sits in the
42
+ prompt, not only on the first line.
43
+ - **Google Gemini**, on its own wire: an AI Studio key, or a Google account through the
44
+ Antigravity backend with several accounts in rotation. Google's terms name the account route
45
+ as a breach and accounts were suspended for it; the sign-in says so before it starts.
46
+ - **Each routed model gets its own effort menu in the Claude app.** DeepSeek and GLM show
47
+ low/high/max instead of Claude's whole ladder; a model that takes no effort shows none.
48
+ - **Fast mode on GPT.** `/fast` in Claude Code asks the ChatGPT backend for the Codex fast tier.
49
+ - **Updates from the dashboard**: the version row checks and starts an update.
50
+ - **A typed model ID** for a provider that lists no models (#42).
51
+ - **An explicit Auto mode classifier target** (`autoModeClassifier`, JSON only; #43 by
52
+ @Sociopacific). The README says what pointing it at a weaker model gives up.
53
+
54
+ ### Changed
55
+
56
+ - **Measuring an OpenCode provider takes one request per model**, its effort ladder read from
57
+ models.dev, four models at a time, instead of about ten sequential requests each (three
58
+ OpenCode Go models had taken 2m33s). The ladders are the catalogue's: glm-5.3-flash is
59
+ low/high/max, where requests had accepted seven levels.
60
+ - **Generated workers run at `@high`** unless a marker says otherwise.
61
+ - **A clamping tie goes up**: medium on a low/high/max model is high, not low.
62
+
63
+ ### Fixed
64
+
65
+ - **A settings save no longer undoes what the router wrote.** The dashboard saved the whole
66
+ config from the copy it loaded when it opened, so measured ladders and hand edits came back
67
+ with the next unrelated save. Every save now starts from the router's current config.
68
+ - **Routed workers are no longer told to call `advisor`** after the tool was dropped (#47, #48
69
+ by @115dkk).
70
+ - **`claude-login` finds the Desktop-cached CLI** in its new `<version>/<hash>/` folder (#45,
71
+ #46 by @115dkk). macOS had the same layout.
72
+ - **Codex context overflow** is answered as "prompt is too long", so Claude Code compacts
73
+ instead of retrying the same request (#44 by @Sociopacific); a stream held for that opens
74
+ after five seconds regardless.
75
+ - The Codex app sees a GPT model released after the router started; subagent titles keep the
76
+ model and effort under Desktop's new task panel; a provider's connection test reads a
77
+ model-not-found 404 as an accepted key; a Gemini provider waiting on Google's verification
78
+ is not shown as connected, and the page to open is passed on.
79
+
3
80
  ## 0.7.0 — 2026-09-29
4
81
 
5
82
  ### Added
package/README.ko.md CHANGED
@@ -101,7 +101,9 @@ Anthropic이 만든 커넥터가 전부 그대로 동작합니다. 1P에 남는
101
101
 
102
102
  ### OpenAI API 키가 필요한가요?
103
103
 
104
- 필요 없습니다. ChatGPT Plus나 Pro 구독이면 됩니다. 앱에서 로그인하면 되고 API 키는 쓰지 않습니다.
104
+ 필요 없습니다. Codex가 포함된 ChatGPT 요금제면 됩니다 — Plus·Pro·Business, 그리고 워크스페이스 관리자가
105
+ Codex를 켜 둔 Enterprise·Edu. 앱에서 로그인하면 되고 API 키는 쓰지 않습니다. 사용량 한도는 요금제 것을 따르며,
106
+ 한도를 다 쓰면 구매해 둔 Codex 크레딧으로 이어서 씁니다.
105
107
  API 키는 DeepSeek·Kimi·GLM·OpenRouter처럼 다른 프로바이더를 붙일 때만 씁니다.
106
108
 
107
109
  ### 어떤 모델을 쓸 수 있나요?
@@ -132,6 +134,15 @@ ClaudeRipple이 Claude에 하는 일은 작고 확인할 수 있습니다. Claud
132
134
  돌려 쓰는 것도 같은 약관 아래 각자 판단할 일입니다. 계정을 어떻게 다룰지는 Anthropic만 정할 수 있으니, 여기 적은
133
135
  내용은 보장이 아닙니다.
134
136
 
137
+ ### Gemini를 Google 계정으로 쓰면요?
138
+
139
+ AI Studio API 키는 Google이 공식 지원하는 방식이고 안전합니다. Google 계정 로그인(Antigravity)은 다릅니다.
140
+ Google [Antigravity 약관](https://antigravity.google/terms) 6조와 [Gemini CLI FAQ](https://geminicli.com/docs/resources/faq/)는
141
+ 그 로그인 정보를 다른 프로그램에서 쓰는 것을 위반으로 명시합니다. Google은 2026년 2월 이렇게 쓰인 계정을 정지했고(정지되면
142
+ Gemini CLI와 Code Assist도 막혔습니다), [2차 위반은 영구 정지](https://github.com/google-gemini/gemini-cli/discussions/20632)라고
143
+ 밝혔습니다. ClaudeRipple은 다른 도구들도 제공하고 요청이 있어 이 로그인을 넣었으며, 시작 전에 이 경고를 보여 주고 트래픽을
144
+ 숨기는 일은 하지 않습니다. 쓰신다면 주 계정이 아닌 계정을 쓰세요.
145
+
135
146
  ### ChatGPT 계정을 여러 개 쓸 수 있나요?
136
147
 
137
148
  됩니다. `clauderipple login`을 할 때마다(또는 대시보드의 **+ ChatGPT 계정 추가**) 계정이 하나씩 늘어납니다.
@@ -226,6 +237,10 @@ ClaudeRipple은 Claude Code 프로세스만 신뢰하는 작은 HTTPS 프록시
226
237
  서버 측 스레드 처리, 도구 호출과 이미지 왕복, 모델이 받는 범위로 추론 강도 클램프, 호환 벤더에는 Anthropic 전용 필드 제거.
227
238
  - **제대로 된 요청 로그.** 누가 물었고 어떤 모델이 답했는지, 입력·캐시·출력 토큰, 지연, 상태를 요청마다. 한 시간 요약 포함.
228
239
  - **서브에이전트 이름표.** 백그라운드 작업 패널에 "Agent" 대신 `Terra·high · Review`가 보입니다.
240
+ - **입력창에서 보이는 경로.** Claude Code mod가 입력창 위에 한 줄을 띄웁니다. 마지막 요청이 어디로 갔는지, 추론 강도·캐시
241
+ 적중률·시간까지. `Log`(또는 `/ripple-log`)로 요청 로그를 옆 패널에 프로바이더·결과별 색으로 띄우고, `×`(또는
242
+ `/ripple-bar`)로 줄을 숨깁니다. 같은 mod가 Agent 호출에 다른 모델이 붙어도 워커를 제 모델로 돌리고,
243
+ `[[ripple: 이름@강도]]` 표식을 프롬프트 어디에 두든 읽습니다. 클라이언트 화면의 스위치 하나, 또는 `clauderipple mod on`.
229
244
  - **ChatGPT 구독으로 이미지 생성.** `clauderipple image "<프롬프트>"`. 참고 이미지와 투명 배경도 됩니다. 서브에이전트에도
230
245
  방법이 적혀 있어, GPT나 DeepSeek 워커가 이미지 도구 없이 이미지를 만들고 직접 확인합니다.
231
246
  - **원하면 가벼운 워커.** 스위치 하나로, 생성된 서브에이전트에 세션의 MCP·플러그인 도구 전부 대신 워커가 쓰는 도구만
@@ -320,7 +335,7 @@ node packages/cli/src/index.ts ui # 브라우저에서 로컬 GUI 열기
320
335
  ```
321
336
 
322
337
  `uninstall`은 전부 되돌리고 `~/.claude/settings.json`을 백업에서 복원합니다. 그 밖의 명령: `status`, `start`, `stop`,
323
- `restart`, `logs -f`, `login`, `logout`, `claude-login`, `claude-logout`, `picker on|off`, `agent-title on|off`,
338
+ `restart`, `logs -f`, `login`, `logout`, `claude-login`, `claude-logout`, `picker on|off`, `agent-title on|off`, `mod on|off`,
324
339
  `codex on|off`, `update`, `image "<프롬프트>"`.
325
340
  </details>
326
341
 
@@ -419,6 +434,7 @@ Anthropic API 키로 갑니다. 구독 로그인 재사용은 Anthropic 약관
419
434
  | 프로바이더 | 종류 | 인증 | 모델 목록 | 비고 |
420
435
  |---|---|---|---|---|
421
436
  | ChatGPT 구독 | Codex 백엔드 | 로그인 여러 개, 한도 차면 자동 전환(Codex 로그인도 재사용) | 구독에서 자동으로 읽음(GPT-6 Sol·Luna·Astra, GPT-5.6 …) | 추론 강도 low…max(Luna는 ultra), 프롬프트 캐시 94~99 % |
437
+ | Google Gemini | Gemini API | AI Studio API 키, 또는 Google 계정 로그인(Antigravity, 위 FAQ 참고) | 자동 검색 | 번역(Messages ⇄ generateContent), 턴 사이 thought signature 유지 |
422
438
  | OpenRouter | Anthropic 호환 | API 키 | 400+, 자동 검색 | 모델별 추론 강도 지원을 API에서 읽음 |
423
439
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen(국제/중국) | Anthropic 호환 | API 키 | 프리셋 | 벤더 공식 문서로 확인 |
424
440
  | xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI 호환 | API 키 | 자동 검색 | 번역(Chat Completions / Responses) |
package/README.md CHANGED
@@ -114,8 +114,10 @@ Claude. Most people map one or two names to GPT and leave the rest alone.
114
114
 
115
115
  ### Do I need an OpenAI API key?
116
116
 
117
- No. A ChatGPT Plus or Pro subscription works — you sign in from the app and no API key
118
- is involved. API keys are for the other providers (DeepSeek, Kimi, GLM, OpenRouter and
117
+ No. Any ChatGPT plan that includes Codex works — Plus, Pro and Business, and Enterprise or
118
+ Edu where the workspace admin has turned Codex on. You sign in from the app and no API key
119
+ is involved. The plan's usage limits apply; once they are used up, Codex credits you have
120
+ bought carry on. API keys are for the other providers (DeepSeek, Kimi, GLM, OpenRouter and
119
121
  anything else with an Anthropic- or OpenAI-compatible endpoint).
120
122
 
121
123
  ### Which models can I use?
@@ -151,6 +153,18 @@ subscription login. That use is subject to Anthropic's terms; an Anthropic API k
151
153
  the question. Rotating several Claude subscriptions is likewise your call under those
152
154
  terms. Only Anthropic can say how an account is treated, so nothing here is a promise.
153
155
 
156
+ ### What about Gemini through a Google account?
157
+
158
+ An AI Studio API key is the way Google supports, and the safe one. The Google account
159
+ sign-in (Antigravity) is different: Google's [Antigravity terms](https://antigravity.google/terms)
160
+ (section 6) and the [Gemini CLI FAQ](https://geminicli.com/docs/resources/faq/) name using
161
+ those credentials from another program as a breach. In February 2026 Google suspended
162
+ accounts used this way — the suspension also cut off Gemini CLI and Code Assist — and
163
+ [said](https://github.com/google-gemini/gemini-cli/discussions/20632) a second violation
164
+ is permanent. ClaudeRipple offers the sign-in because others do and people asked, shows
165
+ this warning before it starts, and does nothing to hide the traffic. Use a secondary
166
+ account if you use it at all.
167
+
154
168
  ### Can I use several ChatGPT accounts?
155
169
 
156
170
  Yes. Each `clauderipple login` (or **+ Add ChatGPT account** in the dashboard) adds one.
@@ -243,7 +257,7 @@ you map go to your provider, everything else goes to Anthropic byte for byte.
243
257
 
244
258
  - **Any model in Claude Desktop and Claude Code.** GPT-6 Sol / Luna / Astra and
245
259
  GPT-5.6 Terra / Sol / Luna through your ChatGPT Plus/Pro subscription (new models
246
- appear the day OpenAI serves them), or DeepSeek, Kimi, GLM,
260
+ appear the day OpenAI serves them), Google Gemini, or DeepSeek, Kimi, GLM,
247
261
  MiniMax, Qwen, Grok, Mistral, Groq, Together, Fireworks, OpenRouter (400+ models)
248
262
  and local Ollama / LM Studio. Under their real names in the picker, or mapped onto
249
263
  a Claude name.
@@ -272,6 +286,12 @@ you map go to your provider, everything else goes to Anthropic byte for byte.
272
286
  latency, status, per request, with a one-hour summary.
273
287
  - **Subagent name tags.** The background-task panel shows `Terra·high · Review`
274
288
  instead of a generic "Agent".
289
+ - **The route, where you type.** A Claude Code mod puts one line above the prompt:
290
+ where your last request went, its effort, cache hit and time. `Log` (or `/ripple-log`)
291
+ opens the request log in a side pane, color-coded by provider and result; `×` (or
292
+ `/ripple-bar`) hides the line. The same mod keeps a worker on its own model when the
293
+ Agent call names another, and reads its `[[ripple: name@level]]` marker wherever it sits
294
+ in the prompt. One switch on the Clients screen, or `clauderipple mod on`.
275
295
  - **Images from your ChatGPT subscription.** `clauderipple image "<prompt>"`, with
276
296
  reference images and transparent backgrounds. Subagents are told how, so a GPT or
277
297
  DeepSeek worker can make an image and look at it without an image tool.
@@ -374,7 +394,7 @@ node packages/cli/src/index.ts ui # open the local GUI in your browser
374
394
 
375
395
  `uninstall` reverses everything and restores `~/.claude/settings.json` from a backup.
376
396
  Other commands: `status`, `start`, `stop`, `restart`, `logs -f`, `login`, `logout`,
377
- `claude-login`, `claude-logout`, `picker on|off`, `agent-title on|off`,
397
+ `claude-login`, `claude-logout`, `picker on|off`, `agent-title on|off`, `mod on|off`,
378
398
  `codex on|off`, `update`, `image "<prompt>"`.
379
399
  </details>
380
400
 
@@ -491,6 +511,7 @@ provider you configured is available the same way.
491
511
  | Provider | Kind | Auth | Model list | Notes |
492
512
  |---|---|---|---|---|
493
513
  | ChatGPT subscription | Codex backend | several sign-ins, switching when one runs out (Codex login reused too) | read from your subscription (GPT-6 Sol, Luna, Astra, GPT-5.6 …) | effort low…max (Luna: ultra), prompt cache 94–99 % |
514
+ | Google Gemini | Gemini API | AI Studio API key, or Google account sign-in (Antigravity, see below) | discovered | translated (Messages ⇄ generateContent), thought signatures kept across turns |
494
515
  | OpenRouter | Anthropic-compatible | API key | 400+, discovered | per-model effort support read from the API |
495
516
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen (intl / cn) | Anthropic-compatible | API key | preset | verified against vendor docs |
496
517
  | xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI-compatible | API key | discovered | translated (Chat Completions / Responses) |
@@ -509,7 +530,8 @@ Claude Desktop / claude CLI ──HTTPS_PROXY──▶ ClaudeRipple ──▶ ap
509
530
  │
510
531
  mapped model ──────────┼──▶ chatgpt.com/backend-api (Responses ⇄ Messages)
511
532
  ├──▶ Anthropic-compatible vendors (+ compat layer)
512
- └──▶ OpenAI-compatible vendors (Messages ⇄ Chat/Responses)
533
+ ├──▶ OpenAI-compatible vendors (Messages ⇄ Chat/Responses)
534
+ └──▶ Google Gemini (Messages ⇄ generateContent)
513
535
  Codex app / CLI ──/v1/responses──▶ ClaudeRipple ingress ──▶ Claude (your login or API key) / vendors
514
536
  ```
515
537
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "commonjs"
5
5
  }
@@ -39,6 +39,40 @@ function versionBinaries(versionDir) {
39
39
  ? BINARY_NAMES.map((name) => path.join(versionDir, name))
40
40
  : [path.join(versionDir, "claude.app", "Contents", "MacOS", "claude")];
41
41
  }
42
+ /**
43
+ * The launcher of one cached version: in the version directory itself, else one directory below it,
44
+ * newest first. Claude Desktop 2.19675 on Windows caches CLI 2.1.286 as <version>\<12 hex>\claude.exe
45
+ * beside .payload and .verified files, and moved the 2.1.284 it already had into the same shape, so
46
+ * no version directory held a launcher directly any more (2026-10-02).
47
+ */
48
+ function cachedBinary(versionDir) {
49
+ const flat = versionBinaries(versionDir).find((candidate) => fs.existsSync(candidate));
50
+ if (flat)
51
+ return flat;
52
+ try {
53
+ const subdirectories = fs.readdirSync(versionDir)
54
+ .flatMap((entry) => {
55
+ const directory = path.join(versionDir, entry);
56
+ try {
57
+ const stat = fs.statSync(directory);
58
+ return stat.isDirectory() ? [{ directory, mtimeMs: stat.mtimeMs }] : [];
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ })
64
+ .sort((left, right) => right.mtimeMs - left.mtimeMs);
65
+ for (const { directory } of subdirectories) {
66
+ const binary = versionBinaries(directory).find((candidate) => fs.existsSync(candidate));
67
+ if (binary)
68
+ return binary;
69
+ }
70
+ }
71
+ catch {
72
+ // A missing or unreadable version directory has no usable binary.
73
+ }
74
+ return null;
75
+ }
42
76
  export function latestDesktopClaude() {
43
77
  for (const directory of desktopClaudeCodeDirs()) {
44
78
  try {
@@ -46,7 +80,7 @@ export function latestDesktopClaude() {
46
80
  .filter((entry) => /^\d+\.\d+\.\d+$/.test(entry))
47
81
  .sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
48
82
  for (const version of versions.reverse()) {
49
- const binary = versionBinaries(path.join(directory, version)).find((candidate) => fs.existsSync(candidate));
83
+ const binary = cachedBinary(path.join(directory, version));
50
84
  if (binary)
51
85
  return binary;
52
86
  }
@@ -104,6 +104,29 @@ export function writeCodexCatalog(models, home = codexHome()) {
104
104
  fs.writeFileSync(file, JSON.stringify({ models: [...theirs, ...ours] }, null, 1), { mode: 0o600 });
105
105
  return file;
106
106
  }
107
+ /**
108
+ * Our catalog is a copy of Codex's cache, so it goes stale when Codex refreshes that cache: a model
109
+ * OpenAI released after the router started stayed missing from the app (gpt-6.1-sol, 2026-09-30 —
110
+ * the copy was written at router start, Codex's cache gained the model half an hour later). Rewrite
111
+ * the copy whenever the cache changes, while `codex on` is in effect. Polls, as Codex replaces the
112
+ * file rather than editing it; once a minute is plenty, since the app reads the catalog only when it
113
+ * starts. Returns a stop function.
114
+ */
115
+ export function followCodexCache(models, home = codexHome(), intervalMs = 60_000, onError = () => { }) {
116
+ const cache = path.join(home, "models_cache.json");
117
+ const listener = (now, before) => {
118
+ if (now.mtimeMs === before.mtimeMs || !codexEnabled(home))
119
+ return;
120
+ try {
121
+ writeCodexCatalog(models(), home);
122
+ }
123
+ catch (e) {
124
+ onError(e);
125
+ }
126
+ };
127
+ fs.watchFile(cache, { interval: intervalMs, persistent: false }, listener);
128
+ return () => fs.unwatchFile(cache, listener);
129
+ }
107
130
  function catalogBlock(file) {
108
131
  return `${CATALOG_START}
109
132
  model_catalog_json = ${JSON.stringify(file)}
@@ -1,12 +1,19 @@
1
1
  // Claude Code PreToolUse hook (matcher: Agent|Task): put the real model and thinking depth into the
2
2
  // subagent's title, e.g. "Terra·high · Provider presets", so the Claude Desktop background-task
3
- // panel shows which model a subagent runs on. The app's own second line is a fixed "Agent" label
4
- // (verified in the app renderer), so the description is the only text we can influence.
3
+ // panel shows which model a subagent runs on. The app's model line names Claude models only (its
4
+ // display table is a fixed list of Claude families, app web bundle 2026-09-30), so for a routed
5
+ // model the title is the only text we can influence.
5
6
  //
6
- // Model resolution mirrors the router (packages/router/src/routing.ts): a `[[gpt: sol@xhigh]]`
7
- // marker in the prompt's first 5 lines, else the agent definition's frontmatter `model:`, else the
8
- // explicit `model` argument, else the parent session's model from the transcript tail.
9
- // Depth = the hook input's effort.level. Runs in <50ms, never fails the tool call (empty output = no-op).
7
+ // The panel shows this title only while it follows the session live: rebuilt from the transcript
8
+ // (another session opened and back, app restart) it takes the description the parent model wrote,
9
+ // which a hook cannot change. So the generated agent files also ask the parent to write the same
10
+ // prefix itself (packages/router/src/agents.ts); here it is corrected to what the router will do.
11
+ //
12
+ // Model resolution mirrors the router (packages/router/src/routing.ts): a `[[ripple: sol@xhigh]]`
13
+ // marker at the very top of the prompt, else the agent definition's frontmatter `model:`, else the
14
+ // explicit `model` argument, else the parent session's model from the transcript tail. Depth = the
15
+ // marker's level, else the agent file's `@level`, else the hook input's effort.level, after the
16
+ // router's `effortClamp`. Runs in <50ms, never fails the tool call (empty output = no-op).
10
17
  //
11
18
  // Installed by `clauderipple agent-title on` into ~/.claude/settings.json; the router ships no
12
19
  // Python, so this is TypeScript run by the Node recorded in <home>/paths.json.
@@ -14,36 +21,42 @@ import fs from "node:fs";
14
21
  import os from "node:os";
15
22
  import path from "node:path";
16
23
  import { fileURLToPath } from "node:url";
17
- const MARKER = /\[\[\s*gpt\s*:\s*([a-z0-9.\-]+)\s*(?:@\s*([a-z]+))?\s*\]\]/i;
24
+ // The router's marker (routing.ts MARKER): honoured only at the very top of the prompt.
25
+ const MARKER = /^\s*\[\[\s*(?:ripple|gpt)\s*:\s*([A-Za-z0-9.\-]+)\s*(?:@\s*([A-Za-z]+))?\s*\]\]/;
26
+ const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
18
27
  const OUR_PREFIX = /^[^\s·]+(?:·[a-z]+)? · /;
19
28
  const CLAUDE_ID = /^claude-(opus|sonnet|haiku|fable)-(\d+(?:-\d+)*?)(?:-\d{8})?$/;
20
- /** Display names: config's picker names (e.g. "GPT-5.6 Terra" → "Terra"), then a built-in table. */
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", "gpt-6-sol": "Sol", "gpt-6-luna": "Luna" };
29
+ /** The router's default `effortClamp` (config.ts), for when config.json cannot be read. */
30
+ const DEFAULT_CLAMP = { ultra: "max" };
31
+ /** A model's name in a title: the last word of its display name ("GPT-6.1 Sol" → "Sol"). */
32
+ export function shortName(name) {
33
+ const words = name.trim().split(/\s+/);
34
+ return words[words.length - 1];
35
+ }
36
+ function titleConfig() {
37
+ const names = { "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
38
  const home = process.env.CLAUDERIPPLE_HOME ?? path.join(os.homedir(), ".clauderipple");
24
39
  try {
25
40
  const cfg = JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8"));
26
41
  const add = (id, name) => {
27
- if (!id || !name)
28
- return;
29
- const words = name.trim().split(/\s+/);
30
- out[id.toLowerCase()] = words.length > 1 ? words[words.length - 1] : name.trim();
42
+ if (id && name)
43
+ names[id.toLowerCase()] = shortName(name);
31
44
  };
32
45
  for (const m of cfg.cli?.extraModels ?? [])
33
46
  add(m.model, m.name);
34
47
  for (const p of Object.values(cfg.providers ?? {}))
35
48
  for (const m of p.models ?? [])
36
49
  add(m.id, m.name);
50
+ return { names, aliases: cfg.aliases ?? {}, effortClamp: { ...DEFAULT_CLAMP, ...(cfg.effortClamp ?? {}) } };
37
51
  }
38
52
  catch {
39
- /* no config: built-ins only */
53
+ return { names, aliases: {}, effortClamp: DEFAULT_CLAMP };
40
54
  }
41
- return out;
42
55
  }
56
+ /** The `[[ripple: <name>@<effort>]]` a prompt starts with, name lowercased. Not resolved. */
43
57
  export function fromMarker(prompt) {
44
- const head = prompt.split("\n").slice(0, 5).join("\n");
45
- const m = MARKER.exec(head);
46
- return m ? { model: m[1].toLowerCase(), effort: (m[2] ?? "").toLowerCase() } : null;
58
+ const m = MARKER.exec(prompt.replace(REMINDER, ""));
59
+ return m ? { name: m[1].toLowerCase(), effort: (m[2] ?? "").toLowerCase() } : null;
47
60
  }
48
61
  export function fromAgentFile(subagentType, dirs) {
49
62
  if (!subagentType || subagentType.includes("/") || subagentType.startsWith("."))
@@ -106,28 +119,44 @@ function parentModel(transcriptPath) {
106
119
  }
107
120
  return null;
108
121
  }
109
- export function retitle(input, agentDirs, names) {
122
+ /**
123
+ * The model and effort the router will use for this call. The marker's name resolves as the router's
124
+ * aliases do (agents.ts withAgentAliases): an explicit alias, else an agent file's model, else the
125
+ * name itself; its effort, when absent, is the one the subagent's own `model:` asks for.
126
+ */
127
+ function routed(inp, agentDirs, aliases) {
128
+ const own = fromAgentFile(typeof inp.subagent_type === "string" ? inp.subagent_type : "", agentDirs);
129
+ const marker = fromMarker(typeof inp.prompt === "string" ? inp.prompt : "");
130
+ if (!marker)
131
+ return own;
132
+ const model = aliases[marker.name]?.toLowerCase() ?? fromAgentFile(marker.name, agentDirs)?.model ?? marker.name;
133
+ return { model, effort: marker.effort || (own?.effort ?? "") };
134
+ }
135
+ export function retitle(input, agentDirs, cfg) {
110
136
  if (input.tool_name !== "Agent" && input.tool_name !== "Task")
111
137
  return null;
112
138
  const inp = input.tool_input ?? {};
113
139
  const desc = inp.description;
114
140
  if (typeof desc !== "string" || desc.trim() === "")
115
141
  return null;
116
- const effort = (typeof input.effort === "object" && input.effort ? input.effort.level : typeof input.effort === "string" ? input.effort : "") ?? "";
117
- const got = fromMarker(typeof inp.prompt === "string" ? inp.prompt : "") ?? fromAgentFile(typeof inp.subagent_type === "string" ? inp.subagent_type : "", agentDirs);
142
+ const clamp = cfg.effortClamp ?? DEFAULT_CLAMP;
143
+ const level = (e) => clamp[e] ?? e;
144
+ const effort = level((typeof input.effort === "object" && input.effort ? input.effort.level : typeof input.effort === "string" ? input.effort : "") ?? "");
145
+ const got = routed(inp, agentDirs, cfg.aliases ?? {});
146
+ const names = cfg.names;
118
147
  let tag = null;
119
- if (got) {
120
- // Marker short forms ("sol") resolve against full ids ("gpt-5.6-sol"), as the router's aliases do.
148
+ if (got && !claudePretty(got.model)) {
149
+ // Legacy short forms ("sol") resolve against full ids ("gpt-5.6-sol").
121
150
  const key = names[got.model] !== undefined ? got.model : Object.keys(names).find((k) => k.endsWith(`-${got.model}`));
122
- const name = (key ? names[key] : undefined) ?? (got.model.startsWith("gpt") || got.model.includes("/") ? got.model : null);
123
- if (name)
124
- tag = got.effort ? `${name}·${got.effort}` : name;
151
+ const name = (key ? names[key] : undefined) ?? got.model;
152
+ tag = got.effort ? `${name}·${level(got.effort)}` : name;
125
153
  }
126
154
  if (tag === null) {
127
155
  const name = claudePretty(typeof inp.model === "string" ? inp.model : undefined) ?? (got ? claudePretty(got.model) : null) ?? claudePretty(parentModel(input.transcript_path) ?? undefined);
128
156
  if (!name)
129
157
  return null;
130
- tag = effort ? `${name}·${effort}` : name;
158
+ const e = got?.effort ? level(got.effort) : effort;
159
+ tag = e ? `${name}·${e}` : name;
131
160
  }
132
161
  const prefix = `${tag} · `;
133
162
  if (desc.startsWith(prefix))
@@ -150,7 +179,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToP
150
179
  try {
151
180
  const raw = fs.readFileSync(0, "utf8");
152
181
  const data = JSON.parse(raw || "{}");
153
- const updated = retitle(data, defaultAgentDirs(), prettyNames());
182
+ const updated = retitle(data, defaultAgentDirs(), titleConfig());
154
183
  if (updated)
155
184
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: updated } }) + "\n");
156
185
  }
@@ -315,7 +315,7 @@ async function status() {
315
315
  const p = await probe({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
316
316
  rows.push(["probe", `${p.ok ? "ok" : "FAIL"}: ${p.detail} (${p.ms}ms)`]);
317
317
  for (const [name, p2] of Object.entries(cfg.providers)) {
318
- const providerUrl = p2.type === "chatgpt" ? (p2.url ?? "https://chatgpt.com") : p2.type === "anthropic" ? "https://api.anthropic.com" : p2.url;
318
+ const providerUrl = p2.type === "chatgpt" ? (p2.url ?? "https://chatgpt.com") : p2.type === "anthropic" ? "https://api.anthropic.com" : p2.type === "google" ? (p2.url ?? "https://generativelanguage.googleapis.com") : p2.url;
319
319
  const u = new URL(providerUrl);
320
320
  rows.push([`provider ${name}`, await tcpCheck(u.hostname, Number(u.port) || (u.protocol === "https:" ? 443 : 80))]);
321
321
  }
@@ -458,12 +458,15 @@ function help() {
458
458
  logout forget every ChatGPT account added with "login" (the Codex CLI's own login is left alone)
459
459
  claude-login connect a Claude subscription in the browser (--setup-token: via \`claude setup-token\`; --manual: paste the code)
460
460
  claude-logout remove every Claude subscription added to ClaudeRipple
461
+ google-login add a Google account for the Antigravity (Cloud Code Assist) backend (prints Google's terms warning first)
462
+ google-logout remove every Google account added to ClaudeRipple
461
463
  image "<prompt>" [-o FILE] [--aspect square|landscape|portrait] [--transparent] [--ref FILE ...]
462
464
  generate an image with your ChatGPT subscription and print the file's path
463
465
  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)
464
466
  codex on|off add/remove ClaudeRipple's local OpenAI provider and selection profile for Codex CLI
465
467
  agent-title on|off|status
466
468
  prefix subagent titles with the real model and thinking depth ("Terra·high · …") via a Claude Code hook
469
+ mod on|off|status show where each request went above the Claude Code prompt, and the request log in a pane (/ripple-log), via a Claude Code mod
467
470
 
468
471
  Home directory: ${homeDir()} (override with CLAUDERIPPLE_HOME)`);
469
472
  }
@@ -540,6 +543,23 @@ try {
540
543
  console.log(agentTitleHookEnabled() ? "on" : "off");
541
544
  break;
542
545
  }
546
+ case "mod": {
547
+ const sub = args[1];
548
+ const { setStatusMod, statusModEnabled } = await import("./settings.js");
549
+ if (sub === "on" || sub === "off") {
550
+ const r = setStatusMod(sub === "on", installedRuntime.modDir);
551
+ for (const n of r.notes)
552
+ console.log(`✓ ${n}`);
553
+ if (!r.changed)
554
+ console.log(`✓ already ${sub}`);
555
+ if (r.backup)
556
+ console.log(` backup: ${r.backup}`);
557
+ console.log("Applies to Claude Code sessions started from now on.");
558
+ }
559
+ else
560
+ console.log(statusModEnabled() ? "on" : "off");
561
+ break;
562
+ }
543
563
  case "login": {
544
564
  const { login } = await import("../../router/src/providers/chatgpt/auth.js");
545
565
  const { saveChatGptAccount, readChatGptAccounts, chatgptAccountsPath } = await import("../../router/src/providers/chatgpt/accounts.js");
@@ -592,6 +612,31 @@ try {
592
612
  case "claude-logout":
593
613
  console.log(claudeLogout(homeDir()) ? "✓ ClaudeRipple Claude accounts removed" : "no ClaudeRipple Claude accounts stored");
594
614
  break;
615
+ case "google-login": {
616
+ const { GoogleOAuthSession } = await import("../../router/src/providers/google/login.js");
617
+ const { ANTIGRAVITY_WARNING } = await import("../../router/src/providers/google/antigravity.js");
618
+ const { readGoogleAccounts, googleAccountsPath } = await import("../../router/src/providers/google/accounts.js");
619
+ // The terms warning is printed once, before anything else, so a sign-in is never accidental.
620
+ console.log(`⚠ ${ANTIGRAVITY_WARNING}\n`);
621
+ const before = readGoogleAccounts(homeDir()).length;
622
+ const session = new GoogleOAuthSession({ home: homeDir() }, ANTIGRAVITY_WARNING);
623
+ const { url } = await session.start();
624
+ console.log(before > 0
625
+ ? `Opening your browser to add a Google account (${before} signed in already). Sign in with the account to add; this window waits up to 5 minutes.`
626
+ : "Opening your browser to sign in with Google. Sign in there; this window waits up to 5 minutes.");
627
+ if (!openBrowser(url))
628
+ console.log(`Open this URL manually:\n${url}`);
629
+ await session.result;
630
+ const total = readGoogleAccounts(homeDir()).length;
631
+ console.log(`✓ Google account added. ${total} account${total === 1 ? "" : "s"} in ${googleAccountsPath(homeDir())}; refreshed automatically.`);
632
+ break;
633
+ }
634
+ case "google-logout": {
635
+ const { removeAllGoogleAccounts } = await import("../../router/src/providers/google/accounts.js");
636
+ const removed = removeAllGoogleAccounts(homeDir());
637
+ console.log(removed > 0 ? `✓ removed ${removed} Google account${removed === 1 ? "" : "s"}` : "no Google account stored");
638
+ break;
639
+ }
595
640
  case "image":
596
641
  await image();
597
642
  break;
@@ -152,8 +152,12 @@ export function caTrusted(caPem) {
152
152
  const out = powershell(`@(Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -eq 'CN=${CA_NAME}' }).Count`);
153
153
  return Number(out.trim()) > 0;
154
154
  }
155
- execFileSync("security", ["find-certificate", "-c", CA_NAME, loginKeychain()], { stdio: "ignore" });
156
- return true;
155
+ // Ask for the trust setting, not the keychain item: the certificate can sit in the login keychain
156
+ // with no trust (new Mac, 2026-10-06), and a presence check then made `picker on` skip the trust
157
+ // step while Claude Desktop rejected every claude.ai certificate (ERR_CERT_AUTHORITY_INVALID).
158
+ // `add-trusted-cert` without -d writes the user trust domain, which this lists one "Cert N: <name>" per line.
159
+ const out = execFileSync("security", ["dump-trust-settings"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
160
+ return out.split("\n").some((line) => /^Cert \d+: /.test(line) && line.slice(line.indexOf(": ") + 2).trim() === CA_NAME);
157
161
  }
158
162
  catch {
159
163
  return false;
@@ -175,9 +179,10 @@ export function trustCa(caPem) {
175
179
  export function untrustCa(caPem) {
176
180
  if (isLinux)
177
181
  return nssUntrust();
178
- if (!caTrusted(caPem))
179
- return false;
182
+ const trusted = caTrusted(caPem);
180
183
  if (isWindows) {
184
+ if (!trusted)
185
+ return false;
181
186
  try {
182
187
  powershell(`Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -eq 'CN=${CA_NAME}' } | ForEach-Object { Remove-Item -Path $_.PSPath -Force }`, { stdio: "inherit", interactive: true });
183
188
  }
@@ -186,19 +191,22 @@ export function untrustCa(caPem) {
186
191
  }
187
192
  return true;
188
193
  }
189
- try {
190
- execFileSync("security", ["remove-trusted-cert", caPem], { stdio: "inherit" });
191
- }
192
- catch {
193
- /* trust settings may already be gone */
194
+ // macOS: an untrusted copy can still be in the keychain, so the item is deleted either way.
195
+ if (trusted) {
196
+ try {
197
+ execFileSync("security", ["remove-trusted-cert", caPem], { stdio: "inherit" });
198
+ }
199
+ catch {
200
+ /* trust settings may already be gone */
201
+ }
194
202
  }
195
203
  try {
196
204
  execFileSync("security", ["delete-certificate", "-c", CA_NAME, loginKeychain()], { stdio: "ignore" });
205
+ return true;
197
206
  }
198
207
  catch {
199
- /* ignore */
208
+ return trusted;
200
209
  }
201
- return true;
202
210
  }
203
211
  function readMeta() {
204
212
  try {
@@ -21,6 +21,7 @@ export function runtime() {
21
21
  cli: path.join(resources, "packages", "cli", "src", "index.ts"),
22
22
  router: path.join(resources, "packages", "router", "src", "index.ts"),
23
23
  hookScript: path.join(resources, "packages", "cli", "src", "hooks", "agent-title.ts"),
24
+ modDir: path.join(resources, "packages", "mod"),
24
25
  repo: resources,
25
26
  trayMain: "",
26
27
  packaged: true,
@@ -37,6 +38,7 @@ export function runtime() {
37
38
  cli: path.join(resources, "packages", "cli", "src", "index.ts"),
38
39
  router: path.join(resources, "packages", "router", "src", "index.ts"),
39
40
  hookScript: path.join(resources, "packages", "cli", "src", "hooks", "agent-title.ts"),
41
+ modDir: path.join(resources, "packages", "mod"),
40
42
  repo: resources,
41
43
  trayMain: "",
42
44
  packaged: true,
@@ -55,6 +57,7 @@ export function runtime() {
55
57
  cli,
56
58
  router: path.resolve(here, `../../router/src/index${ext}`),
57
59
  hookScript: path.resolve(here, `hooks/agent-title${ext}`),
60
+ modDir: path.resolve(here, "../../..", ext === ".ts" ? "packages/mod" : "dist/mod"),
58
61
  repo: path.resolve(here, "../../.."),
59
62
  trayMain: path.resolve(here, "../../..", ext === ".ts" ? "packages/app/dist/main.js" : "dist/app/dist/main.js"),
60
63
  packaged: false,