clauderipple 0.8.9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.es.md +0 -4
  3. package/README.ja.md +0 -4
  4. package/README.ko.md +5 -1
  5. package/README.md +5 -1
  6. package/README.pt-BR.md +0 -4
  7. package/README.zh-CN.md +0 -4
  8. package/dist/app/dist/main.js +23 -3
  9. package/dist/app/package.json +1 -1
  10. package/dist/cli/src/codex.js +3 -1
  11. package/dist/cli/src/index.js +1 -1
  12. package/dist/cli/src/settings.js +5 -2
  13. package/dist/cli/src/tray.js +41 -5
  14. package/dist/mod/.claude-plugin/plugin.json +9 -1
  15. package/dist/mod/hooks/cache.ts +60 -0
  16. package/dist/mod/hooks/compact.ts +40 -0
  17. package/dist/mod/hooks/handoff.ts +43 -0
  18. package/dist/mod/hooks/i18n.ts +2716 -0
  19. package/dist/mod/hooks/judge.ts +226 -0
  20. package/dist/mod/hooks/ladder.js +179 -0
  21. package/dist/mod/hooks/ladder.ts +174 -0
  22. package/dist/mod/hooks/modes.ts +93 -0
  23. package/dist/mod/hooks/register.tsx +1458 -256
  24. package/dist/mod/hooks/session.ts +62 -0
  25. package/dist/mod/hooks/settings.js +342 -0
  26. package/dist/mod/hooks/settings.ts +321 -0
  27. package/dist/mod/hooks/stats.ts +59 -0
  28. package/dist/mod/hooks/ui/agents.tsx +138 -0
  29. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  30. package/dist/mod/hooks/ui/art.ts +628 -0
  31. package/dist/mod/hooks/ui/band.tsx +836 -0
  32. package/dist/mod/hooks/ui/kit.tsx +258 -0
  33. package/dist/mod/hooks/ui/log.tsx +109 -0
  34. package/dist/mod/hooks/ui/parts.tsx +217 -0
  35. package/dist/mod/hooks/ui/ripple.ts +614 -0
  36. package/dist/mod/hooks/ui/settings.tsx +376 -0
  37. package/dist/mod/hooks/ui/setup.tsx +148 -0
  38. package/dist/mod/hooks/ui/theme.ts +217 -0
  39. package/dist/mod/hooks/ui/view.ts +371 -0
  40. package/dist/mod/hooks/worker.ts +1 -1
  41. package/dist/mod/hooks/workers.ts +217 -0
  42. package/dist/mod/tests/register.test.tsx +1730 -0
  43. package/dist/mod/tests/ui.test.tsx +853 -0
  44. package/dist/mod/tsconfig.json +2 -1
  45. package/dist/mod/types/index.d.ts +320 -1
  46. package/dist/router/src/admin.js +195 -53
  47. package/dist/router/src/agents.js +24 -5
  48. package/dist/router/src/bootstrap.js +4 -2
  49. package/dist/router/src/compat.js +10 -2
  50. package/dist/router/src/config.js +3 -0
  51. package/dist/router/src/health.js +33 -0
  52. package/dist/router/src/index.js +1 -0
  53. package/dist/router/src/ingress/models.js +4 -1
  54. package/dist/router/src/mod-settings.js +119 -0
  55. package/dist/router/src/model-policy.js +6 -0
  56. package/dist/router/src/model-scores.js +159 -0
  57. package/dist/router/src/picker.js +7 -0
  58. package/dist/router/src/providers/anthropic-account-pool.js +10 -4
  59. package/dist/router/src/providers/anthropic-accounts.js +26 -1
  60. package/dist/router/src/providers/chatgpt/catalog.js +45 -14
  61. package/dist/router/src/providers/chatgpt/index.js +9 -3
  62. package/dist/router/src/providers/chatgpt/translate.js +9 -5
  63. package/dist/router/src/providers/google/index.js +6 -2
  64. package/dist/router/src/providers/openai/index.js +4 -1
  65. package/dist/router/src/providers/openai/translate.js +1 -1
  66. package/dist/router/src/proxy.js +14 -3
  67. package/dist/router/src/version.js +1 -1
  68. package/dist/ui/app.js +932 -209
  69. package/dist/ui/favicon-32.png +0 -0
  70. package/dist/ui/favicon.svg +12 -0
  71. package/dist/ui/i18n.js +923 -237
  72. package/dist/ui/index.html +159 -87
  73. package/dist/ui/style.css +789 -408
  74. package/docs/ARCHITECTURE.md +134 -2
  75. package/package.json +7 -3
@@ -23,12 +23,20 @@ import path from "node:path";
23
23
  import http from "node:http";
24
24
  import { execFile, spawn } from "node:child_process";
25
25
  import { fileURLToPath } from "node:url";
26
- import { homeDir, searchProviderFor, validate } from "./config.js";
26
+ import { adminPort, homeDir, searchProviderFor, validate } from "./config.js";
27
+ export { adminPort } from "./config.js";
28
+ import { generatedAgents } from "./agents.js";
29
+ import { isJudgeOnlyModel } from "./model-policy.js";
30
+ import { ModSettingsInputError, readModSettings, validateSettings, validateSettingsSchema, writeModSettings } from "./mod-settings.js";
31
+ import { billingOf, claudeChoices, fillLadder, ladderNeedsFill } from "../../mod/hooks/ladder.js";
32
+ import { normalizeSettings } from "../../mod/hooks/settings.js";
33
+ import { chatPickerModels } from "./picker.js";
27
34
  import { PRESETS } from "./presets.js";
28
35
  import { resolveCompatibleCaps } from "./compat.js";
29
36
  import { CHATGPT_FALLBACK_MODELS } from "./providers/chatgpt/catalog.js";
30
37
  import { measureModel, refusedByPlan } from "./capabilities.js";
31
38
  import { catalogEntry } from "./catalog.js";
39
+ import { ModelScoreCatalog, buildModelScores } from "./model-scores.js";
32
40
  import { ClaudeCodeAuthStore, nativeAnthropicHeaders } from "./providers/anthropic.js";
33
41
  import { fetchClaudeModels, newClaudeReleases } from "./providers/anthropic-models.js";
34
42
  import { codexEnabled, codexHome } from "../../cli/src/codex.js";
@@ -39,9 +47,9 @@ import { desktopClaudeCodeDirs } from "../../cli/src/claude-auth.js";
39
47
  import { statusModEnabled, syncModelSlots } from "../../cli/src/settings.js";
40
48
  import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
41
49
  import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
42
- import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
50
+ import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount, setPreferredClaudeAccount } from "./providers/anthropic-accounts.js";
43
51
  import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
44
- import { IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
52
+ import { exhaustedForMs, IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
45
53
  import { ANTIGRAVITY_WARNING, staticAntigravityModels } from "./providers/google/antigravity.js";
46
54
  import { readGoogleAccounts, removeGoogleAccount, summarize as summarizeGoogleAccount, updateGoogleAccount } from "./providers/google/accounts.js";
47
55
  import { GoogleOAuthSession } from "./providers/google/login.js";
@@ -193,9 +201,6 @@ async function claudeModelList(deps) {
193
201
  const live = await deps.claudeModels?.().catch(() => null);
194
202
  return live?.length ? live.map(({ line: _line, createdAt: _createdAt, ...model }) => ({ ...model, name: model.name ?? model.id })) : CLAUDE_MODEL_FALLBACK;
195
203
  }
196
- export function adminPort(cfg) {
197
- return cfg.admin?.port ?? cfg.listen.port + 1;
198
- }
199
204
  function settingsPath() {
200
205
  return process.env.CLAUDE_SETTINGS_PATH ?? path.join(os.homedir(), ".claude", "settings.json");
201
206
  }
@@ -217,16 +222,6 @@ function readSettingsEnv() {
217
222
  return {};
218
223
  }
219
224
  }
220
- /** Whether ClaudeRipple's agent-title hook is registered in settings.json (see packages/cli/src/settings.ts). */
221
- function agentTitleHookEnabled() {
222
- try {
223
- const s = JSON.parse(fs.readFileSync(settingsPath(), "utf8"));
224
- return (s.hooks?.PreToolUse ?? []).some((e) => e._clauderipple === "agent-title");
225
- }
226
- catch {
227
- return false;
228
- }
229
- }
230
225
  /** Newest mtime of the served GUI files; the page reloads itself when this changes (an open window would otherwise run stale JS). */
231
226
  function uiRevision() {
232
227
  try {
@@ -297,8 +292,119 @@ export function googleHealth(accounts) {
297
292
  const inRotation = accounts.filter((a) => a.state !== "paused" && a.state !== "needs-login");
298
293
  return inRotation.length > 0 && inRotation.every((a) => a.state === "needs-verification") ? { needsLogin: false, needsVerification: true } : { needsLogin: false };
299
294
  }
295
+ /** Shared by status and mod options: refresh old quota, then read per-account rotation state. */
296
+ async function chatgptStatus(deps, opts = {}) {
297
+ const chatgpt = deps.chatgpt?.() ?? { quota: {}, auth: {} };
298
+ const staleReasons = {};
299
+ const now = Date.now();
300
+ for (const [name, q] of Object.entries(chatgpt.quota)) {
301
+ if (!chatgpt.refresh)
302
+ break;
303
+ const at = q && typeof q.at === "number" ? q.at : 0;
304
+ if (!opts.refresh && at && now - at < 10 * 60 * 1000)
305
+ continue;
306
+ const fresh = await chatgpt.refresh(name);
307
+ if (fresh)
308
+ chatgpt.quota[name] = fresh;
309
+ else
310
+ staleReasons[name] = opts.refresh ? "refresh requested but lookup failed" : "lookup failed; showing last known value";
311
+ }
312
+ const signedIn = {};
313
+ for (const [name, p] of Object.entries(deps.config().providers)) {
314
+ if (p.type === "chatgpt")
315
+ signedIn[name] = chatgptSignedIn(p.auth);
316
+ }
317
+ return {
318
+ quota: chatgpt.quota, auth: chatgpt.auth, signedIn, accounts: chatgpt.accounts?.() ?? {},
319
+ ...(Object.keys(staleReasons).length ? { stale: true, staleReason: staleReasons } : {}),
320
+ };
321
+ }
322
+ const MOD_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
323
+ const modEfforts = (levels) => (levels ?? []).filter((level) => MOD_EFFORTS.includes(level));
324
+ async function modOptions(deps, scores) {
325
+ const cfg = deps.config();
326
+ const chatgpt = await chatgptStatus(deps);
327
+ const google = deps.google?.().accounts() ?? {};
328
+ const credentials = deps.credentials?.() ?? {};
329
+ const health = deps.providerHealth?.() ?? {};
330
+ const providers = Object.entries(cfg.providers).map(([name, p]) => {
331
+ let usable = true;
332
+ if (p.type === "chatgpt") {
333
+ const accounts = chatgpt.accounts[name];
334
+ usable = accounts ? accounts.some(a => a.state === "ready" && !exhaustedForMs(a.quota))
335
+ : chatgpt.signedIn[name] === true && !exhaustedForMs(chatgpt.quota[name]);
336
+ }
337
+ else if (p.type === "google") {
338
+ usable = p.auth === "antigravity" ? (google[name] ?? []).some(a => a.state === "ready")
339
+ : !!(p.apiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY);
340
+ }
341
+ else if (p.type === "anthropic") {
342
+ usable = p.auth === "api-key" ? !!(p.apiKey || process.env.ANTHROPIC_API_KEY) : claudeAuthStore(deps).describeSource() !== null;
343
+ }
344
+ if (credentials[name])
345
+ usable = usable && credentials[name].some(c => c.state === "ready");
346
+ const exhausted = health[name];
347
+ return { name, type: p.type, usable: usable && !exhausted, billing: billingOf(p),
348
+ ...(exhausted ? { reason: exhausted.reason, exhaustedAt: exhausted.at, exhaustedUntil: exhausted.until } : {}), };
349
+ });
350
+ const models = Object.entries(cfg.providers).flatMap(([provider, p]) => (p.models ?? []).map(m => ({ model: m.id, provider, name: m.name ?? m.id, judgeOnly: isJudgeOnlyModel(m.id) })));
351
+ const zen = Object.values(cfg.providers).find(p => {
352
+ if (!p.models?.some(m => isJudgeOnlyModel(m.id)))
353
+ return false;
354
+ if ("preset" in p && p.preset === "opencode-zen")
355
+ return true;
356
+ if (!("url" in p) || !p.url)
357
+ return false;
358
+ const url = new URL(p.url);
359
+ return url.hostname === "opencode.ai" && /^\/zen\/v1(?:\/|$)/.test(url.pathname);
360
+ });
361
+ const model = zen?.models?.find(m => isJudgeOnlyModel(m.id))?.id ?? null;
362
+ const catalog = effortLevels(cfg).providers;
363
+ const agents = generatedAgents(cfg, deps.agentDir, undefined, deps.log).map(a => ({
364
+ ...a,
365
+ efforts: modEfforts(catalog[a.provider]?.models?.[a.model] ?? catalog[a.provider]?.default),
366
+ billing: billingOf(cfg.providers[a.provider], a.model),
367
+ }));
368
+ const scored = buildModelScores(cfg, await scores.get());
369
+ const claudeEfforts = modEfforts(catalog.anthropic?.default);
370
+ return { agents, models, providers, zen: { configured: model !== null, model }, scores: scored, claudeEfforts };
371
+ }
372
+ /**
373
+ * What the four workers can be filled from: the generated workers whose provider can run now, with
374
+ * their ladders, billing and cheapest measured cost, then Claude's own models (always there).
375
+ */
376
+ function ladderChoices(options) {
377
+ const usable = new Set(options.providers.filter(p => p.usable).map(p => p.name));
378
+ return [
379
+ ...options.agents.filter(a => usable.has(a.provider)).map(a => {
380
+ const costs = (options.scores.models.find(m => m.model === a.model && m.provider === a.provider)?.variants ?? []).flatMap(v => typeof v.costPerTask === "number" ? [v.costPerTask] : []);
381
+ return { target: { agent: a.name }, model: a.model, provider: a.provider, billing: a.billing, efforts: a.efforts, ...(costs.length ? { cost: Math.min(...costs) } : {}) };
382
+ }),
383
+ ...claudeChoices(options.claudeEfforts),
384
+ ];
385
+ }
386
+ /**
387
+ * Fill empty ordered lists in Base and saved modes from the same ticked, connected choices.
388
+ * Existing lists survive availability and allow-list changes; dispatch handles skips.
389
+ */
390
+ async function withFilledLadder(deps, scores, settings) {
391
+ const general = settings.rules.general;
392
+ if (![general, ...settings.modes].some(set => ladderNeedsFill(set.rungs)))
393
+ return settings;
394
+ try {
395
+ const choices = ladderChoices(await modOptions(deps, scores));
396
+ const { rungs } = fillLadder(general.rungs, settings.allow, choices);
397
+ const modes = settings.modes.map(mode => ({ ...mode, rungs: fillLadder(mode.rungs, settings.allow, choices).rungs }));
398
+ return { ...settings, rules: { general: { mode: "ladder", rungs } }, modes };
399
+ }
400
+ catch (error) {
401
+ deps.log.warn(`admin: filling the worker ladder failed: ${error.message}`);
402
+ return settings;
403
+ }
404
+ }
300
405
  async function buildStatus(deps, opts = {}) {
301
406
  const cfg = deps.config();
407
+ const health = deps.providerHealth?.() ?? {};
302
408
  const providers = {};
303
409
  // Keyed in the order the config lists them. Assigning inside the Promise.all callbacks ordered
304
410
  // them by whichever TCP check answered first, so the Health list reshuffled on every poll.
@@ -316,6 +422,8 @@ async function buildStatus(deps, opts = {}) {
316
422
  url,
317
423
  type: p.type,
318
424
  reachable,
425
+ usable: reachable && !health[name],
426
+ ...(health[name] ? { exhaustion: health[name] } : {}),
319
427
  // Whether the router could run a `WebSearch` side request through this provider, so the
320
428
  // Clients screen can say so beside a model name. Nothing on that screen said which ones
321
429
  // those are, and a `smallFast` slot pointed at one turns every search into a visible failure
@@ -348,35 +456,7 @@ async function buildStatus(deps, opts = {}) {
348
456
  }));
349
457
  for (const [name, entry] of checked)
350
458
  providers[name] = entry;
351
- const chatgpt = deps.chatgpt?.() ?? { quota: {}, auth: {} };
352
- // A snapshot from response headers ages the moment GPT traffic stops: measured 2026-09-20, the
353
- // status route showed 1% from 11 hours earlier while the account was at 40%. Past ten minutes,
354
- // ask the backend directly. The lookup shares one in-flight promise per provider, so concurrent
355
- // status polls (the GUI does one every 5s) collapse into a single request. A failed lookup keeps
356
- // the last good numbers and says why, rather than blanking the read the budget decision uses.
357
- const STALE_MS = 10 * 60 * 1000;
358
- const staleReasons = {};
359
- const refresh = deps.chatgpt?.().refresh;
360
- if (refresh) {
361
- const now = Date.now();
362
- for (const [name, q] of Object.entries(chatgpt.quota)) {
363
- const at = q && typeof q.at === "number" ? q.at : 0;
364
- if (!opts.refresh && at && now - at < STALE_MS)
365
- continue;
366
- const fresh = await refresh(name);
367
- if (fresh)
368
- chatgpt.quota[name] = fresh;
369
- else
370
- staleReasons[name] = opts.refresh ? "refresh requested but lookup failed" : "lookup failed; showing last known value";
371
- }
372
- }
373
- const stale = Object.keys(staleReasons).length > 0;
374
- const signedIn = {};
375
- for (const [name, p] of Object.entries(cfg.providers)) {
376
- if (p.type !== "chatgpt")
377
- continue;
378
- signedIn[name] = chatgptSignedIn(p.auth);
379
- }
459
+ const chatgpt = await chatgptStatus(deps, opts);
380
460
  const env = readSettingsEnv();
381
461
  const wantProxy = `http://127.0.0.1:${cfg.listen.port}`;
382
462
  const picker = deps.picker?.() ?? { enabled: false, hosts: [], last: null };
@@ -420,15 +500,14 @@ async function buildStatus(deps, opts = {}) {
420
500
  cliVersion: cliVersion(),
421
501
  // `quota` stays one snapshot per provider (the account in use) for the readers that want one
422
502
  // number; `accounts` is the per-account view, read after the refresh above so it is as fresh.
423
- chatgpt: { ...chatgpt, signedIn, accounts: chatgpt.accounts?.() ?? {}, ...(stale ? { stale: true, staleReason: staleReasons } : {}) },
503
+ chatgpt,
424
504
  ...(deps.claudeUsage && hasClaudeSubscription(cfg) ? { claude: { accounts: await claudeAccountUsage(deps) } } : {}),
425
505
  // Only present for providers that declare a pool; a provider with one credential has nothing
426
506
  // to report and would only add a row that never changes.
427
507
  credentials: deps.credentials?.() ?? {},
428
508
  picker,
429
- agentTitle: agentTitleHookEnabled(),
430
509
  statusMod: statusModEnabled(),
431
- pickerModels: cfg.cli.extraModels.map((m) => m.name || m.model),
510
+ pickerModels: chatPickerModels(cfg).map((m) => m.name || m.model),
432
511
  uiRevision: uiRevision(),
433
512
  };
434
513
  }
@@ -581,15 +660,17 @@ export function parseImageRequest(raw) {
581
660
  }
582
661
  return out;
583
662
  }
584
- function readBody(req, limit = MAX_BODY) {
663
+ function readBody(req, limit = MAX_BODY, overflowError) {
585
664
  return new Promise((resolveP, reject) => {
586
665
  const chunks = [];
587
666
  let n = 0;
588
667
  req.on("data", (c) => {
589
668
  n += c.length;
590
669
  if (n > limit) {
591
- reject(new Error("body too large"));
592
- req.destroy();
670
+ chunks.length = 0;
671
+ reject(overflowError ?? new Error("body too large"));
672
+ if (!overflowError)
673
+ req.destroy(); // mod saves must receive their 413, not a reset
593
674
  return;
594
675
  }
595
676
  chunks.push(c);
@@ -1224,7 +1305,7 @@ async function pickerModels(deps) {
1224
1305
  const injected = new Set(deps.config().cli.extraModels.map((m) => m.model));
1225
1306
  const seen = new Set();
1226
1307
  const models = entries.filter((e) => {
1227
- if (injected.has(e.id) || seen.has(e.id))
1308
+ if (injected.has(e.id) || seen.has(e.id) || isJudgeOnlyModel(e.id))
1228
1309
  return false;
1229
1310
  seen.add(e.id);
1230
1311
  return true;
@@ -1246,7 +1327,9 @@ function tailLines(file, n) {
1246
1327
  }
1247
1328
  function safeStaticPath(urlPath) {
1248
1329
  const decoded = decodeURIComponent(urlPath.split("?")[0] ?? "/");
1249
- const rel = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
1330
+ // Clients that ask for /favicon.ico without reading the page's <link rel="icon"> get the PNG;
1331
+ // browsers sniff the bytes, so a PNG served for .ico shows.
1332
+ const rel = decoded === "/" ? "index.html" : decoded === "/favicon.ico" ? "favicon-32.png" : decoded.replace(/^\/+/, "");
1250
1333
  const full = path.resolve(UI_ROOT, rel);
1251
1334
  const rootWithSep = UI_ROOT.endsWith(path.sep) ? UI_ROOT : UI_ROOT + path.sep;
1252
1335
  if (full !== UI_ROOT && !full.startsWith(rootWithSep))
@@ -1290,6 +1373,7 @@ function serveStatic(urlPath, res) {
1290
1373
  });
1291
1374
  }
1292
1375
  export function startAdmin(deps) {
1376
+ const scores = new ModelScoreCatalog({ home: homeDir(), warn: message => deps.log.warn(message), ...(deps.modelScoresFetch ? { fetch: deps.modelScoresFetch } : {}) });
1293
1377
  // Set again once the socket is bound (port 0 in tests); the Origin check below compares against it.
1294
1378
  let boundPort = adminPort(deps.config());
1295
1379
  const server = http.createServer((req, res) => {
@@ -1349,6 +1433,36 @@ export function startAdmin(deps) {
1349
1433
  sendJson(res, 200, await buildStatus(deps, { refresh }));
1350
1434
  return;
1351
1435
  }
1436
+ if (pathname === "/api/mod-settings" && method === "GET") {
1437
+ const snapshot = readModSettings();
1438
+ // Filled here as well as on save, so a ladder with empty rungs (Auto migrated, or never set) reads whole to every surface.
1439
+ sendJson(res, 200, { ...snapshot, settings: await withFilledLadder(deps, scores, snapshot.settings) });
1440
+ return;
1441
+ }
1442
+ if (pathname === "/api/mod-settings" && method === "PUT") {
1443
+ const bytes = await readBody(req, MAX_BODY, new ModSettingsInputError("body too large", 413));
1444
+ let parsed;
1445
+ try {
1446
+ parsed = JSON.parse(bytes.toString("utf8"));
1447
+ }
1448
+ catch {
1449
+ throw new ModSettingsInputError("invalid JSON");
1450
+ }
1451
+ validateSettingsSchema(parsed);
1452
+ const body = parsed;
1453
+ if (body && typeof body === "object" && body.settings && typeof body.settings === "object" && !Array.isArray(body.settings)) {
1454
+ // Checked before normalizing, which would drop a credential field the check is there to refuse.
1455
+ validateSettings(body.settings);
1456
+ body.settings = await withFilledLadder(deps, scores, normalizeSettings(body.settings));
1457
+ }
1458
+ const result = writeModSettings(parsed);
1459
+ sendJson(res, result.stale ? 409 : 200, result.current);
1460
+ return;
1461
+ }
1462
+ if (pathname === "/api/mod-options" && method === "GET") {
1463
+ sendJson(res, 200, await modOptions(deps, scores));
1464
+ return;
1465
+ }
1352
1466
  if (pathname === "/api/presets" && method === "GET") {
1353
1467
  sendJson(res, 200, { presets: PRESETS });
1354
1468
  return;
@@ -1643,6 +1757,30 @@ export function startAdmin(deps) {
1643
1757
  });
1644
1758
  return;
1645
1759
  }
1760
+ // Which account a new conversation tries first: a stored account id, or null for the current login.
1761
+ if (pathname === "/api/claude-accounts" && method === "PUT") {
1762
+ let preferred;
1763
+ try {
1764
+ preferred = JSON.parse((await readBody(req)).toString("utf8")).preferred;
1765
+ }
1766
+ catch {
1767
+ sendJson(res, 400, { error: "invalid JSON" });
1768
+ return;
1769
+ }
1770
+ if (preferred === "current")
1771
+ preferred = null;
1772
+ if (preferred !== null && (typeof preferred !== "string" || !preferred)) {
1773
+ sendJson(res, 400, { error: "expected {preferred: account id or null}" });
1774
+ return;
1775
+ }
1776
+ if (!setPreferredClaudeAccount(homeDir(), preferred)) {
1777
+ sendJson(res, 404, { error: "Claude account not found" });
1778
+ return;
1779
+ }
1780
+ deps.log.info(`admin: Claude accounts now try ${preferred === null ? "the current login" : `account ${preferred.slice(0, 8)}`} first`);
1781
+ sendJson(res, 200, { ok: true });
1782
+ return;
1783
+ }
1646
1784
  if (pathname.startsWith("/api/claude-accounts/") && (method === "PATCH" || method === "DELETE")) {
1647
1785
  let id;
1648
1786
  try {
@@ -2046,6 +2184,10 @@ export function startAdmin(deps) {
2046
2184
  sendJson(res, 404, { error: "not found" });
2047
2185
  }
2048
2186
  catch (e) {
2187
+ if (e instanceof ModSettingsInputError && !res.headersSent) {
2188
+ sendJson(res, e.status, { error: e.message });
2189
+ return;
2190
+ }
2049
2191
  deps.log.error(`admin error: ${e.stack ?? e}`);
2050
2192
  if (!res.headersSent)
2051
2193
  sendJson(res, 500, { error: e.message });
@@ -12,9 +12,9 @@
12
12
  import fs from "node:fs";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
- import { homeDir } from "./config.js";
15
+ import { adminPort, homeDir } from "./config.js";
16
16
  import { declaredBy } from "./routing.js";
17
- import { adminPort } from "./admin.js";
17
+ import { isJudgeOnlyModel } from "./model-policy.js";
18
18
  import { shortName } from "../../cli/src/hooks/agent-title.js";
19
19
  /** Where Claude Code's Agent tool reads its worker definitions. */
20
20
  export function defaultAgentDir() {
@@ -33,8 +33,8 @@ const BODY = [
33
33
  "너는 이 세션의 실행자다. 위임받은 작업을 직접 끝내고 직접 검증해서 결론만 간결히 보고한다.",
34
34
  "파일 전문·코드 덤프는 보고에 넣지 않는다. 추측은 추측이라 명시하고 근거는 파일:줄번호로 댄다.",
35
35
  "다른 에이전트에게 넘기지 않는다. 프롬프트 첫 줄의 `[[ripple: …]]` 표식은 라우팅용이니 무시한다.",
36
- "**작업 디렉터리(cwd)에 어떤 파일도 만들지 않는다.** 조사·감사처럼 \"읽기만\" 하는 일이어도 마찬가지다.",
37
- "임시 파일이 필요하면 `/tmp` 아래에만 만들고, 끝나면 지운다. 사본을 작업 폴더에 떨구지 마라.",
36
+ "**시키지 않은 파일은 만들지 않는다.** 작업 지시에 결과물 경로가 없으면 조사·감사라도 작업 디렉터리(cwd)에 아무 파일도 남기지 않는다.",
37
+ "작업 지시가 결과물 경로(파일·폴더)를 정했으면 조사·감사·대조 작업이라도 그 경로에는 반드시 만든다. 그 밖의 메모·사본·임시 파일은 `/tmp` 아래에만 만들고, 끝나면 지운다.",
38
38
  ].join("\n");
39
39
  /**
40
40
  * Added to the body when a ChatGPT provider is configured: how a worker makes an image. A worker has
@@ -134,7 +134,7 @@ function scanDir(dir, log) {
134
134
  export function agentAliases(dir, log) {
135
135
  const out = {};
136
136
  for (const entry of scanDir(dir, log).values()) {
137
- if (!entry.model)
137
+ if (!entry.model || isJudgeOnlyModel(entry.model))
138
138
  continue;
139
139
  out[entry.name] = entry.model.replace(/@[a-z]+$/i, "");
140
140
  }
@@ -190,6 +190,23 @@ function readManifest(file) {
190
190
  }
191
191
  return { agents: [] };
192
192
  }
193
+ /** Owned files that still exist and name a configured worker, not hand-written agents. */
194
+ export function generatedAgents(cfg, dir = defaultAgentDir(), manifestPath = path.join(homeDir(), "generated-agents.json"), log) {
195
+ const owned = new Set(readManifest(manifestPath).agents);
196
+ const result = [];
197
+ for (const entry of scanDir(dir, log).values()) {
198
+ if (!owned.has(entry.name) || !entry.model || isJudgeOnlyModel(entry.model))
199
+ continue;
200
+ const model = entry.model.replace(/@[a-z]+$/i, "");
201
+ const owners = declaredBy(model, cfg);
202
+ const provider = owners.length === 1 ? owners[0] : undefined;
203
+ const target = provider ? cfg.providers[provider] : undefined;
204
+ if (!provider || !target || (target.type === "anthropic" && !target.accountPool))
205
+ continue;
206
+ result.push({ name: entry.name, model, provider });
207
+ }
208
+ return result.sort((a, b) => a.name.localeCompare(b.name));
209
+ }
193
210
  function writeManifest(file, manifest, log) {
194
211
  try {
195
212
  fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n");
@@ -224,6 +241,8 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
224
241
  if (provider.type === "anthropic" && !provider.accountPool)
225
242
  continue;
226
243
  for (const model of provider.models ?? []) {
244
+ if (isJudgeOnlyModel(model.id))
245
+ continue;
227
246
  const owners = declaredBy(model.id, cfg);
228
247
  if (owners.length !== 1) {
229
248
  if (owners.length > 1)
@@ -6,6 +6,8 @@ import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { agentAliases } from "./agents.js";
9
+ import { isJudgeOnlyModel } from "./model-policy.js";
10
+ import { chatPickerModels } from "./picker.js";
9
11
  export const BOOTSTRAP_PATH = "/api/claude_cli/bootstrap";
10
12
  /**
11
13
  * Model ids named in the user's agent definitions (~/.claude/agents/*.md frontmatter `model:`),
@@ -27,7 +29,7 @@ export function agentModelIds(dirs = [path.join(os.homedir(), ".claude", "agents
27
29
  try {
28
30
  const head = fs.readFileSync(path.join(dir, f), "utf8").slice(0, 8192);
29
31
  const m = /^model:\s*['"]?([A-Za-z0-9._\/:\-]+(?:@[a-z]+)?)/m.exec(head);
30
- if (m)
32
+ if (m && !isJudgeOnlyModel(m[1]))
31
33
  out.add(m[1]);
32
34
  }
33
35
  catch {
@@ -60,7 +62,7 @@ export function injectBootstrap(body, cfg, agentDirs) {
60
62
  // An "<model>@<effort>" entry is the same model with a different effort, so it has the same window.
61
63
  return { model: id, name: `${named?.name ?? base}${effort ? ` · ${effort}` : ""}`, ...(named?.contextWindow ? { contextWindow: named.contextWindow } : {}) };
62
64
  });
63
- const extra = [...cfg.cli.extraModels, ...fromAgents];
65
+ const extra = chatPickerModels({ ...cfg, aliases, cli: { ...cfg.cli, extraModels: [...cfg.cli.extraModels, ...fromAgents] } });
64
66
  const win = cfg.cli.autoCompactWindow;
65
67
  // Routed models do not share a context window; the global value is only the fallback for entries
66
68
  // that do not name their own, so a per-entry window alone is reason enough to write the map.
@@ -1,7 +1,7 @@
1
1
  // Sanitise Claude Code's Anthropic-only request extensions for providers that expose
2
2
  // an Anthropic-shaped Messages endpoint without implementing Anthropic's full feature set.
3
3
  // Pure: callers receive a new object and an audit list; the input is never mutated.
4
- import { droppedServerToolNote } from "./providers/chatgpt/translate.js";
4
+ import { droppedServerToolNote, scrubSchema } from "./providers/chatgpt/translate.js";
5
5
  export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max", "ultra"];
6
6
  export const STRICT_COMPAT_CAPS = {
7
7
  effortLevels: [],
@@ -169,6 +169,7 @@ export function sanitizeForCompatible(json, caps) {
169
169
  const droppedNames = new Set();
170
170
  let deferred = 0;
171
171
  let dropped = 0;
172
+ const patterns = { count: 0 };
172
173
  for (const tool of out.tools) {
173
174
  const source = record(tool);
174
175
  if (!source) {
@@ -188,13 +189,20 @@ export function sanitizeForCompatible(json, caps) {
188
189
  delete next.defer_loading;
189
190
  deferred++;
190
191
  }
192
+ // A strict regex engine refuses the whole request over one pattern it cannot compile:
193
+ // DeepSeek's Anthropic endpoint answers 400 `"^[^\\0]*$" is not a "regex"` for the Artifact
194
+ // tool (issue #51). The client validates its own tool input, so the pattern adds nothing here.
195
+ if (next.input_schema !== undefined)
196
+ next.input_schema = scrubSchema(next.input_schema, patterns);
191
197
  kept.push(next);
192
198
  }
193
199
  if (deferred > 0)
194
200
  changes.push(`defer_loading×${deferred}`);
195
201
  if (dropped > 0)
196
202
  changes.push(`server_tools×${dropped}`);
197
- if (deferred > 0 || dropped > 0)
203
+ if (patterns.count > 0)
204
+ changes.push(`pattern×${patterns.count}`);
205
+ if (deferred > 0 || dropped > 0 || patterns.count > 0)
198
206
  out.tools = kept;
199
207
  const toolChoice = record(out.tool_choice);
200
208
  if (toolChoice && typeof toolChoice.name === "string" && droppedNames.has(toolChoice.name)) {
@@ -117,6 +117,9 @@ export const DEFAULTS = {
117
117
  health: { maxConsecutiveUpstreamFailures: 20 },
118
118
  log: { maxBytes: 5 * 1024 * 1024, keep: 3 },
119
119
  };
120
+ export function adminPort(cfg) {
121
+ return cfg.admin?.port ?? cfg.listen.port + 1;
122
+ }
120
123
  export function homeDir() {
121
124
  return process.env.CLAUDERIPPLE_HOME ?? path.join(os.homedir(), ".clauderipple");
122
125
  }
@@ -2,6 +2,39 @@
2
2
  // stays bad (observed: 10h of ENOTFOUND, 11 remote-control workers lost). After N
3
3
  // consecutive upstream *connect-level* failures with no success in between, exit
4
4
  // with a distinctive code so the supervisor restarts a fresh process.
5
+ /** Only definite billing/quota exhaustion, never a bare rate limit or RESOURCE_EXHAUSTED.
6
+ * 402 is already `exhausted` in pool.ts; Codex's usage_limit_reached is named in
7
+ * providers/chatgpt/translate.ts. Other vendors share the explicit billing vocabulary.
8
+ * Reasons are codes, not upstream prose (which can contain credentials).
9
+ */
10
+ export function providerExhaustion(status, body = "", retryMs, now = Date.now()) {
11
+ if (status !== 402 && status !== 429)
12
+ return undefined;
13
+ const balance = /\b(?:insufficient[_ -]+(?:balance|credits?|funds)|(?:balance|credits?)[_ -]+(?:insufficient|exhausted|depleted))\b/i.test(body);
14
+ const billing = /\bbilling[_ -]+(?:hard[_ -]+)?(?:limit|exhausted|quota)|\b(?:exhausted|exceeded|insufficient)[_ -]+billing\b/i.test(body);
15
+ const quota = /\b(?:insufficient_quota|usage_limit_reached|quota[_ -]+exhausted|exhausted[_ -]+quota)\b/i.test(body);
16
+ if (status !== 402 && !balance && !billing && !quota)
17
+ return undefined;
18
+ const reason = balance ? "insufficient_balance" : billing || status === 402 ? "billing_exhausted" : "quota_exhausted";
19
+ const wait = retryMs !== undefined && Number.isFinite(retryMs) && retryMs > 0 ? retryMs : 30 * 60_000;
20
+ return { reason, at: now, until: now + wait };
21
+ }
22
+ /** Per router instance, advisory only: manual traffic can prove a topped-up provider healthy. */
23
+ export class ProviderHealth {
24
+ exhausted = new Map();
25
+ observe(provider, status, exhaustion) {
26
+ if (exhaustion)
27
+ this.exhausted.set(provider, exhaustion);
28
+ else if (status >= 200 && status < 300)
29
+ this.exhausted.delete(provider);
30
+ }
31
+ snapshot(now = Date.now()) {
32
+ for (const [name, value] of this.exhausted)
33
+ if (value.until <= now)
34
+ this.exhausted.delete(name);
35
+ return Object.fromEntries(this.exhausted);
36
+ }
37
+ }
5
38
  export const EXIT_UPSTREAM_UNREACHABLE = 75;
6
39
  const CONNECT_ERRORS = new Set([
7
40
  "ENOTFOUND",
@@ -144,6 +144,7 @@ proxy
144
144
  log: log,
145
145
  stats: () => proxy.stats,
146
146
  health: () => health.consecutiveFailures,
147
+ providerHealth: () => proxy.providerHealth.snapshot(),
147
148
  version: VERSION,
148
149
  requests,
149
150
  chatgpt: () => ({
@@ -3,11 +3,12 @@
3
3
  // `GET /v1/models` and by the Codex model catalog that puts these names in the Codex app picker.
4
4
  import { effortLevels } from "../admin.js";
5
5
  import { claudeSupportsEffort } from "./translate.js";
6
+ import { isJudgeOnlyModel } from "../model-policy.js";
6
7
  export function ingressModels(cfg) {
7
8
  const levels = effortLevels(cfg).providers;
8
9
  const out = new Map();
9
10
  const add = (id, provider, name, explicit) => {
10
- if (out.has(id))
11
+ if (out.has(id) || isJudgeOnlyModel(id))
11
12
  return;
12
13
  const p = cfg.providers[provider];
13
14
  if (!p || (p.type !== "anthropic" && p.type !== "anthropic-compatible"))
@@ -22,6 +23,8 @@ export function ingressModels(cfg) {
22
23
  add(model.id, name, model.name, model.effortLevels);
23
24
  }
24
25
  for (const [id, route] of Object.entries(cfg.routes)) {
26
+ if (isJudgeOnlyModel(route.model))
27
+ continue;
25
28
  const target = cfg.providers[route.provider]?.models?.find((m) => m.id === route.model);
26
29
  add(id, route.provider, target?.name, target?.effortLevels);
27
30
  }