clauderipple 0.6.0 → 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,20 @@
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
+
3
18
  ## 0.6.0 — 2026-09-28
4
19
 
5
20
  Both suggested in #8 by @artisthanbohee-del.
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
 
@@ -310,7 +321,7 @@ node packages/cli/src/index.ts ui # 브라우저에서 로컬 GUI 열기
310
321
 
311
322
  `uninstall`은 전부 되돌리고 `~/.claude/settings.json`을 백업에서 복원합니다. 그 밖의 명령: `status`, `start`, `stop`,
312
323
  `restart`, `logs -f`, `login`, `logout`, `claude-login`, `claude-logout`, `picker on|off`, `agent-title on|off`,
313
- `codex on|off`.
324
+ `codex on|off`, `update`, `image "<프롬프트>"`.
314
325
  </details>
315
326
 
316
327
  ## 클라이언트
@@ -355,6 +366,22 @@ model: gpt-5.6-sol@medium
355
366
  알 수 없는 에러로 죽게 두지 않고 이름을 대고 거부합니다(`400 ClaudeRipple: no provider declares "…"`).
356
367
  Claude 모델은 언제나 그대로 통과합니다. 생성을 끄려면 `"cli": { "agentFiles": false }`.
357
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
+
358
385
  Claude Code의 `WebSearch`는 작은 모델에 별도 요청을 보냅니다. ChatGPT로 라우팅한 구성에서 그 요청만
359
386
  Anthropic 한도를 쓰지 않게 하려면 `config.json`에서 같은 ChatGPT 프로바이더를 검색 백엔드로 지정합니다:
360
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
@@ -360,7 +375,7 @@ node packages/cli/src/index.ts ui # open the local GUI in your browser
360
375
  `uninstall` reverses everything and restores `~/.claude/settings.json` from a backup.
361
376
  Other commands: `status`, `start`, `stop`, `restart`, `logs -f`, `login`, `logout`,
362
377
  `claude-login`, `claude-logout`, `picker on|off`, `agent-title on|off`,
363
- `codex on|off`.
378
+ `codex on|off`, `update`, `image "<prompt>"`.
364
379
  </details>
365
380
 
366
381
  ## Clients
@@ -413,6 +428,27 @@ by name (`400 ClaudeRipple: no provider declares "…"`) instead of being sent o
413
428
  to fail somewhere less legible; native Claude models always pass through.
414
429
  Set `"cli": { "agentFiles": false }` to turn generation off.
415
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
+
416
452
  Claude Code runs `WebSearch` as a separate small-model request. To keep a
417
453
  ChatGPT-routed setup from spending Anthropic quota for that request, select the
418
454
  same ChatGPT provider as the search backend in `config.json`:
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "commonjs"
5
5
  }
@@ -120,6 +120,44 @@ function starterConfig(port) {
120
120
  log: DEFAULTS.log,
121
121
  }, null, 2) + "\n";
122
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
+ }
123
161
  /** The version the router on this admin port reports, or null when it does not answer. */
124
162
  async function runningVersion(port) {
125
163
  try {
@@ -420,6 +458,8 @@ function help() {
420
458
  logout forget every ChatGPT account added with "login" (the Codex CLI's own login is left alone)
421
459
  claude-login connect a Claude subscription in the browser (--setup-token: via \`claude setup-token\`; --manual: paste the code)
422
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
423
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)
424
464
  codex on|off add/remove ClaudeRipple's local OpenAI provider and selection profile for Codex CLI
425
465
  agent-title on|off|status
@@ -552,6 +592,9 @@ try {
552
592
  case "claude-logout":
553
593
  console.log(claudeLogout(homeDir()) ? "✓ ClaudeRipple Claude accounts removed" : "no ClaudeRipple Claude accounts stored");
554
594
  break;
595
+ case "image":
596
+ await image();
597
+ break;
555
598
  case "ui":
556
599
  ui();
557
600
  break;
@@ -33,7 +33,10 @@ import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
33
33
  import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
34
34
  import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
35
35
  import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
36
+ import { IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
36
37
  const MAX_BODY = 1024 * 1024;
38
+ /** `/api/image` carries reference images inline, so it gets more room than a config save. */
39
+ const MAX_IMAGE_BODY = 32 * 1024 * 1024;
37
40
  const here = path.dirname(fileURLToPath(import.meta.url));
38
41
  const STARTED_AT = new Date().toISOString();
39
42
  // packages/router/src → packages/ui in a checkout; dist/router/src → dist/ui in an npm install,
@@ -444,13 +447,51 @@ function syncModelSlotsForGui(cfg) {
444
447
  return `model slots were not written to settings.json: ${message}`;
445
448
  }
446
449
  }
447
- function readBody(req) {
450
+ /**
451
+ * The body of `POST /api/image`, checked: `{prompt, aspect?, format?, background?, images?}`,
452
+ * `images` being `data:image/...;base64,` URLs. A string is the reason it was refused.
453
+ */
454
+ export function parseImageRequest(raw) {
455
+ if (!raw || typeof raw !== "object")
456
+ return "expected a JSON object";
457
+ const body = raw;
458
+ if (typeof body.prompt !== "string" || !body.prompt.trim())
459
+ return "prompt is required";
460
+ const out = { prompt: body.prompt };
461
+ const pick = (key, allowed) => {
462
+ const value = body[key];
463
+ if (value === undefined)
464
+ return null;
465
+ if (typeof value !== "string" || !allowed.includes(value))
466
+ return `${key} must be one of ${allowed.join(", ")}`;
467
+ out[key] = value;
468
+ return null;
469
+ };
470
+ const refused = pick("aspect", IMAGE_ASPECTS) ?? pick("format", IMAGE_FORMATS) ?? pick("background", IMAGE_BACKGROUNDS);
471
+ if (refused)
472
+ return refused;
473
+ if (body.images !== undefined) {
474
+ if (!Array.isArray(body.images))
475
+ return "images must be a list of data: URLs";
476
+ const images = [];
477
+ for (const url of body.images) {
478
+ const m = typeof url === "string" ? /^data:(image\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/i.exec(url) : null;
479
+ if (!m)
480
+ return "each image must be a data:image/...;base64, URL";
481
+ images.push({ mediaType: m[1].toLowerCase(), data: m[2].replace(/\s/g, "") });
482
+ }
483
+ if (images.length)
484
+ out.images = images;
485
+ }
486
+ return out;
487
+ }
488
+ function readBody(req, limit = MAX_BODY) {
448
489
  return new Promise((resolveP, reject) => {
449
490
  const chunks = [];
450
491
  let n = 0;
451
492
  req.on("data", (c) => {
452
493
  n += c.length;
453
- if (n > MAX_BODY) {
494
+ if (n > limit) {
454
495
  reject(new Error("body too large"));
455
496
  req.destroy();
456
497
  return;
@@ -1409,6 +1450,49 @@ export function startAdmin(deps) {
1409
1450
  sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1410
1451
  return;
1411
1452
  }
1453
+ if (pathname === "/api/image" && method === "POST") {
1454
+ // The image itself is the response body, so `curl -o out.png` is the whole client. An error
1455
+ // is JSON with a non-2xx status (`curl --fail-with-body` writes it to the same file).
1456
+ if (!deps.image) {
1457
+ sendJson(res, 501, { error: "image generation not available" });
1458
+ return;
1459
+ }
1460
+ let parsed;
1461
+ try {
1462
+ parsed = parseImageRequest(JSON.parse((await readBody(req, MAX_IMAGE_BODY)).toString("utf8")));
1463
+ }
1464
+ catch (e) {
1465
+ sendJson(res, 400, { error: /too large/.test(e.message) ? "body too large (reference images over 32 MB)" : "invalid JSON" });
1466
+ return;
1467
+ }
1468
+ if (typeof parsed === "string") {
1469
+ sendJson(res, 400, { error: parsed });
1470
+ return;
1471
+ }
1472
+ // A caller that gives up should not leave the generation running on the subscription.
1473
+ const abort = new AbortController();
1474
+ res.on("close", () => { if (!res.writableFinished)
1475
+ abort.abort(); });
1476
+ const started = Date.now();
1477
+ try {
1478
+ const image = await deps.image(parsed, abort.signal);
1479
+ deps.log.info(`admin: image ${image.size ?? "?"} ${image.quality ?? "?"} ${image.format}, ${parsed.images?.length ?? 0} reference(s), ${image.data.length} bytes in ${Date.now() - started} ms`);
1480
+ res.writeHead(200, {
1481
+ "content-type": `image/${image.format}`,
1482
+ "content-length": String(image.data.length),
1483
+ ...(image.size ? { "x-image-size": image.size } : {}),
1484
+ ...(image.quality ? { "x-image-quality": image.quality } : {}),
1485
+ ...(image.revisedPrompt ? { "x-revised-prompt": encodeURIComponent(image.revisedPrompt) } : {}),
1486
+ });
1487
+ res.end(image.data);
1488
+ }
1489
+ catch (e) {
1490
+ deps.log.warn(`admin: image failed after ${Date.now() - started} ms: ${e.message}`);
1491
+ if (!res.headersSent)
1492
+ sendJson(res, 502, { error: e.message });
1493
+ }
1494
+ return;
1495
+ }
1412
1496
  if (pathname === "/api/agent-title" && method === "POST") {
1413
1497
  // Registers/removes the PreToolUse hook that prefixes subagent titles with the real model.
1414
1498
  let enabled;
@@ -14,6 +14,7 @@ import os from "node:os";
14
14
  import path from "node:path";
15
15
  import { homeDir } from "./config.js";
16
16
  import { declaredBy } from "./routing.js";
17
+ import { adminPort } from "./admin.js";
17
18
  /** Where Claude Code's Agent tool reads its worker definitions. */
18
19
  export function defaultAgentDir() {
19
20
  return path.join(os.homedir(), ".claude", "agents");
@@ -34,6 +35,27 @@ const BODY = [
34
35
  "**작업 디렉터리(cwd)에 어떤 파일도 만들지 않는다.** 조사·감사처럼 \"읽기만\" 하는 일이어도 마찬가지다.",
35
36
  "임시 파일이 필요하면 `/tmp` 아래에만 만들고, 끝나면 지운다. 사본을 작업 폴더에 떨구지 마라.",
36
37
  ].join("\n");
38
+ /**
39
+ * Added to the body when a ChatGPT provider is configured: how a worker makes an image. A worker has
40
+ * no image tool, and `clauderipple` is not on PATH for everyone (a checkout runs from source), so
41
+ * the instruction is the admin endpoint itself, which curl reaches on every platform.
42
+ */
43
+ function imageLine(port) {
44
+ return [
45
+ "이미지가 필요하면 ChatGPT 구독으로 만든다(한 장 30초~수 분):",
46
+ `\`curl -sS --fail-with-body -o <절대경로>.png -H 'content-type: application/json' -d '{"prompt":"…"}' http://127.0.0.1:${port}/api/image\``,
47
+ "선택 키: aspect(square·landscape·portrait), background(transparent), format(png·jpeg·webp), images(참고 이미지 data: URL 목록). 해상도·품질은 지정할 수 없다. 실패하면 그 파일에 오류 JSON이 담긴다. 만든 뒤 Read로 확인한다.",
48
+ ].join("\n");
49
+ }
50
+ /**
51
+ * The tools a generated worker is given when `cli.limitWorkerTools` is on. Without a `tools:` line a
52
+ * subagent inherits every tool of the session — each MCP server, plugin agent and deferred-tool name —
53
+ * and a general worker's first request measured 43,598 tokens (2026-09-20), resent on every turn;
54
+ * limited, a deepseek worker's first request fell from 32,942 to 8,823 (2026-09-29). A worker reads,
55
+ * edits, searches, runs commands and uses skills; it does not drive browsers, simulators or mail.
56
+ * Off by default: a user whose workers do need those tools would otherwise lose them silently.
57
+ */
58
+ const TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "WebSearch", "Skill"];
37
59
  /**
38
60
  * Parsed `*.md` frontmatter per directory, keyed by file mtime so a request never re-reads the whole
39
61
  * directory. Only the `---` block's own `key: value` lines are read: no YAML library, because the
@@ -134,10 +156,12 @@ function effortSuffix(provider, modelId) {
134
156
  const levels = modelLevels !== undefined ? modelLevels : capsEffortLevels(provider);
135
157
  return levels?.includes("medium") ? "@medium" : "";
136
158
  }
137
- function fileContent(name, modelId, provider, suffix) {
159
+ function fileContent(name, modelId, provider, suffix, limitTools, imagePort) {
138
160
  const description = `${modelId} via ${provider}. Generated by ClaudeRipple from config.json — edits are overwritten; ` +
139
161
  `to customise, copy to another name. Set effort with [[ripple: ${name}@<level>]] on the first line.`;
140
- return `---\nname: ${name}\ndescription: ${description}\nmodel: ${modelId}${suffix}\n---\n${BODY}\n`;
162
+ const tools = limitTools ? `tools: ${TOOLS.join(", ")}\n` : "";
163
+ const body = imagePort === null ? BODY : `${BODY}\n${imageLine(imagePort)}`;
164
+ return `---\nname: ${name}\ndescription: ${description}\nmodel: ${modelId}${suffix}\n${tools}---\n${body}\n`;
141
165
  }
142
166
  function readManifest(file) {
143
167
  try {
@@ -199,6 +223,7 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
199
223
  targets.set(name, { modelId: model.id, provider: providerName, suffix: effortSuffix(provider, model.id) });
200
224
  }
201
225
  }
226
+ const imagePort = Object.values(cfg.providers).some((p) => p.type === "chatgpt") ? adminPort(cfg) : null;
202
227
  const nextOwned = [];
203
228
  for (const [name, t] of targets) {
204
229
  if (handNames.has(name)) {
@@ -206,7 +231,7 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
206
231
  continue;
207
232
  }
208
233
  const file = path.join(dir, `${name}.md`);
209
- const content = fileContent(name, t.modelId, t.provider, t.suffix);
234
+ const content = fileContent(name, t.modelId, t.provider, t.suffix, cfg.cli.limitWorkerTools === true, imagePort);
210
235
  const existing = scanned.get(`${name}.md`);
211
236
  if (existing) {
212
237
  let current = null;
@@ -381,6 +381,8 @@ export function validate(c) {
381
381
  errors.push("listen.port out of range");
382
382
  if (c.cli.agentFiles !== undefined && typeof c.cli.agentFiles !== "boolean")
383
383
  errors.push("cli.agentFiles must be true or false");
384
+ if (c.cli.limitWorkerTools !== undefined && typeof c.cli.limitWorkerTools !== "boolean")
385
+ errors.push("cli.limitWorkerTools must be true or false");
384
386
  if (c.listen.openaiPort !== undefined && !(c.listen.openaiPort >= 0 && c.listen.openaiPort < 65536))
385
387
  errors.push("listen.openaiPort out of range");
386
388
  return errors;
@@ -154,6 +154,7 @@ proxy
154
154
  picker: () => ({ enabled: !!store.get().picker?.enabled, hosts: terminateHosts(store.get()).slice(1), last: proxy.lastPickerInjection }),
155
155
  observedClaudeCodeAuth,
156
156
  claudeUsage: () => proxy.claudeUsage.snapshot(),
157
+ image: (req, signal) => proxy.generateImage(req, signal),
157
158
  shutdown: () => beginDrain("shutdown requested"),
158
159
  });
159
160
  log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
@@ -24,6 +24,17 @@ const USAGE_TIMEOUT_MS = 10_000;
24
24
  const MODELS_PATH = "/codex/models";
25
25
  const MODELS_TIMEOUT_MS = 15_000;
26
26
  const MODELS_CACHE_MS = 60 * 60 * 1000;
27
+ /** One image took about 30 s at low quality (2026-09-29); high quality and references take longer. */
28
+ const IMAGE_TIMEOUT_MS = 5 * 60 * 1000;
29
+ /**
30
+ * The shape, asked for in words. The subscription backend accepts the tool's `size` and `quality`
31
+ * and ignores them — even "999x1" and "bogus" came back 200 as a 1536x1024 image (2026-09-29) —
32
+ * while the prompt steers it: "square composition" gave 1254x1254. So the shape is a prompt hint,
33
+ * and quality is the backend's to choose.
34
+ */
35
+ export const IMAGE_ASPECTS = ["square", "landscape", "portrait"];
36
+ export const IMAGE_FORMATS = ["png", "jpeg", "webp"];
37
+ export const IMAGE_BACKGROUNDS = ["auto", "transparent", "opaque"];
27
38
  function anthropicError(status, type, message) {
28
39
  return { status, body: JSON.stringify({ type: "error", error: { type, message } }) };
29
40
  }
@@ -394,6 +405,107 @@ export class ChatGptAdapter {
394
405
  const prose = text.trim();
395
406
  return prose ? { hits: selected, text: prose } : { hits: selected };
396
407
  }
408
+ /**
409
+ * Hosted image generation, the same way `searchWeb` runs a hosted search: one Responses turn with
410
+ * the `image_generation` tool required. Wire measured 2026-09-29 against the live backend with a
411
+ * ChatGPT subscription: an `image_generation_call` output item whose `result` is the base64 image,
412
+ * beside the `size`, `quality` and `output_format` it chose and a `revised_prompt`; about 30 s.
413
+ */
414
+ async generateImage(model, req, signal) {
415
+ const tokens = await this.anyCredential();
416
+ if (tokens instanceof Error)
417
+ throw tokens;
418
+ const id = crypto.randomUUID();
419
+ const tool = {
420
+ type: "image_generation",
421
+ output_format: req.format ?? "png",
422
+ ...(req.background ? { background: req.background } : {}),
423
+ };
424
+ const prompt = req.aspect ? `${req.prompt}\n\nCompose it as a ${req.aspect} image.` : req.prompt;
425
+ const content = [{ type: "input_text", text: prompt }];
426
+ for (const image of req.images ?? [])
427
+ content.push({ type: "input_image", image_url: `data:${image.mediaType};base64,${image.data}` });
428
+ const body = {
429
+ model,
430
+ instructions: "Generate the requested image. Use any attached images as references.",
431
+ input: [{ type: "message", role: "user", content }],
432
+ tools: [tool],
433
+ tool_choice: "required",
434
+ reasoning: { effort: "low", summary: "auto" },
435
+ store: false,
436
+ stream: true,
437
+ prompt_cache_key: id,
438
+ client_metadata: { session_id: id, thread_id: id, turn_id: crypto.randomUUID(), "x-codex-window-id": `${id}:0` },
439
+ };
440
+ const timeout = AbortSignal.timeout(IMAGE_TIMEOUT_MS);
441
+ const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
442
+ const res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex/responses`, {
443
+ method: "POST",
444
+ headers: {
445
+ "content-type": "application/json",
446
+ accept: "text/event-stream",
447
+ authorization: `Bearer ${tokens.accessToken}`,
448
+ "chatgpt-account-id": tokens.accountId,
449
+ "OpenAI-Beta": "responses=experimental",
450
+ originator: "codex_cli_rs",
451
+ "session-id": id,
452
+ "thread-id": id,
453
+ "x-client-request-id": id,
454
+ "x-codex-window-id": `${id}:0`,
455
+ },
456
+ body: JSON.stringify(body),
457
+ signal: requestSignal,
458
+ });
459
+ this.noteRateLimits(tokens, rateLimitsFromHeaders(res.headers));
460
+ if (!res.ok || !res.body) {
461
+ const text = await res.text().catch(() => "");
462
+ if (res.status === 401)
463
+ void this.accounts.forceRefresh(tokens.ownerId);
464
+ throw new Error(`ChatGPT image generation: HTTP ${res.status}${text ? ` ${redactErrorText(text, [tokens.accessToken], 200)}` : ""}`);
465
+ }
466
+ const parser = new SseParser();
467
+ const reader = res.body.getReader();
468
+ const decoder = new TextDecoder();
469
+ let image;
470
+ let text = "";
471
+ try {
472
+ for (;;) {
473
+ const { done, value } = await reader.read();
474
+ if (done)
475
+ break;
476
+ for (const event of parser.feed(decoder.decode(value, { stream: true }))) {
477
+ if (event.type === "response.output_text.delta")
478
+ text += String(event.delta ?? "");
479
+ if (event.type === "response.output_item.done") {
480
+ const item = event.item;
481
+ if (item?.type === "image_generation_call" && typeof item.result === "string" && item.result)
482
+ image = item;
483
+ }
484
+ if (event.type === "error" || event.type === "response.failed") {
485
+ const error = (event.error ?? event.response?.error);
486
+ throw new Error(`ChatGPT image generation: ${String(error?.message ?? "backend error")}`);
487
+ }
488
+ }
489
+ }
490
+ }
491
+ finally {
492
+ try {
493
+ await reader.cancel();
494
+ }
495
+ catch { /* already closed */ }
496
+ }
497
+ // A refusal comes back as prose and no image; that prose is the only explanation there is.
498
+ if (!image)
499
+ throw new Error(`ChatGPT image generation: no image returned${text.trim() ? `: ${text.trim().slice(0, 300)}` : ""}`);
500
+ const str = (v) => (typeof v === "string" && v ? v : undefined);
501
+ return {
502
+ data: Buffer.from(image.result, "base64"),
503
+ format: str(image.output_format) ?? req.format ?? "png",
504
+ ...(str(image.size) ? { size: str(image.size) } : {}),
505
+ ...(str(image.quality) ? { quality: str(image.quality) } : {}),
506
+ ...(str(image.revised_prompt) ? { revisedPrompt: str(image.revised_prompt) } : {}),
507
+ };
508
+ }
397
509
  /** One in-flight lookup shared by every caller (the status route and the startup refresh). */
398
510
  rateLimitsInFlight = null;
399
511
  /**
@@ -253,6 +253,20 @@ export class Proxy {
253
253
  return this.chatgpt(first[0], first[1]);
254
254
  return this.chatgpt("codex-login", { type: "chatgpt", auth: "borrow-codex" });
255
255
  }
256
+ /**
257
+ * One image for `/api/image`, on the first chatgpt provider and the first model it declares. The
258
+ * model only runs the tool call — the image comes from the backend's own image model — so which
259
+ * one it is barely matters.
260
+ */
261
+ generateImage(req, signal) {
262
+ const first = Object.entries(this.deps.config().providers).find(([, p]) => p.type === "chatgpt");
263
+ if (!first || first[1].type !== "chatgpt")
264
+ return Promise.reject(new Error("image generation needs a ChatGPT provider; add one in the dashboard"));
265
+ const model = first[1].models?.[0]?.id;
266
+ if (!model)
267
+ return Promise.reject(new Error(`provider ${first[0]} declares no models`));
268
+ return this.chatgpt(first[0], first[1]).generateImage(model, req, signal);
269
+ }
256
270
  /** Dashboard action: put one resting ChatGPT account back into rotation now. */
257
271
  chatgptClearCooldown(ownerId) {
258
272
  for (const [name, p] of Object.entries(this.deps.config().providers)) {
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.6.0";
8
+ export const VERSION = "0.7.0";
package/dist/ui/app.js CHANGED
@@ -377,6 +377,12 @@ function renderClients() {
377
377
  agentButton.className = agentEnabled ? "btn secondary" : "btn";
378
378
  agentButton.disabled = agentTitleBusy;
379
379
  agentButton.onclick = () => toggleAgentTitle(!agentEnabled);
380
+ const toolsLimited = Boolean(currentConfig.cli && currentConfig.cli.limitWorkerTools);
381
+ $("#client-worker-tools-rows").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), toolsLimited ? badge("ok", t("workerTools.on")) : el("span", { class: "small", text: t("workerTools.off") })]));
382
+ const toolsButton = $("#client-worker-tools-toggle");
383
+ toolsButton.textContent = toolsLimited ? t("workerTools.turnOff") : t("workerTools.turnOn");
384
+ toolsButton.className = toolsLimited ? "btn secondary" : "btn";
385
+ toolsButton.onclick = () => toggleWorkerTools(!toolsLimited);
380
386
  void renderCodexClient();
381
387
  const mapped = Object.entries(currentConfig.routes || {}).map(([source, route]) => `${labelOf(claudeModels.find((model) => model.id === source) || { id: source })} → ${labelOf((groupedModels(currentConfig).find((group) => group.name === route.provider) || { models: [] }).models.find((model) => model.id === route.model) || { id: route.model })}`);
382
388
  $("#client-claude-code-rows").replaceChildren(el("div", { class: "small", text: mapped.join(" · ") || t("slots.noChanges") }));
@@ -537,6 +543,17 @@ async function toggleAgentTitle(enabled) {
537
543
  void refreshHealth();
538
544
  }
539
545
  }
546
+ async function toggleWorkerTools(enabled) {
547
+ if (!currentConfig) return;
548
+ const next = clone(currentConfig);
549
+ next.cli = { ...(next.cli || {}), limitWorkerTools: enabled };
550
+ try {
551
+ await configRequest(next);
552
+ currentConfig = next;
553
+ toast(t("common.saved"));
554
+ } catch (error) { toast(t("common.saveFailed"), true, error.message); }
555
+ renderClients();
556
+ }
540
557
  async function renderCodexClient() {
541
558
  const rows = $("#client-codex-rows");
542
559
  const button = $("#client-codex-toggle");
package/dist/ui/i18n.js CHANGED
@@ -24,6 +24,12 @@ const I18N = {
24
24
  "agentTitle.off": "꺼짐",
25
25
  "agentTitle.turnOn": "이름표 켜기",
26
26
  "agentTitle.turnOff": "이름표 끄기",
27
+ "workerTools.title": "워커 도구 제한",
28
+ "workerTools.help": "ClaudeRipple이 만든 워커에 읽기·편집·명령 실행·검색·스킬 도구만 줍니다. 세션의 MCP 서버·플러그인 도구가 워커 요청마다 실리지 않아 입력이 크게 줄지만, 브라우저·iOS 시뮬레이터 같은 도구는 쓸 수 없습니다. 그런 도구가 필요한 워커는 ~/.claude/agents에 직접 만든 에이전트 파일로 두세요(ClaudeRipple은 그 파일을 건드리지 않습니다). 새 세션부터 적용됩니다.",
29
+ "workerTools.on": "켜짐",
30
+ "workerTools.off": "꺼짐 (세션 도구 전부 상속)",
31
+ "workerTools.turnOn": "제한 켜기",
32
+ "workerTools.turnOff": "제한 끄기",
27
33
  "providers.probeNoCredits": "키는 맞습니다. 다만 이 계정에 크레딧(잔액)이 없어 응답은 받지 못합니다. 프로바이더 사이트에서 충전하세요.",
28
34
  "providers.probeBadKey": "API 키가 거부됐습니다. 키를 다시 확인하세요.",
29
35
  "providers.searchModels": "모델 검색 (이름 또는 id)",
@@ -57,6 +63,12 @@ const I18N = {
57
63
  "agentTitle.off": "Off",
58
64
  "agentTitle.turnOn": "Turn on",
59
65
  "agentTitle.turnOff": "Turn off",
66
+ "workerTools.title": "Worker tools",
67
+ "workerTools.help": "Gives the workers ClaudeRipple generates only the read, edit, run, search and skill tools. The session's MCP servers and plugin tools no longer ride along on every worker request, which cuts input sharply, but those workers cannot use a browser or the iOS Simulator. A worker that needs them belongs in an agent file of your own in ~/.claude/agents, which ClaudeRipple never touches. Applies to new sessions.",
68
+ "workerTools.on": "On",
69
+ "workerTools.off": "Off (inherits every session tool)",
70
+ "workerTools.turnOn": "Limit tools",
71
+ "workerTools.turnOff": "Stop limiting",
60
72
  "providers.probeNoCredits": "The key is valid, but this account has no credits, so no answer came back. Top up on the provider site.",
61
73
  "providers.probeBadKey": "The API key was rejected. Check the key.",
62
74
  "providers.searchModels": "Search models (name or id)",
@@ -67,6 +67,11 @@
67
67
  <p class="hint" data-i18n="agentTitle.help"></p>
68
68
  <div class="rows" id="client-agent-title-rows"></div>
69
69
  <div class="actions"><button class="btn" id="client-agent-title-toggle" type="button"></button></div>
70
+ <div class="client-divider"></div>
71
+ <h3 data-i18n="workerTools.title">Worker tools</h3>
72
+ <p class="hint" data-i18n="workerTools.help"></p>
73
+ <div class="rows" id="client-worker-tools-rows"></div>
74
+ <div class="actions"><button class="btn" id="client-worker-tools-toggle" type="button"></button></div>
70
75
  </div>
71
76
  <div class="card">
72
77
  <h2 data-i18n="clients.codex.title">Codex CLI</h2>
@@ -166,7 +166,19 @@ chat is out of reach for every approach, ours included.
166
166
  records what it wrote in `<home>/generated-agents.json` and touches **only**
167
167
  those files: a hand-written agent of the same name wins and is left alone, an
168
168
  unticked model removes its generated file and nothing else, identical content is
169
- not rewritten. `cli.agentFiles: false` turns generation off. The marker alias
169
+ not rewritten. `cli.agentFiles: false` turns generation off;
170
+ `cli.limitWorkerTools: true` adds a `tools:` line so a worker does not inherit
171
+ every MCP and plugin tool of the session (first request 32,942 → 8,823 tokens,
172
+ 2026-09-29), at the cost of browser and simulator tools. It is opt-in for that
173
+ reason. With a chatgpt provider configured the body also tells a worker how to
174
+ make an image: `POST /api/image` on the admin port, which runs one Codex
175
+ Responses turn with the hosted `image_generation` tool required and answers
176
+ with the image bytes (`clauderipple image` is the same call). Measured
177
+ 2026-09-29 on a subscription: the image is the `image_generation_call` output
178
+ item's base64 `result`, about 30 s; `output_format` and `background` are
179
+ honoured, `size` and `quality` are accepted and ignored (even "999x1" and
180
+ "bogus" returned 200), and the shape follows the prompt ("square composition"
181
+ → 1254x1254). So the API takes `aspect` and says it in words. The marker alias
170
182
  table is the union of `aliases` (explicit, wins) and every agent file's
171
183
  `name → model` — so `[[ripple: <agent>@<effort>]]` resolves for any agent that
172
184
  exists, generated or not, with no second registry to keep in step. Why: on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Use GPT (ChatGPT Plus/Pro), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
5
5
  "license": "GPL-3.0-only",
6
6
  "workspaces": [