clauderipple 0.8.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1 — 2026-10-06
4
+
5
+ ### Fixed
6
+
7
+ - **Codex credits are spent instead of refused.** Once a ChatGPT plan's window read 100%, the
8
+ router rested the account until the reset, even though OpenAI serves an account with bought
9
+ credits past its window. An account with credits (or an unlimited balance) now stays in
10
+ rotation; a real usage-limit refusal still rests it.
11
+ - **Fewer failed Claude app requests in picker mode.** claude.ai closes idle kept-alive
12
+ connections, and a request written on one as it closed came back as a 502 (about 65 a day
13
+ here, mostly the app's start-up requests). A GET, HEAD or OPTIONS that meets such a socket is
14
+ now sent again; a POST is not.
15
+ - **Picker mode on a new Mac.** The ClaudeRipple CA counted as trusted when it was merely in the
16
+ login keychain, so `picker on` could skip the trust step and leave Claude Desktop unable to
17
+ load claude.ai. Trust is now read from the user trust settings, and `picker off` removes the
18
+ certificate either way.
19
+ - **The route band said "router not answering"** on Claude Code 2.1.288, which sends plain
20
+ `http://` requests through the proxy too and got 405. They are now relayed.
21
+
22
+ ### Docs
23
+
24
+ - Any ChatGPT plan that includes Codex works (Plus, Pro, Business; Enterprise/Edu with Codex
25
+ turned on), not just Plus and Pro.
26
+ - `ultra` effort is sent as `max`: the Codex backend refuses `ultra` (measured), and Codex's own
27
+ ultra is max reasoning plus its sub-agents, which in Claude Code are Claude Code's subagents.
28
+
3
29
  ## 0.8.0 — 2026-10-05
4
30
 
5
31
  ### Added
package/README.ko.md CHANGED
@@ -101,7 +101,9 @@ Anthropic이 만든 커넥터가 전부 그대로 동작합니다. 1P에 남는
101
101
 
102
102
  ### OpenAI API 키가 필요한가요?
103
103
 
104
- 필요 없습니다. ChatGPT Plus나 Pro 구독이면 됩니다. 앱에서 로그인하면 되고 API 키는 쓰지 않습니다.
104
+ 필요 없습니다. Codex가 포함된 ChatGPT 요금제면 됩니다 — Plus·Pro·Business, 그리고 워크스페이스 관리자가
105
+ Codex를 켜 둔 Enterprise·Edu. 앱에서 로그인하면 되고 API 키는 쓰지 않습니다. 사용량 한도는 요금제 것을 따르며,
106
+ 한도를 다 쓰면 구매해 둔 Codex 크레딧으로 이어서 씁니다.
105
107
  API 키는 DeepSeek·Kimi·GLM·OpenRouter처럼 다른 프로바이더를 붙일 때만 씁니다.
106
108
 
107
109
  ### 어떤 모델을 쓸 수 있나요?
package/README.md CHANGED
@@ -114,8 +114,10 @@ Claude. Most people map one or two names to GPT and leave the rest alone.
114
114
 
115
115
  ### Do I need an OpenAI API key?
116
116
 
117
- No. A ChatGPT Plus or Pro subscription works — you sign in from the app and no API key
118
- is involved. API keys are for the other providers (DeepSeek, Kimi, GLM, OpenRouter and
117
+ No. Any ChatGPT plan that includes Codex works — Plus, Pro and Business, and Enterprise or
118
+ Edu where the workspace admin has turned Codex on. You sign in from the app and no API key
119
+ is involved. The plan's usage limits apply; once they are used up, Codex credits you have
120
+ bought carry on. API keys are for the other providers (DeepSeek, Kimi, GLM, OpenRouter and
119
121
  anything else with an Anthropic- or OpenAI-compatible endpoint).
120
122
 
121
123
  ### Which models can I use?
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "type": "commonjs"
5
5
  }
@@ -152,8 +152,12 @@ export function caTrusted(caPem) {
152
152
  const out = powershell(`@(Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -eq 'CN=${CA_NAME}' }).Count`);
153
153
  return Number(out.trim()) > 0;
154
154
  }
155
- execFileSync("security", ["find-certificate", "-c", CA_NAME, loginKeychain()], { stdio: "ignore" });
156
- return true;
155
+ // Ask for the trust setting, not the keychain item: the certificate can sit in the login keychain
156
+ // with no trust (new Mac, 2026-10-06), and a presence check then made `picker on` skip the trust
157
+ // step while Claude Desktop rejected every claude.ai certificate (ERR_CERT_AUTHORITY_INVALID).
158
+ // `add-trusted-cert` without -d writes the user trust domain, which this lists one "Cert N: <name>" per line.
159
+ const out = execFileSync("security", ["dump-trust-settings"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
160
+ return out.split("\n").some((line) => /^Cert \d+: /.test(line) && line.slice(line.indexOf(": ") + 2).trim() === CA_NAME);
157
161
  }
158
162
  catch {
159
163
  return false;
@@ -175,9 +179,10 @@ export function trustCa(caPem) {
175
179
  export function untrustCa(caPem) {
176
180
  if (isLinux)
177
181
  return nssUntrust();
178
- if (!caTrusted(caPem))
179
- return false;
182
+ const trusted = caTrusted(caPem);
180
183
  if (isWindows) {
184
+ if (!trusted)
185
+ return false;
181
186
  try {
182
187
  powershell(`Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -eq 'CN=${CA_NAME}' } | ForEach-Object { Remove-Item -Path $_.PSPath -Force }`, { stdio: "inherit", interactive: true });
183
188
  }
@@ -186,19 +191,22 @@ export function untrustCa(caPem) {
186
191
  }
187
192
  return true;
188
193
  }
189
- try {
190
- execFileSync("security", ["remove-trusted-cert", caPem], { stdio: "inherit" });
191
- }
192
- catch {
193
- /* trust settings may already be gone */
194
+ // macOS: an untrusted copy can still be in the keychain, so the item is deleted either way.
195
+ if (trusted) {
196
+ try {
197
+ execFileSync("security", ["remove-trusted-cert", caPem], { stdio: "inherit" });
198
+ }
199
+ catch {
200
+ /* trust settings may already be gone */
201
+ }
194
202
  }
195
203
  try {
196
204
  execFileSync("security", ["delete-certificate", "-c", CA_NAME, loginKeychain()], { stdio: "ignore" });
205
+ return true;
197
206
  }
198
207
  catch {
199
- /* ignore */
208
+ return trusted;
200
209
  }
201
- return true;
202
210
  }
203
211
  function readMeta() {
204
212
  try {
@@ -97,13 +97,36 @@ export function rateLimitsFromHeaders(h) {
97
97
  if (!primary)
98
98
  return null;
99
99
  const secondary = window("secondary");
100
+ const flag = (k) => {
101
+ const v = h.get(k)?.toLowerCase();
102
+ return v === "true" ? true : v === "false" ? false : undefined;
103
+ };
104
+ const credits = creditsSnapshot(flag("x-codex-credits-has-credits"), flag("x-codex-credits-unlimited"), h.get("x-codex-credits-balance"), undefined);
100
105
  return {
101
106
  type: "codex.rate_limits",
102
107
  plan_type: h.get("x-codex-plan-type") ?? undefined,
103
108
  rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
109
+ ...(credits ? { credits } : {}),
104
110
  at: Date.now(),
105
111
  };
106
112
  }
113
+ /**
114
+ * The account's purchased credits, which the backend draws on once a plan window is used up — the
115
+ * account keeps answering at 100%. Measured 2026-10-06: response headers carry
116
+ * `x-codex-credits-has-credits: False` / `-unlimited: False` / `-balance: 0` (Python-style
117
+ * booleans), and `/wham/usage` carries `credits: {has_credits, unlimited, overage_limit_reached,
118
+ * balance}`. Null when the backend said nothing about credits.
119
+ */
120
+ function creditsSnapshot(hasCredits, unlimited, balance, overageReached) {
121
+ if (typeof hasCredits !== "boolean" && typeof unlimited !== "boolean")
122
+ return null;
123
+ return {
124
+ has_credits: hasCredits === true,
125
+ unlimited: unlimited === true,
126
+ ...(typeof balance === "string" || typeof balance === "number" ? { balance: String(balance) } : {}),
127
+ ...(typeof overageReached === "boolean" ? { overage_limit_reached: overageReached } : {}),
128
+ };
129
+ }
107
130
  /**
108
131
  * Map the `/wham/usage` JSON body onto the same shape as `rateLimitsFromHeaders`, so `/api/status`
109
132
  * and the GUI read one thing whether the snapshot came from response headers or the active call.
@@ -131,10 +154,13 @@ export function rateLimitsFromUsage(body) {
131
154
  if (!primary)
132
155
  return null;
133
156
  const secondary = win(b?.rate_limit?.secondary_window);
157
+ const c = b?.credits;
158
+ const credits = c ? creditsSnapshot(c.has_credits, c.unlimited, c.balance, c.overage_limit_reached) : null;
134
159
  return {
135
160
  type: "codex.rate_limits",
136
161
  plan_type: typeof b?.plan_type === "string" ? b.plan_type : undefined,
137
162
  rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
163
+ ...(credits ? { credits } : {}),
138
164
  at: Date.now(),
139
165
  };
140
166
  }
@@ -182,9 +208,14 @@ function windowResetMs(w, now) {
182
208
  /**
183
209
  * How long an account is out, from a rate-limit snapshot: the latest reset among the windows it
184
210
  * has used up, since it is usable only once every full window has reset. Undefined when no window
185
- * is full — the account is not out, whatever else the snapshot says.
211
+ * is full — the account is not out, whatever else the snapshot says — and undefined when the
212
+ * account has credits to spend past the window: the backend keeps serving it, and when the credits
213
+ * run out it says so with a 429, which rests the account the ordinary way.
186
214
  */
187
- export function exhaustedForMs(snapshot, now = Date.now()) {
215
+ export function exhaustedForMs(snapshot, now = Date.now(), creditsCount = true) {
216
+ const credits = snapshot?.credits;
217
+ if (creditsCount && credits && (credits.has_credits === true || credits.unlimited === true) && credits.overage_limit_reached !== true)
218
+ return undefined;
188
219
  const limits = (snapshot?.rate_limits ?? null);
189
220
  let out;
190
221
  for (const w of [limits?.primary, limits?.secondary]) {
@@ -762,7 +793,8 @@ export class ChatGptAdapter {
762
793
  const headerRecord = Object.fromEntries(upstream.headers.entries());
763
794
  const snapshot = rateLimitsFromHeaders(upstream.headers);
764
795
  const retryHeader = headerRecord["retry-after"] ? retryAfterMs({ "retry-after": headerRecord["retry-after"] }) : undefined;
765
- const waitMs = retryHeader ?? exhaustedForMs(snapshot) ?? retryAfterMs(headerRecord);
796
+ // The account was refused, so whatever credits the headers report did not carry it past the window.
797
+ const waitMs = retryHeader ?? exhaustedForMs(snapshot, Date.now(), false) ?? retryAfterMs(headerRecord);
766
798
  // A refusal here is a rest, never a pool quarantine: "sign in again" is the store's to say
767
799
  // (needsReauth, above), and a quarantine would outlive the new token a sign-in brings.
768
800
  const verdict = this.pool.penalise(this.name, credential.id, credentialRefused ? 403 : status, waitMs, text);
@@ -47,11 +47,15 @@ const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "tra
47
47
  const CLIENT_AUTH = new Set(["authorization", "x-api-key"]);
48
48
  /** How much of an upstream error body is kept for the log. */
49
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"]);
50
52
  /**
51
53
  * Claude Code's Remote Control registration uses HTTPS absolute-form proxy requests instead of
52
54
  * CONNECT (`POST https://api.anthropic.com/v1/environments/bridge HTTP/1.1`). Convert that legal
53
55
  * forward-proxy form to the origin form expected inside a TLS connection. Credentials and the
54
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).
55
59
  */
56
60
  export function absoluteProxyRequest(header) {
57
61
  const lines = header.toString("latin1").split("\r\n");
@@ -66,9 +70,10 @@ export function absoluteProxyRequest(header) {
66
70
  catch {
67
71
  return null;
68
72
  }
69
- 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)
70
75
  return null;
71
- const port = url.port ? Number(url.port) : 443;
76
+ const port = url.port ? Number(url.port) : secure ? 443 : 80;
72
77
  if (!Number.isInteger(port) || port < 1 || port > 65535)
73
78
  return null;
74
79
  // URL.hostname keeps brackets around IPv6 literals; net/tls expect the bare address while the
@@ -79,10 +84,10 @@ export function absoluteProxyRequest(header) {
79
84
  !/^connection\s*:/i.test(line));
80
85
  const authority = url.port ? `${url.hostname}:${url.port}` : url.hostname;
81
86
  const path = `${url.pathname || "/"}${url.search}`;
82
- // 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
83
88
  // request target directly to the origin, so explicitly ask both peers to close after the response.
84
89
  const rewritten = [`${match[1]} ${path} ${match[3]}`, `Host: ${authority}`, "Connection: close", ...headers].join("\r\n") + "\r\n\r\n";
85
- return { host, port, path, head: Buffer.from(rewritten, "latin1") };
90
+ return { host, port, path, head: Buffer.from(rewritten, "latin1"), tls: secure };
86
91
  }
87
92
  /**
88
93
  * The same header list with one credential swapped for another. Names carried by either credential
@@ -425,7 +430,7 @@ export class Proxy {
425
430
  if (method !== "CONNECT" || !target) {
426
431
  const absolute = absoluteProxyRequest(head.subarray(0, end));
427
432
  if (absolute) {
428
- 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}`);
429
434
  this.forwardAbsolute(sock, absolute, rest);
430
435
  return;
431
436
  }
@@ -450,7 +455,9 @@ export class Proxy {
450
455
  /** Forward an HTTPS absolute-form request without terminating it through the routing layer. */
451
456
  forwardAbsolute(sock, request, rest) {
452
457
  const servername = net.isIP(request.host) ? undefined : request.host;
453
- 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 });
454
461
  const kill = () => {
455
462
  sock.destroy();
456
463
  up.destroy();
@@ -462,7 +469,7 @@ export class Proxy {
462
469
  up.destroy();
463
470
  });
464
471
  sock.on("error", kill);
465
- up.once("secureConnect", () => {
472
+ up.once(request.tls ? "secureConnect" : "connect", () => {
466
473
  up.write(request.head);
467
474
  if (rest.length)
468
475
  up.write(rest);
@@ -936,7 +943,7 @@ export class Proxy {
936
943
  }
937
944
  }
938
945
  else if (isApiHost) {
939
- 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: {} };
940
947
  tag = `PASS ${typeof model === "string" ? model : "-"}`;
941
948
  // Passthrough stays byte-exact unless another model's thinking would get the turn refused.
942
949
  if (json && dropForeignThinking(json) > 0)
@@ -1005,7 +1012,11 @@ export class Proxy {
1005
1012
  let upReq;
1006
1013
  /** Credentials already spent on this turn, so a retry cannot pick one of them again. */
1007
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;
1008
1018
  const send = (attemptHeaders) => {
1019
+ lastAttemptHeaders = attemptHeaders;
1009
1020
  upReq = lib.request({
1010
1021
  protocol: target.protocol,
1011
1022
  host: target.host,
@@ -1073,6 +1084,18 @@ export class Proxy {
1073
1084
  }
1074
1085
  });
1075
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
+ }
1076
1099
  if (target.host === cfg.upstream)
1077
1100
  this.deps.health.failure(e);
1078
1101
  // Nothing reached the provider, so this says nothing about the credential — but it does say
@@ -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.8.0";
8
+ export const VERSION = "0.8.1";
@@ -302,8 +302,22 @@ request went, and a `Pane` holds the request log.
302
302
  `/zen/v1/responses`, not the Codex backend): gpt-6-sol, gpt-6-luna,
303
303
  gpt-5.6-sol and gpt-5.6-luna all accept `none, low, medium, high, xhigh,
304
304
  max` and answer `400 param: reasoning.effort` to both `minimal` and
305
- `ultra`. So `ultra` is not a level that API takes on any of them; whether
306
- the Codex backend does is still open.
305
+ `ultra`. So `ultra` is not a level that API takes on any of them. **Settled
306
+ 2026-10-06 on the Codex backend too:** gpt-6.1-sol with `reasoning.effort:
307
+ "ultra"` answered `400 Invalid value: 'ultra'. Supported values are: 'none',
308
+ 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'`, while `max` answered
309
+ 200. The catalogue's own description of `ultra` is "Maximum reasoning with
310
+ automatic task delegation": it is `max` on the wire plus the Codex CLI
311
+ spawning its own sub-agents, which in Claude Code are Claude Code's
312
+ subagents. Keep the clamp. (`reasoning.mode` takes `standard`/`pro`, and
313
+ gpt-6.1-sol refuses `pro`: "not supported with this model".)
314
+ - **Credits (2026-10-06).** Every Codex response carries
315
+ `x-codex-credits-has-credits` / `-unlimited` (`True`/`False`) and
316
+ `-balance`; `/wham/usage` has `credits: {has_credits, unlimited,
317
+ overage_limit_reached, balance}`. OpenAI's help centre: included usage is
318
+ spent first, then purchased credits. So a window at 100% with credits left
319
+ is not an exhausted account — `exhaustedForMs` no longer rests it, and a
320
+ real 429 still does.
307
321
  - **Model catalogue (2026-09-23).** `GET {base}/codex/models?client_version=<v>`
308
322
  with the same credentials answers `{ models: [{ slug, display_name,
309
323
  visibility: "list"|"hide", context_window, supported_reasoning_levels:
@@ -1097,6 +1111,7 @@ subscription exists in this environment. Files are under `src/`:
1097
1111
  | Windows: the scheduled task inherited the console that started it, so closing the installer window killed the router with `0xC000013A` (Ctrl+C), 2026-09-14 | The task runs a PowerShell launcher that uses `Start-Process -PassThru -Wait`: detached from any console, still tracked so `RestartCount` keeps acting as KeepAlive and the exit code (including the health self-exit 75) propagates. |
1098
1112
  | Windows: `install` registered a logon-triggered task and stopped there, so the router did not exist until the next sign-in — launchd starts at bootstrap (`RunAtLoad`) and the difference was invisible | `install` starts the supervisor itself on both platforms and prints the result. |
1099
1113
  | Windows: trusting the CA failed with "this operation cannot use the UI" — every PowerShell call carried `-NonInteractive`, and the confirmation dialog never appeared, so picker mode stopped with no certificate and no explanation | Certificate trust and removal run WITHOUT `-NonInteractive` (and without `windowsHide`). They are UI operations by design: the OS must be able to show the user what it is being asked to trust. |
1114
+ | macOS, new Mac (2026-10-06): picker mode on, router up, and Claude Desktop would not open — every claude.ai load failed `ERR_CERT_AUTHORITY_INVALID`, router logged `tls alert certificate unknown`; `dump-trust-settings` had no entry for the CA. "Trusted" was decided by `find-certificate` (the item is in the login keychain), which says nothing about trust, so `picker on` could skip the trust step and `status` could read an untrusted CA as trusted | On macOS the CA counts as trusted only when the user trust domain (`security dump-trust-settings`) lists it. `picker off` deletes the keychain item even when it carries no trust. |
1100
1115
  | Windows: sign-in failed with `missing_required_parameter` because the OAuth URL was opened via `cmd /c start`, and cmd reads `&` as a command separator — everything after the first parameter was cut off and run as commands (2026-09-14) | Browsers are opened with `Start-Process <url>` as a single quoted argument. Any URL we hand to a shell must survive its metacharacters. |
1101
1116
  | The provider form's "also show in the Claude app picker" box only records `cli.extraModels`; picker mode itself stayed off, nothing was injected, and the user believed the picker was on (Windows x64, 2026-09-14: `config.json` had no `picker` key, no CA, no Config Library) | When that box is ticked while picker mode is off, the form says so under the box and offers to turn picker mode on right after saving. Any control that *looks* like it enables picker mode must either enable it or say what is still missing. |
1102
1117
  | A DeepSeek mapping answered `401` on every real request while the provider form's own "Test connection" stayed green — the test sends the provider's headers alone, live traffic also carried the caller's `authorization` (2026-09-15, Windows) | A routed request carries provider authentication only (§4). The connection test and live traffic must present the same auth set, or the test certifies a path nobody uses. |
@@ -1113,6 +1128,7 @@ subscription exists in this environment. Files are under `src/`:
1113
1128
  | The worker registry was four places that did not know each other — `providers.*.models`, `aliases`, `~/.claude/agents/*.md`, and prose in CLAUDE.md — and every new provider needed all four edited by hand; the one left out was the one that failed (2026-09-19, DeepSeek) | One source: a ticked model *is* a worker. Agent files and marker aliases are generated from `config.json` (§4 "worker definitions"); nothing about a worker is written twice. |
1114
1129
  | ChatGPT prompt-cache hit fell from 93% (2026-09-13) to 9% (2026-09-20) with nothing in our request changing — the backend had started keying the cache on the conversation's identity (`session-id`/`thread-id`/`x-client-request-id`/`client_metadata`), which we never sent; `prompt_cache_key` alone no longer earned a write | The adapter states the conversation's identity the way the Codex CLI does (§4) and echoes `x-codex-turn-state`. The acceptance metric (≥90% on translated providers) is watched per day in `requests.jsonl`; a fall with an unchanged request means the wire changed under us, and the reference to diff against is the real CLI captured through a local proxy, not our own memory of the protocol. |
1115
1130
  | Remote Control registration failed locally with 405 before Anthropic saw it: Claude Code 2.1.275 uses HTTPS absolute-form (`POST https://api.anthropic.com/v1/environments/bridge HTTP/1.1`) rather than CONNECT for registration, polling and heartbeats, while the forward-proxy socket accepted CONNECT only (issue #10, Windows 11) | Accept valid HTTPS absolute-form, strip proxy-only headers, replace Host, rewrite only the target to origin-form, and relay one request over direct TLS outside model routing. Force `Connection: close`: reusing that TLS socket could send a later proxy-form request to the first origin. Parser tests plus a live local TLS origin verify body and credential preservation. |
1131
+ | The status mod's band said "router not answering" on every tick while the admin API answered curl (2026-10-06, CLI 2.1.288): the host's `$.http.fetch("http://127.0.0.1:8792/…")` went through `HTTPS_PROXY` as plain-HTTP absolute-form (`GET http://127.0.0.1:8792/… HTTP/1.1`), loopback included, and the proxy socket answered 405 to anything but CONNECT or `https:` absolute-form. Measured with a probe mod under `claude -p --plugin-dir`: 405 with an empty body | `http:` absolute-form is relayed like `https:` (same header rewrite, `Connection: close`) over plain TCP. A live loopback origin test covers it. |
1116
1132
  | Re-running `clauderipple install` as a normal Windows user rewrote the launcher, then `Register-ScheduledTask -Force` could answer `0x80070005 Access is denied` even though the existing per-user task was already correct (issue #9) | Reuse an existing task only when its entire intended definition matches: one PowerShell action and exact arguments, current user, limited interactive logon, one matching logon trigger, restart count/interval, no execution limit, IgnoreNew, and battery policy. A missing, stale or foreign same-named task still takes the normal registration/error path. |
1117
1133
  | OpenCode Go's relay answered `403 {"message":"Error from provider (Console Go): Upstream request failed: …"}` during a few seconds of its own trouble. The 403 was classified as an auth failure, which both cooled the credential for 60s **and** removed it from the retry candidate list, so the router could not retry inside the turn and answered the user with what read as an authentication error. Five times in a day, each cleared by asking again (2026-09-21) | A 403 whose body reports an upstream failure is charged a **zero** cooldown. It is not a statement about the credential, and any wait at all moves the conversation off a key that did nothing and takes its prompt cache with it (§4). With no cooldown the credential stays retryable, so the turn is answered on the next attempt instead of becoming a user-visible failure. The body is the only way to tell this apart from a real 403 (policy, region, blocked model), so it is read for 403 and only 403. |
1118
1134
  | The same relay 403 kept reaching the user after that fix, because it was the wrong layer: DeepSeek runs on `openai-compatible`, whose adapter was **never** retried at all — the in-turn retry lives only in the anthropic-compatible wire path (`nextCredential`), and `credentialsOf` gives a translated provider exactly one credential and no fallback. So a relay hiccup had nowhere to go but the user (2026-09-21, root cause of the day's repeated "authentication failed") | Every translated adapter retries the `fetch` **before** any status or byte is written, so the turn is still replaceable (§4 "the first byte commits the turn"). `fetchWithRetry` retries only `transient` verdicts — connect failures, 5xx, and a 403 reporting a broken upstream — to the ceiling of 10 attempts the CLI itself uses, with a 250ms→2s backoff. A 401/429 is **not** retried: the adapter holds one credential, so asking the key that just refused the identical request again only delays the error. `classify` is reused for the judgement, with `kind === "transient"` narrowing a pool's answer to an adapter's. |
@@ -1121,6 +1137,8 @@ subscription exists in this environment. Files are under `src/`:
1121
1137
  | Re-running the installer — the documented update — replaced the files and left the router running the old code: `install` treats a running router as done, and on Windows and Linux an unchanged supervisor definition is not re-registered, so nothing restarted it until the next login (found 2026-09-28 while building `update`; the tray's own restart covered only the packaged app) | `install` asks a router that is already running for its version and restarts one that differs. Measured in the Windows 11 VM: 0.5.1 running, installer run with 0.6.0 → "restarting it on 0.6.0", status 0.6.0. |
1122
1138
  | The tray's Electron (~270MB) was fetched into the installed package's own `node_modules`, and `npm install -g` of any other version deleted it with the old files (measured 2026-09-28): every update silently took the tray away, and on Windows a running tray held `electron.exe` inside the folder npm had to replace | Electron lives in `<home>/tray-runtime`, outside anything an update replaces. Both install scripts move one found in the old place there before installing (Windows first stops a tray running from it, and starts it again after setup) — checked on macOS and in the VM with a locked `electron.exe`. |
1123
1139
  | Adding a provider in the settings window restored three OpenCode effort ladders realigned by hand five hours earlier, so the app's effort menus for DeepSeek and GLM never narrowed (2026-10-02 23:13, found 10-05). `PUT /api/config` takes the whole config, and the screen built it from the copy it loaded at open and never re-read; the router itself also writes `config.json` (a measurement's results), so any of those was undone by the next unrelated save | Every GUI save fetches the router's config first and applies its own change to that. A provider form, which holds per-model fields (wire, ladder, window) from when it opened and has no control for them, takes a saved model's values from the router's copy. The provider and client screens re-read the config whenever they load. |
1140
+ | In picker mode, claude.ai requests failed as `502 … ECONNRESET` in under a second — 259 times in four days (2026-10-03..06), mostly `current_user_access`, `desktop/features` and `event_logging`. The kept-alive upstream socket had been closed by claude.ai as the next request was written on it, and Node does not resend | A GET/HEAD/OPTIONS whose reused socket resets before any response is sent again (twice at most), logged `STALE`. A POST is never resent: a reset does not prove the upstream did not act on it. Not yet shown to be the cause of the Windows "chat history loads forever" report (DC, 2026-10-06). |
1141
+ | ChatGPT accounts that had bought Codex credits were rested by the router the moment a window read 100%, though OpenAI serves them from credits past the window | `exhaustedForMs` ignores a full window while `credits.has_credits` or `unlimited` holds (and `overage_limit_reached` does not); an actual 429 still rests the account until the window resets. |
1124
1142
 
1125
1143
  ## 6. Blocked paths (measured, do not retry)
1126
1144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Use GPT (ChatGPT Plus/Pro), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
5
5
  "license": "GPL-3.0-only",
6
6
  "workspaces": [