clauderipple 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.ko.md +18 -2
  3. package/README.md +27 -5
  4. package/dist/app/package.json +1 -1
  5. package/dist/cli/src/claude-auth.js +35 -1
  6. package/dist/cli/src/codex.js +23 -0
  7. package/dist/cli/src/hooks/agent-title.js +58 -29
  8. package/dist/cli/src/index.js +46 -1
  9. package/dist/cli/src/picker.js +19 -11
  10. package/dist/cli/src/runtime.js +3 -0
  11. package/dist/cli/src/settings.js +49 -0
  12. package/dist/mod/.claude-plugin/plugin.json +1 -0
  13. package/dist/mod/hooks/hooks.json +1 -0
  14. package/dist/mod/hooks/register.tsx +309 -0
  15. package/dist/mod/hooks/worker.ts +49 -0
  16. package/dist/mod/tsconfig.json +3 -0
  17. package/dist/mod/types/index.d.ts +30 -0
  18. package/dist/router/src/admin.js +411 -32
  19. package/dist/router/src/agents.js +24 -8
  20. package/dist/router/src/catalog.js +80 -0
  21. package/dist/router/src/compat.js +27 -1
  22. package/dist/router/src/config.js +31 -0
  23. package/dist/router/src/index.js +12 -1
  24. package/dist/router/src/ingress/server.js +3 -2
  25. package/dist/router/src/picker.js +33 -2
  26. package/dist/router/src/presets.js +2 -1
  27. package/dist/router/src/providers/chatgpt/index.js +77 -18
  28. package/dist/router/src/providers/chatgpt/translate.js +34 -6
  29. package/dist/router/src/providers/google/accounts.js +345 -0
  30. package/dist/router/src/providers/google/antigravity.js +392 -0
  31. package/dist/router/src/providers/google/index.js +617 -0
  32. package/dist/router/src/providers/google/login.js +249 -0
  33. package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
  34. package/dist/router/src/providers/google/translate.js +539 -0
  35. package/dist/router/src/providers/google/transport.js +118 -0
  36. package/dist/router/src/providers/openai/translate.js +3 -3
  37. package/dist/router/src/proxy.js +116 -9
  38. package/dist/router/src/requestlog.js +2 -0
  39. package/dist/router/src/routing.js +22 -0
  40. package/dist/router/src/version.js +1 -1
  41. package/dist/ui/app.js +420 -45
  42. package/dist/ui/i18n.js +76 -4
  43. package/dist/ui/index.html +5 -0
  44. package/dist/ui/style.css +4 -0
  45. package/docs/ARCHITECTURE.md +307 -4
  46. package/package.json +1 -1
@@ -8,32 +8,42 @@
8
8
  // GET /api/chatgpt-login ChatGPT browser login state and credential status
9
9
  // GET /api/chatgpt-accounts?provider= signed-in ChatGPT accounts, rotation state, quota (no tokens)
10
10
  // PATCH/DELETE /api/chatgpt-accounts/:id rename, pause/resume, clear a cooldown; remove
11
- // GET /* static files from packages/ui (the GUI itself)
11
+ // POST /api/google-login begin a Google (Antigravity) browser sign-in; GET polls the state
12
+ // POST /api/google-login/code paste the code when no loopback callback could be used
13
+ // GET /api/google-accounts?provider= signed-in Google accounts, rotation state (no tokens)
14
+ // PATCH/DELETE /api/google-accounts/:id rename, pause/resume, clear a cooldown; remove
15
+ // GET /api/update whether a newer version is published, and how this copy was installed
16
+ // POST /api/update start `clauderipple update` in the background (it restarts this router)
17
+ // GET /* static files from packages/ui (the GUI itself)
12
18
  import crypto from "node:crypto";
13
19
  import fs from "node:fs";
14
20
  import net from "node:net";
15
21
  import os from "node:os";
16
22
  import path from "node:path";
17
23
  import http from "node:http";
18
- import { execFile } from "node:child_process";
24
+ import { execFile, spawn } from "node:child_process";
19
25
  import { fileURLToPath } from "node:url";
20
26
  import { homeDir, validate } from "./config.js";
21
27
  import { PRESETS } from "./presets.js";
22
28
  import { resolveCompatibleCaps } from "./compat.js";
23
29
  import { CHATGPT_FALLBACK_MODELS } from "./providers/chatgpt/catalog.js";
24
30
  import { measureModel, refusedByPlan } from "./capabilities.js";
31
+ import { catalogEntry } from "./catalog.js";
25
32
  import { ClaudeCodeAuthStore, nativeAnthropicHeaders } from "./providers/anthropic.js";
26
33
  import { codexEnabled, codexHome } from "../../cli/src/codex.js";
27
34
  import { openBrowser } from "../../cli/src/browser.js";
28
35
  import { caTrusted, currentAppProxy } from "../../cli/src/picker.js";
29
36
  import { certPaths } from "../../cli/src/certs.js";
30
37
  import { desktopClaudeCodeDirs } from "../../cli/src/claude-auth.js";
31
- import { syncModelSlots } from "../../cli/src/settings.js";
38
+ import { statusModEnabled, syncModelSlots } from "../../cli/src/settings.js";
32
39
  import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
33
40
  import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
34
41
  import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
35
42
  import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
36
43
  import { IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
44
+ import { ANTIGRAVITY_WARNING, staticAntigravityModels } from "./providers/google/antigravity.js";
45
+ import { readGoogleAccounts, removeGoogleAccount, summarize as summarizeGoogleAccount, updateGoogleAccount } from "./providers/google/accounts.js";
46
+ import { GoogleOAuthSession } from "./providers/google/login.js";
37
47
  const MAX_BODY = 1024 * 1024;
38
48
  /** `/api/image` carries reference images inline, so it gets more room than a config save. */
39
49
  const MAX_IMAGE_BODY = 32 * 1024 * 1024;
@@ -82,6 +92,23 @@ const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
82
92
  let chatgptLogin = { running: false };
83
93
  /** The Claude subscription sign-in in progress (or the last one), for GET /api/claude-oauth. */
84
94
  let claudeOAuth = null;
95
+ /** The Google (Antigravity) sign-in in progress (or the last one), for GET /api/google-login. */
96
+ let googleOAuth = null;
97
+ /** When the GUI last started an update. It restarts this router, so a fresh process starts at 0. */
98
+ let updateStartedAt = 0;
99
+ const UPDATE_RUN_MS = 10 * 60 * 1000;
100
+ /** The last line of `clauderipple update --check --json`, or null when it said something else. */
101
+ export function parseUpdateCheck(output) {
102
+ try {
103
+ const check = JSON.parse(output.trim().split("\n").pop() ?? "");
104
+ if (typeof check.current !== "string" || typeof check.newer !== "boolean" || typeof check.kind !== "string")
105
+ return null;
106
+ return { current: check.current, latest: typeof check.latest === "string" ? check.latest : null, newer: check.newer, kind: check.kind };
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
85
112
  /** Shared catalog for the GUI: model-specific values override provider defaults. */
86
113
  export function effortLevels(cfg) {
87
114
  const providers = {
@@ -110,6 +137,13 @@ export function effortLevels(cfg) {
110
137
  };
111
138
  continue;
112
139
  }
140
+ if (provider.type === "google") {
141
+ // Gemini exposes its ladder per model (the probe fills `effortLevels`); there is no
142
+ // provider-wide default, so the union of what the models declared is the fallback.
143
+ const levels = [...new Set((provider.models ?? []).flatMap((model) => model.effortLevels ?? []))];
144
+ providers[name] = { default: levels, ...(Object.keys(modelLevels).length ? { models: modelLevels } : {}) };
145
+ continue;
146
+ }
113
147
  if (provider.type === "anthropic") {
114
148
  providers[name] = { default: ANTHROPIC_EFFORT_LEVELS };
115
149
  continue;
@@ -122,6 +156,21 @@ export function effortLevels(cfg) {
122
156
  }
123
157
  return { providers };
124
158
  }
159
+ /** One routed model's ladder, from the provider that declares it — what the picker's effort menu
160
+ * should offer for it. Undefined when no provider declares the id: the menu is then left alone. */
161
+ export function effortLevelsForModel(cfg, modelId) {
162
+ const catalog = effortLevels(cfg).providers;
163
+ for (const [name, provider] of Object.entries(cfg.providers)) {
164
+ if (provider.type === "anthropic")
165
+ continue;
166
+ const own = catalog[name];
167
+ if (own?.models?.[modelId])
168
+ return own.models[modelId];
169
+ if (own && provider.models?.some((model) => model.id === modelId))
170
+ return own.default;
171
+ }
172
+ return undefined;
173
+ }
125
174
  const CLAUDE_MODEL_FALLBACK = [
126
175
  { id: "claude-fable-5-1", name: "Fable 5.1" },
127
176
  { id: "claude-opus-5-5", name: "Opus 5.5" },
@@ -228,13 +277,23 @@ export function chatgptSignedIn(mode) {
228
277
  const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
229
278
  return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
230
279
  }
280
+ /**
281
+ * An Antigravity provider is only as usable as its accounts: none signed in, or every one waiting on
282
+ * Google's verification page, answers nothing however reachable the host is.
283
+ */
284
+ export function googleHealth(accounts) {
285
+ if (accounts.every((a) => a.state === "needs-login"))
286
+ return { needsLogin: true };
287
+ const inRotation = accounts.filter((a) => a.state !== "paused" && a.state !== "needs-login");
288
+ return inRotation.length > 0 && inRotation.every((a) => a.state === "needs-verification") ? { needsLogin: false, needsVerification: true } : { needsLogin: false };
289
+ }
231
290
  async function buildStatus(deps, opts = {}) {
232
291
  const cfg = deps.config();
233
292
  const providers = {};
234
293
  // Keyed in the order the config lists them. Assigning inside the Promise.all callbacks ordered
235
294
  // them by whichever TCP check answered first, so the Health list reshuffled on every poll.
236
295
  const checked = await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
237
- const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.url;
296
+ const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.type === "google" ? (p.url ?? "https://generativelanguage.googleapis.com") : p.url;
238
297
  let reachable = false;
239
298
  try {
240
299
  const u = new URL(url);
@@ -262,6 +321,7 @@ async function buildStatus(deps, opts = {}) {
262
321
  // Reaching the host says nothing about being able to use it: a chatgpt provider with no
263
322
  // credentials is not "connected", and calling it that sends the user off believing it works.
264
323
  ...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
324
+ ...(p.type === "google" && p.auth === "antigravity" ? googleHealth(deps.google?.().accounts?.()[name] ?? []) : {}),
265
325
  ...(p.type === "anthropic"
266
326
  ? {
267
327
  authSource: p.auth === "claude-code" ? claudeAuthStore(deps).describeSource() : null,
@@ -352,6 +412,7 @@ async function buildStatus(deps, opts = {}) {
352
412
  credentials: deps.credentials?.() ?? {},
353
413
  picker,
354
414
  agentTitle: agentTitleHookEnabled(),
415
+ statusMod: statusModEnabled(),
355
416
  pickerModels: cfg.cli.extraModels.map((m) => m.name || m.model),
356
417
  uiRevision: uiRevision(),
357
418
  };
@@ -372,30 +433,50 @@ function pickerTrust() {
372
433
  pickerTrustMemo = { at: now, value };
373
434
  return value;
374
435
  }
375
- /** Run the ClaudeRipple CLI with the Node that installed us (`<home>/paths.json`, written by `install`). */
436
+ /** The ClaudeRipple CLI and the Node that installed us (`<home>/paths.json`, written by `install`). */
437
+ function cliInvocation() {
438
+ let node = process.execPath;
439
+ // Same walk either way; the extension says which layout this file was loaded from.
440
+ let cli = path.resolve(here, `../../cli/src/index${path.extname(fileURLToPath(import.meta.url))}`);
441
+ let runtimeEnv = {};
442
+ try {
443
+ const p = JSON.parse(fs.readFileSync(path.join(homeDir(), "paths.json"), "utf8"));
444
+ if (p.node)
445
+ node = p.node;
446
+ if (p.env)
447
+ runtimeEnv = p.env;
448
+ if (p.cli)
449
+ cli = p.cli;
450
+ }
451
+ catch {
452
+ /* not installed via the CLI: fall back to our own node + repo layout */
453
+ }
454
+ return { node, cli, env: { ...process.env, ...runtimeEnv, CLAUDERIPPLE_HOME: homeDir() } };
455
+ }
376
456
  function runCli(args, timeout = 180_000) {
377
457
  return new Promise((resolveP) => {
378
- let node = process.execPath;
379
- // Same walk either way; the extension says which layout this file was loaded from.
380
- let cli = path.resolve(here, `../../cli/src/index${path.extname(fileURLToPath(import.meta.url))}`);
381
- let runtimeEnv = {};
382
- try {
383
- const p = JSON.parse(fs.readFileSync(path.join(homeDir(), "paths.json"), "utf8"));
384
- if (p.node)
385
- node = p.node;
386
- if (p.env)
387
- runtimeEnv = p.env;
388
- if (p.cli)
389
- cli = p.cli;
390
- }
391
- catch {
392
- /* not installed via the CLI: fall back to our own node + repo layout */
393
- }
394
- execFile(node, [cli, ...args], { env: { ...process.env, ...runtimeEnv, CLAUDERIPPLE_HOME: homeDir() }, timeout }, (err, stdout, stderr) => {
458
+ const { node, cli, env } = cliInvocation();
459
+ execFile(node, [cli, ...args], { env, timeout }, (err, stdout, stderr) => {
395
460
  resolveP({ ok: !err, output: `${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim() });
396
461
  });
397
462
  });
398
463
  }
464
+ /**
465
+ * Start the CLI in its own process group, writing to `log`. An update ends by restarting this
466
+ * router, and launchd stops a job by killing its whole process group — an ordinary child would die
467
+ * with the router halfway through the install that restarted it.
468
+ */
469
+ function startCliDetached(args, log) {
470
+ const { node, cli, env } = cliInvocation();
471
+ fs.mkdirSync(path.dirname(log), { recursive: true });
472
+ const out = fs.openSync(log, "w");
473
+ try {
474
+ spawn(node, [cli, ...args], { env, detached: true, stdio: ["ignore", out, out], windowsHide: true }).unref();
475
+ }
476
+ finally {
477
+ fs.closeSync(out);
478
+ }
479
+ }
399
480
  /**
400
481
  * Whether this provider can actually run a web search — measured capability, not an assumption from
401
482
  * the vendor name. The rule is the router's own (`Proxy.canRunServerTools`, mirrored here because
@@ -565,7 +646,7 @@ async function probeAnthropicApiKey(apiKey, probeFetch = fetchWithTimeout) {
565
646
  if (response.ok)
566
647
  return { ok: true, auth: "ok", models };
567
648
  const detail = snippet(await response.text());
568
- if (response.status === 400 && /model.{0,80}(not.?found|invalid|unsupported|does not exist)|unknown.{0,20}model/i.test(detail))
649
+ if (refusedForModel(response.status, detail))
569
650
  return { ok: true, auth: "ok", models };
570
651
  if (response.status === 402 || /insufficient|balance|credit|quota|billing/i.test(detail))
571
652
  return { ok: true, auth: "ok", models, error: `no-credits: ${response.status} ${detail}` };
@@ -584,6 +665,86 @@ function probeClaudeCodeAuth(deps) {
584
665
  const signedIn = accounts.length > 0 ? "oauth" : readClaudeAuthFile(homeDir())?.source ?? null;
585
666
  return { ok: source !== null || hasUsableAccount, auth: source !== null || hasUsableAccount ? "ok" : "missing", source, signedIn, accountCount: accounts.length, models: CLAUDE_MODEL_FALLBACK };
586
667
  }
668
+ /** The reasoning levels a `GET /v1beta/models` entry says the model takes, or none to offer. */
669
+ function googleEffortLevels(entry) {
670
+ // `thinking: true` is the listing's own flag that a model reasons. The Generative Language API
671
+ // documents no per-model effort ladder, so a thinking model is offered the Gemini-3 levels this
672
+ // router maps onto (translate.ts): a model that only knows a subset clamps server-side.
673
+ return entry.thinking === true ? ["minimal", "low", "medium", "high"] : [];
674
+ }
675
+ /**
676
+ * `GET {base}/v1beta/models` with the key on `x-goog-api-key`, following `nextPageToken`.
677
+ *
678
+ * The auth check is the listing itself: Google answers a bad key with `400 API_KEY_INVALID` (or a
679
+ * 401/403), so there is no separate one-token request to make.
680
+ */
681
+ async function probeGoogle(body, probeFetch = fetchWithTimeout) {
682
+ const apiKey = typeof body.apiKey === "string" && body.apiKey.length > 0 ? body.apiKey : (process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY);
683
+ if (!apiKey)
684
+ return { ok: false, auth: "missing", models: [], error: "no API key; set one or GEMINI_API_KEY" };
685
+ const base = (typeof body.url === "string" && body.url ? body.url : "https://generativelanguage.googleapis.com").replace(/\/+$/, "");
686
+ const models = [];
687
+ let pageToken;
688
+ try {
689
+ // Bounded: a catalogue that keeps handing back a token must not spin the admin request forever.
690
+ for (let page = 0; page < 20; page++) {
691
+ const url = `${base}/v1beta/models?pageSize=1000${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`;
692
+ const response = await probeFetch(url, { headers: { "x-goog-api-key": apiKey } });
693
+ if (response.status === 401 || response.status === 403)
694
+ return { ok: false, auth: "bad-key", models: [], error: `models endpoint returned ${response.status}` };
695
+ if (!response.ok) {
696
+ const detail = snippet(await response.text());
697
+ // A wrong key comes back 400 with `status: API_KEY_INVALID`; that is a bad key, not a bad base.
698
+ const badKey = /API_KEY_INVALID|API key not valid|invalid api key/i.test(detail);
699
+ return { ok: false, auth: badKey ? "bad-key" : response.status >= 500 ? "unreachable" : "unknown", models: [], error: `models endpoint returned ${response.status}: ${detail}` };
700
+ }
701
+ const parsed = await response.json();
702
+ for (const raw of Array.isArray(parsed.models) ? parsed.models : []) {
703
+ if (!raw || typeof raw !== "object")
704
+ continue;
705
+ const entry = raw;
706
+ if (typeof entry.name !== "string")
707
+ continue;
708
+ const id = entry.name.replace(/^models\//, "");
709
+ // Only models that can actually answer `generateContent` are worth listing; embedding and
710
+ // prediction models share the catalogue but cannot serve a turn.
711
+ const methods = Array.isArray(entry.supportedGenerationMethods) ? entry.supportedGenerationMethods : [];
712
+ if (!methods.some((method) => method === "generateContent"))
713
+ continue;
714
+ const levels = googleEffortLevels(entry);
715
+ models.push({
716
+ id,
717
+ ...(typeof entry.displayName === "string" ? { name: entry.displayName } : {}),
718
+ ...(typeof entry.inputTokenLimit === "number" && entry.inputTokenLimit > 0 ? { contextWindow: Math.floor(entry.inputTokenLimit) } : {}),
719
+ ...(levels.length > 0 ? { effortLevels: levels } : {}),
720
+ });
721
+ }
722
+ pageToken = typeof parsed.nextPageToken === "string" && parsed.nextPageToken ? parsed.nextPageToken : undefined;
723
+ if (!pageToken)
724
+ break;
725
+ }
726
+ if (models.length === 0)
727
+ return { ok: true, auth: "ok", models, error: "no generateContent models listed" };
728
+ return { ok: true, auth: "ok", models };
729
+ }
730
+ catch (e) {
731
+ return { ok: false, auth: "unreachable", models, error: `models endpoint: ${errorText(e)}` };
732
+ }
733
+ }
734
+ /**
735
+ * `{type:"google", auth:"antigravity"}`: whether any Google account is signed in, and the model list
736
+ * Cloud Code Assist returns for it (falling back to the static list). No network call is made here
737
+ * beyond the catalogue the provider already serves; a signed-out state is the common answer.
738
+ */
739
+ async function probeGoogleAntigravity(deps) {
740
+ const accounts = deps.google?.().accounts?.() ?? {};
741
+ const signedIn = Object.values(accounts).some((list) => list.length > 0) || readGoogleAccounts(homeDir()).length > 0;
742
+ if (!signedIn)
743
+ return { ok: false, auth: "missing", models: staticAntigravityModels(), error: "no Google account; run `clauderipple google-login`" };
744
+ const named = Object.keys(accounts)[0];
745
+ const models = named ? await deps.google?.().models?.(named).catch(() => null) ?? null : null;
746
+ return { ok: true, auth: "ok", models: models ?? staticAntigravityModels() };
747
+ }
587
748
  function chatCompletionsUrl(base) {
588
749
  return `${base.replace(/\/+$/, "")}/chat/completions`;
589
750
  }
@@ -617,6 +778,15 @@ function parsedModels(value) {
617
778
  function snippet(text) {
618
779
  return text.replace(/\s+/g, " ").trim().slice(0, 200);
619
780
  }
781
+ /**
782
+ * Whether a refused test request was refused for its model alone, which still shows the endpoint is
783
+ * right and the key was accepted. Anthropic's own wire says so with 404 not_found_error rather than
784
+ * 400 — abliteration.ai answers "Requested model was not found." (issue #42) — so 404 counts too,
785
+ * but only with a model named in the body: a bare 404 is a wrong URL.
786
+ */
787
+ function refusedForModel(status, detail) {
788
+ return (status === 400 || status === 404) && /model.{0,80}(not.?found|invalid|unsupported|does not exist)|unknown.{0,20}model/i.test(detail);
789
+ }
620
790
  /**
621
791
  * Fill each discovered model with the wire, endpoint and auth convention its preset declares for it.
622
792
  *
@@ -709,8 +879,8 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
709
879
  if (response.ok)
710
880
  return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
711
881
  const detail = snippet(await response.text());
712
- // A model-validation 400 still demonstrates that the endpoint reached the provider and the key was accepted.
713
- if (response.status === 400 && /model.{0,80}(not.?found|invalid|unsupported|does not exist)|unknown.{0,20}model/i.test(detail)) {
882
+ // A model-validation refusal still demonstrates that the endpoint reached the provider and the key was accepted.
883
+ if (refusedForModel(response.status, detail)) {
714
884
  return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
715
885
  }
716
886
  // Key accepted but the account cannot pay: report auth ok so the user tops up instead of re-checking the key.
@@ -730,6 +900,8 @@ const MEASURE_JOBS = new Map();
730
900
  * (and its error strings) in memory for the life of the process. */
731
901
  const MEASURE_JOB_TTL_MS = 10 * 60 * 1000;
732
902
  const MEASURE_TIMEOUT_MS = 20_000;
903
+ /** Models measured at once. One job is one provider and one key, so this is also its burst. */
904
+ const MEASURE_CONCURRENCY = 4;
733
905
  async function measureFetchWithTimeout(url, init) {
734
906
  return fetch(url, { ...init, signal: AbortSignal.timeout(MEASURE_TIMEOUT_MS) });
735
907
  }
@@ -749,8 +921,12 @@ function sweepMeasureJobs() {
749
921
  * trying the list in catalogue order made it measure as `chat` and quietly overrule the `anthropic`
750
922
  * the preset recorded for it. A recorded wire is still only a starting point: it has to answer like
751
923
  * any other candidate, and the rest of the list is tried when it does not.
924
+ *
925
+ * `catalogWire` is the wire the public catalogue gives this model (catalog.ts). It goes first when
926
+ * the preset's own table says nothing about the model, so a model newer than that table is still
927
+ * settled in one request instead of walking the list.
752
928
  */
753
- function measureCandidates(p, modelId) {
929
+ function measureCandidates(p, modelId, catalogWire) {
754
930
  const defaultWire = p.type === "anthropic-compatible" ? "anthropic"
755
931
  : p.type === "openai-compatible" ? (p.wire ?? "chat")
756
932
  : "chat";
@@ -790,6 +966,11 @@ function measureCandidates(p, modelId) {
790
966
  if (model.wire)
791
967
  add({ wire: model.wire, url: model.url ?? p.url, ...(model.authHeader ? { authHeader: model.authHeader } : {}) });
792
968
  }
969
+ if (!known?.wire && catalogWire) {
970
+ const at = candidates.findIndex((c) => c.wire === catalogWire);
971
+ if (at > 0)
972
+ candidates.unshift(...candidates.splice(at, 1));
973
+ }
793
974
  return candidates;
794
975
  }
795
976
  /** The effort ladder this provider offers, before any per-model override narrows it. */
@@ -858,14 +1039,31 @@ async function runMeasureJob(jobId, providerName, models, deps) {
858
1039
  const fetchImpl = deps.measureFetch ?? measureFetchWithTimeout;
859
1040
  const headers = "headers" in provider ? provider.headers : undefined;
860
1041
  const sessionHeader = "sessionHeader" in provider ? provider.sessionHeader : undefined;
861
- for (const id of models) {
1042
+ const preset = "preset" in provider ? provider.preset : undefined;
1043
+ const catalog = deps.modelCatalog ? await deps.modelCatalog().catch(() => undefined) : undefined;
1044
+ const measureOne = async (id) => {
1045
+ const listed = catalogEntry(catalog, preset, id);
862
1046
  // Per model, not once per job: the order depends on what the preset recorded for this id.
863
- const candidates = measureCandidates(provider, id);
864
- const measured = await measureModel(id, candidates, ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) });
1047
+ const candidates = measureCandidates(provider, id, listed?.wire);
1048
+ // A ladder the catalogue states is taken as stated; the wire is still asked, once, because that
1049
+ // one request is also what finds a model the plan refuses (403 FreeTierError, DataPolicyError).
1050
+ const stated = ladder.length > 0 ? listed?.effortLevels : undefined;
1051
+ const known = stated !== undefined;
1052
+ const measured = await measureModel(id, candidates, known ? [] : ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) });
1053
+ if (stated && measured.wire)
1054
+ measured.effortLevels = stated;
865
1055
  job.results.push(measured);
866
1056
  job.done++;
867
- deps.log.info(`admin: measured ${providerName}/${id} -> ${measured.error ? `error ${measured.error}` : `wire ${measured.wire}${measured.effortLevels ? ` effort ${measured.effortLevels.join(",")}` : ""}`}`);
868
- }
1057
+ deps.log.info(`admin: measured ${providerName}/${id} -> ${measured.error ? `error ${measured.error}` : `wire ${measured.wire}${measured.effortLevels ? ` effort ${measured.effortLevels.join(",")}${known ? " (catalogue)" : ""}` : ""}`}`);
1058
+ };
1059
+ // Models are independent, so a few run at once and a slow upstream answer holds up only its own
1060
+ // model. Levels inside one model stay sequential: a burst there invites 429s, and a level that
1061
+ // meets one is kept rather than measured, which would quietly widen the ladder.
1062
+ const queue = [...models];
1063
+ await Promise.all(Array.from({ length: Math.min(MEASURE_CONCURRENCY, queue.length) }, async () => {
1064
+ for (let id = queue.shift(); id !== undefined; id = queue.shift())
1065
+ await measureOne(id);
1066
+ }));
869
1067
  try {
870
1068
  const outcome = applyMeasurements(deps.configFile, job.results);
871
1069
  if ("error" in outcome) {
@@ -1092,6 +1290,14 @@ export function startAdmin(deps) {
1092
1290
  });
1093
1291
  return;
1094
1292
  }
1293
+ if (probe.type === "google") {
1294
+ if (probe.auth === "antigravity") {
1295
+ sendJson(res, 200, await probeGoogleAntigravity(deps));
1296
+ return;
1297
+ }
1298
+ sendJson(res, 200, await probeGoogle({ apiKey: probe.apiKey, url: probe.url }, deps.probeFetch));
1299
+ return;
1300
+ }
1095
1301
  if ((probe.type !== "anthropic-compatible" && probe.type !== "openai-compatible") ||
1096
1302
  typeof probe.url !== "string" ||
1097
1303
  !/^https?:\/\//.test(probe.url) ||
@@ -1422,6 +1628,126 @@ export function startAdmin(deps) {
1422
1628
  sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1423
1629
  return;
1424
1630
  }
1631
+ // Google (Antigravity) sign-in of our own (browser, PKCE, loopback callback). The GUI starts it
1632
+ // and polls the state; tokens never leave the router and never appear in any response.
1633
+ if (pathname === "/api/google-login" && method === "GET") {
1634
+ const state = googleOAuth?.snapshot ?? { running: false, url: null, startedAt: null, finishedAt: null, ok: null, error: null, account: null, termsWarning: ANTIGRAVITY_WARNING };
1635
+ sendJson(res, 200, state);
1636
+ return;
1637
+ }
1638
+ if (pathname === "/api/google-login" && method === "POST") {
1639
+ if (googleOAuth?.snapshot.running) {
1640
+ sendJson(res, 200, googleOAuth.snapshot);
1641
+ return;
1642
+ }
1643
+ const session = new GoogleOAuthSession({ home: homeDir(), ...(deps.googleOAuthFetch ? { fetch: deps.googleOAuthFetch } : {}), ...(deps.googleOAuthPort !== undefined ? { port: deps.googleOAuthPort } : {}) }, ANTIGRAVITY_WARNING);
1644
+ googleOAuth = session;
1645
+ let started;
1646
+ try {
1647
+ started = await session.start();
1648
+ }
1649
+ catch (error) {
1650
+ sendJson(res, 500, { ...session.snapshot, error: error.message });
1651
+ return;
1652
+ }
1653
+ const opened = deps.openBrowser ? deps.openBrowser(started.url) : openBrowser(started.url);
1654
+ deps.log.info(`admin: google sign-in via GUI -> started (browser ${opened ? "opened" : "not opened"})`);
1655
+ void session.result.then(() => deps.log.info("admin: google sign-in via GUI -> ok"), (error) => deps.log.info(`admin: google sign-in via GUI -> failed: ${error.message}`));
1656
+ sendJson(res, 200, { ...session.snapshot, opened, termsWarning: ANTIGRAVITY_WARNING });
1657
+ return;
1658
+ }
1659
+ if (pathname === "/api/google-login/code" && method === "POST") {
1660
+ const session = googleOAuth;
1661
+ if (!session?.snapshot.running) {
1662
+ sendJson(res, 409, { error: "no Google sign-in is waiting for a code" });
1663
+ return;
1664
+ }
1665
+ let code = "";
1666
+ try {
1667
+ code = String(JSON.parse((await readBody(req)).toString("utf8")).code ?? "");
1668
+ }
1669
+ catch {
1670
+ sendJson(res, 400, { error: "invalid JSON" });
1671
+ return;
1672
+ }
1673
+ await session.submitCode(code);
1674
+ await session.result.catch(() => { });
1675
+ sendJson(res, session.snapshot.ok ? 200 : 400, session.snapshot);
1676
+ return;
1677
+ }
1678
+ if (pathname === "/api/google-login/cancel" && method === "POST") {
1679
+ googleOAuth?.cancel();
1680
+ sendJson(res, 200, googleOAuth?.snapshot ?? { running: false });
1681
+ return;
1682
+ }
1683
+ if (pathname === "/api/google-logout" && method === "POST") {
1684
+ const r = await (deps.runCli ?? runCli)(["google-logout"]);
1685
+ deps.log.info(`admin: google-logout via GUI -> ${r.ok ? "ok" : "failed"}`);
1686
+ sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1687
+ return;
1688
+ }
1689
+ // Safe account metadata only: labels, emails, project ids and rotation state. Tokens stay in
1690
+ // the router and are never serialized.
1691
+ if (pathname === "/api/google-accounts" && method === "GET") {
1692
+ const all = deps.google?.().accounts?.() ?? {};
1693
+ const wanted = new URL(url, "http://127.0.0.1").searchParams.get("provider");
1694
+ const name = wanted && all[wanted] ? wanted : Object.keys(all)[0];
1695
+ const accounts = name ? all[name] : readGoogleAccounts(homeDir()).map(summarizeGoogleAccount);
1696
+ sendJson(res, 200, { provider: name ?? null, accounts });
1697
+ return;
1698
+ }
1699
+ if (pathname.startsWith("/api/google-accounts/") && (method === "PATCH" || method === "DELETE")) {
1700
+ let id;
1701
+ try {
1702
+ id = decodeURIComponent(pathname.slice("/api/google-accounts/".length));
1703
+ }
1704
+ catch {
1705
+ sendJson(res, 400, { error: "invalid account id" });
1706
+ return;
1707
+ }
1708
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
1709
+ sendJson(res, 400, { error: "invalid account id" });
1710
+ return;
1711
+ }
1712
+ if (method === "DELETE") {
1713
+ if (!removeGoogleAccount(homeDir(), id)) {
1714
+ sendJson(res, 404, { error: "Google account not found" });
1715
+ return;
1716
+ }
1717
+ deps.log.info(`admin: removed Google account ${id.slice(0, 8)}`);
1718
+ sendJson(res, 200, { ok: true });
1719
+ return;
1720
+ }
1721
+ let change;
1722
+ try {
1723
+ change = JSON.parse((await readBody(req)).toString("utf8"));
1724
+ }
1725
+ catch {
1726
+ sendJson(res, 400, { error: "invalid JSON" });
1727
+ return;
1728
+ }
1729
+ if ((change.label !== undefined && (typeof change.label !== "string" || !change.label.trim())) || (change.paused !== undefined && typeof change.paused !== "boolean")) {
1730
+ sendJson(res, 400, { error: "expected {label?: non-empty string, paused?: boolean, clearCooldown?: true, recheck?: true}" });
1731
+ return;
1732
+ }
1733
+ if (change.recheck === true) {
1734
+ const result = (await deps.google?.().recheckVerification?.(id)) ?? "unknown";
1735
+ deps.log.info(`admin: rechecked Google verification for ${id.slice(0, 8)} -> ${result}`);
1736
+ sendJson(res, 200, { ok: true, verification: result });
1737
+ return;
1738
+ }
1739
+ if (change.clearCooldown === true)
1740
+ deps.google?.().clearCooldown?.(id);
1741
+ if (change.label !== undefined || change.paused !== undefined) {
1742
+ if (!updateGoogleAccount(homeDir(), id, { ...(typeof change.label === "string" ? { label: change.label } : {}), ...(typeof change.paused === "boolean" ? { paused: change.paused } : {}) })) {
1743
+ sendJson(res, 404, { error: "Google account not found" });
1744
+ return;
1745
+ }
1746
+ }
1747
+ deps.log.info(`admin: updated Google account ${id.slice(0, 8)}`);
1748
+ sendJson(res, 200, { ok: true });
1749
+ return;
1750
+ }
1425
1751
  if (pathname === "/api/picker" && method === "POST") {
1426
1752
  // Runs `clauderipple picker on|off` (keychain trust, app Config Library, config flag).
1427
1753
  // macOS shows its keychain password dialog in the user's session; we never see the password.
@@ -1450,6 +1776,39 @@ export function startAdmin(deps) {
1450
1776
  sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1451
1777
  return;
1452
1778
  }
1779
+ if (pathname === "/api/update" && (method === "GET" || method === "POST")) {
1780
+ // The CLI answers both: it knows how this copy was installed, which decides what an update is
1781
+ // (the tray asks it the same way). A POST checks again rather than trusting the page.
1782
+ const r = await (deps.runCli ?? runCli)(["update", "--check", "--json"], 30_000);
1783
+ const check = parseUpdateCheck(r.output);
1784
+ if (!check) {
1785
+ sendJson(res, 502, { error: "could not check for updates", output: r.output.slice(-2000) });
1786
+ return;
1787
+ }
1788
+ const running = updateStartedAt > 0 && Date.now() - updateStartedAt < UPDATE_RUN_MS;
1789
+ if (method === "GET") {
1790
+ sendJson(res, 200, { ...check, running });
1791
+ return;
1792
+ }
1793
+ if (check.kind !== "script" && check.kind !== "npm") {
1794
+ sendJson(res, 409, { error: `this copy (${check.kind}) is not updated from here`, ...check });
1795
+ return;
1796
+ }
1797
+ if (!check.newer) {
1798
+ sendJson(res, 409, { error: "already the latest version", ...check });
1799
+ return;
1800
+ }
1801
+ if (running) {
1802
+ sendJson(res, 409, { error: "an update is already running", ...check, running });
1803
+ return;
1804
+ }
1805
+ updateStartedAt = Date.now();
1806
+ const log = path.join(homeDir(), "logs", "update.log");
1807
+ (deps.startCliDetached ?? startCliDetached)(["update"], log);
1808
+ deps.log.info(`admin: update ${check.current} -> ${check.latest ?? "?"} started via GUI (output in ${log})`);
1809
+ sendJson(res, 202, { started: true, ...check, log });
1810
+ return;
1811
+ }
1453
1812
  if (pathname === "/api/image" && method === "POST") {
1454
1813
  // The image itself is the response body, so `curl -o out.png` is the whole client. An error
1455
1814
  // is JSON with a non-2xx status (`curl --fail-with-body` writes it to the same file).
@@ -1512,14 +1871,34 @@ export function startAdmin(deps) {
1512
1871
  sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1513
1872
  return;
1514
1873
  }
1874
+ if (pathname === "/api/status-mod" && method === "POST") {
1875
+ // Through the CLI, like agent-title: it knows where this install keeps packages/mod.
1876
+ let enabled;
1877
+ try {
1878
+ enabled = JSON.parse((await readBody(req)).toString("utf8")).enabled;
1879
+ }
1880
+ catch {
1881
+ sendJson(res, 400, { error: "invalid JSON" });
1882
+ return;
1883
+ }
1884
+ if (typeof enabled !== "boolean") {
1885
+ sendJson(res, 400, { error: "expected {enabled: boolean}" });
1886
+ return;
1887
+ }
1888
+ const r = await runCli(["mod", enabled ? "on" : "off"]);
1889
+ deps.log.info(`admin: status mod ${enabled ? "on" : "off"} via GUI -> ${r.ok ? "ok" : "failed"}`);
1890
+ sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1891
+ return;
1892
+ }
1515
1893
  if (pathname === "/api/requests" && method === "GET") {
1516
1894
  const query = new URL(url, "http://x").searchParams;
1517
1895
  const requested = Number(query.get("n") ?? 200);
1518
1896
  const n = Number.isFinite(requested) ? Math.min(2000, Math.max(1, Math.floor(requested))) : 200;
1519
1897
  const provider = query.get("provider") || undefined;
1898
+ const session = query.get("session") || undefined;
1520
1899
  const kindValue = query.get("kind");
1521
1900
  const kind = kindValue === "messages" || kindValue === "count_tokens" || kindValue === "other" ? kindValue : undefined;
1522
- sendJson(res, 200, { requests: deps.requests.list(n, { ...(provider ? { provider } : {}), ...(kind ? { kind } : {}) }) });
1901
+ sendJson(res, 200, { requests: deps.requests.list(n, { ...(provider ? { provider } : {}), ...(kind ? { kind } : {}), ...(session ? { session } : {}) }) });
1523
1902
  return;
1524
1903
  }
1525
1904
  if (pathname === "/api/requests/summary" && method === "GET") {