clauderipple 0.5.1 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 — 2026-09-29
4
+
5
+ ### Added
6
+
7
+ - **Worker tools** (`cli.limitWorkerTools`, off by default; a toggle in the dashboard). Generated
8
+ workers get only the read, edit, run, search and skill tools instead of inheriting every MCP
9
+ server and plugin tool of the session. One worker's first request fell from 32,942 to 8,823
10
+ tokens. Such a worker cannot use a browser or the iOS Simulator; keep those in an agent file of
11
+ your own.
12
+ - **Images from the ChatGPT subscription.** `clauderipple image "<prompt>" -o file.png`, with
13
+ `--aspect square|landscape|portrait`, `--transparent`, `--format` and `--ref FILE` for reference
14
+ images. Underneath is `POST /api/image` on the admin port, which answers with the image itself.
15
+ Generated workers are told the `curl` form, so a worker with only Bash can make one. The backend
16
+ chooses resolution and quality; about 30 seconds an image.
17
+
18
+ ## 0.6.0 — 2026-09-28
19
+
20
+ Both suggested in #8 by @artisthanbohee-del.
21
+
22
+ ### Added
23
+
24
+ - **Updates from the tray and the CLI** (#39). The tray checks for a new version at start and
25
+ twice a day and offers it in its menu; **Check for Updates…** asks on demand. In a terminal:
26
+ `clauderipple update` (`--check` only reports). It updates the way ClaudeRipple was
27
+ installed — the install script again, or npm into the same prefix — and restarts the router
28
+ on the new version. The standalone app points to the releases page instead.
29
+ - **Usage of each Claude subscription account** (#40): the five-hour and weekly limits of the
30
+ current Claude login and of every account added in ClaudeRipple, on the Health screen, in the
31
+ Claude provider's Accounts tab and in the tray menu. Read from the same endpoint Claude
32
+ Code's `/usage` uses, so an account that has not answered anything yet still has a number.
33
+ - **The version in the tray's About box.**
34
+
35
+ ### Fixed
36
+
37
+ - **Re-running the installer now restarts a router still running the old version.** Before,
38
+ the files changed but the router kept the old code until the next login — on Windows and
39
+ Linux always, since an unchanged task or service is not registered again.
40
+ - **The tray survives updates.** Its Electron (~270MB) was fetched into the package folder, and
41
+ every update deleted it; on Windows a running tray could make the update fail. It now lives in
42
+ `~/.clauderipple/tray-runtime`, and the install scripts move an existing copy there instead of
43
+ fetching it again. An install made with plain `npm install -g` fetches it once more with
44
+ `clauderipple tray --install`.
45
+ - **The tray installed from npm reports its own version**, not Electron's: its package manifest
46
+ carried none.
47
+
3
48
  ## 0.5.1 — 2026-09-28
4
49
 
5
50
  ### Fixed
package/README.ko.md CHANGED
@@ -144,6 +144,13 @@ ClaudeRipple이 Claude에 하는 일은 작고 확인할 수 있습니다. Claud
144
144
  Claude 모델이 이름 그대로 뜹니다. Claude Code 로그인이나 Anthropic API 키로 답합니다.
145
145
  [Codex 앱과 Codex CLI](#codex-앱과-codex-cli) 절을 보세요.
146
146
 
147
+ ### 이미지도 만들 수 있나요?
148
+
149
+ 됩니다. API 키 없이 ChatGPT 구독으로 만듭니다: `clauderipple image "빨간 종이배" -o boat.png --aspect square`.
150
+ `--ref 파일`로 참고 이미지를, `--transparent`로 투명 배경을 줄 수 있습니다. 한 장에 30초쯤 걸리고, 해상도와 품질은
151
+ 구독 쪽이 정합니다. ClaudeRipple이 만든 서브에이전트에도 같은 방법이 적혀 있어 워커도 이미지를 만듭니다.
152
+ [Claude Code](#claude-code-터미널-remote-control-서브에이전트) 절을 보세요.
153
+
147
154
  ### Claude Code 안에서는 GPT 성능이 떨어지지 않나요?
148
155
 
149
156
  Claude Code의 시스템 프롬프트와 도구 설명은 Claude에 맞춰 쓰여 있어서, 다른 모델은 남에게 맞춘 지시를 읽는 셈이고
@@ -219,6 +226,10 @@ ClaudeRipple은 Claude Code 프로세스만 신뢰하는 작은 HTTPS 프록시
219
226
  서버 측 스레드 처리, 도구 호출과 이미지 왕복, 모델이 받는 범위로 추론 강도 클램프, 호환 벤더에는 Anthropic 전용 필드 제거.
220
227
  - **제대로 된 요청 로그.** 누가 물었고 어떤 모델이 답했는지, 입력·캐시·출력 토큰, 지연, 상태를 요청마다. 한 시간 요약 포함.
221
228
  - **서브에이전트 이름표.** 백그라운드 작업 패널에 "Agent" 대신 `Terra·high · Review`가 보입니다.
229
+ - **ChatGPT 구독으로 이미지 생성.** `clauderipple image "<프롬프트>"`. 참고 이미지와 투명 배경도 됩니다. 서브에이전트에도
230
+ 방법이 적혀 있어, GPT나 DeepSeek 워커가 이미지 도구 없이 이미지를 만들고 직접 확인합니다.
231
+ - **원하면 가벼운 워커.** 스위치 하나로, 생성된 서브에이전트에 세션의 MCP·플러그인 도구 전부 대신 워커가 쓰는 도구만
232
+ 줍니다. 워커 첫 요청이 3.3만에서 0.9만 토큰으로 줄었습니다.
222
233
  - **터미널이 싫은 사람을 위해.** 원클릭 연결 확인과 모델 자동 검색이 붙은 프로바이더 프리셋, 드롭다운 모델 매핑, 자동 저장,
223
234
  한국어·영어 UI. 런타임을 품고 첫 실행에 스스로 설치하는 메뉴 막대 앱. 서명·공증 완료.
224
235
 
@@ -280,13 +291,15 @@ clauderipple tray
280
291
  Electron으로 도는데 용량이 270MB라 기본 설치에는 넣지 않았습니다. `clauderipple tray --install`이 한 번만
281
292
  받아 옵니다. 나머지 기능은 Electron 없이도 다 돕니다.
282
293
 
283
- **업데이트.**
294
+ **업데이트.** 새 버전이 나오면 트레이가 알려 줍니다(**업데이트 확인…**). 터미널에서는:
284
295
 
285
296
  ```sh
286
- npm install -g clauderipple@latest
287
- clauderipple restart
297
+ clauderipple update
288
298
  ```
289
299
 
300
+ 설치한 방식 그대로(설치 스크립트 또는 npm) 업데이트하고, 라우터를 새 버전으로 다시 시작합니다. 트레이용
301
+ Electron은 업데이트해도 그대로 남습니다.
302
+
290
303
  선택 사항, Desktop 피커에 실제 이름을 띄우려면: **클라이언트 → Claude Desktop → 모델 피커 → 켜기**. 그다음 Claude
291
304
  Desktop을 완전히 종료했다가 다시 엽니다. 로컬 인증서를 사용자 범위로만 신뢰시키는데, 이때 macOS는 로그인 암호를
292
305
  묻고 Windows는 지문이 적힌 확인 창을 띄웁니다. Linux는 묻지 않고 NSS 데이터베이스에 등록합니다. ClaudeRipple은
@@ -308,7 +321,7 @@ node packages/cli/src/index.ts ui # 브라우저에서 로컬 GUI 열기
308
321
 
309
322
  `uninstall`은 전부 되돌리고 `~/.claude/settings.json`을 백업에서 복원합니다. 그 밖의 명령: `status`, `start`, `stop`,
310
323
  `restart`, `logs -f`, `login`, `logout`, `claude-login`, `claude-logout`, `picker on|off`, `agent-title on|off`,
311
- `codex on|off`.
324
+ `codex on|off`, `update`, `image "<프롬프트>"`.
312
325
  </details>
313
326
 
314
327
  ## 클라이언트
@@ -353,6 +366,22 @@ model: gpt-5.6-sol@medium
353
366
  알 수 없는 에러로 죽게 두지 않고 이름을 대고 거부합니다(`400 ClaudeRipple: no provider declares "…"`).
354
367
  Claude 모델은 언제나 그대로 통과합니다. 생성을 끄려면 `"cli": { "agentFiles": false }`.
355
368
 
369
+ 생성된 워커는 기본적으로 세션의 도구를 전부 물려받아, MCP 서버·플러그인 도구가 워커 요청마다 실립니다.
370
+ `"cli": { "limitWorkerTools": true }`(대시보드의 **워커 도구 제한**)를 켜면 읽기·편집·실행·검색·스킬 도구만
371
+ 주어 워커 첫 요청이 약 3.3만에서 0.9만 토큰으로 줄었습니다. 대신 그 워커는 브라우저·시뮬레이터 도구를 못 씁니다.
372
+ 그런 도구가 필요한 워커는 직접 만든 에이전트 파일로 두면 되고, 그 파일은 건드리지 않습니다.
373
+
374
+ ChatGPT 프로바이더가 있으면 같은 구독으로 이미지를 만들 수 있습니다.
375
+
376
+ ```bash
377
+ clauderipple image "빨간 종이배, 플랫 일러스트" -o boat.png --aspect square
378
+ ```
379
+
380
+ `--transparent`, `--format png|jpeg|webp`, `--ref 파일`(참고 이미지, 여러 번 가능)은 선택입니다. 한 장에 30초쯤 걸립니다.
381
+ 구독 백엔드가 해상도와 품질을 직접 정하므로, 모양은 `--aspect`로 말로 요청합니다. 이 명령은 관리 포트의
382
+ `POST /api/image`(응답이 이미지 바이트)를 부르는 클라이언트입니다. 생성된 워커에는 그 `curl` 형태가 적혀 있어,
383
+ Bash만 있는 워커도 이미지를 만들고 Read로 확인할 수 있습니다.
384
+
356
385
  Claude Code의 `WebSearch`는 작은 모델에 별도 요청을 보냅니다. ChatGPT로 라우팅한 구성에서 그 요청만
357
386
  Anthropic 한도를 쓰지 않게 하려면 `config.json`에서 같은 ChatGPT 프로바이더를 검색 백엔드로 지정합니다:
358
387
 
package/README.md CHANGED
@@ -165,6 +165,15 @@ Yes. `clauderipple codex on` adds ClaudeRipple as a provider, and Claude models
165
165
  the Codex app's and CLI's model list under their own names. They answer through your
166
166
  Claude Code login or an Anthropic API key. See [Codex app and Codex CLI](#codex-app-and-codex-cli).
167
167
 
168
+ ### Can it generate images?
169
+
170
+ Yes, with a ChatGPT subscription and no API key:
171
+ `clauderipple image "a red paper boat" -o boat.png --aspect square`. `--ref FILE` adds
172
+ a reference image and `--transparent` a transparent background. One image takes about
173
+ 30 seconds, and the subscription chooses the resolution and quality. Subagents
174
+ ClaudeRipple generates are told the same call, so they can make images too. See
175
+ [Claude Code](#claude-code-terminal-remote-control-subagents).
176
+
168
177
  ### Does GPT do worse inside Claude Code?
169
178
 
170
179
  Claude Code's system prompt and tool descriptions are written for Claude, so another model
@@ -263,6 +272,12 @@ you map go to your provider, everything else goes to Anthropic byte for byte.
263
272
  latency, status, per request, with a one-hour summary.
264
273
  - **Subagent name tags.** The background-task panel shows `Terra·high · Review`
265
274
  instead of a generic "Agent".
275
+ - **Images from your ChatGPT subscription.** `clauderipple image "<prompt>"`, with
276
+ reference images and transparent backgrounds. Subagents are told how, so a GPT or
277
+ DeepSeek worker can make an image and look at it without an image tool.
278
+ - **Lean workers, if you want them.** One switch gives generated subagents only the
279
+ tools a worker uses instead of every MCP server and plugin of the session: one
280
+ worker's first request fell from 33k to 9k tokens.
266
281
  - **Made for people who don't want a terminal.** Provider presets with one-click
267
282
  connection tests and model discovery, drop-down model mapping, auto-save, Korean
268
283
  and English UI. A menu-bar app that carries its own runtime and sets itself up on
@@ -328,13 +343,15 @@ clauderipple tray
328
343
  It runs on Electron, which is about 270MB and is therefore not installed by default.
329
344
  `clauderipple tray --install` fetches it once; everything else works without it.
330
345
 
331
- **Updating.**
346
+ **Updating.** The tray offers a new version when there is one (**Check for Updates…**), or:
332
347
 
333
348
  ```sh
334
- npm install -g clauderipple@latest
335
- clauderipple restart
349
+ clauderipple update
336
350
  ```
337
351
 
352
+ It updates the way you installed — the install script again, or npm — and restarts the router
353
+ on the new version. The tray's Electron is kept across updates.
354
+
338
355
  Optional, for real names in the Desktop picker: **Clients → Claude Desktop → Model
339
356
  picker → on**, then quit and reopen Claude Desktop. To trust the local certificate
340
357
  for your user only, macOS asks for your login password and Windows shows a
@@ -358,7 +375,7 @@ node packages/cli/src/index.ts ui # open the local GUI in your browser
358
375
  `uninstall` reverses everything and restores `~/.claude/settings.json` from a backup.
359
376
  Other commands: `status`, `start`, `stop`, `restart`, `logs -f`, `login`, `logout`,
360
377
  `claude-login`, `claude-logout`, `picker on|off`, `agent-title on|off`,
361
- `codex on|off`.
378
+ `codex on|off`, `update`, `image "<prompt>"`.
362
379
  </details>
363
380
 
364
381
  ## Clients
@@ -411,6 +428,27 @@ by name (`400 ClaudeRipple: no provider declares "…"`) instead of being sent o
411
428
  to fail somewhere less legible; native Claude models always pass through.
412
429
  Set `"cli": { "agentFiles": false }` to turn generation off.
413
430
 
431
+ By default a generated worker inherits every tool of the session, so each MCP
432
+ server and plugin rides along on every worker request. `"cli": {
433
+ "limitWorkerTools": true }` (or **Worker tools** in the dashboard) gives it only
434
+ the read, edit, run, search and skill tools, which cut one worker's first
435
+ request from about 33k to 9k tokens. Such a worker has no browser or simulator
436
+ tools. A worker that needs them belongs in an agent file of your own, which is
437
+ never touched.
438
+
439
+ With a ChatGPT provider configured, images come from the same subscription:
440
+
441
+ ```bash
442
+ clauderipple image "a red paper boat, flat illustration" -o boat.png --aspect square
443
+ ```
444
+
445
+ `--transparent`, `--format png|jpeg|webp` and `--ref FILE` (a reference image,
446
+ repeatable) are optional. One image takes about 30 seconds. The subscription
447
+ backend picks the resolution and quality itself, so the shape is asked for in
448
+ words (`--aspect`). The command is a client of `POST /api/image` on the admin
449
+ port, which answers with the image bytes. Generated workers are told the `curl`
450
+ form of it, so a worker with only Bash can make an image and read it back.
451
+
414
452
  Claude Code runs `WebSearch` as a separate small-model request. To keep a
415
453
  ChatGPT-routed setup from spending Anthropic quota for that request, select the
416
454
  same ChatGPT provider as the search backend in `config.json`:
@@ -73,6 +73,23 @@ const STRINGS = {
73
73
  setupNeeded: "setup not finished",
74
74
  setupNeededDetail: "ClaudeRipple is installed but not set up yet",
75
75
  runSetup: "Finish setting up ClaudeRipple…",
76
+ claudeCurrent: "current login",
77
+ claudeUsage: (label, windows) => `Claude ${label} · ${windows}`,
78
+ window5h: "5h",
79
+ windowWeek: "weekly",
80
+ checkUpdates: "Check for Updates…",
81
+ updateAvailableItem: (v) => `Update to ClaudeRipple ${v}…`,
82
+ upToDate: (v) => `ClaudeRipple ${v} is the latest version.`,
83
+ updateAvailable: (latest, current) => `ClaudeRipple ${latest} is available (you have ${current}).`,
84
+ updateDetail: "The router restarts on the new version once requests in flight have finished. The tray reopens by itself.",
85
+ updateNow: "Update",
86
+ later: "Later",
87
+ updating: (v) => `Updating to ClaudeRipple ${v}…`,
88
+ updateDone: (v) => `Updated to ClaudeRipple ${v}`,
89
+ updateFailed: "The update did not finish",
90
+ updateCheckFailed: "Could not check for updates",
91
+ updatePackaged: "This is the standalone app. Download the new version from the releases page.",
92
+ updateCheckout: "This is a source checkout. Update it with git pull.",
76
93
  },
77
94
  ko: {
78
95
  healthy: "정상",
@@ -132,6 +149,23 @@ const STRINGS = {
132
149
  setupNeeded: "설정이 끝나지 않았습니다",
133
150
  setupNeededDetail: "설치는 됐지만 아직 설정하지 않았습니다",
134
151
  runSetup: "ClaudeRipple 설정 마치기…",
152
+ claudeCurrent: "현재 로그인",
153
+ claudeUsage: (label, windows) => `Claude ${label} · ${windows}`,
154
+ window5h: "5시간",
155
+ windowWeek: "주간",
156
+ checkUpdates: "업데이트 확인…",
157
+ updateAvailableItem: (v) => `새 버전 업데이트: ClaudeRipple ${v}…`,
158
+ upToDate: (v) => `최신 버전입니다: ClaudeRipple ${v}`,
159
+ updateAvailable: (latest, current) => `새 버전이 나왔습니다: ClaudeRipple ${latest} (지금 ${current})`,
160
+ updateDetail: "진행 중인 요청이 끝나면 라우터가 새 버전으로 다시 시작합니다. 트레이는 저절로 다시 열립니다.",
161
+ updateNow: "업데이트",
162
+ later: "나중에",
163
+ updating: (v) => `ClaudeRipple ${v} 업데이트 중…`,
164
+ updateDone: (v) => `ClaudeRipple ${v} 업데이트 완료`,
165
+ updateFailed: "업데이트를 마치지 못했습니다",
166
+ updateCheckFailed: "업데이트를 확인하지 못했습니다",
167
+ updatePackaged: "독립 실행 앱입니다. 릴리스 페이지에서 새 버전을 받으세요.",
168
+ updateCheckout: "소스 체크아웃입니다. git pull로 업데이트하세요.",
135
169
  },
136
170
  };
137
171
  let L = STRINGS.en;
@@ -223,7 +257,8 @@ async function poll() {
223
257
  last = null;
224
258
  lastError = e.message;
225
259
  downSince ??= Date.now();
226
- if (!downNotified && Date.now() - downSince >= DOWN_NOTIFY_AFTER_MS) {
260
+ // An update restarts the router on purpose; that is not the outage the notification is for.
261
+ if (!downNotified && !updating && Date.now() - downSince >= DOWN_NOTIFY_AFTER_MS) {
227
262
  downNotified = true;
228
263
  notifyDown();
229
264
  }
@@ -390,13 +425,77 @@ function cliPaths() {
390
425
  return { node: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1" }, cli };
391
426
  }
392
427
  function runCli(args) {
428
+ return runCliResult(args).then((r) => r.output);
429
+ }
430
+ function runCliResult(args) {
393
431
  const { node, env, cli } = cliPaths();
394
432
  return new Promise((resolve) => {
395
- (0, node_child_process_1.execFile)(node, [cli, ...args], { env: { ...process.env, ...env, CLAUDERIPPLE_HOME: home } }, (err, stdout, stderr) => {
396
- resolve(`${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim());
433
+ // An update prints npm's and the installer's own lines; the default 1MB buffer is not for that.
434
+ (0, node_child_process_1.execFile)(node, [cli, ...args], { env: { ...process.env, ...env, CLAUDERIPPLE_HOME: home }, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
435
+ resolve({ ok: !err, output: `${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim() });
397
436
  });
398
437
  });
399
438
  }
439
+ let availableUpdate = null;
440
+ let updating = false;
441
+ const UPDATE_CHECK_MS = 12 * 60 * 60 * 1000;
442
+ async function checkForUpdate() {
443
+ const r = await runCliResult(["update", "--check", "--json"]);
444
+ try {
445
+ const check = JSON.parse(r.output.split("\n").pop() ?? "");
446
+ availableUpdate = check.newer ? check : null;
447
+ render();
448
+ return check;
449
+ }
450
+ catch {
451
+ return null;
452
+ }
453
+ }
454
+ async function checkUpdatesInteractive() {
455
+ const check = await checkForUpdate();
456
+ if (!check) {
457
+ await electron_1.dialog.showMessageBox({ type: "warning", message: L.updateCheckFailed });
458
+ return;
459
+ }
460
+ if (!check.newer) {
461
+ await electron_1.dialog.showMessageBox({ message: L.upToDate(check.current) });
462
+ return;
463
+ }
464
+ await offerUpdate(check);
465
+ }
466
+ async function offerUpdate(check) {
467
+ if (check.kind === "packaged") {
468
+ const choice = await electron_1.dialog.showMessageBox({ message: L.updateAvailable(check.latest ?? "?", check.current), detail: L.updatePackaged, buttons: [L.updateNow, L.later], defaultId: 0, cancelId: 1 });
469
+ if (choice.response === 0)
470
+ void electron_1.shell.openExternal("https://github.com/PBJ-2/clauderipple/releases/latest");
471
+ return;
472
+ }
473
+ if (check.kind === "checkout") {
474
+ await electron_1.dialog.showMessageBox({ message: L.updateAvailable(check.latest ?? "?", check.current), detail: L.updateCheckout });
475
+ return;
476
+ }
477
+ const choice = await electron_1.dialog.showMessageBox({ message: L.updateAvailable(check.latest ?? "?", check.current), detail: L.updateDetail, buttons: [L.updateNow, L.later], defaultId: 0, cancelId: 1 });
478
+ if (choice.response !== 0 || updating)
479
+ return;
480
+ updating = true;
481
+ render();
482
+ const version = check.latest ?? "";
483
+ if (electron_1.Notification.isSupported())
484
+ new electron_1.Notification({ title: "ClaudeRipple", body: L.updating(version) }).show();
485
+ const r = await runCliResult(["update"]);
486
+ updating = false;
487
+ if (!r.ok) {
488
+ render();
489
+ await electron_1.dialog.showMessageBox({ type: "warning", message: L.updateFailed, detail: r.output.slice(-4000) });
490
+ return;
491
+ }
492
+ availableUpdate = null;
493
+ if (electron_1.Notification.isSupported())
494
+ new electron_1.Notification({ title: "ClaudeRipple", body: L.updateDone(version) }).show();
495
+ // A new tray from the new files, then this one goes.
496
+ await runCliResult(["tray"]);
497
+ electron_1.app.quit();
498
+ }
400
499
  // Offer setup only when there is no working installation at all. A developer who runs the router
401
500
  // from a source checkout keeps that; switching to the app's bundled runtime is a menu action.
402
501
  function needsSetup() {
@@ -436,6 +535,14 @@ async function promptForSetup() {
436
535
  if (choice.response === 0)
437
536
  await setup();
438
537
  }
538
+ /** "5h 14% · weekly 10%", the same reading the dashboard gives. */
539
+ function windowsText(quota) {
540
+ const limits = quota?.rate_limits;
541
+ return [limits?.primary, limits?.secondary]
542
+ .filter((w) => !!w && typeof w.used_percent === "number")
543
+ .map((w) => `${w.window_minutes === 300 ? L.window5h : L.windowWeek} ${Math.round(w.used_percent)}%`)
544
+ .join(" · ");
545
+ }
439
546
  function render() {
440
547
  if (!tray)
441
548
  return;
@@ -462,9 +569,18 @@ function render() {
462
569
  : unconfigured
463
570
  ? L.setupNeededDetail
464
571
  : L.downNotifyTitle;
572
+ // One line per Claude subscription account, current login first (#40).
573
+ const claudeLines = (s?.claude?.accounts ?? [])
574
+ .map((account) => ({ account, windows: windowsText(account.quota) }))
575
+ .filter(({ windows }) => windows)
576
+ .map(({ account, windows }) => ({ label: L.claudeUsage(account.id === "current" ? L.claudeCurrent : account.label, windows), click: () => void openDashboard() }));
465
577
  const template = [
466
578
  { label: headline, click: () => void openDashboard() },
467
579
  ...(detail ? [{ label: detail, click: () => void openDashboard() }] : []),
580
+ ...claudeLines,
581
+ ...(availableUpdate
582
+ ? [{ label: updating ? L.updating(availableUpdate.latest ?? "") : L.updateAvailableItem(availableUpdate.latest ?? ""), enabled: !updating, click: () => void offerUpdate(availableUpdate) }]
583
+ : []),
468
584
  { type: "separator" },
469
585
  // Enabled even when the router is down: it is started first, then the page is opened.
470
586
  { label: L.openDashboard, click: () => void openDashboard() },
@@ -485,7 +601,8 @@ function render() {
485
601
  : []),
486
602
  { label: L.connectClaudeSubscription, click: async () => void electron_1.dialog.showMessageBox({ message: await runCli(["claude-login"]) }) },
487
603
  { type: "separator" },
488
- { label: L.about, click: () => void electron_1.dialog.showMessageBox({ title: "ClaudeRipple", message: "ClaudeRipple", detail: L.aboutDetail }) },
604
+ { label: L.checkUpdates, enabled: !updating, click: () => void checkUpdatesInteractive() },
605
+ { label: L.about, click: () => void electron_1.dialog.showMessageBox({ title: "ClaudeRipple", message: `ClaudeRipple ${electron_1.app.getVersion()}`, detail: L.aboutDetail }) },
489
606
  { label: L.quit, role: "quit" },
490
607
  ];
491
608
  tray.setContextMenu(electron_1.Menu.buildFromTemplate(template));
@@ -511,6 +628,9 @@ electron_1.app.whenReady().then(() => {
511
628
  setInterval(() => void poll(), POLL_MS);
512
629
  if (needsSetup())
513
630
  void promptForSetup();
631
+ // Not at the very start: a tray opened at login shares the first minute with everything else.
632
+ setTimeout(() => void checkForUpdate(), 60_000);
633
+ setInterval(() => void checkForUpdate(), UPDATE_CHECK_MS);
514
634
  });
515
635
  // A tray-only app owns no windows; without this Electron would quit the moment a dialog closes.
516
636
  electron_1.app.on("window-all-closed", () => {
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "private": true,
3
+ "version": "0.7.0",
3
4
  "type": "commonjs"
4
5
  }
@@ -22,7 +22,8 @@ import { codexOff, codexOn } from "./codex.js";
22
22
  import { ingressModels } from "../../router/src/ingress/models.js";
23
23
  import { claudeLogin, claudeLogout, desktopClaudeCodeDirs } from "./claude-auth.js";
24
24
  import { openBrowser } from "./browser.js";
25
- import { installTrayRuntime, startTray } from "./tray.js";
25
+ import { electronPath, installTrayRuntime, startTray } from "./tray.js";
26
+ import { installKind, isNewer, latestVersion, PACKAGE, runUpdate } from "./update.js";
26
27
  import { ClaudeOAuthSession } from "../../router/src/providers/claude-oauth.js";
27
28
  function setPickerEnabled(enabled) {
28
29
  const file = configPath();
@@ -119,6 +120,55 @@ function starterConfig(port) {
119
120
  log: DEFAULTS.log,
120
121
  }, null, 2) + "\n";
121
122
  }
123
+ const IMAGE_USAGE = 'usage: clauderipple image "<prompt>" [-o FILE] [--aspect square|landscape|portrait] [--format png|jpeg|webp] [--transparent] [--ref FILE ...]';
124
+ /**
125
+ * One image through the running router's ChatGPT subscription (`POST /api/image`), written to a
126
+ * file whose path is printed — so a worker with nothing but Bash can make and then look at one.
127
+ */
128
+ async function image() {
129
+ const prompt = args[1];
130
+ if (!prompt || prompt.startsWith("-"))
131
+ throw new Error(IMAGE_USAGE);
132
+ const images = args.flatMap((a, i) => (a === "--ref" && args[i + 1] ? [args[i + 1]] : [])).map((file) => {
133
+ const ext = path.extname(file).slice(1).toLowerCase();
134
+ return `data:image/${ext === "jpg" ? "jpeg" : ext};base64,${fs.readFileSync(file).toString("base64")}`;
135
+ });
136
+ const format = opt("format") ?? "png";
137
+ const out = path.resolve(opt("o") ?? `image-${new Date().toISOString().replace(/[:.]/g, "-")}.${format === "jpeg" ? "jpg" : format}`);
138
+ const body = {
139
+ prompt,
140
+ format,
141
+ ...(opt("aspect") ? { aspect: opt("aspect") } : {}),
142
+ ...(flag("transparent") ? { background: "transparent" } : {}),
143
+ ...(images.length ? { images } : {}),
144
+ };
145
+ const port = adminPort(new ConfigStore(configPath()).get());
146
+ let res;
147
+ try {
148
+ res = await fetch(`http://127.0.0.1:${port}/api/image`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
149
+ }
150
+ catch {
151
+ throw new Error(`the router is not answering on 127.0.0.1:${port}; start it with: clauderipple start`);
152
+ }
153
+ if (!res.ok) {
154
+ const answer = (await res.json().catch(() => ({})));
155
+ throw new Error(answer.error ?? `HTTP ${res.status}`);
156
+ }
157
+ fs.mkdirSync(path.dirname(out), { recursive: true });
158
+ fs.writeFileSync(out, Buffer.from(await res.arrayBuffer()));
159
+ console.log(out);
160
+ }
161
+ /** The version the router on this admin port reports, or null when it does not answer. */
162
+ async function runningVersion(port) {
163
+ try {
164
+ const res = await fetch(`http://127.0.0.1:${port}/api/status`, { signal: AbortSignal.timeout(10_000) });
165
+ const version = res.ok ? (await res.json()).version : null;
166
+ return typeof version === "string" ? version : null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
122
172
  function proxyUrlFor(port) {
123
173
  return `http://127.0.0.1:${port}`;
124
174
  }
@@ -185,6 +235,16 @@ async function install() {
185
235
  // its logon trigger, so the router would only appear after the next sign-in. Start it either way.
186
236
  const started = startAgent();
187
237
  console.log(started === "failed" ? `✗ could not start the router (${supervisorName()})` : `✓ router ${started === "already-running" ? "already running" : "started"}`);
238
+ // Running the installer again is how an update arrives, and it replaces files, not the process:
239
+ // a router left alone keeps serving the old code until the next login. Restart one that
240
+ // answers with another version.
241
+ if (started === "already-running") {
242
+ const running = await runningVersion(adminPort(cfg));
243
+ if (running && running !== VERSION) {
244
+ console.log(` the running router is ${running}; restarting it on ${VERSION}`);
245
+ console.log(describeRestart(restartAgent({ onProgress: (m) => console.log(` ${m}`) })));
246
+ }
247
+ }
188
248
  const p = await probeWithRetry({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
189
249
  console.log(p.ok ? `✓ end-to-end probe passed (${p.detail}, ${p.ms}ms)` : `✗ probe failed: ${p.detail}`);
190
250
  if (!p.ok) {
@@ -342,6 +402,46 @@ function logs() {
342
402
  offset = size;
343
403
  });
344
404
  }
405
+ /**
406
+ * `update --check [--json]` reports; `update` installs the latest version the way this copy was
407
+ * installed (update.ts) and fetches the tray's Electron again if the old copy had it.
408
+ */
409
+ async function update() {
410
+ const kind = installKind(installedRuntime);
411
+ // CLAUDERIPPLE_PACKAGE names what to install instead of the registry's latest (a tarball, say).
412
+ const override = process.env.CLAUDERIPPLE_PACKAGE;
413
+ const latest = override ? null : await latestVersion();
414
+ const newer = latest ? isNewer(latest, VERSION) : true;
415
+ if (flag("check")) {
416
+ if (flag("json"))
417
+ console.log(JSON.stringify({ current: VERSION, latest, newer, kind: kind.kind }));
418
+ else
419
+ console.log(newer ? `ClaudeRipple ${latest} is available (this is ${VERSION}). Update with: clauderipple update` : `ClaudeRipple ${VERSION} is the latest version`);
420
+ return;
421
+ }
422
+ if (kind.kind !== "script" && kind.kind !== "npm") {
423
+ process.exitCode = 1;
424
+ console.log(kind.kind === "packaged" ? "This is the standalone app. Download the new version from https://github.com/PBJ-2/clauderipple/releases"
425
+ : kind.kind === "checkout" ? "This is a source checkout. Update it with git pull."
426
+ : `ClaudeRipple is installed at ${kind.root}, which is not a global npm install. Update it the way you installed it.`);
427
+ return;
428
+ }
429
+ if (!newer && !flag("force")) {
430
+ console.log(`✓ ClaudeRipple ${VERSION} is the latest version`);
431
+ return;
432
+ }
433
+ const hadTray = electronPath() !== null;
434
+ const spec = override ?? `${PACKAGE}@${latest}`;
435
+ console.log(`Updating ClaudeRipple ${VERSION} → ${latest ?? spec} (${kind.kind === "script" ? "install script" : "npm"}, ${kind.prefix})…`);
436
+ const result = runUpdate(kind, installedRuntime, spec);
437
+ if (result.ok && hadTray) {
438
+ // The new files decide where Electron lives; ask them, not this process's copy.
439
+ execFileSync(installedRuntime.node, [installedRuntime.cli, "tray", "--install"], { env: { ...process.env, ...installedRuntime.env }, stdio: "inherit" });
440
+ }
441
+ console.log(result.message);
442
+ if (!result.ok)
443
+ process.exitCode = 1;
444
+ }
345
445
  function help() {
346
446
  console.log(`clauderipple <command>
347
447
 
@@ -353,10 +453,13 @@ function help() {
353
453
  config print the config file path
354
454
  ui open the dashboard in your browser
355
455
  tray [--install] start the menu-bar / tray app (--install fetches Electron, ~270MB, once)
456
+ update [--check] install the latest version the way this one was installed (--check only reports)
356
457
  login add a ChatGPT account (opens your browser; run again to add another — they take turns when one runs out)
357
458
  logout forget every ChatGPT account added with "login" (the Codex CLI's own login is left alone)
358
459
  claude-login connect a Claude subscription in the browser (--setup-token: via \`claude setup-token\`; --manual: paste the code)
359
460
  claude-logout remove every Claude subscription added to ClaudeRipple
461
+ image "<prompt>" [-o FILE] [--aspect square|landscape|portrait] [--transparent] [--ref FILE ...]
462
+ generate an image with your ChatGPT subscription and print the file's path
360
463
  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)
361
464
  codex on|off add/remove ClaudeRipple's local OpenAI provider and selection profile for Codex CLI
362
465
  agent-title on|off|status
@@ -489,9 +592,15 @@ try {
489
592
  case "claude-logout":
490
593
  console.log(claudeLogout(homeDir()) ? "✓ ClaudeRipple Claude accounts removed" : "no ClaudeRipple Claude accounts stored");
491
594
  break;
595
+ case "image":
596
+ await image();
597
+ break;
492
598
  case "ui":
493
599
  ui();
494
600
  break;
601
+ case "update":
602
+ await update();
603
+ break;
495
604
  case "tray": {
496
605
  const result = args.includes("--install") ? installTrayRuntime() : startTray();
497
606
  console.log(result.message);
@@ -9,21 +9,34 @@ import { createRequire } from "node:module";
9
9
  import { spawn, spawnSync } from "node:child_process";
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
+ import { homeDir } from "../../router/src/config.js";
12
13
  import { runtime } from "./runtime.js";
13
14
  /** Electron version to fetch on request. Kept in step with the one the app is developed against. */
14
15
  const ELECTRON_RANGE = "^38.1.0";
15
- /** The Electron executable installed alongside us, or null when the optional dependency is absent. */
16
- export function electronPath() {
16
+ /**
17
+ * Where Electron is fetched to: ClaudeRipple's home, not the package. Until 0.6 it went into the
18
+ * installed package's own node_modules, and every update replaced that folder — measured
19
+ * 2026-09-28, `npm install -g` of another version left no trace of it, so the tray was gone after
20
+ * each update, and on Windows a running tray held electron.exe inside the folder being replaced.
21
+ */
22
+ export function trayRuntimeDir() {
23
+ return path.join(homeDir(), "tray-runtime");
24
+ }
25
+ /** The electron package exports the path to its binary as its module value. */
26
+ function resolveElectron(from) {
17
27
  try {
18
- const require = createRequire(import.meta.url);
19
- // The electron package exports the path to its binary as its module value.
20
- const value = require("electron");
28
+ const value = createRequire(from)("electron");
21
29
  return typeof value === "string" && fs.existsSync(value) ? value : null;
22
30
  }
23
31
  catch {
24
32
  return null;
25
33
  }
26
34
  }
35
+ /** The Electron executable to run the tray with, or null when none has been fetched. */
36
+ export function electronPath() {
37
+ // A checkout or an install from before 0.6 resolves it next to us.
38
+ return resolveElectron(path.join(trayRuntimeDir(), "package.json")) ?? resolveElectron(import.meta.url);
39
+ }
27
40
  /**
28
41
  * Our environment without ELECTRON_RUN_AS_NODE, so Electron starts as the tray rather than as a
29
42
  * plain Node process. The variable has to be removed, not emptied: macOS Electron reads an empty
@@ -87,8 +100,9 @@ export function installTrayRuntime() {
87
100
  const cli = npmCli();
88
101
  if (!cli)
89
102
  return { ok: false, message: `found no npm next to ${process.execPath}; install Electron yourself: npm install electron` };
90
- const target = runtime().repo;
91
- const result = spawnSync(process.execPath, [cli, "install", "--no-save", "--loglevel", "error", `electron@${ELECTRON_RANGE}`], {
103
+ const target = trayRuntimeDir();
104
+ fs.mkdirSync(target, { recursive: true });
105
+ const result = spawnSync(process.execPath, [cli, "install", "--prefix", target, "--no-package-lock", "--loglevel", "error", `electron@${ELECTRON_RANGE}`], {
92
106
  cwd: target,
93
107
  stdio: "inherit",
94
108
  });