clauderipple 0.2.0 → 0.3.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.
- package/CHANGELOG.md +96 -0
- package/README.ko.md +48 -4
- package/README.md +58 -4
- package/dist/cli/src/claude-auth.js +3 -2
- package/dist/cli/src/codex.js +20 -1
- package/dist/cli/src/hooks/agent-title.js +1 -1
- package/dist/cli/src/index.js +4 -4
- package/dist/cli/src/schtasks.js +43 -1
- package/dist/cli/src/settings.js +73 -6
- package/dist/cli/src/tray.js +17 -2
- package/dist/router/src/admin.js +489 -56
- package/dist/router/src/agents.js +250 -0
- package/dist/router/src/bootstrap.js +24 -8
- package/dist/router/src/capabilities.js +214 -0
- package/dist/router/src/compat.js +5 -1
- package/dist/router/src/config.js +264 -11
- package/dist/router/src/index.js +14 -1
- package/dist/router/src/ingress/server.js +24 -14
- package/dist/router/src/picker.js +14 -6
- package/dist/router/src/pool.js +233 -0
- package/dist/router/src/presets.js +163 -2
- package/dist/router/src/providers/anthropic-account-pool.js +139 -0
- package/dist/router/src/providers/anthropic-accounts.js +281 -0
- package/dist/router/src/providers/chatgpt/catalog.js +97 -0
- package/dist/router/src/providers/chatgpt/index.js +343 -12
- package/dist/router/src/providers/chatgpt/sse.js +4 -0
- package/dist/router/src/providers/chatgpt/translate.js +156 -14
- package/dist/router/src/providers/claude-oauth.js +61 -19
- package/dist/router/src/providers/openai/index.js +55 -11
- package/dist/router/src/providers/openai/translate.js +82 -14
- package/dist/router/src/providers/retry.js +88 -0
- package/dist/router/src/proxy.js +713 -82
- package/dist/router/src/requestlog.js +5 -2
- package/dist/router/src/routing.js +151 -17
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +307 -0
- package/dist/router/src/x509.js +7 -2
- package/dist/ui/app.js +740 -160
- package/dist/ui/i18n.js +14 -6
- package/dist/ui/index.html +18 -5
- package/dist/ui/presets-fallback.js +2 -0
- package/dist/ui/style.css +133 -9
- package/docs/ARCHITECTURE.md +381 -20
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,101 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1 — 2026-09-24
|
|
4
|
+
|
|
5
|
+
Fixes for three reports against 0.3.0.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Claude account rotation no longer breaks requests with a 400** (#15). With
|
|
10
|
+
`accountPool` on, the account's `anthropic-beta` header replaced the client's,
|
|
11
|
+
so fields Claude Code enables per request (`cache_control.scope`,
|
|
12
|
+
`context_management`, …) were refused with `Extra inputs are not permitted`.
|
|
13
|
+
A conversation pinned to an added account failed on every retry, because a
|
|
14
|
+
400 does not move it. The account now supplies identity only: its OAuth flags
|
|
15
|
+
are added to the client's, on the first attempt and on every retry.
|
|
16
|
+
- **The Windows tray appears** (#8). It was started with `ELECTRON_RUN_AS_NODE`
|
|
17
|
+
set to an empty string, which macOS Electron treats as unset and Windows
|
|
18
|
+
Electron treats as set, so on Windows the tray ran as plain Node and exited
|
|
19
|
+
while the CLI reported "tray started". The variable is now removed.
|
|
20
|
+
- **GLM-5.3 and GLM-5.3 Flash in the Z.AI preset** (#14). Z.AI serves no model
|
|
21
|
+
list, so the preset's list is the whole picker, and it stopped at GLM-5.2.
|
|
22
|
+
|
|
23
|
+
## 0.3.0 — 2026-09-23
|
|
24
|
+
|
|
25
|
+
Everything on `main` since 0.2.0. The account rotation that issue #8 asked for
|
|
26
|
+
was on `main` from 2026-09-20 while the README already described it, but no
|
|
27
|
+
release carried it. A user looking for the setting in 0.2.0 could not find it.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **Claude subscription account rotation.** A native Anthropic provider can opt in
|
|
32
|
+
with `accountPool: true` and use the current Claude Code/Desktop login followed by
|
|
33
|
+
every OAuth account added through ClaudeRipple. Conversations stay on the account
|
|
34
|
+
that answered to preserve the prompt cache; before any response reaches the client,
|
|
35
|
+
429, 401 and transient upstream failures can move the same turn to the next account.
|
|
36
|
+
Stored grants refresh independently with single-flight and compare-and-swap safety,
|
|
37
|
+
rejected accounts are isolated until re-login, and the dashboard can rename or
|
|
38
|
+
remove them without exposing a token or upstream account id. Existing single-account
|
|
39
|
+
OAuth grants migrate to the private `~/.clauderipple/claude-accounts.json` file on
|
|
40
|
+
the first pool write. This applies to native Claude Desktop/Claude Code Messages;
|
|
41
|
+
the translated OpenAI ingress for Codex picks one highest-priority available login
|
|
42
|
+
per request but deliberately does not rotate accounts.
|
|
43
|
+
- **ChatGPT subscription web search.** When `webSearch` explicitly names a
|
|
44
|
+
ChatGPT provider, Claude Code's separate search request uses the subscription's
|
|
45
|
+
Codex hosted-search tool instead of silently consuming Anthropic quota. The
|
|
46
|
+
adapter requires a reported search and URL citations, preserves allowed-domain
|
|
47
|
+
filters, and refuses unsupported blocked-domain filters.
|
|
48
|
+
- **New ChatGPT models appear without an update.** The model list is read from
|
|
49
|
+
the Codex catalogue the subscription serves, not from a list in the code, so
|
|
50
|
+
GPT-6 Sol and GPT-6 Luna showed up the day they shipped. If the catalogue
|
|
51
|
+
cannot be read, a built-in list is used instead. Newly found models are
|
|
52
|
+
offered unticked. Claude Opus 5.5 is in the Claude list.
|
|
53
|
+
- **Credential pools and route fallbacks.** A provider can hold several keys. A
|
|
54
|
+
rate-limited or refused key rests until its cooldown ends, and the turn moves
|
|
55
|
+
to the next key before the client sees the failure. A model slot can name
|
|
56
|
+
fallback providers for when the primary has nothing usable left. The
|
|
57
|
+
dashboard shows which credential is resting, and until when.
|
|
58
|
+
- **OpenCode Go and OpenCode Zen presets.** One OpenCode Go subscription is one
|
|
59
|
+
preset covering all three of its endpoints, each with the session header its
|
|
60
|
+
prompt cache needs. Each model's request format is taken from the vendor's
|
|
61
|
+
published endpoint table. After a provider is saved, its models are measured
|
|
62
|
+
for request format and effort levels instead of being assumed.
|
|
63
|
+
- **A ticked model is a subagent.** Every model ticked in the GUI gets a
|
|
64
|
+
generated agent file. A model that only one provider carries routes there
|
|
65
|
+
without a hand-written rule. A model no provider carries is refused with a
|
|
66
|
+
message naming it, instead of being sent to Anthropic and coming back 404.
|
|
67
|
+
- **Web search in a routed session.** It reaches a provider that runs the
|
|
68
|
+
search tool itself, uses ChatGPT's hosted search when configured, and is
|
|
69
|
+
refused with a reason where no provider can run it.
|
|
70
|
+
- **Claude Code's own model slots as a setting.**
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- **A cut-off stream is retried, not taken as the answer.** An upstream that
|
|
75
|
+
closed the stream without finishing used to become an empty, successful
|
|
76
|
+
turn. A subagent then stopped silently, which is how long Muse runs appeared
|
|
77
|
+
to stall and die. The turn now fails as overloaded, and Claude Code retries it.
|
|
78
|
+
- **The Codex prompt cache holds across turns again.** The conversation is
|
|
79
|
+
named to the Codex backend, which now keys its cache on that name.
|
|
80
|
+
- **The provider form no longer wipes settings it does not show.** Saving a
|
|
81
|
+
ChatGPT provider in the GUI dropped fields such as `instructionsAppend`.
|
|
82
|
+
- **The request log shows a request's whole input.** A fully cached Anthropic
|
|
83
|
+
turn used to read as a 2-token request. The model column no longer shows
|
|
84
|
+
the agent file's default effort next to the effort that was actually sent.
|
|
85
|
+
- **Routed models keep their own context window,** including across a model
|
|
86
|
+
switch, and tool names the wire rejects are rewritten and restored.
|
|
87
|
+
- **Screenshots in tool results survive translation.**
|
|
88
|
+
|
|
89
|
+
- **Remote Control works through the forward proxy.** Claude Code sends its
|
|
90
|
+
registration, polling and heartbeat requests in HTTPS absolute-form rather
|
|
91
|
+
than CONNECT. ClaudeRipple now rewrites only the request target, strips proxy
|
|
92
|
+
headers and relays it over TLS outside model routing. The connection closes
|
|
93
|
+
after one request so another proxy target cannot leak to the first origin.
|
|
94
|
+
- **Windows reinstall is idempotent without elevation.** An existing scheduled
|
|
95
|
+
task is reused only when its action, principal, trigger, restart policy,
|
|
96
|
+
execution limit, instance policy and battery settings all match the intended
|
|
97
|
+
per-user task. A foreign or stale task is never trusted.
|
|
98
|
+
|
|
3
99
|
## 0.2.0 — 2026-09-16
|
|
4
100
|
|
|
5
101
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
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 {
|
|
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
|
|
112
|
+
return removeAllClaudeAccounts(home);
|
|
112
113
|
}
|
package/dist/cli/src/codex.js
CHANGED
|
@@ -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"));
|
package/dist/cli/src/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
470
|
+
console.log(claudeLogout(homeDir()) ? "✓ ClaudeRipple Claude accounts removed" : "no ClaudeRipple Claude accounts stored");
|
|
471
471
|
break;
|
|
472
472
|
case "ui":
|
|
473
473
|
ui();
|
package/dist/cli/src/schtasks.js
CHANGED
|
@@ -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(
|
|
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`,
|
package/dist/cli/src/settings.js
CHANGED
|
@@ -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 {
|
package/dist/cli/src/tray.js
CHANGED
|
@@ -24,6 +24,20 @@ export function electronPath() {
|
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Our environment without ELECTRON_RUN_AS_NODE, so Electron starts as the tray rather than as a
|
|
29
|
+
* plain Node process. The variable has to be removed, not emptied: macOS Electron reads an empty
|
|
30
|
+
* value as unset, but Windows Electron only asks whether it exists, so an empty one ran the tray
|
|
31
|
+
* as Node — it exited at once and no icon ever appeared (measured 2026-09-24, Electron 38,
|
|
32
|
+
* Windows 11; issue #8).
|
|
33
|
+
*/
|
|
34
|
+
export function trayEnv(env = process.env) {
|
|
35
|
+
const out = { ...env };
|
|
36
|
+
for (const name of Object.keys(out))
|
|
37
|
+
if (name.toUpperCase() === "ELECTRON_RUN_AS_NODE")
|
|
38
|
+
delete out[name];
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
27
41
|
/**
|
|
28
42
|
* Starts the tray and returns immediately. `detached` outlives this process, which is what a
|
|
29
43
|
* command typed in a terminal wants; the supervisor uses the foreground form instead.
|
|
@@ -32,7 +46,8 @@ export function startTray(options = {}) {
|
|
|
32
46
|
const current = runtime();
|
|
33
47
|
if (current.packaged) {
|
|
34
48
|
// The packaged app IS the tray: relaunching its own binary with no script is what opens it.
|
|
35
|
-
|
|
49
|
+
// Our own CLI runs under ELECTRON_RUN_AS_NODE here, and inheriting it would relaunch Node.
|
|
50
|
+
const child = spawn(current.node, [], { detached: true, stdio: "ignore", env: trayEnv() });
|
|
36
51
|
child.unref();
|
|
37
52
|
return { ok: true, message: "✓ tray started" };
|
|
38
53
|
}
|
|
@@ -51,7 +66,7 @@ export function startTray(options = {}) {
|
|
|
51
66
|
detached: options.detached ?? true,
|
|
52
67
|
stdio: "ignore",
|
|
53
68
|
// Electron would otherwise re-enter as a plain Node process, since that is how the CLI runs.
|
|
54
|
-
env:
|
|
69
|
+
env: trayEnv(),
|
|
55
70
|
});
|
|
56
71
|
if (options.detached ?? true)
|
|
57
72
|
child.unref();
|