clauderipple 0.12.1 → 0.12.2

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.2 — 2026-10-22
4
+
5
+ ### Fixed
6
+
7
+ - **Back on Wi-Fi only when a known Wi-Fi is in range.** After a hotspot failover, the way back
8
+ no longer drops the hotspot for a Wi-Fi that is not there, which cut the connection on the move.
9
+
10
+ ### Changed
11
+
12
+ - **Naming a model on a delegation is now explained to the main chat.** `ripple:auto`'s
13
+ description says that `[[ripple: opus@high]]` (or a worker's name) on the first line runs that
14
+ model past every ClaudeRipple rule and still shows in the agents panel, so a session no longer
15
+ starts models outside it to get one. It also says what a brief should state, to check that the
16
+ report did what was asked, and to resume a worker only within 5 minutes of its last reply.
17
+
3
18
  ## 0.12.1 — 2026-10-21
4
19
 
5
20
  ### Changed
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "type": "commonjs"
5
5
  }
@@ -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
@@ -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.2";
@@ -1643,6 +1643,7 @@ plan unless marked otherwise.
1643
1643
  | 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
1644
  | 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
1645
  | 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`. |
1646
+ | 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
1647
  | 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
1648
  | 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
1649
 
@@ -1796,9 +1797,9 @@ join) is **not** remembered — the two could not be told apart — and is logge
1796
1797
  as Wi-Fi before; not remembering it"); that join reads as Wi-Fi and no way back is planned. The
1797
1798
  default gateway was not used: it needs a process per OS (`route -n get default`, `Get-NetRoute`) and
1798
1799
  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
1800
+ before; a Wi-Fi elsewhere that happens to use the learned /24 reads as the hotspot. The way back
1801
+ can drop it only when another saved SSID is visible (visibility guard, 2026-10-21, below);
1802
+ `tether.pin` stops switching entirely. Failover (on Wi-Fi or no
1802
1803
  address, outage): wait at most 30s for an in-flight call, then `sudo -n networksetup
1803
1804
  -setairportnetwork <dev> <NAME>` (macOS takes the password from the System keychain). The probe window
1804
1805
  lives in the process, so it only works if the router is not restarted every few seconds while the
@@ -1811,8 +1812,9 @@ to it for the timeout duration" unless auto shutdown is off — AOSP `SoftApConf
1811
1812
  android-11.0.0_r1, `isAutoShutdownEnabled`). Return (on the hotspot):
1812
1813
  on a fixed schedule counted from landing there — 5, 10, 20, 30, 40, 50 and 60 minutes, then every
1813
1814
  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
1815
+ after reaching a known Wi-Fi — a doubling backoff held it for up to 15 minutes). **Since
1816
+ 2026-10-21 each point first checks whether another saved Wi-Fi is actually in range, without
1817
+ leaving the hotspot** (below). Only with a positive check, no call in flight and none started in the
1816
1818
  last 20s, disassociate via JXA as the console user (wifihop's exact call: `ObjC.import("CoreWLAN");
1817
1819
  $.CWWiFiClient.sharedWiFiClient.interface.disassociate`) so macOS auto-joins the best known network;
1818
1820
  two good probes within 20s keep it; otherwise stay on (or rejoin) the hotspot and move to the next
@@ -1823,11 +1825,57 @@ switching. Every decision is logged once, with its reason. "Ask to join hotspots
1823
1825
  (macOS 26+, Apple 109321) covers the no-Wi-Fi-at-all case by itself; its setting lives in a root-only
1824
1826
  file (`com.apple.airport.preferences.plist`: permission denied, measured), so it is only hinted at.
1825
1827
 
1828
+ **Visibility before return (2026-10-21).** The blind drop was not brief: while travelling with
1829
+ the lid closed and no known Wi-Fi, the owner reported this sequence in router.log, 18:40–19:08 KST:
1830
+ "trying to go back to Wi-Fi: leaving the hotspot so macOS joins the best known network" → "no
1831
+ network was joined" → "networksetup said: Failed to join network PBJ" → `join-failed`. An idle iPhone
1832
+ stops advertising (above), and closed-lid macOS did not reliably rejoin; the owner pinned the
1833
+ network via the API as a stopgap. Both return paths now share a read-only check **before any hold
1834
+ is engaged or network is left** (`TetherBackend.returnBlocked`). No other known SSID visible →
1835
+ "staying on the hotspot: no known Wi-Fi in range", once per scheduled point, then the next fixed
1836
+ point; no disconnect, join or hold. Failed/unsupported scans (including Location refusal) are
1837
+ unknown visibility, never permission to disconnect: the reason is logged and the point is skipped.
1838
+ The schedule and `MIN_SWITCH_INTERVAL_MS` are unchanged. Activity, pin, stop, current network and
1839
+ settings are checked after the asynchronous scan, so a new call or a cancellation is not overlooked.
1840
+
1841
+ Read-only measurements on the owner's Mac (2026-10-21, `sw_vers`: macOS **27.0.1**, build **26A434**;
1842
+ `stat /dev/console` and `id`: console and executing user **pbj, uid 501**; Wi-Fi **en0**, IPv4
1843
+ **172.20.10.10** before/after scanning, no join/disassociate/permission change):
1844
+ - `networksetup -listpreferredwirelessnetworks en0` returned "Preferred networks on en0:" followed
1845
+ by **48** tab-indented SSIDs, including the configured hotspot **PBJ**. **47** remain after excluding it.
1846
+ - CoreWLAN through `/usr/bin/osascript -l JavaScript`, the same user/session path as disassociate:
1847
+ `scanForNetworksWithNameError($(), null)` returned **12** CWNetworks with **unredacted SSIDs**,
1848
+ including PBJ; none of the other preferred SSIDs matched. JXA's ObjC nil is `$()`: passing plain
1849
+ JavaScript `null` as the name gave no usable count (`undefined`), not an empty scan. An early
1850
+ diagnostic attempting to serialize the ObjC error/result also exited **139**; nonzero exit or
1851
+ missing/invalid output must therefore stay safe, not mean visibility.
1852
+ - Directed `scanForNetworksWithNameError(name, null)` for all **48** preferred names returned
1853
+ **PBJ: count 1, SSIDs ["PBJ"]** and **each other name: count 0, SSIDs []**. The absent control
1854
+ `__ClaudeRipple_absent_20261021__` also returned **0**. No SSID was redacted in these successful
1855
+ scans; **redacted directed results have not been measured**. The implementation uses directed
1856
+ set counts, not the returned SSID, so a non-empty set can prove the named SSID's visibility even
1857
+ if a different OS/permission context redacts that field. The returned set's `count` bridges to a JXA string
1858
+ here (`"1"`/`"0"`), so it is converted to Number; unavailable/non-finite results fail closed.
1859
+ - The actual `SCAN_KNOWN_JS` implementation was executed read-only with those lists: **47 preferred
1860
+ SSIDs except PBJ → `none`; [PBJ] → `visible`; [absent control] → `none`**. It passes names as a JSON
1861
+ argv value, never interpolated code, selects en0 explicitly, and has a 30s runner timeout.
1862
+ - `system_profiler SPAirPortDataType -json` returned **24** entries in
1863
+ `spairport_airport_other_local_wireless_networks`; **every `_name` was `<redacted>`**, as was the
1864
+ current network. These were successive snapshots while travelling, not simultaneous counts.
1865
+ This path cannot identify a saved SSID here and is not used.
1866
+
1867
+ Windows uses `netsh wlan show networks mode=ssid interface="<adapter>"` and saved profiles from
1868
+ `show profiles` plus exported XML (SSID and auto/manual mode), excluding the hotspot. Only a visible
1869
+ SSID with an automatic profile permits return; names of profiles alone are not SSID evidence.
1870
+ Location denial, command errors and an empty/redacted/unsupported listing all stay on the hotspot,
1871
+ with a reason. Unit tests use injected runners only. **Unverified:** Windows live behaviour and
1872
+ an actual return with another known Wi-Fi in range; no real switch was made for this change.
1873
+
1826
1874
  **Making the gap (2026-10-20).** "No call in flight and none in the last 20s" never came while Claude
1827
1875
  and subagents worked back-to-back: the return was postponed from 19:22:54 on and the Mac stayed on
1828
1876
  the hotspot (§5). So a return still postponed `RETURN_FORCE_AFTER_MS` (120s) past due stops waiting
1829
1877
  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*`)
1878
+ and positive visibility/`returnBlocked` checks (2026-10-21): (1) engage the hold gate (`hold.ts`), shared by the proxy (`/v1/messages*`)
1831
1879
  and the OpenAI ingress (Responses/Chat calls). A new model call that arrives while it is engaged
1832
1880
  waits before its body is read and before it is counted in `messagesInFlight` — no bytes written, not
1833
1881
  refused, not 503 — until the gate is released or `HOLD_MAX_MS` (120s) has passed for that call, then
@@ -1941,7 +1989,9 @@ anyway netsh says so ("…need location permission… ms-settings:privacy-locati
1941
1989
  interface="<adapter>"` (Windows keeps the password in the profile; no profile → `no-hotspot-profile`,
1942
1990
  "join it once from the Wi-Fi menu"). netsh returns once the request is queued, so the address decides:
1943
1991
  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
1992
+ `connectionMode=auto` **and its SSID is visible in `netsh wlan show networks`** (2026-10-21, visibility
1993
+ check above; unknown visibility never disconnects or holds calls). Otherwise no attempt, a logged
1994
+ reason, the same return schedule; `netsh wlan disconnect` lets
1945
1995
  auto-config join the best automatic network; whether a netsh disconnect suppresses that on Vista+ is
1946
1996
  undocumented (WlanDisconnect's "on-demand profile" note is for XP), so with no address after 8s the
1947
1997
  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.2",
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": [