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
@@ -3,7 +3,7 @@
3
3
  // stable prompt prefix can retain their native prompt-cache behavior.
4
4
  import crypto from "node:crypto";
5
5
  import { clampEffort } from "../../compat.js";
6
- import { conversationKey, estimateTokens, normalizeSchema, serverToolNames, systemText, toolNameForResponses } from "../chatgpt/translate.js";
6
+ import { conversationKey, estimateTokens, normalizeSchema, serverToolNames, systemWithDroppedToolNote, toolNameForResponses } from "../chatgpt/translate.js";
7
7
  import { identityPrefix, instructionsSuffix } from "../../identity.js";
8
8
  function textOf(content) {
9
9
  if (typeof content === "string")
@@ -87,7 +87,7 @@ function mappedEffort(opts) {
87
87
  /** Convert every Anthropic message into OpenAI Chat Completion messages without inventing unstable text. */
88
88
  export function toChatMessages(req, opts) {
89
89
  const messages = [];
90
- const sys = systemText(req.system);
90
+ const sys = systemWithDroppedToolNote(req);
91
91
  const content = opts ? systemWithIdentity(sys, opts) : sys;
92
92
  if (content)
93
93
  messages.push({ role: "system", content });
@@ -248,7 +248,7 @@ export function toOpenAiRequest(req, opts) {
248
248
  input: toResponsesInput(req),
249
249
  stream: true,
250
250
  };
251
- const instructions = systemWithIdentity(systemText(req.system), opts);
251
+ const instructions = systemWithIdentity(systemWithDroppedToolNote(req), opts);
252
252
  if (instructions)
253
253
  out.instructions = instructions;
254
254
  if (responseTools.length) {
@@ -29,9 +29,11 @@ import { classify, CredentialPool, retryAfterMs } from "./pool.js";
29
29
  import { PRESETS } from "./presets.js";
30
30
  import { ChatGptAdapter } from "./providers/chatgpt/index.js";
31
31
  import { OpenAiCompatibleAdapter } from "./providers/openai/index.js";
32
+ import { GoogleAdapter } from "./providers/google/index.js";
32
33
  import { conversationKey } from "./providers/chatgpt/translate.js";
33
34
  import { providerFor, terminateHosts } from "./config.js";
34
35
  import { injectPickerModels, isBootstrapPath } from "./picker.js";
36
+ import { effortLevelsForModel } from "./admin.js";
35
37
  import { ResponseUsageTap } from "./requestlog.js";
36
38
  import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
37
39
  import { ClaudeAccountAuthPool } from "./providers/anthropic-account-pool.js";
@@ -45,11 +47,15 @@ const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "tra
45
47
  const CLIENT_AUTH = new Set(["authorization", "x-api-key"]);
46
48
  /** How much of an upstream error body is kept for the log. */
47
49
  const ERROR_HEAD_MAX = 4096;
50
+ /** Methods safe to send twice (RFC 9110 §9.2.2), and so the only ones resent after a stale socket. */
51
+ const IDEMPOTENT = new Set(["GET", "HEAD", "OPTIONS"]);
48
52
  /**
49
53
  * Claude Code's Remote Control registration uses HTTPS absolute-form proxy requests instead of
50
54
  * CONNECT (`POST https://api.anthropic.com/v1/environments/bridge HTTP/1.1`). Convert that legal
51
55
  * forward-proxy form to the origin form expected inside a TLS connection. Credentials and the
52
56
  * request body remain byte-for-byte client data; proxy-only headers never reach the destination.
57
+ * Plain `http:` takes the same form and is relayed over TCP: Claude Code 2.1.288 sends its http
58
+ * requests through HTTPS_PROXY too, loopback included (a mod reading the admin API got our 405).
53
59
  */
54
60
  export function absoluteProxyRequest(header) {
55
61
  const lines = header.toString("latin1").split("\r\n");
@@ -64,9 +70,10 @@ export function absoluteProxyRequest(header) {
64
70
  catch {
65
71
  return null;
66
72
  }
67
- if (url.protocol !== "https:" || url.username || url.password || url.hash || !url.hostname)
73
+ const secure = url.protocol === "https:";
74
+ if ((!secure && url.protocol !== "http:") || url.username || url.password || url.hash || !url.hostname)
68
75
  return null;
69
- const port = url.port ? Number(url.port) : 443;
76
+ const port = url.port ? Number(url.port) : secure ? 443 : 80;
70
77
  if (!Number.isInteger(port) || port < 1 || port > 65535)
71
78
  return null;
72
79
  // URL.hostname keeps brackets around IPv6 literals; net/tls expect the bare address while the
@@ -77,10 +84,10 @@ export function absoluteProxyRequest(header) {
77
84
  !/^connection\s*:/i.test(line));
78
85
  const authority = url.port ? `${url.hostname}:${url.port}` : url.hostname;
79
86
  const path = `${url.pathname || "/"}${url.search}`;
80
- // One TLS connection serves one absolute-form request. Reusing it would send the next proxy-form
87
+ // One upstream connection serves one absolute-form request. Reusing it would send the next proxy-form
81
88
  // request target directly to the origin, so explicitly ask both peers to close after the response.
82
89
  const rewritten = [`${match[1]} ${path} ${match[3]}`, `Host: ${authority}`, "Connection: close", ...headers].join("\r\n") + "\r\n\r\n";
83
- return { host, port, path, head: Buffer.from(rewritten, "latin1") };
90
+ return { host, port, path, head: Buffer.from(rewritten, "latin1"), tls: secure };
84
91
  }
85
92
  /**
86
93
  * The same header list with one credential swapped for another. Names carried by either credential
@@ -177,6 +184,7 @@ export class Proxy {
177
184
  providerAgents = new Map();
178
185
  chatgptAdapters = new Map();
179
186
  openaiAdapters = new Map();
187
+ googleAdapters = new Map();
180
188
  claudeAccounts;
181
189
  /** Each Claude subscription account's usage, for the admin status (never a token). */
182
190
  claudeUsage;
@@ -292,6 +300,52 @@ export class Proxy {
292
300
  this.openaiAdapters.set(name, { key, adapter });
293
301
  return adapter;
294
302
  }
303
+ google(name, cfg) {
304
+ const key = JSON.stringify(cfg);
305
+ const cur = this.googleAdapters.get(name);
306
+ if (cur && cur.key === key)
307
+ return cur.adapter;
308
+ const adapter = new GoogleAdapter(name, cfg, this.deps.home, this.deps.log, this.pool);
309
+ this.googleAdapters.set(name, { key, adapter });
310
+ return adapter;
311
+ }
312
+ /**
313
+ * Every configured google provider's Antigravity accounts: rotation state only, never a token.
314
+ * Empty for api-key providers and for an adapter that has not been created yet.
315
+ */
316
+ googleAccounts() {
317
+ const out = {};
318
+ for (const [name, p] of Object.entries(this.deps.config().providers)) {
319
+ if (p.type === "google" && p.auth === "antigravity")
320
+ out[name] = this.google(name, p).accountStatus();
321
+ }
322
+ return out;
323
+ }
324
+ /** The models a google provider can reach now, from Cloud Code Assist's own catalogue. */
325
+ googleFetchModels(name) {
326
+ const cfg = this.deps.config().providers[name];
327
+ if (!cfg || cfg.type !== "google" || cfg.auth !== "antigravity")
328
+ return Promise.resolve(null);
329
+ return this.google(name, cfg).fetchModels();
330
+ }
331
+ /** Dashboard action: put one resting Google account back into rotation now. */
332
+ googleClearCooldown(ownerId) {
333
+ for (const [name, p] of Object.entries(this.deps.config().providers)) {
334
+ if (p.type === "google" && p.auth === "antigravity")
335
+ this.google(name, p).clearCooldown(ownerId);
336
+ }
337
+ }
338
+ /** Ask again whether an account still needs Google's verification page; the first provider that knows answers. */
339
+ async googleRecheckVerification(ownerId) {
340
+ for (const [name, p] of Object.entries(this.deps.config().providers)) {
341
+ if (p.type !== "google" || p.auth !== "antigravity")
342
+ continue;
343
+ const result = await this.google(name, p).recheckVerification(ownerId);
344
+ if (result !== "unknown")
345
+ return result;
346
+ }
347
+ return "unknown";
348
+ }
295
349
  constructor(deps) {
296
350
  this.deps = deps;
297
351
  this.claudeAccounts = new ClaudeAccountAuthPool({
@@ -376,7 +430,7 @@ export class Proxy {
376
430
  if (method !== "CONNECT" || !target) {
377
431
  const absolute = absoluteProxyRequest(head.subarray(0, end));
378
432
  if (absolute) {
379
- log.info(`ABSOLUTE ${method ?? "?"} https://${absolute.host}:${absolute.port}${absolute.path}`);
433
+ log.info(`ABSOLUTE ${method ?? "?"} ${absolute.tls ? "https" : "http"}://${absolute.host}:${absolute.port}${absolute.path}`);
380
434
  this.forwardAbsolute(sock, absolute, rest);
381
435
  return;
382
436
  }
@@ -401,7 +455,9 @@ export class Proxy {
401
455
  /** Forward an HTTPS absolute-form request without terminating it through the routing layer. */
402
456
  forwardAbsolute(sock, request, rest) {
403
457
  const servername = net.isIP(request.host) ? undefined : request.host;
404
- const up = (this.deps.tlsConnect ?? tls.connect)({ host: request.host, port: request.port, ...(servername ? { servername } : {}), ALPNProtocols: ["http/1.1"] });
458
+ const up = request.tls
459
+ ? (this.deps.tlsConnect ?? tls.connect)({ host: request.host, port: request.port, ...(servername ? { servername } : {}), ALPNProtocols: ["http/1.1"] })
460
+ : net.connect({ host: request.host, port: request.port });
405
461
  const kill = () => {
406
462
  sock.destroy();
407
463
  up.destroy();
@@ -413,7 +469,7 @@ export class Proxy {
413
469
  up.destroy();
414
470
  });
415
471
  sock.on("error", kill);
416
- up.once("secureConnect", () => {
472
+ up.once(request.tls ? "secureConnect" : "connect", () => {
417
473
  up.write(request.head);
418
474
  if (rest.length)
419
475
  up.write(rest);
@@ -525,6 +581,9 @@ export class Proxy {
525
581
  provider: "anthropic",
526
582
  stream: false,
527
583
  };
584
+ const session = req.headers["x-claude-code-session-id"];
585
+ if (typeof session === "string" && /^[\w-]{1,128}$/.test(session))
586
+ record.session = session;
528
587
  let observedUsage;
529
588
  let observedStopReason;
530
589
  // `failed` counts requests that did not get a proper response (vanished, upstream error,
@@ -747,6 +806,38 @@ export class Proxy {
747
806
  }
748
807
  return;
749
808
  }
809
+ if (provider.type === "google") {
810
+ // Google holds no server-side thread either, so a `continue` is refused as it is for the
811
+ // other translated providers; the CLI resends the turn with the full history.
812
+ const td = threadDecision(json);
813
+ if (td === "refuse") {
814
+ const out = JSON.stringify(THREAD_UNSUPPORTED);
815
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
816
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false);
817
+ return;
818
+ }
819
+ if (td === "strip")
820
+ stripThreadFields(json);
821
+ record = { ...record, target: route.model, provider: route.provider };
822
+ const routeEffort = effortOf(json);
823
+ if (routeEffort)
824
+ record.effort = routeEffort;
825
+ tag = `GOOGLE ${route.tag} effort=${routeEffort ?? "-"}`;
826
+ try {
827
+ const o = await this.google(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
828
+ this.recordOutcome(route.provider, o.status);
829
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
830
+ }
831
+ catch (e) {
832
+ this.recordOutcome(route.provider, 0);
833
+ finish("-", 0, `google error ${e.code ?? ""} ${e.message}`);
834
+ if (!res.headersSent)
835
+ res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
836
+ else
837
+ res.destroy();
838
+ }
839
+ return;
840
+ }
750
841
  if (provider.type === "anthropic") {
751
842
  if (!provider.accountPool || provider.auth !== "claude-code") {
752
843
  finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
@@ -852,7 +943,7 @@ export class Proxy {
852
943
  }
853
944
  }
854
945
  else if (isApiHost) {
855
- target = { protocol: "https:", host: cfg.upstream, port: 443, agent: this.upstreamAgent, extraHeaders: {} };
946
+ target = { protocol: "https:", host: cfg.upstream, port: this.deps.upstreamPort ?? 443, agent: this.deps.upstreamAgent ?? this.upstreamAgent, extraHeaders: {} };
856
947
  tag = `PASS ${typeof model === "string" ? model : "-"}`;
857
948
  // Passthrough stays byte-exact unless another model's thinking would get the turn refused.
858
949
  if (json && dropForeignThinking(json) > 0)
@@ -921,7 +1012,11 @@ export class Proxy {
921
1012
  let upReq;
922
1013
  /** Credentials already spent on this turn, so a retry cannot pick one of them again. */
923
1014
  const tried = new Set(penalised ? [penalised.id] : []);
1015
+ /** The headers of the attempt in flight, so a stale-socket resend repeats exactly that attempt. */
1016
+ let lastAttemptHeaders = headers;
1017
+ let staleRetries = 0;
924
1018
  const send = (attemptHeaders) => {
1019
+ lastAttemptHeaders = attemptHeaders;
925
1020
  upReq = lib.request({
926
1021
  protocol: target.protocol,
927
1022
  host: target.host,
@@ -989,6 +1084,18 @@ export class Proxy {
989
1084
  }
990
1085
  });
991
1086
  const onUpstreamError = (e) => {
1087
+ // A kept-alive socket the upstream had already closed: the reset arrives before any response,
1088
+ // so the request never reached it. claude.ai did this 259 times in four days of picker mode
1089
+ // (2026-10-03..06), each one a 502 on an app request such as `current_user_access`. Send an
1090
+ // idempotent request again on a fresh attempt instead of failing it; a POST is never resent,
1091
+ // since a reset cannot prove the upstream did not act on it.
1092
+ const code = e.code;
1093
+ if (upReq.reusedSocket && (code === "ECONNRESET" || code === "EPIPE") && IDEMPOTENT.has(method) && !clientAborted && !res.headersSent && staleRetries < 2) {
1094
+ staleRetries++;
1095
+ log.info(`STALE ${reqHost}: kept-alive socket was closed (${code}); resending ${method} ${path.slice(0, 60)}`);
1096
+ send(lastAttemptHeaders);
1097
+ return;
1098
+ }
992
1099
  if (target.host === cfg.upstream)
993
1100
  this.deps.health.failure(e);
994
1101
  // Nothing reached the provider, so this says nothing about the credential — but it does say
@@ -1148,7 +1255,7 @@ export class Proxy {
1148
1255
  else if (status === 200 && isPickerBootstrap) {
1149
1256
  try {
1150
1257
  const j = JSON.parse(out.toString("utf8"));
1151
- const r = injectPickerModels(j, cfg.cli.extraModels, cfg.cli.autoCompactWindow);
1258
+ const r = injectPickerModels(j, cfg.cli.extraModels, cfg.cli.autoCompactWindow, (model) => effortLevelsForModel(cfg, model));
1152
1259
  if (r.surfaces.length > 0) {
1153
1260
  this.lastPickerInjection = { at: new Date().toISOString(), ...r };
1154
1261
  out = Buffer.from(JSON.stringify(j));
@@ -56,6 +56,8 @@ export class RequestLog {
56
56
  continue;
57
57
  if (filter.kind && record.kind !== filter.kind)
58
58
  continue;
59
+ if (filter.session && record.session !== filter.session)
60
+ continue;
59
61
  out.push(record);
60
62
  }
61
63
  return out;
@@ -50,6 +50,18 @@ export function markerOverride(body, aliases) {
50
50
  export function resolve(model, body, cfg) {
51
51
  if (typeof model !== "string")
52
52
  return null;
53
+ // Measured on Desktop's engine 2.1.284: the registered AUTO_MODE_MODEL env var is
54
+ // unused. Match the classifier's system prompt, never quoted conversation text.
55
+ // Its decision and wire format remain the engine's; only the inference target changes.
56
+ if (cfg.autoModeClassifier && isAutoModeClassifier(body)) {
57
+ const target = cfg.autoModeClassifier;
58
+ return {
59
+ provider: target.provider,
60
+ model: target.model,
61
+ effort: target.effort,
62
+ tag: `${model}->${target.model} (auto-mode classifier)`,
63
+ };
64
+ }
53
65
  let base = model;
54
66
  let effort;
55
67
  const at = model.indexOf("@");
@@ -128,6 +140,16 @@ export function resolve(model, body, cfg) {
128
140
  return null;
129
141
  return { provider: owner, model: finalModel, effort: ov?.effort ?? effort, tag: `${model}->${finalModel}${ov ? " (marker)" : ""}` };
130
142
  }
143
+ export function isAutoModeClassifier(body) {
144
+ const request = body;
145
+ if (!request || (Array.isArray(request.tools) && request.tools.length > 0))
146
+ return false;
147
+ const system = request.system;
148
+ const texts = typeof system === "string" ? [system] : Array.isArray(system)
149
+ ? system.filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text)
150
+ : [];
151
+ return texts.some((text) => text.trimStart().startsWith("You are a security monitor for autonomous AI coding agents."));
152
+ }
131
153
  /**
132
154
  * Providers whose own `models` list carries this id. Dated and undated forms match each other, the
133
155
  * same way a slot lookup does. Ingress-only providers are included on purpose — see the call site.
@@ -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.7.0";
8
+ export const VERSION = "0.8.1";