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 +15 -0
- package/dist/app/package.json +1 -1
- package/dist/mod/hooks/workers.ts +5 -1
- package/dist/router/src/tether-win.js +24 -2
- package/dist/router/src/tether.js +66 -10
- package/dist/router/src/version.js +1 -1
- package/docs/ARCHITECTURE.md +57 -7
- package/package.json +1 -1
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
|
package/dist/app/package.json
CHANGED
|
@@ -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(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&/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
|
-
|
|
195
|
-
|
|
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 →
|
|
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
|
|
20
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
8
|
+
export const VERSION = "0.12.2";
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
1800
|
-
|
|
1801
|
-
`tether.pin` stops
|
|
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
|
|
1815
|
-
|
|
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
|
|
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`
|
|
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.
|
|
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": [
|