clauderipple 0.12.1 → 0.12.3

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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.3 — 2026-10-22
4
+
5
+ ### Added
6
+
7
+ - **Fast for GPT, separately for the main session and for subagents.** The ChatGPT provider's
8
+ settings have two switches, both off: "Fast for the main session" and "Fast for subagents".
9
+ When on, those GPT requests go out on Codex's fast tier, which answers sooner and uses the
10
+ subscription faster. Claude Code's own Fast toggle does not reach GPT models, so these are the
11
+ way to turn it on. Each request in the log now says whether a subagent made it.
12
+
13
+ ### Fixed
14
+
15
+ - **The phone host says when Claude Code has no login of its own.** On a computer that only ever
16
+ used the Claude app, every phone turn failed at once with "Not logged in". `clauderipple doctor`
17
+ and `clauderipple remote install` now warn about it and say how to log in, and the phone shows
18
+ that instead of the raw error.
19
+
20
+ ## 0.12.2 — 2026-10-22
21
+
22
+ ### Fixed
23
+
24
+ - **Back on Wi-Fi only when a known Wi-Fi is in range.** After a hotspot failover, the way back
25
+ no longer drops the hotspot for a Wi-Fi that is not there, which cut the connection on the move.
26
+
27
+ ### Changed
28
+
29
+ - **Naming a model on a delegation is now explained to the main chat.** `ripple:auto`'s
30
+ description says that `[[ripple: opus@high]]` (or a worker's name) on the first line runs that
31
+ model past every ClaudeRipple rule and still shows in the agents panel, so a session no longer
32
+ starts models outside it to get one. It also says what a brief should state, to check that the
33
+ report did what was asked, and to resume a worker only within 5 minutes of its last reply.
34
+
3
35
  ## 0.12.1 — 2026-10-21
4
36
 
5
37
  ### Changed
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "type": "commonjs"
5
5
  }
@@ -1,6 +1,6 @@
1
1
  // `claude setup-token` invocation for a ClaudeRipple-owned, 0600 token file.
2
2
  // The token is never echoed, logged, or returned from this module.
3
- import { execFileSync } from "node:child_process";
3
+ import { execFile, execFileSync } from "node:child_process";
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
@@ -109,6 +109,97 @@ export function claudeBinary(pathValue = process.env.PATH) {
109
109
  }
110
110
  return latestDesktopClaude();
111
111
  }
112
+ // ---- does the Claude Code CLI itself have a login? -------------------------------------------
113
+ //
114
+ // The Ripple Remote host drives Claude Code through the Agent SDK and passes no credential of its
115
+ // own, so every turn needs the CLI's own login. Where only the Claude desktop app is used, that
116
+ // login never exists and the CLI answers "Not logged in · Please run /login" (issue #71).
117
+ //
118
+ // This asks whether the login is there. It never reads, returns or logs a secret: the keychain
119
+ // item is looked up by name only (without -w, so the password is never fetched) and the
120
+ // credential file is only parsed for the shape of its content.
121
+ //
122
+ // The router reads the same two stores for its own `claude-code` provider
123
+ // (router/src/providers/anthropic.ts:41-74). That reader is not reused here: it returns the
124
+ // secret, and it parses a login as present only when it is also unexpired — this asks for
125
+ // presence, and for a home directory of the caller's choosing.
126
+ /** The keychain service the CLI keeps its OAuth grant in (the router names it the same way). */
127
+ const KEYCHAIN_SERVICE = "Claude Code-credentials";
128
+ /** Where the CLI keeps its credential file. */
129
+ export function claudeCredentialsFile(home = os.homedir()) {
130
+ return path.join(home, ".claude", ".credentials.json");
131
+ }
132
+ /**
133
+ * True when the file holds a `claudeAiOauth` block: a Claude Code login of its own. A file that
134
+ * only holds an `mcpOAuth` block is not one (that is what an MCP server's OAuth flow writes), and
135
+ * an empty access token is a login that was emptied rather than one that is there.
136
+ */
137
+ export function hasClaudeAiOauth(raw) {
138
+ if (!raw)
139
+ return false;
140
+ try {
141
+ const parsed = JSON.parse(raw);
142
+ const oauth = parsed.claudeAiOauth;
143
+ if (oauth === null || typeof oauth !== "object" || Array.isArray(oauth))
144
+ return false;
145
+ const accessToken = oauth.accessToken;
146
+ return accessToken === undefined || (typeof accessToken === "string" && accessToken.length > 0);
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
152
+ /** macOS: the CLI's keychain item, looked up by name. The secret itself is never read. */
153
+ async function keychainHasCredentials() {
154
+ try {
155
+ // Without -w the password is never fetched, so what comes back is the item's attributes only.
156
+ await execFile("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE], { timeout: 5_000, windowsHide: true });
157
+ return true;
158
+ }
159
+ catch {
160
+ // No item, or the keychain would not answer: there is no login to count.
161
+ return false;
162
+ }
163
+ }
164
+ /**
165
+ * Whether the Claude Code CLI has a login of its own, and where it is. Presence only: no secret
166
+ * is read, returned or logged. The environment is checked first because it costs nothing, and the
167
+ * keychain probe (a child process) runs only when nothing cheaper answered.
168
+ */
169
+ export async function claudeCodeLogin(options = {}) {
170
+ const env = options.env ?? process.env;
171
+ const tokens = [
172
+ ["CLAUDE_CODE_OAUTH_TOKEN", "the CLAUDE_CODE_OAUTH_TOKEN environment variable"],
173
+ ["ANTHROPIC_API_KEY", "the ANTHROPIC_API_KEY environment variable"],
174
+ ["ANTHROPIC_AUTH_TOKEN", "the ANTHROPIC_AUTH_TOKEN environment variable"],
175
+ ];
176
+ for (const [key, where] of tokens)
177
+ if (env[key])
178
+ return { ok: true, where };
179
+ const platform = options.platform ?? process.platform;
180
+ if (platform === "darwin") {
181
+ const probe = options.keychain ?? keychainHasCredentials;
182
+ if (await probe())
183
+ return { ok: true, where: `the macOS keychain (${KEYCHAIN_SERVICE})` };
184
+ }
185
+ const home = options.home ?? os.homedir();
186
+ let raw = null;
187
+ try {
188
+ raw = fs.readFileSync(claudeCredentialsFile(home), "utf8");
189
+ }
190
+ catch {
191
+ raw = null;
192
+ }
193
+ if (hasClaudeAiOauth(raw))
194
+ return { ok: true, where: `${claudeCredentialsFile(home)} (claudeAiOauth)` };
195
+ return { ok: false, where: "no Claude Code login: no token in the environment, no keychain item, no credential file" };
196
+ }
197
+ /** What a person whose Claude Code CLI has no login is told to do. */
198
+ export function claudeLoginHint(binary) {
199
+ return binary
200
+ ? `Run the Claude Code CLI once and log in with /login: ${binary}`
201
+ : "Run the Claude Code CLI once and log in with /login (install Claude Code if it is not on this computer)";
202
+ }
112
203
  function needsTerminal() {
113
204
  return (`Claude sign-in needs a terminal: run \`clauderipple claude-login\` in ${isWindows ? "PowerShell" : "Terminal"}. ` +
114
205
  "If Claude Code is already signed in on this computer, its login is reused automatically and this step is not needed.");
@@ -6,9 +6,11 @@
6
6
  // Everything that reaches the machine is a dependency (`RecoveryDeps`), so tests run with a fake
7
7
  // supervisor and temp files and never touch launchd, the user's settings or the live router.
8
8
  import fs from "node:fs";
9
+ import os from "node:os";
9
10
  import path from "node:path";
10
11
  import { ConfigStore, adminPort } from "../../router/src/config.js";
11
12
  import { certPaths } from "./certs.js";
13
+ import { claudeBinary, claudeCodeLogin, claudeLoginHint } from "./claude-auth.js";
12
14
  import { applyAppProxy, currentAppProxy } from "./picker.js";
13
15
  import { applyProxyEnv, settingsEnv } from "./settings.js";
14
16
  export async function fetchRouterStatus(adminUrl, timeoutMs) {
@@ -66,6 +68,21 @@ export function classifyMode(cli, desktop, pickerEnabled) {
66
68
  function configFile(home) {
67
69
  return path.join(home, "config.json");
68
70
  }
71
+ /**
72
+ * Whether the phone host's own background service is installed here (a launchd agent, a scheduled
73
+ * task, a systemd user unit). The host package owns that path, so it is asked for it; a host that
74
+ * is not installed cannot need a CLI login of its own.
75
+ */
76
+ async function remoteHostInstalled() {
77
+ try {
78
+ const { servicePaths } = await import("../../remote/src/host-service.js");
79
+ return fs.existsSync(servicePaths(process.platform, os.homedir()).definition);
80
+ }
81
+ catch {
82
+ // A host package that cannot be read is a host that is not installed here.
83
+ return false;
84
+ }
85
+ }
69
86
  async function snapshot(deps) {
70
87
  const installed = fs.existsSync(configFile(deps.home));
71
88
  const cfg = new ConfigStore(configFile(deps.home)).get();
@@ -106,6 +123,13 @@ async function snapshot(deps) {
106
123
  add("desktop-foreign-proxy", `Claude Desktop goes through another proxy (${desktop.url}), not ClaudeRipple.`);
107
124
  if (mode === "mixed")
108
125
  add("mixed-mode", `Claude Code ${cli.proxy === "ripple" ? "goes" : "does not go"} through ClaudeRipple, Claude Desktop ${desktop.proxy === "ripple" ? "does" : "does not"}.`);
126
+ // The phone host starts Claude Code through the Agent SDK and passes no credential of its own, so
127
+ // without a login of the CLI's own every turn fails at once (issue #71).
128
+ if (await (deps.remoteHostInstalled ?? remoteHostInstalled)()) {
129
+ const login = await (deps.claudeLogin ?? (() => claudeCodeLogin()))();
130
+ if (!login.ok)
131
+ add("cli-not-logged-in", `Claude Code has no login on this computer, so the phone host's sessions fail at once. ${claudeLoginHint(claudeBinary())}`);
132
+ }
109
133
  return {
110
134
  report: {
111
135
  mode,
@@ -11,7 +11,11 @@ export const AUTO_DESCRIPTION = 'Delegate any task here; ClaudeRipple picks the
11
11
  'If you know how hard the task is, put [[ripple: light]], [[ripple: standard]], [[ripple: hard]] or [[ripple: max]] on the prompt\'s first line: ' +
12
12
  'light = lookups, mechanical edits, one small fix; standard = carry out a designed change (even across several files), research, summarize, read-only review; ' +
13
13
  'hard = subtle debugging, security review, or work where a wrong call is costly; max = planning, design or architecture where the deliverable is the decision itself. ' +
14
- 'Size, file count or the brief\'s warnings do not by themselves make a task hard. Leave the marker out when unsure, and ClaudeRipple judges it.'
14
+ 'Size, file count or the brief\'s warnings do not by themselves make a task hard. Leave the marker out when unsure, and ClaudeRipple judges it. ' +
15
+ 'To run one particular model instead (the user asked for it, or a comparison needs it), name it on the first line: [[ripple: opus@high]] for a Claude model (opus, sonnet, haiku), or [[ripple: <worker agent name>@<effort>]]. ' +
16
+ 'It runs as named, past every ClaudeRipple rule, and still shows in the agents panel; never start a model another way, such as claude -p, to get around the rules. ' +
17
+ 'A brief states the scope, what must not be touched, the done criteria and how to verify them. Read the report for whether it did what was asked before using it. ' +
18
+ 'Continue a worker with SendMessage only within 5 minutes of its last reply; later its cache has lapsed, so start a new one with a summary of what is done and what is left.'
15
19
 
16
20
  /**
17
21
  * The tier markers the main chat may write on a ripple:auto prompt, as the judge's verdict they
@@ -8,6 +8,14 @@ import { setTimeout as delay } from "node:timers/promises";
8
8
  export const DESKTOP_BLOCKED = "Claude 앱에서 열려 있는 세션에 지금은 보낼 수 없습니다.";
9
9
  export const LOGIN_EXPIRED = "이 컴퓨터의 Claude 로그인이 만료되었습니다. Claude 앱을 한 번 열어 주십시오.";
10
10
  export const LOGIN_REFUSED = "이 컴퓨터의 Claude 로그인이 거절되었습니다 (401). Claude 앱을 한 번 열어 주십시오.";
11
+ // The Claude Code CLI has no login of its own. The host starts the CLI through the Agent SDK and
12
+ // passes no credential, so the CLI's own "Not logged in · Please run /login" is the whole error a
13
+ // phone sees; this says it in the host's words (issue #71).
14
+ export const CLI_NOT_LOGGED_IN = "이 컴퓨터의 Claude Code에 로그인되어 있지 않습니다. 터미널에서 Claude Code를 한 번 실행해 /login 해 주십시오.";
15
+ /** The CLI's own words for the same thing, tolerant of the spacing around them. */
16
+ export function isCliNotLoggedIn(text) {
17
+ return /^\s*not\s+logged\s+in\b/i.test(text);
18
+ }
11
19
  class BridgeNetworkError extends Error {
12
20
  }
13
21
  /**
@@ -29,10 +29,11 @@ import { RelayHost, loadHostKey } from "./relay.js";
29
29
  import { RouterClient } from "./router.js";
30
30
  import { HostServer } from "./server.js";
31
31
  import { LocalControl } from "./local-control.js";
32
- import { findClaude, specFor } from "./service.js";
32
+ import { findClaude, specFor, cliLoginWarning } from "./service.js";
33
33
  import { installService, uninstallService, serviceStatus, servicePaths, renderService, supervisorName, controlShutdown } from "./host-service.js";
34
34
  import { Sessions } from "./sessions.js";
35
35
  import { desktopHandoff } from "./handoff.js";
36
+ import { claudeBinary, claudeCodeLogin } from "../../cli/src/claude-auth.js";
36
37
  const here = path.dirname(fileURLToPath(import.meta.url));
37
38
  const VERSION = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8")).version;
38
39
  const out = (s) => { process.stdout.write(s); };
@@ -231,7 +232,9 @@ async function installCmd() {
231
232
  if (process.platform === "win32" && !process.argv.includes("--print"))
232
233
  await controlShutdown(dir);
233
234
  const cfg = process.argv.includes("--print") ? loadConfig(dir, false) : configFromArgs(dir);
234
- const spec = specFor({ script: fileURLToPath(import.meta.url), claudePath: findClaude(cfg.claudePath), ...(process.env.RIPPLE_REMOTE_HOME ? { remoteHome: process.env.RIPPLE_REMOTE_HOME } : {}) });
235
+ // The CLI the host will start; also what a person is told to run when it has no login.
236
+ const claudePath = findClaude(cfg.claudePath);
237
+ const spec = specFor({ script: fileURLToPath(import.meta.url), claudePath, ...(process.env.RIPPLE_REMOTE_HOME ? { remoteHome: process.env.RIPPLE_REMOTE_HOME } : {}) });
235
238
  if (process.argv.includes("--print")) {
236
239
  out(renderService(spec));
237
240
  return;
@@ -239,6 +242,12 @@ async function installCmd() {
239
242
  for (const line of await installService(spec))
240
243
  out(`${line}\n`);
241
244
  out(`Pair the phone: clauderipple remote pair\n`);
245
+ // The host starts Claude Code through the Agent SDK and passes no credential of its own, so a
246
+ // machine where the CLI was never logged in fails on every turn (issue #71). Say so now; the
247
+ // install itself is never held up by it.
248
+ const warning = cliLoginWarning(await claudeCodeLogin(), claudePath ?? claudeBinary() ?? null);
249
+ if (warning)
250
+ out(`⚠ ${warning}\n`);
242
251
  }
243
252
  async function main() {
244
253
  const sub = process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : null;
@@ -4,7 +4,7 @@ import { execFileSync } from "node:child_process";
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { claudeBinary } from "../../cli/src/claude-auth.js";
7
+ import { claudeBinary, claudeLoginHint } from "../../cli/src/claude-auth.js";
8
8
  export const LABEL = "com.clauderipple.host";
9
9
  // ---- where things are --------------------------------------------------------------------------
10
10
  /** The user's own CLI when there is one: it is the version the router's measurements and the ripple mod are kept current with. */
@@ -26,6 +26,16 @@ export function findClaude(configured, exists = fs.existsSync, opts = {}) {
26
26
  // Shared discovery also covers both AppData roots and Desktop's version/hash cache layout.
27
27
  return platform === process.platform && exists === fs.existsSync ? claudeBinary() ?? undefined : undefined;
28
28
  }
29
+ /**
30
+ * The one line `remote install` prints when the Claude Code CLI has no login of its own: the host
31
+ * starts Claude Code through the Agent SDK and passes no credential, so every phone turn would
32
+ * fail at once with the CLI's own "Not logged in" (issue #71). Null when there is a login.
33
+ */
34
+ export function cliLoginWarning(login, binary) {
35
+ if (login.ok)
36
+ return null;
37
+ return `Claude Code has no login on this computer, so phone sessions will fail until it has one. ${claudeLoginHint(binary)}`;
38
+ }
29
39
  const xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
30
40
  export function plist(spec) {
31
41
  const program = [spec.node, "--disable-warning=ExperimentalWarning", spec.script, ...spec.args];
@@ -11,7 +11,7 @@ import { attachmentContent, messageContent, sendUserFileImages } from "./images.
11
11
  import { ATTACHMENT_CAP, ATTACHMENT_COUNT } from "./protocol.js";
12
12
  import { DesktopSessions } from "./desktop.js";
13
13
  import { DesktopSidebar, PHONE_GROUP } from "./sidebar.js";
14
- import { BridgeAuthError, BridgeClient, DESKTOP_BLOCKED, object, slashCommands } from "./bridge-client.js";
14
+ import { BridgeAuthError, BridgeClient, CLI_NOT_LOGGED_IN, DESKTOP_BLOCKED, isCliNotLoggedIn, object, slashCommands } from "./bridge-client.js";
15
15
  import { isDirectory } from "./folders.js";
16
16
  import { readJson, writeFileAtomic } from "./fsutil.js";
17
17
  import { createWorktree } from "./worktree.js";
@@ -1474,12 +1474,23 @@ export class Sessions {
1474
1474
  this.live.delete(r.id);
1475
1475
  this.opts.permissions.expireSession(r.id, "aborted");
1476
1476
  if (failure) {
1477
- this.append(r.id, { kind: "result", ok: false, error: failure, ms: this.now() - live.turnStart, turns: 0 });
1478
- this.setState(r, "error", failure.slice(0, 300));
1477
+ const text = this.failureText(r.id, failure);
1478
+ this.append(r.id, { kind: "result", ok: false, error: text, ms: this.now() - live.turnStart, turns: 0 });
1479
+ this.setState(r, "error", text.slice(0, 300));
1479
1480
  }
1480
1481
  else if (r.state !== "idle" && r.state !== "error")
1481
1482
  this.setState(r, "stopped");
1482
1483
  }
1484
+ /**
1485
+ * The CLI's own words for "no login" become the host's, so a phone reads what to do about it.
1486
+ * The raw text stays in the host's own log line, where the exact CLI wording belongs.
1487
+ */
1488
+ failureText(id, raw) {
1489
+ if (!isCliNotLoggedIn(raw))
1490
+ return raw;
1491
+ (this.opts.log ?? console.error)(`[session ${id.slice(0, 8)}] Claude Code CLI: ${raw.trim()}`);
1492
+ return CLI_NOT_LOGGED_IN;
1493
+ }
1483
1494
  handle(r, live, msg) {
1484
1495
  const parent = msg.parent_tool_use_id ?? null;
1485
1496
  switch (msg.type) {
@@ -1638,7 +1649,7 @@ export class Sessions {
1638
1649
  for (const [model, u] of Object.entries(usage ?? {}))
1639
1650
  if (typeof u.contextWindow === "number")
1640
1651
  this.learnWindow(model, u.contextWindow);
1641
- const error = ok ? undefined : String(msg.result ?? (Array.isArray(msg.errors) ? msg.errors.join("; ") : msg.subtype ?? "error")).slice(0, 500);
1652
+ const error = ok ? undefined : this.failureText(r.id, String(msg.result ?? (Array.isArray(msg.errors) ? msg.errors.join("; ") : msg.subtype ?? "error"))).slice(0, 500);
1642
1653
  this.append(r.id, { kind: "result", ok, ...(error ? { error } : {}), ms: typeof msg.duration_ms === "number" ? msg.duration_ms : this.now() - live.turnStart, turns: typeof msg.num_turns === "number" ? msg.num_turns : 0 });
1643
1654
  this.setState(r, live.pendingTurns > 0 ? "running" : ok ? "idle" : "error");
1644
1655
  if (live.pendingTurns === 0)
@@ -8,6 +8,19 @@ import os from "node:os";
8
8
  import path from "node:path";
9
9
  import { PRESETS } from "./presets.js";
10
10
  import { hostProblem, portProblem } from "./remote-host.js";
11
+ /**
12
+ * A provider's Fast switches as two plain booleans.
13
+ *
14
+ * Anything that is not exactly `true` is off: the switches are read from a file a person can edit
15
+ * by hand, and a value that is not a boolean ("yes", 1, null) must not be read as "on". A `fast`
16
+ * that is not an object at all is off the same way, rather than a crash on the first request.
17
+ */
18
+ export function fastSwitches(provider) {
19
+ const fast = provider?.fast;
20
+ if (!fast || typeof fast !== "object")
21
+ return { main: false, subagent: false };
22
+ return { main: fast.main === true, subagent: fast.subagent === true };
23
+ }
11
24
  /**
12
25
  * The provider's own key wherever its headers carry it, and every header that is not that one.
13
26
  *
@@ -502,6 +515,12 @@ export function validate(c) {
502
515
  }
503
516
  if (p.url && !/^https?:\/\//.test(p.url))
504
517
  errors.push(`provider ${name}: url must start with http:// or https://`);
518
+ // Only the shape is refused here. A value inside that is not a boolean is not an error: it is
519
+ // read as off (fastSwitches), so a hand-edited file cannot put the router on a tier by
520
+ // accident, and one odd value does not keep the whole config from loading.
521
+ if (p.fast !== undefined && (!p.fast || typeof p.fast !== "object" || Array.isArray(p.fast))) {
522
+ errors.push(`provider ${name}: fast must be an object with main and subagent booleans`);
523
+ }
505
524
  }
506
525
  else if (p.type === "openai-compatible") {
507
526
  if (!/^https?:\/\//.test(p.url))
@@ -14,7 +14,7 @@ import { credentialHeaderValues, redactErrorText } from "../../redact.js";
14
14
  import { codexClientVersion, latestCodexVersion, parseCodexCatalog } from "./catalog.js";
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
- import { homeDir } from "../../config.js";
17
+ import { homeDir, fastSwitches } from "../../config.js";
18
18
  export const DEFAULT_BASE = "https://chatgpt.com/backend-api";
19
19
  const PING_MS = 15_000;
20
20
  /** How long a stream may hold its headers waiting for upstream's first item (see `handle`). Tests shorten it. */
@@ -980,8 +980,11 @@ export class ChatGptAdapter {
980
980
  * to replay is read out of the request itself; `carried` is filled in as the input is built and the
981
981
  * outcome lands in the request log. `replay: false` is the retry after the backend refused that
982
982
  * carrier, and it puts back exactly the request this adapter sent before any of this existed.
983
+ *
984
+ * `subagent` is the one bit about the caller the tier needs: Claude Code names the agent in the
985
+ * `x-claude-code-agent-id` header, and only a subagent's request carries it.
983
986
  */
984
- responsesOptions(carried, model, effort, replay = true) {
987
+ responsesOptions(carried, model, effort, replay = true, subagent = false) {
985
988
  return {
986
989
  model,
987
990
  effort: effort ?? this.cfg.defaultEffort ?? "high",
@@ -992,12 +995,20 @@ export class ChatGptAdapter {
992
995
  // else: reasoning outlives the turn that made it, and a conversation moved to another model
993
996
  // must not carry the old one's sealed state with it (reasoning.ts).
994
997
  env: { p: this.name, m: model },
998
+ // The Fast switches are the provider's own, so the tier is the same however the turn arrived.
999
+ fast: fastSwitches(this.cfg),
1000
+ subagent,
995
1001
  carried,
996
1002
  log: (line) => this.log.info(`chatgpt ${this.name}: ${line} (${model})`),
997
1003
  };
998
1004
  }
999
- /** Handle a fully-read Messages request. `model`/`effort` already resolved by routing. */
1000
- async handle(req, res, path, json, model, effort) {
1005
+ /**
1006
+ * Handle a fully-read Messages request. `model`/`effort` already resolved by routing.
1007
+ *
1008
+ * `options.subagent` says the turn was made inside a subagent, which is what the provider's
1009
+ * `fast.subagent` switch is about.
1010
+ */
1011
+ async handle(req, res, path, json, model, effort, options) {
1001
1012
  if (path.startsWith("/v1/messages/count_tokens")) {
1002
1013
  const body = JSON.stringify({ input_tokens: estimateTokens(json) });
1003
1014
  res.writeHead(200, { "content-type": "application/json", "content-length": String(body.length) }).end(body);
@@ -1009,7 +1020,8 @@ export class ChatGptAdapter {
1009
1020
  if (serverTools.size > 0)
1010
1021
  this.log.warn(`chatgpt ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
1011
1022
  const carried = { replayed: 0, undecodable: 0 };
1012
- let upstreamReq = toResponsesRequest(json, this.responsesOptions(carried, model, effort));
1023
+ const subagent = options?.subagent === true;
1024
+ let upstreamReq = toResponsesRequest(json, this.responsesOptions(carried, model, effort, true, subagent));
1013
1025
  let body = JSON.stringify(upstreamReq);
1014
1026
  const cacheKey = upstreamReq.prompt_cache_key;
1015
1027
  // Input estimate for message_start: a conversation only grows, so the last measured total is a floor.
@@ -1050,7 +1062,7 @@ export class ChatGptAdapter {
1050
1062
  // must never be the reason a user's turn fails (reasoning.ts).
1051
1063
  if (carried.replayed > 0 && sent.kind === "refused" && refusesReplayedReasoning(sent.status, sent.text)) {
1052
1064
  this.log.warn(`chatgpt ${this.name}: upstream ${sent.status} refused ${carried.replayed} replayed reasoning item(s) for ${model}; asking again without them: ${sent.text.slice(0, 200)}`);
1053
- upstreamReq = toResponsesRequest(json, this.responsesOptions({ replayed: 0, undecodable: 0 }, model, effort, false));
1065
+ upstreamReq = toResponsesRequest(json, this.responsesOptions({ replayed: 0, undecodable: 0 }, model, effort, false, subagent));
1054
1066
  body = JSON.stringify(upstreamReq);
1055
1067
  carried.replayed = 0;
1056
1068
  sent = await ask();
@@ -373,7 +373,11 @@ export function toResponsesRequest(req, opts) {
373
373
  out.tool_choice = tool_choice;
374
374
  // Claude Code's `/fast` arrives as `speed: "fast"` (Anthropic fast mode). The ChatGPT backend's
375
375
  // equivalent is the Codex CLI's Fast mode, which bills the plan at a higher rate (Codex docs, Speed).
376
- if (req.speed === "fast")
376
+ // The CLI never sends `speed` for a GPT model, so the provider's own switches choose the tier:
377
+ // `fast.subagent` for a turn made inside a subagent, `fast.main` for the session's own turns.
378
+ const fast = opts.fast;
379
+ const fastOn = fast ? (opts.subagent === true ? fast.subagent === true : fast.main === true) : false;
380
+ if (req.speed === "fast" || fastOn)
377
381
  out.service_tier = "priority";
378
382
  return out;
379
383
  }
@@ -692,6 +692,14 @@ export class Proxy {
692
692
  const session = req.headers["x-claude-code-session-id"];
693
693
  if (typeof session === "string" && /^[\w-]{1,128}$/.test(session))
694
694
  record.session = session;
695
+ // Claude Code names the agent a request was made inside, and only a subagent's request carries
696
+ // the name (CLI 2.1.304: `...Y?.agentId&&{"x-claude-code-agent-id":…}`). This one bit is what
697
+ // tells the main session from a subagent's turns, so it is recorded for every provider and read
698
+ // by the ChatGPT adapter to choose its tier.
699
+ const agentId = req.headers["x-claude-code-agent-id"];
700
+ const subagent = typeof agentId === "string" && agentId.length > 0;
701
+ if (subagent)
702
+ record.subagent = true;
695
703
  let observedUsage;
696
704
  let observedStopReason;
697
705
  // Set once a Google turn came back unwritten (a prompt refusal or no model capacity), which allows
@@ -980,7 +988,7 @@ export class Proxy {
980
988
  try {
981
989
  // The adapter reports each account's outcome to the shared pool itself, so a slot pointing
982
990
  // here fails over once every account is out (chooseTarget asks the adapter).
983
- const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
991
+ const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json), { subagent });
984
992
  if (!path.startsWith("/v1/messages/count_tokens"))
985
993
  this.providerHealth.observe(route.provider, o.status, o.exhaustion);
986
994
  finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
@@ -76,6 +76,13 @@ export function parseProfileNames(text) {
76
76
  }
77
77
  return names;
78
78
  }
79
+ /** `netsh wlan show networks mode=ssid`: SSID is the protocol label, not the translated fields below it. */
80
+ export function parseVisibleSsids(text) {
81
+ return [...new Set(text.split(/\r?\n/).flatMap((line) => {
82
+ const name = /^\s*SSID\s+\d+\s*:\s?(.*)$/.exec(line)?.[1]?.trim();
83
+ return name ? [name] : [];
84
+ }))];
85
+ }
79
86
  function xmlText(s) {
80
87
  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&amp;/g, "&");
81
88
  }
@@ -103,6 +110,8 @@ export async function listWifiProfiles(run) {
103
110
  const r = await run(NETSH, ["wlan", "export", "profile", `folder="${dir}"`], { verbatim: true });
104
111
  if (needsLocation(r))
105
112
  return { profiles: [], location: true };
113
+ if (r.code !== 0)
114
+ return { profiles: [], error: (r.stderr || r.stdout).trim().slice(0, 200) || `profile export exited ${r.code}` };
106
115
  const parsed = [];
107
116
  for (const f of fs.readdirSync(dir)) {
108
117
  if (!f.toLowerCase().endsWith(".xml"))
@@ -191,8 +200,21 @@ export class WinTetherBackend {
191
200
  if (list.location)
192
201
  return "Windows needs Location on for Wi-Fi commands";
193
202
  if (list.error)
194
- return null; // cannot tell: try anyway
195
- return autoProfiles(list.profiles, ssid).length ? null : "no other Wi-Fi on this PC is set to connect automatically";
203
+ throw new Error(`saved Wi-Fi profiles failed: ${list.error}`);
204
+ const known = autoProfiles(list.profiles, ssid).filter((p) => p.ssid !== ssid);
205
+ if (!known.length)
206
+ return "no other Wi-Fi on this PC is set to connect automatically";
207
+ if (!this.adapter)
208
+ throw new Error("no Wi-Fi adapter for visibility scan");
209
+ const shown = await this.run(NETSH, ["wlan", "show", "networks", "mode=ssid", `interface="${this.adapter}"`], { verbatim: true, timeoutMs: 20_000 });
210
+ if (needsLocation(shown))
211
+ return "Windows needs Location on to scan for known Wi-Fi; visibility is unknown";
212
+ if (shown.code !== 0)
213
+ throw new Error(`netsh Wi-Fi scan failed: ${(shown.stderr || shown.stdout).trim().slice(0, 200) || `exit ${shown.code}`}`);
214
+ const visible = parseVisibleSsids(shown.stdout);
215
+ if (!visible.length)
216
+ return "no known Wi-Fi in range (netsh returned no usable SSIDs; empty, redacted or unsupported scan)";
217
+ return known.some((p) => visible.includes(p.ssid)) ? null : "no known Wi-Fi in range";
196
218
  }
197
219
  async leave() {
198
220
  if (!this.adapter)
@@ -11,13 +11,14 @@
11
11
  // failover on Wi-Fi (or none), 3 of the last 5 probe rounds failed → `sudo -n networksetup
12
12
  // -setairportnetwork <dev> <hotspot>`; macOS takes the password from the System keychain.
13
13
  // return on the hotspot, at 5, 10, 20, 30, 40, 50 and 60 min after landing and every 10 min
14
- // after that, while no model call runs → disassociate (CoreWLAN through JXA, as the
14
+ // after that, only if another known Wi-Fi is visible, while no model call runs →
15
+ // disassociate (CoreWLAN through JXA, as the
15
16
  // console user — the router is a launchd agent in the user's session) so macOS
16
17
  // auto-joins the best known network in range; probe it; if that does not give working
17
18
  // Wi-Fi within 20s, rejoin the hotspot and wait for the next point. The times are fixed
18
19
  // from landing rather than a doubling backoff (owner's decision, 2026-10-20): at most
19
- // ~10 min on the hotspot after arriving at a known Wi-Fi, and a brief drop every 10 min
20
- // on the move, instead of a cap of 15 min.
20
+ // ~10 min on the hotspot after arriving at a known Wi-Fi. On the move, a read-only scan
21
+ // replaces the old blind drop (2026-10-21): no known Wi-Fi in range → no leave, no hold.
21
22
  // gap a return still postponed RETURN_FORCE_AFTER_MS past due (calls back-to-back, so never
22
23
  // a quiet moment — 2026-10-20) makes the gap itself: new model calls are held (hold.ts:
23
24
  // they wait, they are not refused), the running ones get up to RETURN_DRAIN_WAIT_MS to
@@ -43,18 +44,36 @@ export const NETWORKSETUP = "/usr/sbin/networksetup";
43
44
  export const OSASCRIPT = "/usr/bin/osascript";
44
45
  /** wifihop's exact call (verified in its source, `mac_disassociate_js`): a zero-argument ObjC method is invoked by reading it. */
45
46
  export const DISASSOCIATE_JS = 'ObjC.import("CoreWLAN"); $.CWWiFiClient.sharedWiFiClient.interface.disassociate; "ok";';
47
+ /** Read-only directed scans: a non-empty set proves visibility even if CWNetwork.ssid is redacted. */
48
+ export const SCAN_KNOWN_JS = `function run(argv) {
49
+ ObjC.import("CoreWLAN");
50
+ var wifi = $.CWWiFiClient.sharedWiFiClient.interfaceWithName(argv[0]);
51
+ var names = JSON.parse(argv[1]);
52
+ for (var n = 0; n < names.length; n++) {
53
+ var error = Ref();
54
+ var found = wifi.scanForNetworksWithNameError(names[n], error);
55
+ var count = Number(found.count);
56
+ if (!isFinite(count) || count < 0) {
57
+ throw new Error("CoreWLAN scan unavailable: " + (ObjC.unwrap(error[0].localizedDescription) || "no result set"));
58
+ }
59
+ if (count > 0) return "visible";
60
+ }
61
+ return "none";
62
+ }`;
46
63
  export const PROBE_URLS = {
47
64
  apple: "http://captive.apple.com/hotspot-detect.html",
48
65
  google: "http://connectivitycheck.gstatic.com/generate_204",
49
66
  };
50
67
  export const PROBE_TIMEOUT_MS = 4_000;
51
68
  export const MIN_SWITCH_INTERVAL_MS = 60_000;
69
+ /** How long a scan that saw a known Wi-Fi stands for a return still waiting on busy calls. */
70
+ export const KNOWN_VISIBLE_TRUST_MS = 60_000;
52
71
  export const RETURN_PROBE_MS = 20_000;
53
72
  /**
54
73
  * When to try the way back, counted from the moment the Mac landed on the hotspot: 5, 10, 20, 30,
55
74
  * 40, 50 and 60 min, then every 10 min for as long as it stays there (owner's decision, 2026-10-20 —
56
75
  * a fixed schedule rather than a doubling one: at most ~10 min on the hotspot after arriving at a
57
- * known Wi-Fi, and only a brief drop every 10 min on the move).
76
+ * known Wi-Fi). Each point scans first; without a visible known network it never drops the hotspot.
58
77
  */
59
78
  export const RETURN_SCHEDULE_MINUTES = [5, 10, 20, 30, 40, 50, 60];
60
79
  /** After the last scheduled point, every this long. */
@@ -90,6 +109,10 @@ export function parseWifiDevice(text) {
90
109
  }
91
110
  return null;
92
111
  }
112
+ /** `networksetup -listpreferredwirelessnetworks`: preserve SSID spaces, discard only the indent. */
113
+ export function parsePreferredNetworks(text) {
114
+ return [...new Set(text.split(/\r?\n/).filter((line) => line.startsWith("\t")).map((line) => line.slice(1)).filter(Boolean))];
115
+ }
93
116
  /** The interface's IPv4 address, or null when it has none (not associated, or no DHCP yet). */
94
117
  export function wifiIPv4(ifaces, device) {
95
118
  return ifaces[device]?.find((a) => a.family === "IPv4" && !a.internal)?.address ?? null;
@@ -279,6 +302,22 @@ class MacTetherBackend {
279
302
  this.log.warn(`tether: networksetup said: ${(r.stdout + r.stderr).trim().slice(0, 200)}`);
280
303
  return failed;
281
304
  }
305
+ async returnBlocked(ssid) {
306
+ const preferred = await this.run(NETWORKSETUP, ["-listpreferredwirelessnetworks", this.device]);
307
+ if (preferred.code !== 0)
308
+ throw new Error(`preferred Wi-Fi list failed: ${(preferred.stderr || preferred.stdout).trim().slice(0, 200) || `exit ${preferred.code}`}`);
309
+ const names = parsePreferredNetworks(preferred.stdout).filter((name) => name !== ssid);
310
+ if (!names.length)
311
+ return "no known Wi-Fi in range";
312
+ const r = await this.run(OSASCRIPT, ["-l", "JavaScript", "-e", SCAN_KNOWN_JS, this.device, JSON.stringify(names)], { timeoutMs: 30_000 });
313
+ if (r.code !== 0)
314
+ throw new Error(`CoreWLAN scan failed: ${(r.stderr || r.stdout).trim().slice(0, 200) || `exit ${r.code}`}`);
315
+ if (r.stdout.trim() === "visible")
316
+ return null;
317
+ if (r.stdout.trim() === "none")
318
+ return "no known Wi-Fi in range";
319
+ throw new Error("CoreWLAN scan returned an unsupported result");
320
+ }
282
321
  async leave() {
283
322
  const r = await this.run(OSASCRIPT, ["-l", "JavaScript", "-e", DISASSOCIATE_JS], { timeoutMs: 15_000 });
284
323
  return r.code === 0 ? null : (r.stderr || r.stdout).trim().slice(0, 200);
@@ -297,6 +336,8 @@ export class TetherController {
297
336
  state = "off";
298
337
  pinned = false;
299
338
  lastSwitchAt = 0;
339
+ /** When a scan last saw a known Wi-Fi other than the hotspot. */
340
+ knownVisibleAt = -Infinity;
300
341
  lastSwitch = null;
301
342
  lastProbe = null;
302
343
  /** When the Mac landed on the hotspot (ours or by hand); null off the hotspot. The schedule counts from here. */
@@ -570,16 +611,31 @@ export class TetherController {
570
611
  async tryReturn(settings) {
571
612
  if (this.now() - this.lastSwitchAt < MIN_SWITCH_INTERVAL_MS)
572
613
  return;
614
+ // A return postponed for busy calls comes back every tick; one scan that saw a known Wi-Fi is
615
+ // trusted for a minute rather than repeated each time (a scan takes seconds on the radio).
616
+ if (this.now() - this.knownVisibleAt >= KNOWN_VISIBLE_TRUST_MS) {
617
+ let blocked;
618
+ try {
619
+ blocked = await this.backend.returnBlocked(settings.hotspotSsid);
620
+ }
621
+ catch (e) {
622
+ blocked = `known Wi-Fi scan failed: ${e instanceof Error ? e.message : String(e)}`;
623
+ }
624
+ if (blocked) {
625
+ this.backOff(blocked);
626
+ return;
627
+ }
628
+ this.knownVisibleAt = this.now();
629
+ }
630
+ // A scan is asynchronous: a pin/stop/settings change or an OS join meanwhile cancels the return.
631
+ const current = this.deps.settings();
632
+ if (this.stopped || this.pinned || !current.enabled || current.hotspotSsid !== settings.hotspotSsid || !this.onHotspot(this.currentIp()))
633
+ return;
573
634
  const idle = this.idle();
574
635
  if (!idle && (this.now() - this.nextReturnAt < RETURN_FORCE_AFTER_MS || this.hold.closed)) {
575
636
  this.decide(`Wi-Fi return postponed: a model call is running or just started (after ${RETURN_FORCE_AFTER_MS / 1000}s of this, new calls are held to make a gap)`);
576
637
  return;
577
638
  }
578
- const blocked = (await this.backend.returnBlocked?.(settings.hotspotSsid)) ?? null;
579
- if (blocked) {
580
- this.backOff(blocked);
581
- return;
582
- }
583
639
  if (idle)
584
640
  await this.leaveHotspot(settings);
585
641
  else
@@ -704,7 +760,7 @@ export class TetherController {
704
760
  const landed = this.landedOnHotspotAt ?? this.now();
705
761
  this.nextReturnAt = nextReturnPoint(landed, this.now());
706
762
  this.lastLogged = "";
707
- this.decide(`staying on the hotspot (${reason}); next try in ${Math.round((this.nextReturnAt - this.now()) / 1000)}s`);
763
+ this.decide(`staying on the hotspot: ${reason}; next try in ${Math.round((this.nextReturnAt - this.now()) / 1000)}s`);
708
764
  }
709
765
  recordSwitch(to, ok, reason) {
710
766
  this.lastSwitch = { at: new Date(this.now()).toISOString(), to, ok, reason };
@@ -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.12.1";
8
+ export const VERSION = "0.12.3";
package/dist/ui/app.js CHANGED
@@ -3171,17 +3171,25 @@ function openProviderForm(options) {
3171
3171
  const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
3172
3172
  const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
3173
3173
  const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
3174
+ // The Codex fast tier, one switch for the session's own turns and one for a subagent's.
3175
+ const fastMain = el("input", { type: "checkbox", checked: Boolean(existing && existing.fast && existing.fast.main === true) });
3176
+ const fastSubagent = el("input", { type: "checkbox", checked: Boolean(existing && existing.fast && existing.fast.subagent === true) });
3174
3177
  chatgptFields = [
3175
3178
  inputRow(t("providers.credentials"), auth, t("providers.credentialsHelp")),
3176
3179
  el("div", { class: "form-field" }, [el("span", { text: t("providers.login") }), login, loginHelp]),
3177
3180
  inputRow(t("providers.defaultEffort"), effort, t("providers.defaultEffortHelp")),
3181
+ el("div", { class: "form-field" }, [
3182
+ el("label", { class: "check" }, [fastMain, el("span", { text: t("providers.fastMain") })]),
3183
+ el("label", { class: "check" }, [fastSubagent, el("span", { text: t("providers.fastSubagent") })]),
3184
+ el("small", { text: t("providers.fastHelp") }),
3185
+ ]),
3178
3186
  ];
3179
3187
  advancedContent.append(
3180
3188
  el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
3181
3189
  el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
3182
3190
  inputRow(t("providers.append"), append, t("providers.appendHelp")),
3183
3191
  );
3184
- advancedContent._chatgpt = { auth, effort, identity, limits: limitsInput, append };
3192
+ advancedContent._chatgpt = { auth, effort, identity, limits: limitsInput, append, fastMain, fastSubagent };
3185
3193
  }
3186
3194
  advanced.appendChild(advancedContent);
3187
3195
  const customUrlRow = isCustom ? inputRow(t("providers.url"), urlInput, t("providers.urlHelp")) : null;
@@ -3223,7 +3231,7 @@ function openProviderForm(options) {
3223
3231
  function draftProvider() {
3224
3232
  if (isChatgpt) {
3225
3233
  const chat = advancedContent._chatgpt;
3226
- return { type: "chatgpt", auth: chat.auth.value, defaultEffort: chat.effort.value, identity: chat.identity.checked, limits: chat.limits.checked, ...(chat.append.value.trim() ? { instructionsAppend: chat.append.value.trim() } : {}) };
3234
+ return { type: "chatgpt", auth: chat.auth.value, defaultEffort: chat.effort.value, identity: chat.identity.checked, limits: chat.limits.checked, fast: { main: chat.fastMain.checked, subagent: chat.fastSubagent.checked }, ...(chat.append.value.trim() ? { instructionsAppend: chat.append.value.trim() } : {}) };
3227
3235
  }
3228
3236
  const prompt = advancedContent._prompt;
3229
3237
  return {
@@ -3331,7 +3339,7 @@ function openProviderForm(options) {
3331
3339
  // (debugDump, a hand-written setting) is carried over from the saved one rather than dropped.
3332
3340
  // A field the form owns is left to the draft, so clearing it in the form still clears it.
3333
3341
  const formKeys = new Set(isChatgpt
3334
- ? ["type", "auth", "defaultEffort", "identity", "limits", "instructionsAppend", "models"]
3342
+ ? ["type", "auth", "defaultEffort", "identity", "limits", "instructionsAppend", "fast", "models"]
3335
3343
  : ["type", "url", "identity", "limits", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
3336
3344
  const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
3337
3345
  next.providers[providerName] = { ...kept, ...provider };
package/dist/ui/i18n.js CHANGED
@@ -15,6 +15,9 @@ const I18N = {
15
15
  "slots.webSearch": "웹 검색·페이지 읽기", "slots.webSearchFollow": "작고 빠른 모델을 따름", "slots.webSearchHelp": "WebSearch 검색과 WebFetch의 페이지 읽기를 이 모델이 답합니다. 저장 즉시 적용되며, 답변을 시작하기 전에 실패하면 원래 모델로 넘어갑니다.", "slots.webSearchSaved": "웹 검색·페이지 읽기 모델을 저장했습니다. 바로 적용됩니다.",
16
16
  "slots.windowGlobal": "기본값", "slots.windowHelp": "모델이 한 번에 읽을 수 있는 컨텍스트 크기를 토큰 단위로 정합니다. 모델마다 지원하는 크기가 달라 따로 정합니다. 비워 두면 프로바이더가 알려 준 값을 쓰고, 그 값도 없으면 시스템 기본값을 씁니다. 대화를 압축할 시점이 아니라 모델이 실제로 읽을 수 있는 최대 크기를 입력하세요.",
17
17
  "providers.title": "프로바이더", "providers.subtitle": "연결한 서비스와 계정을 한곳에서 관리합니다. 프로바이더를 고르면 오른쪽에 세부 설정과 계정 목록이 나옵니다.", "providers.search": "프로바이더 검색…", "providers.overview": "개요", "providers.accounts": "계정","providers.modelsTab": "모델", "providers.selectedModels": "선택한 모델", "providers.modelsCountShort": "모델 {count}개", "providers.noSelection": "왼쪽에서 프로바이더를 고르세요.", "providers.rotationOn": "자동 전환 켜짐", "providers.rotationOff": "자동 전환 꺼짐", "providers.rotationSaving": "자동 전환 설정 저장 중…", "providers.rotationSaved": "자동 전환 설정을 저장했습니다.", "providers.accountCount": "연결된 계정 {count}개", "providers.accountCountHelp": "현재 Claude에 로그인한 계정과 리플에 추가한 계정을 모두 합한 수입니다.", "providers.accountPoolTitle": "Claude 계정", "providers.accountPoolSubtitle": "한 대화는 같은 계정으로 이어갑니다. 답변을 시작하기 전에 사용 한도에 걸리거나 인증 오류가 날 때만 다음 계정으로 넘어갑니다.", "providers.addClaudeAccount": "+ Claude 계정 추가", "providers.currentAccountHelp": "지금 Claude Code나 Claude Desktop에 로그인한 계정입니다. 리플에서는 삭제할 수 없습니다.", "providers.addedAccountHelp": "리플이 이 컴퓨터의 현재 사용자만 읽을 수 있는 파일에 OAuth 로그인 정보를 안전하게 저장하고 자동으로 갱신합니다.", "providers.reauthAction": "다시 로그인", "providers.preferBadge": "1순위", "providers.preferAction": "1순위로 지정", "providers.preferHelp": "새 대화는 목록 맨 위의 1순위 계정부터 씁니다. 한도에 닿으면 다음 계정으로 넘어가고, 한도가 초기화되면 새 대화부터 다시 1순위 계정을 씁니다. 진행 중인 대화는 프롬프트 캐시를 지키려고 같은 계정으로 이어갑니다.","providers.dangerZone": "추가 계정 관리","providers.connection": "연결 상태","providers.rotationRequired": "계정을 여러 개 추가해도 자동 전환을 켜야 Claude 앱과 Claude Code에서 쓸 수 있습니다.", "providers.anthropic": "Claude (Anthropic)", "providers.anthropicHelp": "Claude 구독 계정을 자동으로 돌려 쓰거나, Codex 같은 OpenAI 호환 도구에서 Claude 모델을 쓰게 연결합니다.", "providers.anthropicIngressOnly": "자동 전환을 끄면 고른 모델은 Codex 같은 OpenAI 호환 도구에서만 쓸 수 있고, Claude 앱의 모델 매핑에는 나오지 않습니다.", "providers.anthropicPoolRouting": "자동 전환을 켜면 Claude 앱과 Claude Code의 대화마다 같은 계정을 써서 프롬프트 캐시를 지킵니다. 답변을 만들기 전에 429 한도 초과나 인증 거부 오류가 오면 다음 계정으로 넘어갑니다. 구독 계정을 여러 개 쓰는 것은 Anthropic 이용약관에 따라 제한될 수 있습니다. 본인 계정만 허용된 용도로 쓰세요.","providers.anthropicPool": "Claude 계정 자동 전환","providers.anthropicCurrent": "현재 로그인 계정", "providers.anthropicReauth": "다시 로그인 필요", "providers.anthropicNoAccounts": "리플에 추가한 계정이 없습니다.", "providers.anthropicRenamePrompt": "이 계정을 표시할 이름", "providers.anthropicRemoveConfirm": "리플에서 Claude 계정 {name}을(를) 삭제할까요?", "providers.anthropicLogoutAll": "추가 계정 모두 삭제", "providers.anthropicLogoutAllConfirm": "리플에 추가한 Claude 계정을 모두 삭제할까요? Claude Code와 Claude Desktop의 현재 로그인은 그대로 둡니다.", "providers.anthropicAuthClaudeCode": "Claude Code 로그인 사용 (자동)", "providers.anthropicAuthApiKey": "API 키", "providers.anthropicCredentialsHelp": "Claude Code 로그인을 그대로 쓸지, Anthropic API 키로 연결할지 고르세요.", "providers.anthropicSourceObserved": "Claude Desktop 대화에서 확인됨", "providers.anthropicSourceClaudeCode": "터미널 claude 로그인", "providers.anthropicSourceTokenFile": "리플 토큰", "providers.anthropicSourceMissing": "없음 — 아래 버튼으로 연결","providers.anthropicOAuthTitle": "Claude 계정 추가", "providers.anthropicOAuthWarning": "Claude 구독의 OAuth 로그인을 리플 같은 다른 회사 프로그램에서 쓰는 것은 Anthropic이 공식 지원하지 않습니다. 약관을 어겨 계정 이용이 제한될 수 있습니다. 반드시 본인 계정만 허용된 용도로 쓰세요.", "providers.anthropicOAuthAccept": "위험을 확인했고 OAuth로 계속합니다.", "providers.anthropicOAuthContinue": "OAuth로 계속","providers.anthropicLoginDone": "Claude 구독을 연결했습니다.", "providers.anthropicSignInBrowser": "브라우저에서 Claude에 로그인하세요. 창이 열리지 않으면 아래 링크를 여세요.", "providers.anthropicSignInLink": "로그인 페이지 열기", "providers.anthropicSignInPaste": "브라우저에서 로그인한 뒤 나오는 코드를 여기에 붙여넣으세요.", "providers.anthropicSignInSubmit": "코드 입력 완료", "providers.anthropicSignInManual": "코드 붙여넣기로 다시 시도", "providers.anthropicSignInWaiting": "브라우저에서 승인 대기 중… (최대 5분)", "providers.anthropicSignInFailed": "로그인하지 못했습니다.","providers.add": "+ 프로바이더 추가", "providers.refresh": "전체 연결 확인", "providers.check": "연결 확인", "providers.checking": "확인 중", "providers.empty": "아직 연결된 프로바이더가 없습니다. 위쪽 버튼으로 추가하세요.","providers.noModels": "고른 모델이 없습니다. 수정 메뉴에서 모델을 고르세요.", "providers.removeConfirm": "프로바이더 {name}을(를) 삭제할까요? 이 프로바이더를 쓰는 모델 매핑도 함께 삭제됩니다.", "providers.choose": "프로바이더 추가", "providers.chooseHelp": "연결할 서비스를 고르세요.", "providers.chatgpt": "ChatGPT 구독", "providers.chatgptHelp": "Codex가 포함된 ChatGPT 요금제(Plus, Pro, Business 등)로 GPT 모델을 씁니다. API 키 없이 로그인만 하면 연결됩니다.", "providers.presetHelp": "API 키만 입력하면 바로 연결됩니다.", "providers.openaiGroup": "OpenAI 호환", "providers.openaiGroupHelp": "리플이 요청을 OpenAI API 규격에 맞게 바꿔 보냅니다.","providers.custom": "직접 입력", "providers.customName": "새 프로바이더", "providers.customHelp": "목록에 없는 Anthropic 호환 또는 OpenAI 호환 API를 연결합니다. API 주소(엔드포인트 URL)와 키를 직접 입력하세요.", "providers.formatHelp": "서비스 문서에 나온 규격을 고르세요. API 주소가 /chat/completions로 끝나면 OpenAI Chat Completions 규격입니다.", "providers.addTitle": "프로바이더 추가", "providers.edit": "프로바이더 수정", "providers.name": "이름", "providers.nameHelp": "목록과 모델 매핑에 표시할 프로바이더 이름입니다.", "providers.nameRequired": "이름을 입력하세요.", "providers.apiKey": "API 키", "providers.keyHelp": "프로바이더에서 발급받은 API 키입니다. 이 컴퓨터의 설정 파일에만 저장합니다.", "providers.keyPlaceholder": "API 키를 붙여넣으세요", "providers.keySaved": "저장한 키를 쓰고 있습니다. 바꾸려면 새 키를 입력하세요.", "providers.url": "API 기본 주소 (URL)", "providers.urlHelp": "Anthropic 호환 API의 기본 주소입니다. 서비스 문서의 주소와 다를 때만 바꾸세요.", "providers.openaiUrlHelp": "OpenAI 호환 API의 기본 주소입니다. 요청은 Chat Completions 또는 Responses API에 맞게 바꿔 보냅니다.", "providers.wire": "API 규격", "providers.wireHelp": "대부분 Chat Completions를 씁니다. 프로바이더가 이전 대화를 서버에 저장하지 않는 Responses API를 지원할 때만 Responses를 고르세요.", "providers.urlRequired": "올바른 URL을 입력하세요.", "providers.keyType": "인증 헤더", "providers.keyTypeHelp": "보통 x-api-key를 씁니다. 서비스 문서에 Authorization: Bearer 방식이 나와 있으면 그 항목을 고르세요.", "providers.extraHeaders": "추가 HTTP 헤더", "providers.extraHeadersHelp": "서비스에서 요구하는 추가 헤더를 '이름: 값' 형식으로 한 줄에 하나씩 입력하세요.", "providers.models": "사용할 모델", "providers.modelsHelp": "고른 모델이 모델 매핑에서 고를 수 있는 목록에 나옵니다.", "providers.modelsFound": "프로바이더에서 받아 온 모델 목록입니다.", "providers.modelsFallback": "모델 목록을 받아 오지 못해 기본으로 알려진 모델을 보여 줍니다.", "measure.running": "모델 기능 확인 중… ({done}/{total})", "measure.help": "모델마다 어떤 API 규격과 추론 강도를 지원하는지 확인합니다. 공개 모델 목록(models.dev)에 정보가 있으면 그 정보를 쓰고, 없으면 모델에 직접 요청을 보냅니다.", "measure.done": "모델 기능 확인 완료: {summary}", "measure.nothing": "모델이 응답하지 않아 기능을 확인하지 못했습니다.", "measure.notEntitled": "{models}: 이 프로바이더의 자체 앱에서만 쓸 수 있는 모델입니다. 여기서 골라도 쓸 수 없습니다.", "measure.failed": "모델 기능을 확인하지 못했습니다.", "measure.authFailed": "API 키 인증이 거부되어 모델 기능을 확인하지 못했습니다.", "providers.probeNotEntitled": "API 키는 정상이지만 요금제 제한으로 요청이 거부되었습니다.", "providerStatus.notEntitled": "요금제 제한", "providers.probeOk": "연결했습니다.", "providers.probeFailed": "연결하지 못했습니다.", "providers.apiSoon": "잠시 후 다시 시도하세요.", "providers.showInPicker": "Claude 앱 피커에 실제 모델 이름 표시", "providers.pickerOffHint": "피커 모드가 꺼져 있어 골라도 피커에 나오지 않습니다. 저장할 때 피커를 켤지 묻습니다.", "providers.pickerOffPrompt": "피커 모드가 꺼져 있어 이 모델이 Claude Desktop 피커에 나오지 않습니다. 지금 켤까요? 인증서를 신뢰하도록 설정할 때 운영체제의 확인 창이 열립니다.", "providers.credentials": "로그인 방식", "providers.credentialsHelp": "어디에 저장된 ChatGPT 로그인 정보(토큰)를 쓸지 정합니다.", "providers.authAuto": "자동 (리플 로그인 우선, 없으면 Codex CLI 로그인 사용)", "providers.authOwn": "리플에서 직접 로그인", "providers.authBorrow": "Codex CLI 로그인 사용 (~/.codex/auth.json)", "providers.login": "ChatGPT 로그인 안내", "providers.loginHelp": "메뉴 막대 앱에서 'ChatGPT 로그인…'을 누르거나 터미널에서 clauderipple login을 실행하면 브라우저 로그인 창이 열립니다. 토큰은 이 컴퓨터에만 저장합니다.","providers.chatgptLogin": "ChatGPT 로그인", "providers.chatgptLoginWaiting": "브라우저에서 로그인을 마치면 이 화면이 자동으로 새로고침됩니다.", "providers.chatgptLoginDone": "ChatGPT에 로그인했습니다.", "providers.chatgptAccountsTitle": "ChatGPT 계정", "providers.chatgptAccountsSubtitle": "계정을 여러 개 추가하면 한 계정이 한도에 닿았을 때 다음 계정이 같은 요청을 이어받습니다. 답한 뒤에는 같은 계정으로 대화를 이어가 프롬프트 캐시를 지킵니다. 계정은 목록 순서대로 씁니다.", "providers.addChatgptAccount": "+ ChatGPT 계정 추가", "providers.chatgptAddWaiting": "브라우저에서 추가할 계정으로 로그인하세요. 이미 로그인한 계정이 나오면 다른 계정을 고르세요.", "providers.chatgptAdded": "ChatGPT 계정을 추가했습니다.", "providers.chatgptNoAccounts": "로그인한 ChatGPT 계정이 없습니다.", "providers.chatgptAccountCount": "계정 {count}개", "providers.chatgptCodexHelp": "Codex CLI에 로그인한 계정입니다. 리플은 로그인 정보를 읽기만 하고 자동으로 갱신하지 않습니다. 다른 계정을 모두 쓴 뒤 마지막에 씁니다.", "providers.chatgptOwnHelp": "리플이 이 컴퓨터의 현재 사용자만 읽을 수 있는 파일에 계정 정보를 저장하고 자동으로 갱신합니다.", "providers.chatgptInUse": "현재 사용 중", "providers.chatgptStandby": "대기", "providers.chatgptPause": "일시 중지", "providers.chatgptResume": "다시 사용", "providers.chatgptPaused": "일시 중지됨", "providers.chatgptClearCooldown": "대기 해제", "providers.chatgptRemoveConfirm": "리플에서 ChatGPT 계정 {name}을(를) 삭제할까요?", "providers.chatgptReauthHelp": "로그인이 만료되었거나 거부되었습니다. 같은 계정으로 다시 로그인하면 사용할 수 있습니다.", "pool.coolingFor": "{duration} 후 다시 사용", "time.hoursMinutes": "{h}시간 {m}분", "time.minutes": "{m}분", "time.seconds": "{s}초", "quota.window5h": "5시간", "quota.windowWeek": "주간", "quota.windowHours": "{hours}시간", "quota.line": "{window} {percent}%", "quota.resetAt": "{time} 초기화", "providers.effortLevels": "추론 강도: {levels}", "providers.effortNone": "지원 안 함", "providers.modelEffort": "추론 강도 설정", "providers.modelNoEffort": "추론 강도 지원 안 함", "providers.defaultEffort": "기본 추론 강도 (effort)", "providers.defaultEffortHelp": "모델 매핑이나 앱에서 추론 강도를 따로 정하지 않았을 때 쓸 값입니다.", "providers.identity": "모델에 자기 이름 알려 주기 (identity)", "providers.append": "시스템 프롬프트 추가 문구", "providers.appendHelp": "모든 요청의 시스템 프롬프트 끝에 덧붙이는 고정 문구입니다. 자주 바꾸면 프롬프트 캐시를 쓸 수 없어 비용이 늘 수 있습니다.",
18
+ "providers.fastMain": "메인 세션 Fast",
19
+ "providers.fastSubagent": "서브에이전트 Fast",
20
+ "providers.fastHelp": "켜면 GPT 요청을 Codex 빠른 등급으로 보냅니다. 응답이 빨라지는 대신 구독 사용량이 더 빨리 줄어듭니다.",
18
21
  "providerStatus.connected": "연결됨", "providerStatus.keyNeeded": "API 키 확인 필요", "providerStatus.loginNeeded": "로그인 필요", "providerStatus.disconnected": "연결 안 됨", "providerStatus.checking": "확인 중",
19
22
  "clients.title": "클라이언트", "clients.subtitle": "리플에 연결해서 쓸 앱을 설정합니다.", "clients.desktop.title": "Claude Desktop", "clients.desktop.help": "프로바이더의 모델을 피커에 실제 이름으로 표시할지 정합니다.", "clients.codex.title": "Codex (앱과 CLI)", "clients.codex.help": "Codex 설정(~/.codex/config.toml)에 리플 프로바이더를 추가합니다. 터미널에서는 `codex --profile clauderipple -m <모델>`을 실행하고, Codex 앱에서는 설정에서 clauderipple 프로바이더를 기본값으로 고르세요.", "clients.codex.note": "켜면 Codex의 GPT 요청도 리플이 처리합니다. Codex 로그인은 그대로입니다. ChatGPT 계정을 여러 개 추가해 두면 한 계정이 한도에 걸려도 로그아웃할 필요 없이 다음 계정이 이어받습니다. 아래는 Codex에서 더 쓸 수 있는 Claude 모델과 Anthropic 호환 모델입니다.", "clients.codex.turnOn": "Codex에서 리플 사용","clients.claudeCode.title": "터미널 Claude Code", "clients.claudeCode.help": "터미널의 `claude`도 같은 리플에 연결됩니다. `/model <이름>` 명령어로 모델을 고르세요.",
20
23
  "logs.title": "로그", "logs.requests": "요청", "logs.raw": "원본 로그", "logs.lastHour": "최근 1시간", "logs.autoscroll": "자동 스크롤", "logs.polling": "3초마다 새로고침", "logs.rawPolling": "3초마다 새로고침 · 최근 200줄", "logs.provider": "프로바이더", "logs.all": "전체","logs.empty": "아직 요청이 없습니다.", "logs.summary.requests": "요청 수", "logs.summary.success": "성공 / 실패", "logs.summary.input": "입력 토큰", "logs.summary.output": "출력 토큰", "logs.summary.duration": "평균 응답 시간", "logs.cacheHit": "캐시 {percent}%", "logs.th.time": "시간", "logs.th.models": "모델", "logs.th.effort": "추론 강도", "logs.th.input": "입력", "logs.th.output": "출력", "logs.th.duration": "소요 시간", "logs.th.status": "상태", "logs.status.ok": "성공", "logs.status.error": "오류", "logs.status.resent": "재전송", "logs.status.resentHelp": "오류가 아닙니다. 클라이언트가 같은 요청을 다시 보냈습니다.", "logs.detail.id": "요청 ID", "logs.detail.kind": "종류", "logs.detail.stop": "종료 이유", "logs.detail.uncached": "캐시를 쓰지 않은 입력", "logs.detail.cacheRead": "캐시 읽기", "logs.detail.cacheWrite": "캐시 저장", "logs.detail.note": "메모", "logs.none": "없음", "logs.seconds": "{value}초",
@@ -232,6 +235,9 @@ const I18N = {
232
235
  "slots.webSearch": "Web search & page reading", "slots.webSearchFollow": "Follow the small, fast model", "slots.webSearchHelp": "This model answers WebSearch and WebFetch page reading. Applies immediately; failure before the response starts falls back to the original model.", "slots.webSearchSaved": "Web search & page reading model saved. Applies immediately.",
233
236
  "slots.windowGlobal": "default", "slots.windowHelp": "This model's context window, in tokens. Routed models do not share one, so each carries its own. Left blank it takes what the provider reported, and otherwise the global default. Enter the model's real window, not a compaction point.",
234
237
  "providers.title": "Providers", "providers.subtitle": "Manage connected services and accounts in one place. Pick a provider to open its settings and accounts.", "providers.search": "Search providers…", "providers.overview": "Overview", "providers.accounts": "Accounts","providers.modelsTab": "Models", "providers.selectedModels": "Selected models", "providers.modelsCountShort": "{count} models", "providers.noSelection": "Select a provider on the left.", "providers.rotationOn": "Rotation on", "providers.rotationOff": "Rotation off", "providers.rotationSaving": "Saving rotation…", "providers.rotationSaved": "Account rotation saved.", "providers.accountCount": "{count} connected accounts", "providers.accountCountHelp": "Includes the current Claude login and accounts added to ClaudeRipple.", "providers.accountPoolTitle": "Claude accounts", "providers.accountPoolSubtitle": "A conversation stays on one account. ClaudeRipple switches only when quota or authentication fails before output starts.", "providers.addClaudeAccount": "+ Add Claude account", "providers.currentAccountHelp": "The external sign-in currently used by Claude Code or Claude Desktop. ClaudeRipple cannot remove it.", "providers.addedAccountHelp": "ClaudeRipple stores this OAuth grant on this computer and refreshes it automatically.", "providers.reauthAction": "Sign in again", "providers.preferBadge": "First", "providers.preferAction": "Use first", "providers.preferHelp": "New conversations start on the account at the top. When it hits its limit they move to the next one, and once it resets new conversations go back to it. A conversation already running stays on its account to keep its cache.","providers.dangerZone": "Added account management","providers.connection": "Connection","providers.rotationRequired": "Added accounts are used for Claude app and Claude Code requests only when account rotation is on.", "providers.anthropic": "Claude (Anthropic)", "providers.anthropicHelp": "Rotate Claude subscription accounts, or serve Claude models to OpenAI-style tools such as Codex.", "providers.anthropicIngressOnly": "With account rotation off, the models chosen here are served only to OpenAI-style tools such as Codex and do not appear in Claude app mappings.", "providers.anthropicPoolRouting": "With account rotation on, Claude app and Claude Code requests stay on one account per conversation to preserve the prompt cache. A 429 or authentication refusal before the response starts moves the request to the next account. Using multiple subscriptions may be subject to Anthropic's terms and account restrictions; use only your own authorized accounts.","providers.anthropicPool": "Rotate Claude accounts automatically","providers.anthropicCurrent": "Current login", "providers.anthropicReauth": "Sign in again", "providers.anthropicNoAccounts": "No accounts have been added to ClaudeRipple.", "providers.anthropicRenamePrompt": "Display name for this account", "providers.anthropicRemoveConfirm": "Remove Claude account {name} from ClaudeRipple?", "providers.anthropicLogoutAll": "Remove all added accounts", "providers.anthropicLogoutAllConfirm": "Remove every Claude account added to ClaudeRipple? The current Claude Code and Claude Desktop login is not changed.", "providers.anthropicAuthClaudeCode": "Reuse Claude Code sign-in (automatic)", "providers.anthropicAuthApiKey": "API key", "providers.anthropicCredentialsHelp": "Choose your Claude Code sign-in or an Anthropic API key.", "providers.anthropicSourceObserved": "Detected from a Claude Desktop session", "providers.anthropicSourceClaudeCode": "Terminal Claude sign-in", "providers.anthropicSourceTokenFile": "ClaudeRipple token", "providers.anthropicSourceMissing": "None — connect below","providers.anthropicOAuthTitle": "Add Claude account", "providers.anthropicOAuthWarning": "Using Claude subscription OAuth through a third-party proxy is not an Anthropic-supported integration and may be subject to terms or account restrictions. Use only your own accounts for authorized purposes.", "providers.anthropicOAuthAccept": "I understand the risk and want to continue with OAuth.", "providers.anthropicOAuthContinue": "Continue with OAuth","providers.anthropicLoginDone": "Claude subscription connected.", "providers.anthropicSignInBrowser": "Sign in to Claude in your browser. If no window opened, use the link below.", "providers.anthropicSignInLink": "Open the sign-in page", "providers.anthropicSignInPaste": "After signing in, paste the code the page shows here.", "providers.anthropicSignInSubmit": "Submit code", "providers.anthropicSignInManual": "Try again by pasting a code", "providers.anthropicSignInWaiting": "Waiting for the browser… (up to 5 minutes)", "providers.anthropicSignInFailed": "Sign-in failed","providers.add": "+ Add provider", "providers.refresh": "Test all", "providers.check": "Test connection", "providers.checking": "Checking", "providers.empty": "No providers connected yet. Add one with the button above.","providers.noModels": "No models chosen. Pick some under Edit.", "providers.removeConfirm": "Remove provider {name}? Model mappings that use it are removed too.", "providers.choose": "Add provider", "providers.chooseHelp": "Pick the service to connect.", "providers.chatgpt": "ChatGPT subscription", "providers.chatgptHelp": "Use GPT models through any ChatGPT plan that includes Codex (Plus, Pro, Business…). Sign-in only, no API key.", "providers.presetHelp": "Paste an API key and you are done.", "providers.openaiGroup": "OpenAI-compatible", "providers.openaiGroupHelp": "ClaudeRipple translates requests into the OpenAI API format.","providers.custom": "Enter manually", "providers.customName": "New provider", "providers.customHelp": "Connect any Anthropic- or OpenAI-compatible API by URL and key.", "providers.formatHelp": "Pick the format the service docs name. A service whose endpoint ends in /chat/completions is OpenAI Chat Completions.", "providers.addTitle": "Add provider", "providers.edit": "Edit provider", "providers.name": "Name", "providers.nameHelp": "How this provider is called in lists and mappings.", "providers.nameRequired": "Enter a name.", "providers.apiKey": "API key", "providers.keyHelp": "The API key issued on the provider's site. Stored only in this computer's config file.", "providers.keyPlaceholder": "Paste your API key", "providers.keySaved": "The saved key stays in use. Paste a new key to replace it.", "providers.url": "API base URL", "providers.urlHelp": "Base URL of the Anthropic-compatible endpoint. Change only if the service docs differ.", "providers.openaiUrlHelp": "Base URL of the OpenAI-compatible API. Requests are translated to its Chat Completions or Responses endpoint.", "providers.wire": "API format", "providers.wireHelp": "Use Chat Completions for most providers. Select Responses only when the provider supports the stateless Responses API.", "providers.urlRequired": "Enter a valid URL.", "providers.keyType": "Auth header", "providers.keyTypeHelp": "Usually x-api-key. Pick Authorization: Bearer only if the service docs say so.", "providers.extraHeaders": "Extra HTTP headers", "providers.extraHeadersHelp": "Only headers the service requires, one 'name: value' per line.", "providers.models": "Models to use", "providers.modelsHelp": "Checked models appear in the Model mapping list.", "providers.modelsFound": "Model list reported by the provider.", "providers.modelsFallback": "Could not fetch the list; showing known default models.", "measure.running": "Measuring model capabilities… ({done}/{total})", "measure.help": "Checks which API format each model answers in and which reasoning efforts it takes — from the public model catalogue (models.dev) when it lists the model, otherwise by asking the model.", "measure.done": "Measured: {summary}", "measure.nothing": "Nothing was measured: no model answered.", "measure.notEntitled": "{models}: usable only from the provider\u2019s own app. Ticking it here will not make it work.", "measure.failed": "Could not measure model capabilities.", "measure.authFailed": "The API key was refused, so nothing could be measured.", "providers.probeNotEntitled": "The key was accepted; the plan refused.", "providerStatus.notEntitled": "Plan refused", "providers.probeOk": "Connected.", "providers.probeFailed": "Could not connect.", "providers.apiSoon": "Please try again shortly.", "providers.showInPicker": "Also show by real name in the Claude app picker", "providers.pickerOffHint": "Picker mode is off right now, so ticking this does not show them yet. You will be asked to turn it on when you save.", "providers.pickerOffPrompt": "Picker mode is off, so these models will not appear in the Claude Desktop picker yet. Turn it on now? Your operating system will ask you to confirm trusting the certificate.", "providers.credentials": "Sign-in source", "providers.credentialsHelp": "Where the ChatGPT account token comes from.", "providers.authAuto": "Auto (own sign-in if present, else Codex CLI's)", "providers.authOwn": "Sign in with ClaudeRipple", "providers.authBorrow": "Reuse Codex CLI sign-in (~/.codex/auth.json)", "providers.login": "How to sign in", "providers.loginHelp": "'Sign in to ChatGPT…' in the menu-bar app or clauderipple login in a terminal opens the browser sign-in. Tokens stay on this computer.","providers.chatgptLogin": "Sign in to ChatGPT", "providers.chatgptLoginWaiting": "Finish signing in in your browser. This will update when it is done.", "providers.chatgptLoginDone": "ChatGPT sign-in complete.", "providers.chatgptAccountsTitle": "ChatGPT accounts", "providers.chatgptAccountsSubtitle": "With several accounts, when one hits its limit the next one takes over the same request. A conversation stays on the account that answered, to keep its cache. Accounts are used in list order.", "providers.addChatgptAccount": "+ Add ChatGPT account", "providers.chatgptAddWaiting": "Sign in with the account to add in your browser. If it shows an account you already added, pick another.", "providers.chatgptAdded": "ChatGPT account added.", "providers.chatgptNoAccounts": "No ChatGPT account is signed in.", "providers.chatgptAccountCount": "{count} accounts", "providers.chatgptCodexHelp": "The Codex CLI's own login. ClaudeRipple only reads it, never refreshes it, and uses it last.", "providers.chatgptOwnHelp": "ClaudeRipple stores this login in a file only your user can read and refreshes it automatically.", "providers.chatgptInUse": "in use now", "providers.chatgptStandby": "standby", "providers.chatgptPause": "Pause", "providers.chatgptResume": "Resume", "providers.chatgptPaused": "paused", "providers.chatgptClearCooldown": "Use now", "providers.chatgptRemoveConfirm": "Remove ChatGPT account {name} from ClaudeRipple?", "providers.chatgptReauthHelp": "This login expired or was refused. Signing in again with the same account restores it in place.", "pool.coolingFor": "back in {duration}", "time.hoursMinutes": "{h}h {m}m", "time.minutes": "{m}m", "time.seconds": "{s}s", "quota.window5h": "5h", "quota.windowWeek": "weekly", "quota.windowHours": "{hours}h", "quota.line": "{window} {percent}%", "quota.resetAt": "resets {time}", "providers.effortLevels": "Reasoning effort: {levels}", "providers.effortNone": "Does not take reasoning effort", "providers.modelEffort": "effort", "providers.modelNoEffort": "no effort", "providers.defaultEffort": "Default reasoning effort", "providers.defaultEffortHelp": "Used when neither the mapping nor the app sets an effort.", "providers.identity": "Tell the model what it is (identity)", "providers.append": "System prompt addendum", "providers.appendHelp": "Fixed text appended to every request's system prompt. Changing it often breaks the prompt cache and raises cost.",
238
+ "providers.fastMain": "Fast for the main session",
239
+ "providers.fastSubagent": "Fast for subagents",
240
+ "providers.fastHelp": "When on, GPT requests go out on Codex's fast tier. Answers come back quicker, but your subscription's usage runs down faster.",
235
241
  "providerStatus.connected": "Connected", "providerStatus.keyNeeded": "Key needed", "providerStatus.loginNeeded": "Sign-in needed", "providerStatus.disconnected": "Not connected", "providerStatus.checking": "Checking",
236
242
  "clients.title": "Clients", "clients.subtitle": "Set up the apps that use ClaudeRipple.", "clients.desktop.title": "Claude Desktop", "clients.desktop.help": "Choose whether provider models appear by real name in the model picker.", "clients.codex.title": "Codex (app and CLI)", "clients.codex.help": "Adds ClaudeRipple as a provider to Codex's config (~/.codex/config.toml). Terminal: `codex --profile clauderipple -m <model>`; Codex app: make clauderipple the default provider in that config.", "clients.codex.note": "When on, Codex's GPT requests go through ClaudeRipple too. Codex keeps its own sign-in, and with several ChatGPT accounts added, the next one takes over when one hits its limit — no sign-out. The list below is the Claude and Anthropic-compatible models Codex gains.", "clients.codex.turnOn": "Use ClaudeRipple in Codex","clients.claudeCode.title": "Terminal Claude Code", "clients.claudeCode.help": "Terminal `claude` uses the same router. Choose with `/model <name>`.",
237
243
  "logs.title": "Logs", "logs.requests": "Requests", "logs.raw": "Raw logs", "logs.lastHour": "Last hour", "logs.autoscroll": "Auto-scroll", "logs.polling": "Updates every 3s", "logs.rawPolling": "Updates every 3s · last 200 lines", "logs.provider": "Provider", "logs.all": "All","logs.empty": "No requests yet.", "logs.summary.requests": "Requests", "logs.summary.success": "Succeeded / failed", "logs.summary.input": "Input tokens", "logs.summary.output": "Output tokens", "logs.summary.duration": "Average response", "logs.cacheHit": "Cache {percent}%", "logs.th.time": "Time", "logs.th.models": "Model", "logs.th.effort": "effort", "logs.th.input": "Input", "logs.th.output": "Output", "logs.th.duration": "Duration", "logs.th.status": "Status", "logs.status.ok": "ok", "logs.status.error": "error", "logs.status.resent": "resent", "logs.status.resentHelp": "Not a failure: the client sent this request again on its own.", "logs.detail.id": "Request ID", "logs.detail.kind": "Kind", "logs.detail.stop": "Stop reason", "logs.detail.uncached": "Uncached input", "logs.detail.cacheRead": "Cache read", "logs.detail.cacheWrite": "Cache write", "logs.detail.note": "Note", "logs.none": "None", "logs.seconds": "{value}s",
@@ -679,6 +685,9 @@ const I18N = {
679
685
  "providers.modelNoEffort": "不支持强度设置",
680
686
  "providers.defaultEffort": "默认推理强度",
681
687
  "providers.defaultEffortHelp": "映射和应用都未设置推理强度时使用。",
688
+ "providers.fastMain": "主会话 Fast",
689
+ "providers.fastSubagent": "子智能体 Fast",
690
+ "providers.fastHelp": "开启后,GPT 请求会以 Codex 的快速档位发送。回答更快,但订阅用量消耗得也更快。",
682
691
  "providers.identity": "告知模型身份",
683
692
  "providers.append": "系统提示词附加内容",
684
693
  "providers.appendHelp": "固定追加到每个请求系统提示词末尾的文本。修改此内容往往会破坏提示缓存,增加费用。",
@@ -1196,6 +1205,9 @@ const I18N = {
1196
1205
  "providers.modelNoEffort": "強度なし",
1197
1206
  "providers.defaultEffort": "デフォルトの推論強度",
1198
1207
  "providers.defaultEffortHelp": "マッピングでもアプリでも推論強度が指定されていないときに使います。",
1208
+ "providers.fastMain": "メインセッション Fast",
1209
+ "providers.fastSubagent": "サブエージェント Fast",
1210
+ "providers.fastHelp": "オンにすると GPT リクエストを Codex の高速ティアで送ります。応答は速くなりますが、サブスクリプションの使用量も速く減ります。",
1199
1211
  "providers.identity": "モデルに自身の情報を伝える(アイデンティティ)",
1200
1212
  "providers.append": "システムプロンプトへの追記",
1201
1213
  "providers.appendHelp": "すべてのリクエストのシステムプロンプトに追加される固定テキストです。頻繁に変えるとプロンプトキャッシュが効かなくなり、コストが上がります。",
@@ -1713,6 +1725,9 @@ const I18N = {
1713
1725
  "providers.modelNoEffort": "sin esfuerzo",
1714
1726
  "providers.defaultEffort": "Esfuerzo de razonamiento predeterminado",
1715
1727
  "providers.defaultEffortHelp": "Se usa cuando ni la asignación ni la app indican un esfuerzo.",
1728
+ "providers.fastMain": "Fast para la sesión principal",
1729
+ "providers.fastSubagent": "Fast para subagentes",
1730
+ "providers.fastHelp": "Al activarlo, las peticiones de GPT salen en el nivel rápido de Codex. Las respuestas llegan antes, pero el uso de la suscripción se consume más rápido.",
1716
1731
  "providers.identity": "Decirle al modelo quién es (identidad)",
1717
1732
  "providers.append": "Añadido al prompt del sistema",
1718
1733
  "providers.appendHelp": "Texto fijo que se añade al final del prompt del sistema en cada solicitud. Cambiarlo a menudo rompe la caché de prompts y encarece el uso.",
@@ -2230,6 +2245,9 @@ const I18N = {
2230
2245
  "providers.modelNoEffort": "sem esforço",
2231
2246
  "providers.defaultEffort": "Esforço de raciocínio padrão",
2232
2247
  "providers.defaultEffortHelp": "Usado quando nem o mapeamento nem o app definem um esforço.",
2248
+ "providers.fastMain": "Fast para a sessão principal",
2249
+ "providers.fastSubagent": "Fast para subagentes",
2250
+ "providers.fastHelp": "Ao ativar, as requisições de GPT saem no nível rápido do Codex. As respostas chegam antes, mas o uso da assinatura acaba mais depressa.",
2233
2251
  "providers.identity": "Dizer ao modelo quem ele é (identidade)",
2234
2252
  "providers.append": "Adendo ao prompt de sistema",
2235
2253
  "providers.appendHelp": "Texto fixo acrescentado ao prompt de sistema de toda requisição. Alterá-lo com frequência quebra o cache de prompt e aumenta o custo.",
@@ -856,7 +856,8 @@ text is added. Source: `requestlog.ts`, `proxy.ts`, provider adapter outcomes.
856
856
  - `upstreamCalled`: `false` only for a known pre-model-API exit; omitted when a search backend was attempted or the call state is unknown.
857
857
  - `refusal`: `thread-continue`, `drain`, `unroutable`, `no-account`, `unknown-provider`, `web-search-unsupported` or `prompt-incompatible`, from the actual exit branch. `prompt-incompatible` follows an upstream request; it does not set `upstreamCalled: false`.
858
858
  - `purpose`: `auto-mode-classifier` from the security-monitor request shape regardless of routing settings, or `web-search` for router-served searches.
859
- - `requestedTier`: the service tier actually sent upstream, including ChatGPT's `speed: "fast"` → `priority`; omitted when no tier was sent.
859
+ - `requestedTier`: the service tier actually sent upstream, including ChatGPT's `speed: "fast"` → `priority`; omitted when no tier was sent. Claude Code never puts `speed` on a GPT request (its client gate allows fast only for model ids it knows, CLI 2.1.304), so a ChatGPT provider's `fast: {main, subagent}` switches (both off by default; issue #69, 2026-10-22) send `priority` themselves: `subagent` for requests carrying `x-claude-code-agent-id`, which the CLI adds only inside a subagent, `main` for the rest.
860
+ - `subagent`: `true` when the request carried `x-claude-code-agent-id`, for every provider; omitted on a main session's own turns.
860
861
  - `servedTier`: the response-reported tier (Responses/Chat `service_tier` or Anthropic `usage.service_tier`); never inferred from the request.
861
862
 
862
863
  **Hourly usage history (`usage-hourly.jsonl`, 2026-10-20).** `requests.jsonl` is
@@ -1643,6 +1644,7 @@ plan unless marked otherwise.
1643
1644
  | 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. |
1644
1645
  | After ~120 host↔relay reconnects no phone could connect for minutes — web and native sat at "connecting" while the host's control socket was open and answering pings, and a host restart did not help (the simulator's own log: every attempt 19:10–19:17 timed out waiting for the relay's `open`, then the next one worked with no change on the phone; 2026-10-15). Measured on the deployed relay with throwaway server ids: a replaced host control socket whose other end never answers the close stays in `getWebSockets("host")`, in acceptance order, for 8+ minutes, and the room sent every phone's `connect` to `sockets("host")[0]` — that dead socket — through any number of reconnects and a restart | The room routes a phone only to the **newest** host socket (each is tagged `at:<accepted ms>`), and only while it pinged within `HOST_SILENT_MS` (the runtime's auto-response timestamp); a silent newest socket is closed `4408` and the phone hears `4404 host offline` at once. The host walks the phone's path itself after every hello and every 5 min (a client socket to its own room must come back `open` within 10 s) and reconnects when it does not; a ping timer that ran late (sleep, blocked event loop) is not taken for a dead socket. Close codes, reasons and ISO timestamps are in `host.log`. In-process tests reproduce the sequence over a network that keeps dead sockets listed (`relay-reconnect.test.ts`). |
1645
1646
  | Return to Wi-Fi postponed forever while subagents keep working (2026-10-20): on the iPhone hotspot, the way back ran only with no model call in flight and none started in the last 20s; with Claude and subagents working back-to-back it logged "Wi-Fi return postponed" at 19:22:54 and never went back to the home Wi-Fi | A return still postponed `RETURN_FORCE_AFTER_MS` (120s) past due makes the gap itself (§8 "Making the gap"): new model calls are **held** at a gate in the proxy and the OpenAI ingress (`hold.ts`) — they wait unread, nothing is written, nothing refused or 503'd, each at most 120s — the running ones get up to 60s to finish, then the usual leave → probe runs and the gate is released whatever the outcome; calls that do not finish in time → release, next scheduled point (the fixed 5/10/20/30/40/50/60-minute return schedule of §8, 2026-10-20). The release is in a `finally` (a thrown error releases it), `stop()` releases it, the gate has its own failsafe timer, and a draining router closes it so a held call meets the retryable 503 instead of being cut. Tests: `tether.test.ts`, `hold.test.ts`. |
1647
+ | Blind returns repeatedly cut the only connection while travelling with the lid closed (2026-10-21 18:40–19:08 KST, owner-reported router.log): at each point, "trying to go back to Wi-Fi: leaving the hotspot so macOS joins the best known network", then "no network was joined", "networksetup said: Failed to join network PBJ" → `join-failed`. No known Wi-Fi was in range, and an idle iPhone stops advertising; with the lid closed macOS did not reliably rejoin it. The owner pinned the network as a stopgap. | Never leave the hotspot speculatively. Before either quiet return or the hold gate, a read-only backend check must prove another saved Wi-Fi is visible (on Windows, also an automatic profile). No match → "staying on the hotspot: no known Wi-Fi in range", once per point, then the next point on the unchanged schedule. Scan/list failure, unsupported response or Location refusal → stay and log why; never hold calls or disconnect on unknown visibility. macOS: preferred SSIDs + directed CoreWLAN JXA scans, Windows: saved profile SSIDs intersected with `netsh wlan show networks` (§8, measurements 2026-10-21). |
1646
1648
  | Android hotspot never recognised (2026-10-20, found by reading the code for an owner with Windows laptops and Android phones in mind): "on the hotspot" was the iPhone range 172.20.10.0/28 alone, on macOS and Windows. An Android hotspot hands out 192.168.43.x (≤ Android 10) or a random /24 (Android 11+; 192.168/16, 172.16/12, 10/8 by Android 14), so after our own failover `join()` saw no hotspot address and reported `join-failed`/`hotspot-not-visible`, while on the phone the controller read Wi-Fi and never planned the way back, and a by-hand join was never recognised | The product does not assume an iPhone. After our own join of the configured SSID is accepted by the OS, a new, non-link-local address within the settle time is the join, and its subnet (address + netmask from `os.networkInterfaces()`: no Location, no elevation) is kept in `<home>/tether.json` for that hotspot name; "on the hotspot" = iPhone range OR that network (§8). Only our own join replaces it, and a subnet equal to the Wi-Fi watched before is not remembered (logged as ambiguous) so home Wi-Fi is never read as the hotspot. Hints name both phones where the step differs. Not yet run with a real Android phone. |
1647
1649
  | An expired Claude login on the Mac wrote `[desktop state] …로그인이 만료되었습니다…` every few seconds per Claude app session and read the keychain (a synchronous child process) for every request, while phones learned of it only by a failed send (2026-10-15) | A login problem is one host state: logged when it starts and ends, sent to phones as `HostInfo.desktopProblem` (a banner), and the keychain is reread at most every 30 s while expired or after a 401. Per-session Remote Control errors log once per change. The host never refreshes the login itself. |
1648
1650
 
@@ -1796,9 +1798,9 @@ join) is **not** remembered — the two could not be told apart — and is logge
1796
1798
  as Wi-Fi before; not remembering it"); that join reads as Wi-Fi and no way back is planned. The
1797
1799
  default gateway was not used: it needs a process per OS (`route -n get default`, `Get-NetRoute`) and
1798
1800
  says nothing the subnet does not. Limits: a by-hand join is recognised only on a network learned
1799
- before; a Wi-Fi elsewhere that happens to use the learned /24 reads as the hotspot, so the way back
1800
- drops it for a few seconds at each scheduled point and the OS rejoins it (no harm beyond those drops;
1801
- `tether.pin` stops them). Failover (on Wi-Fi or no
1801
+ before; a Wi-Fi elsewhere that happens to use the learned /24 reads as the hotspot. The way back
1802
+ can drop it only when another saved SSID is visible (visibility guard, 2026-10-21, below);
1803
+ `tether.pin` stops switching entirely. Failover (on Wi-Fi or no
1802
1804
  address, outage): wait at most 30s for an in-flight call, then `sudo -n networksetup
1803
1805
  -setairportnetwork <dev> <NAME>` (macOS takes the password from the System keychain). The probe window
1804
1806
  lives in the process, so it only works if the router is not restarted every few seconds while the
@@ -1811,8 +1813,9 @@ to it for the timeout duration" unless auto shutdown is off — AOSP `SoftApConf
1811
1813
  android-11.0.0_r1, `isAutoShutdownEnabled`). Return (on the hotspot):
1812
1814
  on a fixed schedule counted from landing there — 5, 10, 20, 30, 40, 50 and 60 minutes, then every
1813
1815
  10 minutes for as long as it stays (owner's decision, 2026-10-20: at most ~10 minutes on the hotspot
1814
- after reaching a known Wi-Fi, and only a brief drop every 10 minutes on the move — a doubling
1815
- backoff held it for up to 15 minutes) — and only with no call in flight and none started in the
1816
+ after reaching a known Wi-Fi — a doubling backoff held it for up to 15 minutes). **Since
1817
+ 2026-10-21 each point first checks whether another saved Wi-Fi is actually in range, without
1818
+ leaving the hotspot** (below). Only with a positive check, no call in flight and none started in the
1816
1819
  last 20s, disassociate via JXA as the console user (wifihop's exact call: `ObjC.import("CoreWLAN");
1817
1820
  $.CWWiFiClient.sharedWiFiClient.interface.disassociate`) so macOS auto-joins the best known network;
1818
1821
  two good probes within 20s keep it; otherwise stay on (or rejoin) the hotspot and move to the next
@@ -1823,11 +1826,57 @@ switching. Every decision is logged once, with its reason. "Ask to join hotspots
1823
1826
  (macOS 26+, Apple 109321) covers the no-Wi-Fi-at-all case by itself; its setting lives in a root-only
1824
1827
  file (`com.apple.airport.preferences.plist`: permission denied, measured), so it is only hinted at.
1825
1828
 
1829
+ **Visibility before return (2026-10-21).** The blind drop was not brief: while travelling with
1830
+ the lid closed and no known Wi-Fi, the owner reported this sequence in router.log, 18:40–19:08 KST:
1831
+ "trying to go back to Wi-Fi: leaving the hotspot so macOS joins the best known network" → "no
1832
+ network was joined" → "networksetup said: Failed to join network PBJ" → `join-failed`. An idle iPhone
1833
+ stops advertising (above), and closed-lid macOS did not reliably rejoin; the owner pinned the
1834
+ network via the API as a stopgap. Both return paths now share a read-only check **before any hold
1835
+ is engaged or network is left** (`TetherBackend.returnBlocked`). No other known SSID visible →
1836
+ "staying on the hotspot: no known Wi-Fi in range", once per scheduled point, then the next fixed
1837
+ point; no disconnect, join or hold. Failed/unsupported scans (including Location refusal) are
1838
+ unknown visibility, never permission to disconnect: the reason is logged and the point is skipped.
1839
+ The schedule and `MIN_SWITCH_INTERVAL_MS` are unchanged. Activity, pin, stop, current network and
1840
+ settings are checked after the asynchronous scan, so a new call or a cancellation is not overlooked.
1841
+
1842
+ Read-only measurements on the owner's Mac (2026-10-21, `sw_vers`: macOS **27.0.1**, build **26A434**;
1843
+ `stat /dev/console` and `id`: console and executing user **pbj, uid 501**; Wi-Fi **en0**, IPv4
1844
+ **172.20.10.10** before/after scanning, no join/disassociate/permission change):
1845
+ - `networksetup -listpreferredwirelessnetworks en0` returned "Preferred networks on en0:" followed
1846
+ by **48** tab-indented SSIDs, including the configured hotspot **PBJ**. **47** remain after excluding it.
1847
+ - CoreWLAN through `/usr/bin/osascript -l JavaScript`, the same user/session path as disassociate:
1848
+ `scanForNetworksWithNameError($(), null)` returned **12** CWNetworks with **unredacted SSIDs**,
1849
+ including PBJ; none of the other preferred SSIDs matched. JXA's ObjC nil is `$()`: passing plain
1850
+ JavaScript `null` as the name gave no usable count (`undefined`), not an empty scan. An early
1851
+ diagnostic attempting to serialize the ObjC error/result also exited **139**; nonzero exit or
1852
+ missing/invalid output must therefore stay safe, not mean visibility.
1853
+ - Directed `scanForNetworksWithNameError(name, null)` for all **48** preferred names returned
1854
+ **PBJ: count 1, SSIDs ["PBJ"]** and **each other name: count 0, SSIDs []**. The absent control
1855
+ `__ClaudeRipple_absent_20261021__` also returned **0**. No SSID was redacted in these successful
1856
+ scans; **redacted directed results have not been measured**. The implementation uses directed
1857
+ set counts, not the returned SSID, so a non-empty set can prove the named SSID's visibility even
1858
+ if a different OS/permission context redacts that field. The returned set's `count` bridges to a JXA string
1859
+ here (`"1"`/`"0"`), so it is converted to Number; unavailable/non-finite results fail closed.
1860
+ - The actual `SCAN_KNOWN_JS` implementation was executed read-only with those lists: **47 preferred
1861
+ SSIDs except PBJ → `none`; [PBJ] → `visible`; [absent control] → `none`**. It passes names as a JSON
1862
+ argv value, never interpolated code, selects en0 explicitly, and has a 30s runner timeout.
1863
+ - `system_profiler SPAirPortDataType -json` returned **24** entries in
1864
+ `spairport_airport_other_local_wireless_networks`; **every `_name` was `<redacted>`**, as was the
1865
+ current network. These were successive snapshots while travelling, not simultaneous counts.
1866
+ This path cannot identify a saved SSID here and is not used.
1867
+
1868
+ Windows uses `netsh wlan show networks mode=ssid interface="<adapter>"` and saved profiles from
1869
+ `show profiles` plus exported XML (SSID and auto/manual mode), excluding the hotspot. Only a visible
1870
+ SSID with an automatic profile permits return; names of profiles alone are not SSID evidence.
1871
+ Location denial, command errors and an empty/redacted/unsupported listing all stay on the hotspot,
1872
+ with a reason. Unit tests use injected runners only. **Unverified:** Windows live behaviour and
1873
+ an actual return with another known Wi-Fi in range; no real switch was made for this change.
1874
+
1826
1875
  **Making the gap (2026-10-20).** "No call in flight and none in the last 20s" never came while Claude
1827
1876
  and subagents worked back-to-back: the return was postponed from 19:22:54 on and the Mac stayed on
1828
1877
  the hotspot (§5). So a return still postponed `RETURN_FORCE_AFTER_MS` (120s) past due stops waiting
1829
1878
  for a quiet moment and makes one (`returnWithGap`, tether.ts), after the same `MIN_SWITCH_INTERVAL`
1830
- and `returnBlocked` checks: (1) engage the hold gate (`hold.ts`), shared by the proxy (`/v1/messages*`)
1879
+ and positive visibility/`returnBlocked` checks (2026-10-21): (1) engage the hold gate (`hold.ts`), shared by the proxy (`/v1/messages*`)
1831
1880
  and the OpenAI ingress (Responses/Chat calls). A new model call that arrives while it is engaged
1832
1881
  waits before its body is read and before it is counted in `messagesInFlight` — no bytes written, not
1833
1882
  refused, not 503 — until the gate is released or `HOLD_MAX_MS` (120s) has passed for that call, then
@@ -1941,7 +1990,9 @@ anyway netsh says so ("…need location permission… ms-settings:privacy-locati
1941
1990
  interface="<adapter>"` (Windows keeps the password in the profile; no profile → `no-hotspot-profile`,
1942
1991
  "join it once from the Wi-Fi menu"). netsh returns once the request is queued, so the address decides:
1943
1992
  none within 15s → `hotspot-not-visible`. Return: only if some other profile has
1944
- `connectionMode=auto` (else no attempt, a hint, the same return schedule); `netsh wlan disconnect` lets
1993
+ `connectionMode=auto` **and its SSID is visible in `netsh wlan show networks`** (2026-10-21, visibility
1994
+ check above; unknown visibility never disconnects or holds calls). Otherwise no attempt, a logged
1995
+ reason, the same return schedule; `netsh wlan disconnect` lets
1945
1996
  auto-config join the best automatic network; whether a netsh disconnect suppresses that on Vista+ is
1946
1997
  undocumented (WlanDisconnect's "on-demand profile" note is for XP), so with no address after 8s the
1947
1998
  first automatic profile is joined by name. Profile names come from `show profiles` (the text after
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "description": "Use GPT (any ChatGPT plan with Codex), 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": [