clauderipple 0.12.6 → 0.12.7

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.7 — 2026-10-22
4
+
5
+ ### Fixed
6
+
7
+ - **A GPT main session knows it can make images.** How to call `/api/image` was written only into
8
+ generated worker files, so a session running GPT as its own model said it had no image tool and
9
+ drew an SVG. The same lines now end its instructions; subagent turns are unchanged.
10
+ - **A relay's 402 for a downed model no longer parks the whole account.** OpenCode Go answered 402
11
+ while one model's endpoint was down, which read as billing exhaustion and rested the account for
12
+ 30 minutes, DeepSeek included. A refusal whose body says the upstream failed is now transient.
13
+ - **A failed hotspot join is tried again within seconds** (10 s, 20 s, 30 s … up to a minute)
14
+ instead of waiting out the once-a-minute switch rule.
15
+ - **`ripple:auto` asks for a bare task description,** so the Claude app's running-agent row no
16
+ longer shows another worker's model name copied into the title.
17
+
3
18
  ## 0.12.6 — 2026-10-22
4
19
 
5
20
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.12.6",
3
+ "version": "0.12.7",
4
4
  "type": "commonjs"
5
5
  }
@@ -7,7 +7,11 @@ import { kindOf, parseEffortVerdict, verdictObject } from './judge'
7
7
  import { claudeChoices, rungAbove, rungOf, tickedChoices } from './ladder'
8
8
 
9
9
  /** The one agent the main chat delegates to; the judge picks the worker (docs/specs/2026-10-12-worker-tiers.md). */
10
+ // The description line: a chat that had seen other workers' "Sol·high · " titles wrote one here
11
+ // too, and the app's running-agent row showed "Sol·high" while Sonnet ran (2026-10-22). The row
12
+ // shows the call as written; only the task list gets the mod's corrected title.
10
13
  export const AUTO_DESCRIPTION = 'Delegate any task here; ClaudeRipple picks the model. If a worker fails or returns nothing usable, delegate again here once and say the earlier attempt failed. ' +
14
+ 'Write the Agent description as the task alone, with no model or effort in front: ClaudeRipple puts the model it picks there. ' +
11
15
  '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
16
  'light = lookups, mechanical edits, one small fix; standard = carry out a designed change (even across several files), research, summarize, read-only review; ' +
13
17
  '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. ' +
@@ -45,9 +45,10 @@ const BODY = [
45
45
  /**
46
46
  * Added to the body when a ChatGPT provider is configured: how a worker makes an image. A worker has
47
47
  * no image tool, and `clauderipple` is not on PATH for everyone (a checkout runs from source), so
48
- * the instruction is the admin endpoint itself, which curl reaches on every platform.
48
+ * the instruction is the admin endpoint itself, which curl reaches on every platform. A GPT main
49
+ * session gets the same lines in its instructions (proxy.ts).
49
50
  */
50
- function imageLine(port) {
51
+ export function imageLine(port) {
51
52
  return [
52
53
  "이미지가 필요하면 ChatGPT 구독으로 만든다(한 장 30초~수 분):",
53
54
  `\`curl -sS --fail-with-body -o <절대경로>.png -H 'content-type: application/json' -d '{"prompt":"…"}' http://127.0.0.1:${port}/api/image\``,
@@ -12,6 +12,7 @@
12
12
  // A check that cannot run (no curl) exits as before: the documented fix stays the default.
13
13
  import path from "node:path";
14
14
  import { PROBE_URLS } from "./tether.js";
15
+ import { RELAY_UPSTREAM_FAILED } from "./pool.js";
15
16
  /** Only definite billing/quota exhaustion, never a bare rate limit or RESOURCE_EXHAUSTED.
16
17
  * 402 is already `exhausted` in pool.ts; Codex's usage_limit_reached is named in
17
18
  * providers/chatgpt/translate.ts. Other vendors share the explicit billing vocabulary.
@@ -20,6 +21,9 @@ import { PROBE_URLS } from "./tether.js";
20
21
  export function providerExhaustion(status, body = "", retryMs, now = Date.now()) {
21
22
  if (status !== 402 && status !== 429)
22
23
  return undefined;
24
+ // A relay's 402 for its upstream being down is not about money (pool.ts, RELAY_UPSTREAM_FAILED).
25
+ if (RELAY_UPSTREAM_FAILED.test(body))
26
+ return undefined;
23
27
  const balance = /\b(?:insufficient[_ -]+(?:balance|credits?|funds)|(?:balance|credits?)[_ -]+(?:insufficient|exhausted|depleted))\b/i.test(body);
24
28
  const billing = /\bbilling[_ -]+(?:hard[_ -]+)?(?:limit|exhausted|quota)|\b(?:exhausted|exceeded|insufficient)[_ -]+billing\b/i.test(body);
25
29
  const quota = /\b(?:insufficient_quota|usage_limit_reached|quota[_ -]+exhausted|exhausted[_ -]+quota)\b/i.test(body);
@@ -26,6 +26,8 @@
26
26
  const DEFAULT_RATE_LIMIT_MS = 60_000;
27
27
  const TRANSIENT_MS = 10_000;
28
28
  const EXHAUSTED_MS = 30 * 60_000;
29
+ /** A relay passing on its upstream's failure; whatever status it chose, the credential did nothing. */
30
+ export const RELAY_UPSTREAM_FAILED = /upstream (request )?failed/i;
29
31
  /** A `Retry-After` beyond this is treated as "not within this session"; the credential is parked. */
30
32
  const MAX_COOLDOWN_MS = 6 * 60 * 60_000;
31
33
  /**
@@ -48,13 +50,19 @@ export function classify(status, retryAfterMs, body = "") {
48
50
  // held for no time at all rather than for a shorter time — any cooldown at all would move the
49
51
  // conversation to another credential and take the prompt cache with it (§4). `bounded` is
50
52
  // bypassed deliberately: its one-second floor is exactly the cache cost this avoids.
51
- if (/upstream (request )?failed/i.test(body)) {
53
+ if (RELAY_UPSTREAM_FAILED.test(body)) {
52
54
  return { retryable: true, kind: "transient", cooldownMs: 0, quarantine: false };
53
55
  }
54
56
  return { retryable: true, kind: "auth", cooldownMs: bounded(retryAfterMs ?? DEFAULT_RATE_LIMIT_MS), quarantine: false };
55
57
  }
56
58
  if (status === 429)
57
59
  return { retryable: true, kind: "rate-limit", cooldownMs: bounded(retryAfterMs ?? DEFAULT_RATE_LIMIT_MS), quarantine: false };
60
+ // The same relay answers 402 for a model whose own endpoint is down ("server_error … Upstream
61
+ // request failed: Endpoint is unavailable", OpenCode Go for minimax-m3.1-flash-free, 2026-10-22).
62
+ // That is no statement about money: read as exhaustion it parked the whole account for half an
63
+ // hour and its other models with it.
64
+ if (status === 402 && RELAY_UPSTREAM_FAILED.test(body))
65
+ return { retryable: true, kind: "transient", cooldownMs: 0, quarantine: false };
58
66
  if (status === 402)
59
67
  return { retryable: true, kind: "exhausted", cooldownMs: bounded(retryAfterMs ?? EXHAUSTED_MS), quarantine: false };
60
68
  // 408 and 425 are the server saying "ask again"; 5xx is the server being broken. Both may work
@@ -984,12 +984,13 @@ export class ChatGptAdapter {
984
984
  * `subagent` is the one bit about the caller the tier needs: Claude Code names the agent in the
985
985
  * `x-claude-code-agent-id` header, and only a subagent's request carries it.
986
986
  */
987
- responsesOptions(carried, model, effort, replay = true, subagent = false) {
987
+ responsesOptions(carried, model, effort, replay = true, subagent = false, imageHint) {
988
988
  return {
989
989
  model,
990
990
  effort: effort ?? this.cfg.defaultEffort ?? "high",
991
991
  identity: this.cfg.identity ?? true,
992
992
  ...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
993
+ ...(imageHint ? { imageHint } : {}),
993
994
  replay,
994
995
  // The envelope names this provider and this model, and the request builder refuses anything
995
996
  // else: reasoning outlives the turn that made it, and a conversation moved to another model
@@ -1006,7 +1007,7 @@ export class ChatGptAdapter {
1006
1007
  * Handle a fully-read Messages request. `model`/`effort` already resolved by routing.
1007
1008
  *
1008
1009
  * `options.subagent` says the turn was made inside a subagent, which is what the provider's
1009
- * `fast.subagent` switch is about.
1010
+ * `fast.subagent` switch is about. `options.imageHint` is how the session makes an image (translate.ts).
1010
1011
  */
1011
1012
  async handle(req, res, path, json, model, effort, options) {
1012
1013
  if (path.startsWith("/v1/messages/count_tokens")) {
@@ -1021,7 +1022,7 @@ export class ChatGptAdapter {
1021
1022
  this.log.warn(`chatgpt ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
1022
1023
  const carried = { replayed: 0, undecodable: 0 };
1023
1024
  const subagent = options?.subagent === true;
1024
- let upstreamReq = toResponsesRequest(json, this.responsesOptions(carried, model, effort, true, subagent));
1025
+ let upstreamReq = toResponsesRequest(json, this.responsesOptions(carried, model, effort, true, subagent, options?.imageHint));
1025
1026
  let body = JSON.stringify(upstreamReq);
1026
1027
  const cacheKey = upstreamReq.prompt_cache_key;
1027
1028
  // Input estimate for message_start: a conversation only grows, so the last measured total is a floor.
@@ -1062,7 +1063,7 @@ export class ChatGptAdapter {
1062
1063
  // must never be the reason a user's turn fails (reasoning.ts).
1063
1064
  if (carried.replayed > 0 && sent.kind === "refused" && refusesReplayedReasoning(sent.status, sent.text)) {
1064
1065
  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)}`);
1065
- upstreamReq = toResponsesRequest(json, this.responsesOptions({ replayed: 0, undecodable: 0 }, model, effort, false, subagent));
1066
+ upstreamReq = toResponsesRequest(json, this.responsesOptions({ replayed: 0, undecodable: 0 }, model, effort, false, subagent, options?.imageHint));
1066
1067
  body = JSON.stringify(upstreamReq);
1067
1068
  carried.replayed = 0;
1068
1069
  sent = await ask();
@@ -233,6 +233,8 @@ export function toResponsesRequest(req, opts) {
233
233
  parts.push(sys);
234
234
  if (opts.instructionsAppend)
235
235
  parts.push(opts.instructionsAppend);
236
+ if (opts.imageHint && opts.subagent !== true)
237
+ parts.push(opts.imageHint);
236
238
  const input = [];
237
239
  // Claude Code sends side queries whose history starts with a bare tool_result (e.g. summarising a
238
240
  // large tool output). Anthropic tolerates the orphan; the Responses API rejects a function_call_output
@@ -21,7 +21,7 @@ import zlib from "node:zlib";
21
21
  import { ProviderHealth, providerExhaustion, UpstreamHealth } from "./health.js";
22
22
  import { BOOTSTRAP_PATH, injectBootstrap } from "./bootstrap.js";
23
23
  import { THREAD_UNSUPPORTED, effortOf, isAutoModeClassifier, resolve, rewriteBody, stripThreadFields, threadDecision, unroutableReason } from "./routing.js";
24
- import { defaultAgentDir, withAgentAliases } from "./agents.js";
24
+ import { defaultAgentDir, imageLine, withAgentAliases } from "./agents.js";
25
25
  import { forwardCompatibleHeader, resolveCompatibleCaps, sanitizeForCompatible } from "./compat.js";
26
26
  import { applyIdentityToAnthropicBody } from "./identity.js";
27
27
  import { anthropicServerToolBackend, webPluginBackend, webSearchBlocks, webSearchErrorBlocks, webSearchMessage, webSearchQuery, webSearchSse } from "./websearch.js";
@@ -33,7 +33,7 @@ import { GoogleAdapter } from "./providers/google/index.js";
33
33
  import { OpenCodeUsage, isOpenCodeGo } from "./providers/opencode-usage.js";
34
34
  import { GrokAdapter } from "./providers/grok/index.js";
35
35
  import { conversationKey } from "./providers/chatgpt/translate.js";
36
- import { providerFor, searchProviderFor, terminateHosts } from "./config.js";
36
+ import { adminPort, providerFor, searchProviderFor, terminateHosts } from "./config.js";
37
37
  import { chatPickerModels, injectPickerModels, isBootstrapPath } from "./picker.js";
38
38
  import { effortLevels, effortLevelsForModel } from "./admin.js";
39
39
  import { isWebFetchRequest } from "./webfetch.js";
@@ -989,7 +989,7 @@ export class Proxy {
989
989
  try {
990
990
  // The adapter reports each account's outcome to the shared pool itself, so a slot pointing
991
991
  // here fails over once every account is out (chooseTarget asks the adapter).
992
- const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json), { subagent });
992
+ const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json), { subagent, imageHint: imageLine(adminPort(cfg)) });
993
993
  if (!path.startsWith("/v1/messages/count_tokens"))
994
994
  this.providerHealth.observe(route.provider, o.status, o.exhaustion);
995
995
  finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
@@ -66,6 +66,13 @@ export const PROBE_URLS = {
66
66
  };
67
67
  export const PROBE_TIMEOUT_MS = 4_000;
68
68
  export const MIN_SWITCH_INTERVAL_MS = 60_000;
69
+ /**
70
+ * After a join that failed, the next try comes this much later per failure in a row (10, 20, 30 s…)
71
+ * up to MIN_SWITCH_INTERVAL_MS. A failed join switched nothing, so the once-a-minute rule against
72
+ * flapping does not apply: walking out of the office, the first join failed as the hotspot was only
73
+ * just coming into range, and the Mac sat offline for the rest of the minute (2026-10-22).
74
+ */
75
+ export const JOIN_RETRY_MS = 10_000;
69
76
  /** How long a scan that saw a known Wi-Fi stands for a return still waiting on busy calls. */
70
77
  export const KNOWN_VISIBLE_TRUST_MS = 60_000;
71
78
  export const RETURN_PROBE_MS = 20_000;
@@ -96,6 +103,8 @@ export const RETURN_HOLD_FAILSAFE_MS = RETURN_DRAIN_WAIT_MS + 3 * 60_000;
96
103
  const JOIN_SETTLE_MS = 15_000;
97
104
  /** States that say why the last join failed; they stay (with their hint) until the next attempt. */
98
105
  const JOIN_FAILURES = new Set(["hotspot-not-visible", "join-failed", "needs-setup", "no-hotspot-profile", "needs-location"]);
106
+ /** Failures a moment later can cure (the phone coming into range); setup problems wait the full minute. */
107
+ const RETRIABLE_JOIN = new Set(["hotspot-not-visible", "join-failed"]);
99
108
  // ---- Pure helpers -------------------------------------------------------------------------------
100
109
  /** The Wi-Fi device from `networksetup -listallhardwareports` ("Hardware Port: Wi-Fi\nDevice: en0"). */
101
110
  export function parseWifiDevice(text) {
@@ -336,6 +345,8 @@ export class TetherController {
336
345
  state = "off";
337
346
  pinned = false;
338
347
  lastSwitchAt = 0;
348
+ /** Hotspot joins that failed in a row; the retry gap grows with it (JOIN_RETRY_MS). */
349
+ joinFailures = 0;
339
350
  /** When a scan last saw a known Wi-Fi other than the hotspot. */
340
351
  knownVisibleAt = -Infinity;
341
352
  lastSwitch = null;
@@ -486,6 +497,7 @@ export class TetherController {
486
497
  }
487
498
  if (this.network === "hotspot") {
488
499
  // Landed here by our own failover or by hand: either way, plan the way back from here.
500
+ this.joinFailures = 0;
489
501
  if (this.landedOnHotspotAt === null) {
490
502
  this.landedOnHotspotAt = this.now();
491
503
  this.nextReturnAt = nextReturnPoint(this.landedOnHotspotAt, this.now());
@@ -500,6 +512,8 @@ export class TetherController {
500
512
  // On Wi-Fi (or none): the way back is no longer pending, and landing again starts the schedule over.
501
513
  this.landedOnHotspotAt = null;
502
514
  if (!this.window.outage()) {
515
+ if (this.network === "wifi")
516
+ this.joinFailures = 0;
503
517
  this.state = this.network === "wifi" ? "watching" : "no-wifi";
504
518
  if (this.network === "wifi")
505
519
  this.decide(`watching Wi-Fi (${5 - this.window.failures()}/5 recent probes ok)`);
@@ -517,11 +531,13 @@ export class TetherController {
517
531
  return;
518
532
  }
519
533
  const since = this.now() - this.lastSwitchAt;
520
- if (since < MIN_SWITCH_INTERVAL_MS) {
534
+ const retrying = this.joinFailures > 0 && RETRIABLE_JOIN.has(this.state);
535
+ const gap = retrying ? Math.min(MIN_SWITCH_INTERVAL_MS, JOIN_RETRY_MS * this.joinFailures) : MIN_SWITCH_INTERVAL_MS;
536
+ if (since < gap) {
521
537
  // A failed join keeps saying why (and its hint) until the next attempt.
522
538
  if (!JOIN_FAILURES.has(this.state))
523
539
  this.state = "outage";
524
- this.decide(`${why}; waiting ${Math.ceil((MIN_SWITCH_INTERVAL_MS - since) / 1000)}s (at most one switch per minute)`);
540
+ this.decide(`${why}; waiting ${Math.ceil((gap - since) / 1000)}s (${retrying ? "trying the hotspot again" : "at most one switch per minute"})`);
525
541
  return;
526
542
  }
527
543
  // A call in flight on a dead network cannot finish anyway; give it a short chance, no more.
@@ -533,6 +549,7 @@ export class TetherController {
533
549
  this.decide(`${why}: joining hotspot "${settings.hotspotSsid}"`);
534
550
  const outcome = await this.join(settings.hotspotSsid);
535
551
  this.recordSwitch("hotspot", outcome === "joined", outcome === "joined" ? why : outcome);
552
+ this.joinFailures = outcome === "joined" ? 0 : this.joinFailures + 1;
536
553
  if (outcome === "joined" && !this.onHotspot(this.currentIp())) {
537
554
  // Joined, but on a network that could not be remembered (see learn()): it reads as Wi-Fi.
538
555
  this.window.reset();
@@ -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.6";
8
+ export const VERSION = "0.12.7";
@@ -427,7 +427,10 @@ must preserve **Data: Artificial Analysis (artificialanalysis.ai)** attribution.
427
427
  reason. With a chatgpt provider configured the body also tells a worker how to
428
428
  make an image: `POST /api/image` on the admin port, which runs one Codex
429
429
  Responses turn with the hosted `image_generation` tool required and answers
430
- with the image bytes (`clauderipple image` is the same call). Measured
430
+ with the image bytes (`clauderipple image` is the same call). A GPT main session
431
+ gets the same lines at the end of its instructions (not a subagent's turns, whose
432
+ agent file has them): without them a GPT session told the operator it could not
433
+ make images and drew an SVG (2026-10-22). Measured
431
434
  2026-09-29 on a subscription: the image is the `image_generation_call` output
432
435
  item's base64 `result`, about 30 s; `output_format` and `background` are
433
436
  honoured, `size` and `quality` are accepted and ignored (even "999x1" and
@@ -1647,6 +1650,7 @@ plan unless marked otherwise.
1647
1650
  | 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). |
1648
1651
  | 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. |
1649
1652
  | 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. |
1653
+ | OpenCode Go answered **402** for `minimax-m3.1-flash-free` while that model's endpoint was down (`server_error … Upstream request failed: Endpoint is unavailable`); read as billing exhaustion, it parked the whole `opencode-go` account for 30 minutes, DeepSeek with it, and delegated work fell back to Opus while the account had credit (2026-10-22) | A relay's refusal whose body says its upstream failed (`RELAY_UPSTREAM_FAILED`, `pool.ts`) is transient whatever status it carries — 402 as well as 403 — and never a provider exhaustion (`health.ts`). A plain 402 or a body naming balance, billing or quota still is. |
1650
1654
 
1651
1655
  ### Router-owned automatic updates (2026-10-21)
1652
1656
 
@@ -1802,7 +1806,11 @@ before; a Wi-Fi elsewhere that happens to use the learned /24 reads as the hotsp
1802
1806
  can drop it only when another saved SSID is visible (visibility guard, 2026-10-21, below);
1803
1807
  `tether.pin` stops switching entirely. Failover (on Wi-Fi or no
1804
1808
  address, outage): wait at most 30s for an in-flight call, then `sudo -n networksetup
1805
- -setairportnetwork <dev> <NAME>` (macOS takes the password from the System keychain). The probe window
1809
+ -setairportnetwork <dev> <NAME>` (macOS takes the password from the System keychain). Switches are at
1810
+ least a minute apart, but a join that failed ("Failed to join network", or not visible) switched
1811
+ nothing, so it is tried again 10 s, 20 s, 30 s … later up to that minute: walking out of the office
1812
+ the first join came as the hotspot was only just in range, failed, and the Mac stayed offline for the
1813
+ rest of the minute (2026-10-22). Setup failures (sudo, profile, Location) still wait the minute. The probe window
1806
1814
  lives in the process, so it only works if the router is not restarted every few seconds while the
1807
1815
  machine is offline (§5, health self-exit refined 2026-10-20). networksetup
1808
1816
  reports failure on stdout and may exit 0, so its text decides: "Could not find network" →
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.12.6",
3
+ "version": "0.12.7",
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": [