clauderipple 0.9.5 → 0.11.0

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.
Files changed (163) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +70 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +133 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/hooks/agent-title.js +7 -4
  39. package/dist/cli/src/index.js +227 -31
  40. package/dist/cli/src/launchd.js +29 -20
  41. package/dist/cli/src/launcher.js +215 -0
  42. package/dist/cli/src/power.js +182 -0
  43. package/dist/cli/src/registration.js +157 -0
  44. package/dist/cli/src/remote.js +263 -0
  45. package/dist/cli/src/schtasks.js +50 -40
  46. package/dist/cli/src/settings.js +26 -1
  47. package/dist/cli/src/tray.js +5 -2
  48. package/dist/mod/hooks/i18n.ts +473 -227
  49. package/dist/mod/hooks/judge.ts +35 -17
  50. package/dist/mod/hooks/modes.ts +56 -1
  51. package/dist/mod/hooks/register.tsx +237 -32
  52. package/dist/mod/hooks/session.ts +102 -1
  53. package/dist/mod/hooks/settings.js +7 -3
  54. package/dist/mod/hooks/settings.ts +9 -4
  55. package/dist/mod/hooks/stats.ts +25 -1
  56. package/dist/mod/hooks/ui/agents.tsx +903 -79
  57. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  58. package/dist/mod/hooks/ui/art.ts +33 -7
  59. package/dist/mod/hooks/ui/band.tsx +262 -81
  60. package/dist/mod/hooks/ui/flat.ts +265 -0
  61. package/dist/mod/hooks/ui/kit.tsx +70 -55
  62. package/dist/mod/hooks/ui/look.ts +129 -0
  63. package/dist/mod/hooks/ui/settings.tsx +71 -46
  64. package/dist/mod/hooks/ui/setup.tsx +15 -13
  65. package/dist/mod/hooks/ui/theme.ts +3 -0
  66. package/dist/mod/hooks/ui/tools.ts +124 -0
  67. package/dist/mod/hooks/ui/view.ts +120 -4
  68. package/dist/mod/hooks/workers.ts +33 -6
  69. package/dist/mod/tests/register.test.tsx +155 -11
  70. package/dist/mod/tests/ui.test.tsx +408 -27
  71. package/dist/mod/types/index.d.ts +66 -2
  72. package/dist/remote/package.json +28 -0
  73. package/dist/remote/src/away.js +85 -0
  74. package/dist/remote/src/bridge-client.js +336 -0
  75. package/dist/remote/src/composer-menu.js +11 -0
  76. package/dist/remote/src/config.js +102 -0
  77. package/dist/remote/src/desktop.js +1018 -0
  78. package/dist/remote/src/e2ee.js +126 -0
  79. package/dist/remote/src/e2ee.ts +126 -0
  80. package/dist/remote/src/eventlog.js +298 -0
  81. package/dist/remote/src/expo-push.js +127 -0
  82. package/dist/remote/src/folders.js +79 -0
  83. package/dist/remote/src/fsutil.js +39 -0
  84. package/dist/remote/src/handoff.js +38 -0
  85. package/dist/remote/src/host-service.js +168 -0
  86. package/dist/remote/src/images.js +215 -0
  87. package/dist/remote/src/local-control.js +138 -0
  88. package/dist/remote/src/main.js +264 -0
  89. package/dist/remote/src/permissions.js +124 -0
  90. package/dist/remote/src/power.js +76 -0
  91. package/dist/remote/src/processes.js +24 -0
  92. package/dist/remote/src/protocol.js +32 -0
  93. package/dist/remote/src/protocol.ts +284 -0
  94. package/dist/remote/src/push-content.js +32 -0
  95. package/dist/remote/src/push.js +134 -0
  96. package/dist/remote/src/receipts.js +111 -0
  97. package/dist/remote/src/relay.js +438 -0
  98. package/dist/remote/src/router.js +208 -0
  99. package/dist/remote/src/sdk-launcher.js +18 -0
  100. package/dist/remote/src/server.js +706 -0
  101. package/dist/remote/src/service.js +157 -0
  102. package/dist/remote/src/sessions.js +1800 -0
  103. package/dist/remote/src/sidebar-storage.js +211 -0
  104. package/dist/remote/src/sidebar.js +173 -0
  105. package/dist/remote/src/tool-details.js +205 -0
  106. package/dist/remote/src/tool-details.ts +153 -0
  107. package/dist/remote/src/worktree.js +99 -0
  108. package/dist/remote/web/app.ts +746 -0
  109. package/dist/remote/web/conn.ts +193 -0
  110. package/dist/remote/web/demo.ts +134 -0
  111. package/dist/remote/web/dom.ts +124 -0
  112. package/dist/remote/web/esc.ts +4 -0
  113. package/dist/remote/web/i18n.ts +374 -0
  114. package/dist/remote/web/icon.svg +1 -0
  115. package/dist/remote/web/index.html +24 -0
  116. package/dist/remote/web/manifest.webmanifest +10 -0
  117. package/dist/remote/web/mascot.ts +60 -0
  118. package/dist/remote/web/md.ts +78 -0
  119. package/dist/remote/web/notify.ts +55 -0
  120. package/dist/remote/web/pushstate.ts +27 -0
  121. package/dist/remote/web/secure.ts +109 -0
  122. package/dist/remote/web/sidebar.ts +106 -0
  123. package/dist/remote/web/style.css +311 -0
  124. package/dist/remote/web/sw.ts +48 -0
  125. package/dist/remote/web/timeline.ts +224 -0
  126. package/dist/remote/web/views.ts +323 -0
  127. package/dist/router/src/admin.js +331 -5
  128. package/dist/router/src/awake-win.js +590 -0
  129. package/dist/router/src/awake.js +432 -0
  130. package/dist/router/src/config.js +94 -0
  131. package/dist/router/src/health.js +86 -3
  132. package/dist/router/src/hold.js +107 -0
  133. package/dist/router/src/index.js +108 -6
  134. package/dist/router/src/ingress/server.js +26 -4
  135. package/dist/router/src/ingress/translate.js +6 -2
  136. package/dist/router/src/phone.js +82 -0
  137. package/dist/router/src/power.js +107 -0
  138. package/dist/router/src/providers/anthropic-usage.js +33 -1
  139. package/dist/router/src/providers/google/antigravity.js +73 -10
  140. package/dist/router/src/providers/google/index.js +191 -14
  141. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  142. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  143. package/dist/router/src/providers/google/transport.js +1 -1
  144. package/dist/router/src/providers/google/usage.js +302 -0
  145. package/dist/router/src/providers/grok/index.js +18 -0
  146. package/dist/router/src/providers/grok/usage.js +77 -0
  147. package/dist/router/src/providers/openai/translate.js +13 -2
  148. package/dist/router/src/providers/opencode-usage.js +116 -0
  149. package/dist/router/src/providers/retry.js +4 -1
  150. package/dist/router/src/proxy.js +371 -261
  151. package/dist/router/src/remote-host.js +320 -0
  152. package/dist/router/src/remote-tunnel.js +320 -0
  153. package/dist/router/src/requestlog.js +48 -39
  154. package/dist/router/src/tether-win.js +211 -0
  155. package/dist/router/src/tether.js +712 -0
  156. package/dist/router/src/usage-history.js +227 -0
  157. package/dist/router/src/version.js +1 -1
  158. package/dist/ui/app.js +1083 -59
  159. package/dist/ui/i18n.js +670 -69
  160. package/dist/ui/index.html +33 -6
  161. package/dist/ui/style.css +177 -0
  162. package/docs/ARCHITECTURE.md +402 -14
  163. package/package.json +8 -2
@@ -0,0 +1,712 @@
1
+ // Fail over to a phone's hotspot (iPhone or Android) when Wi-Fi has no internet, and come back to a known
2
+ // Wi-Fi later. The probe/decision loop below is shared; the OS part is a TetherBackend — macOS here,
3
+ // Windows in tether-win.ts (netsh, no elevation).
4
+ //
5
+ // What decides "no internet" is a real HTTP probe through the Wi-Fi interface, not the OS path
6
+ // status: a router with a dead uplink or a captive portal reads "satisfied" to NWPathMonitor, which
7
+ // is why Insomnia never failed over in that state (research §1.3, §3.3). Behaviour follows wifihop
8
+ // (captive.apple.com "Success" + gstatic 204, 3 of the last 5 rounds) and WiFiGuard (switch back when
9
+ // a better network returns); the code is our own.
10
+ //
11
+ // failover on Wi-Fi (or none), 3 of the last 5 probe rounds failed → `sudo -n networksetup
12
+ // -setairportnetwork <dev> <hotspot>`; macOS takes the password from the System keychain.
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
15
+ // console user — the router is a launchd agent in the user's session) so macOS
16
+ // auto-joins the best known network in range; probe it; if that does not give working
17
+ // Wi-Fi within 20s, rejoin the hotspot and wait for the next point. The times are fixed
18
+ // 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.
21
+ // gap a return still postponed RETURN_FORCE_AFTER_MS past due (calls back-to-back, so never
22
+ // a quiet moment — 2026-10-20) makes the gap itself: new model calls are held (hold.ts:
23
+ // they wait, they are not refused), the running ones get up to RETURN_DRAIN_WAIT_MS to
24
+ // finish, then the return above runs and the hold is released whatever the outcome.
25
+ //
26
+ // Never more than one switch per 60s; nothing switches while the user has pinned the network.
27
+ // "On the hotspot" is read from the address, not the SSID (macOS redacts it without Location;
28
+ // Windows 11 24H2 gates the query that returns it, see tether-win.ts): an iPhone hotspot hands out
29
+ // 172.20.10.0/28, and any other phone's network is learned. An Android hotspot hands out a /24 that
30
+ // is not fixed — 192.168.43.x up to Android 10 (IpServer WIFI_HOST_IFACE_ADDR), a random /24 of
31
+ // 192.168.0.0/16 from Android 11, of 192.168.0.0/16, 172.16.0.0/12 or 10.0.0.0/8 by Android 14
32
+ // (PrivateAddressCoordinator) — so after our own join of the hotspot SSID succeeds, the subnet the
33
+ // Wi-Fi interface then has (os.networkInterfaces(): address + netmask, no process, no privilege) is
34
+ // remembered as the hotspot's network and kept in <home>/tether.json.
35
+ import fs from "node:fs";
36
+ import http from "node:http";
37
+ import os from "node:os";
38
+ import path from "node:path";
39
+ import { SUDO, sudoRefused } from "./awake.js";
40
+ import { HoldGate } from "./hold.js";
41
+ import { HOTSPOT_NOT_VISIBLE_STEPS, WinTetherBackend } from "./tether-win.js";
42
+ export const NETWORKSETUP = "/usr/sbin/networksetup";
43
+ export const OSASCRIPT = "/usr/bin/osascript";
44
+ /** wifihop's exact call (verified in its source, `mac_disassociate_js`): a zero-argument ObjC method is invoked by reading it. */
45
+ export const DISASSOCIATE_JS = 'ObjC.import("CoreWLAN"); $.CWWiFiClient.sharedWiFiClient.interface.disassociate; "ok";';
46
+ export const PROBE_URLS = {
47
+ apple: "http://captive.apple.com/hotspot-detect.html",
48
+ google: "http://connectivitycheck.gstatic.com/generate_204",
49
+ };
50
+ export const PROBE_TIMEOUT_MS = 4_000;
51
+ export const MIN_SWITCH_INTERVAL_MS = 60_000;
52
+ export const RETURN_PROBE_MS = 20_000;
53
+ /**
54
+ * When to try the way back, counted from the moment the Mac landed on the hotspot: 5, 10, 20, 30,
55
+ * 40, 50 and 60 min, then every 10 min for as long as it stays there (owner's decision, 2026-10-20 —
56
+ * 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).
58
+ */
59
+ export const RETURN_SCHEDULE_MINUTES = [5, 10, 20, 30, 40, 50, 60];
60
+ /** After the last scheduled point, every this long. */
61
+ export const RETURN_INTERVAL_AFTER_MS = 10 * 60_000;
62
+ /** A model call this recent counts as "Claude is working": no return attempt (it drops the link for seconds). */
63
+ export const RETURN_QUIET_MS = 20_000;
64
+ /** Failover waits at most this long for an in-flight call; a dead network cannot serve it anyway. */
65
+ export const FAILOVER_IDLE_WAIT_MS = 30_000;
66
+ /** A return postponed this long past due stops waiting for a quiet moment and makes one. */
67
+ export const RETURN_FORCE_AFTER_MS = 2 * 60_000;
68
+ /**
69
+ * With new calls held, the running ones get this long to finish before the attempt is given up.
70
+ * With the switch after it (up to ~35s) it stays inside a held call's HOLD_MAX_MS, so no held call
71
+ * goes ahead and refills the gap before the switch; and well under the ~180s after which Claude
72
+ * Code gave up on a call that had no answer yet (13 Google turns closed at 182–190s, 2026-10-20).
73
+ */
74
+ export const RETURN_DRAIN_WAIT_MS = 60_000;
75
+ /** The hold releases itself after this even if the return never comes back to release it. */
76
+ export const RETURN_HOLD_FAILSAFE_MS = RETURN_DRAIN_WAIT_MS + 3 * 60_000;
77
+ const JOIN_SETTLE_MS = 15_000;
78
+ /** States that say why the last join failed; they stay (with their hint) until the next attempt. */
79
+ const JOIN_FAILURES = new Set(["hotspot-not-visible", "join-failed", "needs-setup", "no-hotspot-profile", "needs-location"]);
80
+ // ---- Pure helpers -------------------------------------------------------------------------------
81
+ /** The Wi-Fi device from `networksetup -listallhardwareports` ("Hardware Port: Wi-Fi\nDevice: en0"). */
82
+ export function parseWifiDevice(text) {
83
+ const blocks = text.split(/\n\s*\n/);
84
+ for (const b of blocks) {
85
+ if (!/Hardware Port:\s*(Wi-Fi|AirPort)\s*$/m.test(b))
86
+ continue;
87
+ const dev = /Device:\s*(\S+)/.exec(b)?.[1];
88
+ if (dev && /^en\d+$/.test(dev))
89
+ return dev;
90
+ }
91
+ return null;
92
+ }
93
+ /** The interface's IPv4 address, or null when it has none (not associated, or no DHCP yet). */
94
+ export function wifiIPv4(ifaces, device) {
95
+ return ifaces[device]?.find((a) => a.family === "IPv4" && !a.internal)?.address ?? null;
96
+ }
97
+ /** An iPhone Personal Hotspot hands out 172.20.10.0/28 (172.20.10.0–15). */
98
+ export function isHotspotAddress(ip) {
99
+ const m = ip ? /^172\.20\.10\.(\d{1,3})$/.exec(ip) : null;
100
+ return !!m && Number(m[1]) <= 15;
101
+ }
102
+ function ipv4ToInt(ip) {
103
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
104
+ if (!m)
105
+ return null;
106
+ const parts = m.slice(1).map(Number);
107
+ if (parts.some((p) => p > 255))
108
+ return null;
109
+ return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
110
+ }
111
+ function intToIpv4(n) {
112
+ return [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".");
113
+ }
114
+ /** "192.168.43.17" + "255.255.255.0" → "192.168.43.0/24"; null for anything else (no or a non-contiguous mask). */
115
+ export function ipv4Network(address, netmask) {
116
+ const a = ipv4ToInt(address);
117
+ const m = ipv4ToInt(netmask);
118
+ if (a === null || m === null)
119
+ return null;
120
+ const bits = m.toString(2).replace(/0+$/, "");
121
+ if (m !== 0 && bits.includes("0"))
122
+ return null;
123
+ const len = m === 0 ? 0 : bits.length;
124
+ if (len < 8 || len > 30)
125
+ return null; // a /31 or /32 is no DHCP network; shorter than /8 is no hotspot
126
+ return `${intToIpv4((a & m) >>> 0)}/${len}`;
127
+ }
128
+ /** Whether `ip` is inside `network` ("a.b.c.d/len"). */
129
+ export function inNetwork(ip, network) {
130
+ if (!ip || !network)
131
+ return false;
132
+ const [base, lenText] = network.split("/");
133
+ const a = ipv4ToInt(ip);
134
+ const b = base ? ipv4ToInt(base) : null;
135
+ const len = Number(lenText);
136
+ if (a === null || b === null || !Number.isInteger(len) || len < 0 || len > 32)
137
+ return false;
138
+ const mask = len === 0 ? 0 : (0xffffffff << (32 - len)) >>> 0;
139
+ return ((a & mask) >>> 0) === ((b & mask) >>> 0);
140
+ }
141
+ /** 169.254.0.0/16: what an interface gives itself when DHCP did not answer — not a join. */
142
+ export function isLinkLocal(ip) {
143
+ return inNetwork(ip, "169.254.0.0/16");
144
+ }
145
+ /** The iPhone range, or the network remembered from our own join of the hotspot (`learned`). */
146
+ export function classifyNetwork(ip, learned = null) {
147
+ return ip === null ? "none" : isHotspotAddress(ip) || inNetwork(ip, learned) ? "hotspot" : "wifi";
148
+ }
149
+ /** The interface's IPv4 network ("a.b.c.d/len"), from the same os.networkInterfaces() entry as wifiIPv4. */
150
+ export function wifiIPv4Network(ifaces, device) {
151
+ const a = ifaces[device]?.find((x) => x.family === "IPv4" && !x.internal);
152
+ return a ? ipv4Network(a.address, a.netmask) : null;
153
+ }
154
+ export function readLearnedHotspot(file) {
155
+ try {
156
+ const j = JSON.parse(fs.readFileSync(file, "utf8"));
157
+ const h = j.hotspot;
158
+ if (!h || typeof h.ssid !== "string" || typeof h.network !== "string")
159
+ return null;
160
+ const [base, len] = h.network.split("/");
161
+ const bits = Number(len);
162
+ // Only a canonical "network/len" this module could have written; anything else is forgotten.
163
+ if (!base || !Number.isInteger(bits) || bits < 8 || bits > 30)
164
+ return null;
165
+ if (ipv4Network(base, intToIpv4((0xffffffff << (32 - bits)) >>> 0)) !== h.network)
166
+ return null;
167
+ return { ssid: h.ssid, network: h.network, learnedAt: String(h.learnedAt ?? "") };
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ export function writeLearnedHotspot(file, h) {
174
+ fs.mkdirSync(path.dirname(file), { recursive: true });
175
+ const tmp = `${file}.tmp-${process.pid}`;
176
+ fs.writeFileSync(tmp, JSON.stringify({ hotspot: h }) + "\n", { mode: 0o600 });
177
+ fs.renameSync(tmp, file);
178
+ }
179
+ /**
180
+ * The next scheduled return time at or after `now + minGapMs`, on the fixed schedule counted from
181
+ * `landedAt` (5, 10, 20, 30, 40, 50, 60 min, then every 10). A point already passed is skipped, so a
182
+ * long gap attempt or a slow tick never retries in the past or bursts two switches together.
183
+ */
184
+ export function nextReturnPoint(landedAt, now, minGapMs = MIN_SWITCH_INTERVAL_MS) {
185
+ const floor = now + minGapMs;
186
+ for (const minutes of RETURN_SCHEDULE_MINUTES) {
187
+ const at = landedAt + minutes * 60_000;
188
+ if (at >= floor)
189
+ return at;
190
+ }
191
+ const last = landedAt + RETURN_SCHEDULE_MINUTES[RETURN_SCHEDULE_MINUTES.length - 1] * 60_000;
192
+ const steps = Math.max(1, Math.ceil((floor - last) / RETURN_INTERVAL_AFTER_MS));
193
+ return last + steps * RETURN_INTERVAL_AFTER_MS;
194
+ }
195
+ /** The last five probe rounds; an outage is 3 failures among them, so a 1s blip never switches. */
196
+ export class ProbeWindow {
197
+ rounds = [];
198
+ push(ok) {
199
+ this.rounds.push(ok);
200
+ if (this.rounds.length > 5)
201
+ this.rounds.shift();
202
+ }
203
+ failures() {
204
+ return this.rounds.filter((ok) => !ok).length;
205
+ }
206
+ outage() {
207
+ return this.failures() >= 3;
208
+ }
209
+ reset() {
210
+ this.rounds = [];
211
+ }
212
+ }
213
+ /** One HTTP GET bound to a local address (so the request leaves through that interface). */
214
+ export const httpGet = (url, localAddress, timeoutMs) => new Promise((resolve, reject) => {
215
+ const req = http.get(url, { localAddress, timeout: timeoutMs, headers: { "cache-control": "no-cache" } }, (res) => {
216
+ let body = "";
217
+ res.setEncoding("utf8");
218
+ res.on("data", (c) => {
219
+ if (body.length < 4096)
220
+ body += c;
221
+ });
222
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
223
+ res.on("error", reject);
224
+ });
225
+ req.on("timeout", () => req.destroy(new Error("probe timed out")));
226
+ req.on("error", reject);
227
+ });
228
+ /** One round: both endpoints at once; the round fails only if both fail. */
229
+ export async function probeRound(localAddress, get = httpGet) {
230
+ const apple = get(PROBE_URLS.apple, localAddress, PROBE_TIMEOUT_MS).then((r) => r.status === 200 && r.body.includes("Success"), () => false);
231
+ const google = get(PROBE_URLS.google, localAddress, PROBE_TIMEOUT_MS).then((r) => r.status === 204, () => false);
232
+ const [a, g] = await Promise.all([apple, google]);
233
+ return a || g;
234
+ }
235
+ /**
236
+ * `networksetup -setairportnetwork` reports failure on stdout and may still exit 0
237
+ * ("Could not find network X." / "Failed to join network X."), so the text decides.
238
+ */
239
+ export function classifyJoin(r) {
240
+ if (sudoRefused(r))
241
+ return "needs-setup";
242
+ const text = `${r.stdout}\n${r.stderr}`;
243
+ if (/could not find network/i.test(text))
244
+ return "hotspot-not-visible";
245
+ if (r.code !== 0 || /failed|error/i.test(text))
246
+ return "join-failed";
247
+ return null;
248
+ }
249
+ /** Waiting this long after leave() with no address at all, a backend may join a known network itself. */
250
+ export const JOIN_KNOWN_AFTER_MS = 8_000;
251
+ const MAC_HINTS = {
252
+ "hotspot-not-visible": `The phone is not broadcasting its hotspot, so the Mac cannot see it. ${HOTSPOT_NOT_VISIBLE_STEPS}`,
253
+ "needs-setup": "Run `clauderipple awake setup --ssid <hotspot name>` once: joining the hotspot needs the rule it installs (and the name must match it).",
254
+ "no-hotspot-ssid": "Set the phone hotspot's name: `clauderipple awake setup --ssid <hotspot name>`.",
255
+ "no-wifi": "Wi-Fi is not connected. macOS joins an iPhone hotspot by itself when no Wi-Fi is available if System Settings → Wi-Fi → \"Ask to join hotspots\" is Automatic (ClaudeRipple cannot read that setting: the file is root-only); an Android hotspot is joined like any known Wi-Fi with Auto-Join on.",
256
+ "join-failed": "Joining the hotspot failed. Check the hotspot name, and that the Mac has joined it once before (macOS keeps its password).",
257
+ pinned: "Switching is paused because the network was pinned.",
258
+ };
259
+ class MacTetherBackend {
260
+ noAddressOutcome = "join-failed";
261
+ hints = MAC_HINTS;
262
+ run;
263
+ log;
264
+ device = null;
265
+ constructor(run, log) {
266
+ this.run = run;
267
+ this.log = log;
268
+ }
269
+ async findWifi() {
270
+ const r = await this.run(NETWORKSETUP, ["-listallhardwareports"]);
271
+ this.device = r.code === 0 ? parseWifiDevice(r.stdout) : null;
272
+ return this.device;
273
+ }
274
+ /** `sudo -n networksetup -setairportnetwork <dev> <ssid>`. */
275
+ async join(ssid) {
276
+ const r = await this.run(SUDO, ["-n", NETWORKSETUP, "-setairportnetwork", this.device, ssid], { timeoutMs: 30_000 });
277
+ const failed = classifyJoin(r);
278
+ if (failed && failed !== "needs-setup")
279
+ this.log.warn(`tether: networksetup said: ${(r.stdout + r.stderr).trim().slice(0, 200)}`);
280
+ return failed;
281
+ }
282
+ async leave() {
283
+ const r = await this.run(OSASCRIPT, ["-l", "JavaScript", "-e", DISASSOCIATE_JS], { timeoutMs: 15_000 });
284
+ return r.code === 0 ? null : (r.stderr || r.stdout).trim().slice(0, 200);
285
+ }
286
+ }
287
+ export class TetherController {
288
+ deps;
289
+ now;
290
+ sleep;
291
+ interfaces;
292
+ probe;
293
+ backend;
294
+ window = new ProbeWindow();
295
+ device = null;
296
+ network = "none";
297
+ state = "off";
298
+ pinned = false;
299
+ lastSwitchAt = 0;
300
+ lastSwitch = null;
301
+ lastProbe = null;
302
+ /** When the Mac landed on the hotspot (ours or by hand); null off the hotspot. The schedule counts from here. */
303
+ landedOnHotspotAt = null;
304
+ nextReturnAt = 0;
305
+ busy = null;
306
+ cancel = null;
307
+ lastLogged = "";
308
+ hold;
309
+ /** True while this controller has the gate engaged (stop() releases it). */
310
+ holding = false;
311
+ stopped = false;
312
+ /** The hotspot's network learned from our own join (outside the iPhone range); see learn(). */
313
+ learned;
314
+ /** The subnet last watched as ordinary Wi-Fi: a join landing on it cannot be told apart from it. */
315
+ lastWifiNetwork = null;
316
+ constructor(deps) {
317
+ this.deps = deps;
318
+ this.learned = deps.stateFile ? readLearnedHotspot(deps.stateFile) : null;
319
+ this.hold = deps.hold ?? new HoldGate();
320
+ this.now = deps.now ?? Date.now;
321
+ this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
322
+ this.interfaces = deps.interfaces ?? os.networkInterfaces;
323
+ this.probe = deps.probe ?? ((addr) => probeRound(addr));
324
+ this.backend =
325
+ deps.platform === "darwin" ? new MacTetherBackend(deps.run, deps.log) : deps.platform === "win32" ? new WinTetherBackend(deps.run, deps.log) : null;
326
+ if (!this.backend)
327
+ this.state = "unsupported";
328
+ }
329
+ get supported() {
330
+ return this.backend !== null;
331
+ }
332
+ start() {
333
+ if (!this.supported)
334
+ return;
335
+ this.stopped = false;
336
+ this.loop();
337
+ }
338
+ /** Stops the loop; a hold this controller engaged is released now (a return in progress gives up). */
339
+ stop() {
340
+ this.stopped = true;
341
+ this.cancel?.();
342
+ this.cancel = null;
343
+ if (this.holding)
344
+ this.releaseHold("the controller stopped");
345
+ }
346
+ /** Run a tick soon (after the settings changed) without waiting for it. */
347
+ kick() {
348
+ if (this.supported)
349
+ void this.tick();
350
+ }
351
+ setPin(on) {
352
+ if (this.pinned === on)
353
+ return;
354
+ this.pinned = on;
355
+ this.deps.log.info(`tether: network ${on ? "pinned; switching paused" : "unpinned; switching resumes"}`);
356
+ if (on)
357
+ this.state = "pinned";
358
+ this.window.reset();
359
+ }
360
+ status() {
361
+ const s = this.deps.settings();
362
+ return {
363
+ enabled: s.enabled,
364
+ network: this.network,
365
+ state: this.state,
366
+ pinned: this.pinned,
367
+ hotspotSsid: s.hotspotSsid,
368
+ lastSwitch: this.lastSwitch,
369
+ lastProbe: this.lastProbe,
370
+ hint: this.backend?.hints[this.state] ?? null,
371
+ };
372
+ }
373
+ loop() {
374
+ const schedule = this.deps.schedule ?? ((fn, ms) => {
375
+ const t = setTimeout(fn, ms);
376
+ t.unref();
377
+ return () => clearTimeout(t);
378
+ });
379
+ const every = Math.max(5, this.deps.settings().probeEverySec) * 1000;
380
+ this.cancel = schedule(() => {
381
+ void this.tick().finally(() => {
382
+ if (this.cancel)
383
+ this.loop();
384
+ });
385
+ }, every);
386
+ }
387
+ tick() {
388
+ if (!this.supported)
389
+ return Promise.resolve();
390
+ if (!this.busy)
391
+ this.busy = this.evaluate().catch((e) => this.deps.log.warn(`tether: ${e.message}`)).finally(() => (this.busy = null));
392
+ return this.busy;
393
+ }
394
+ decide(line) {
395
+ if (line === this.lastLogged)
396
+ return;
397
+ this.lastLogged = line;
398
+ this.deps.log.info(`tether: ${line}`);
399
+ }
400
+ currentIp() {
401
+ return this.device ? wifiIPv4(this.interfaces(), this.device) : null;
402
+ }
403
+ currentNetwork() {
404
+ return this.device ? wifiIPv4Network(this.interfaces(), this.device) : null;
405
+ }
406
+ /** The learned network, while it belongs to the hotspot name configured now. */
407
+ learnedNetwork() {
408
+ return this.learned && this.learned.ssid === this.deps.settings().hotspotSsid ? this.learned.network : null;
409
+ }
410
+ onHotspot(ip) {
411
+ return classifyNetwork(ip, this.learnedNetwork()) === "hotspot";
412
+ }
413
+ idle() {
414
+ const a = this.deps.activity();
415
+ return a.inFlight === 0 && (a.lastStartedAt === undefined || this.now() - a.lastStartedAt >= RETURN_QUIET_MS);
416
+ }
417
+ async evaluate() {
418
+ const settings = this.deps.settings();
419
+ if (!settings.enabled) {
420
+ if (this.state !== "off")
421
+ this.decide("disabled");
422
+ this.state = "off";
423
+ this.landedOnHotspotAt = null;
424
+ this.window.reset();
425
+ return;
426
+ }
427
+ if (!this.device) {
428
+ this.device = await this.backend.findWifi();
429
+ if (!this.device) {
430
+ this.state = "no-wifi-device";
431
+ this.decide("no Wi-Fi interface found");
432
+ return;
433
+ }
434
+ }
435
+ const ip = this.currentIp();
436
+ this.network = classifyNetwork(ip, this.learnedNetwork());
437
+ if (this.network === "wifi")
438
+ this.lastWifiNetwork = this.currentNetwork();
439
+ const ok = ip ? await this.probe(ip) : false;
440
+ this.window.push(ok);
441
+ this.lastProbe = { at: new Date(this.now()).toISOString(), ok, network: this.network };
442
+ if (this.pinned) {
443
+ this.state = "pinned";
444
+ return;
445
+ }
446
+ if (this.network === "hotspot") {
447
+ // Landed here by our own failover or by hand: either way, plan the way back from here.
448
+ if (this.landedOnHotspotAt === null) {
449
+ this.landedOnHotspotAt = this.now();
450
+ this.nextReturnAt = nextReturnPoint(this.landedOnHotspotAt, this.now());
451
+ }
452
+ if (this.state !== "on-hotspot")
453
+ this.decide(`on the hotspot; next try for Wi-Fi in ${Math.round((this.nextReturnAt - this.now()) / 1000)}s`);
454
+ this.state = "on-hotspot";
455
+ if (this.now() >= this.nextReturnAt)
456
+ await this.tryReturn(settings);
457
+ return;
458
+ }
459
+ // On Wi-Fi (or none): the way back is no longer pending, and landing again starts the schedule over.
460
+ this.landedOnHotspotAt = null;
461
+ if (!this.window.outage()) {
462
+ this.state = this.network === "wifi" ? "watching" : "no-wifi";
463
+ if (this.network === "wifi")
464
+ this.decide(`watching Wi-Fi (${5 - this.window.failures()}/5 recent probes ok)`);
465
+ else
466
+ this.decide("Wi-Fi has no address");
467
+ return;
468
+ }
469
+ await this.failover(settings);
470
+ }
471
+ async failover(settings) {
472
+ const why = this.network === "wifi" ? "Wi-Fi has no internet (3 of the last 5 probes failed)" : "Wi-Fi is not connected";
473
+ if (!settings.hotspotSsid) {
474
+ this.state = "no-hotspot-ssid";
475
+ this.decide(`${why}, but no hotspot name is set`);
476
+ return;
477
+ }
478
+ const since = this.now() - this.lastSwitchAt;
479
+ if (since < MIN_SWITCH_INTERVAL_MS) {
480
+ // A failed join keeps saying why (and its hint) until the next attempt.
481
+ if (!JOIN_FAILURES.has(this.state))
482
+ this.state = "outage";
483
+ this.decide(`${why}; waiting ${Math.ceil((MIN_SWITCH_INTERVAL_MS - since) / 1000)}s (at most one switch per minute)`);
484
+ return;
485
+ }
486
+ // A call in flight on a dead network cannot finish anyway; give it a short chance, no more.
487
+ const waitUntil = this.now() + FAILOVER_IDLE_WAIT_MS;
488
+ while (this.deps.activity().inFlight > 0 && this.now() < waitUntil)
489
+ await this.sleep(1_000);
490
+ this.state = "joining-hotspot";
491
+ this.lastSwitchAt = this.now();
492
+ this.decide(`${why}: joining hotspot "${settings.hotspotSsid}"`);
493
+ const outcome = await this.join(settings.hotspotSsid);
494
+ this.recordSwitch("hotspot", outcome === "joined", outcome === "joined" ? why : outcome);
495
+ if (outcome === "joined" && !this.onHotspot(this.currentIp())) {
496
+ // Joined, but on a network that could not be remembered (see learn()): it reads as Wi-Fi.
497
+ this.window.reset();
498
+ this.network = "wifi";
499
+ this.state = "watching";
500
+ this.decide("joined the hotspot, but its network cannot be told apart from Wi-Fi; watching it as Wi-Fi (no way back is planned)");
501
+ }
502
+ else if (outcome === "joined") {
503
+ this.window.reset();
504
+ this.network = "hotspot";
505
+ this.state = "on-hotspot";
506
+ this.landedOnHotspotAt = this.now();
507
+ this.nextReturnAt = nextReturnPoint(this.landedOnHotspotAt, this.now());
508
+ this.decide(`joined the hotspot; next try for Wi-Fi in ${Math.round((this.nextReturnAt - this.now()) / 1000)}s`);
509
+ }
510
+ else {
511
+ this.state = outcome;
512
+ this.decide(`could not join the hotspot: ${outcome}`);
513
+ }
514
+ }
515
+ /**
516
+ * Asks the OS to join, then waits for the hotspot's address: one in the iPhone range or the learned
517
+ * network, or else — after a join the OS accepted — any new address that is not link-local, whose
518
+ * network is then learned. Neither OS lets us read the SSID without Location (macOS redacts it;
519
+ * Windows 24H2 gates `netsh wlan show interfaces`), so "the OS accepted a join of this SSID, and
520
+ * the address changed" is the strongest evidence there is.
521
+ */
522
+ async join(ssid) {
523
+ const before = this.currentIp();
524
+ const wifiBefore = [before && !this.onHotspot(before) ? this.currentNetwork() : null, this.lastWifiNetwork];
525
+ const failed = await this.backend.join(ssid);
526
+ if (failed)
527
+ return failed;
528
+ const deadline = this.now() + JOIN_SETTLE_MS;
529
+ for (;;) {
530
+ const ip = this.currentIp();
531
+ if (this.onHotspot(ip))
532
+ return "joined";
533
+ if (ip && ip !== before && !isLinkLocal(ip)) {
534
+ this.learn(ssid, ip, wifiBefore);
535
+ return "joined";
536
+ }
537
+ if (this.now() >= deadline)
538
+ return this.backend.noAddressOutcome;
539
+ await this.sleep(1_000);
540
+ }
541
+ }
542
+ /**
543
+ * Remembers the network our join of `ssid` landed on. Only a join we made replaces it, and never
544
+ * with a network just watched as ordinary Wi-Fi: that one would read as the hotspot from then on.
545
+ */
546
+ learn(ssid, ip, wifiBefore) {
547
+ const network = this.currentNetwork();
548
+ if (!network) {
549
+ this.step(`joined "${ssid}" at ${ip}, but its netmask could not be read; its network is not remembered`);
550
+ return;
551
+ }
552
+ if (wifiBefore.includes(network)) {
553
+ this.step(`joined "${ssid}", but its network ${network} is the one watched as Wi-Fi before; not remembering it (the two could not be told apart)`);
554
+ return;
555
+ }
556
+ if (this.learned?.ssid === ssid && this.learned.network === network)
557
+ return;
558
+ const was = this.learned?.ssid === ssid ? ` (was ${this.learned.network})` : "";
559
+ this.learned = { ssid, network, learnedAt: new Date(this.now()).toISOString() };
560
+ this.step(`remembering ${network} as hotspot "${ssid}"'s network${was}`);
561
+ if (!this.deps.stateFile)
562
+ return;
563
+ try {
564
+ writeLearnedHotspot(this.deps.stateFile, this.learned);
565
+ }
566
+ catch (e) {
567
+ this.deps.log.warn(`tether: could not save the hotspot's network: ${e.message}`);
568
+ }
569
+ }
570
+ async tryReturn(settings) {
571
+ if (this.now() - this.lastSwitchAt < MIN_SWITCH_INTERVAL_MS)
572
+ return;
573
+ const idle = this.idle();
574
+ if (!idle && (this.now() - this.nextReturnAt < RETURN_FORCE_AFTER_MS || this.hold.closed)) {
575
+ 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
+ return;
577
+ }
578
+ const blocked = (await this.backend.returnBlocked?.(settings.hotspotSsid)) ?? null;
579
+ if (blocked) {
580
+ this.backOff(blocked);
581
+ return;
582
+ }
583
+ if (idle)
584
+ await this.leaveHotspot(settings);
585
+ else
586
+ await this.returnWithGap(settings);
587
+ }
588
+ /**
589
+ * Calls never stop (Claude and subagents back-to-back), so make the quiet moment: hold new model
590
+ * calls, let the running ones finish, go back to Wi-Fi, release. The hold is released on every
591
+ * path out of here, a thrown error included.
592
+ */
593
+ async returnWithGap(settings) {
594
+ const late = Math.round((this.now() - this.nextReturnAt) / 1000);
595
+ if (!this.hold.engage("going back to Wi-Fi", RETURN_HOLD_FAILSAFE_MS))
596
+ return;
597
+ this.holding = true;
598
+ try {
599
+ const running = this.deps.activity().inFlight;
600
+ this.step(`Wi-Fi return postponed ${late}s past due while model calls keep running: holding new model calls to make a gap (waiting up to ${RETURN_DRAIN_WAIT_MS / 1000}s for ${running} running)`);
601
+ const started = this.now();
602
+ const until = started + RETURN_DRAIN_WAIT_MS;
603
+ while (!this.stopped && !this.pinned && this.deps.activity().inFlight > 0 && this.now() < until)
604
+ await this.sleep(1_000);
605
+ // stop() already released; a pin made meanwhile pauses switching, so the attempt is dropped.
606
+ if (this.stopped || this.pinned)
607
+ return;
608
+ const left = this.deps.activity().inFlight;
609
+ if (left > 0) {
610
+ this.backOff(`${left} model call${left === 1 ? "" : "s"} still running after ${Math.round((this.now() - started) / 1000)}s; held calls released`);
611
+ return;
612
+ }
613
+ this.step(`running model calls finished after ${Math.round((this.now() - started) / 1000)}s; ${this.hold.waiting} held`);
614
+ await this.leaveHotspot(settings);
615
+ }
616
+ finally {
617
+ this.releaseHold();
618
+ }
619
+ }
620
+ releaseHold(why) {
621
+ if (!this.holding)
622
+ return;
623
+ this.holding = false;
624
+ const n = this.hold.release();
625
+ this.step(`hold released${why ? ` (${why})` : ""}; ${n} held model call${n === 1 ? "" : "s"} going ahead`);
626
+ }
627
+ /** A one-off step: always logged, and the next state line is logged again after it. */
628
+ step(line) {
629
+ this.lastLogged = "";
630
+ this.deps.log.info(`tether: ${line}`);
631
+ }
632
+ /** Leave the hotspot so the OS joins its best known network; keep it only if it has internet. */
633
+ async leaveHotspot(settings) {
634
+ const backend = this.backend;
635
+ this.state = "returning";
636
+ this.lastSwitchAt = this.now();
637
+ this.decide(`trying to go back to Wi-Fi: leaving the hotspot so ${this.deps.platform === "win32" ? "Windows" : "macOS"} joins the best known network`);
638
+ const left = await backend.leave();
639
+ if (left !== null) {
640
+ this.deps.log.warn(`tether: leaving the hotspot failed: ${left}`);
641
+ this.state = "on-hotspot";
642
+ this.backOff("leaving the hotspot failed");
643
+ return;
644
+ }
645
+ // Back on Wi-Fi only after two good probes through it (one could be a lucky blip).
646
+ let good = 0;
647
+ let askedKnown = false;
648
+ const started = this.now();
649
+ const deadline = started + RETURN_PROBE_MS;
650
+ while (this.now() < deadline) {
651
+ await this.sleep(2_000);
652
+ const ip = this.currentIp();
653
+ if (!ip && !askedKnown && backend.joinKnown && this.now() - started >= JOIN_KNOWN_AFTER_MS) {
654
+ askedKnown = true;
655
+ await backend.joinKnown(settings.hotspotSsid);
656
+ }
657
+ if (ip && !this.onHotspot(ip) && (await this.probe(ip))) {
658
+ if (++good >= 2)
659
+ break;
660
+ }
661
+ else
662
+ good = 0;
663
+ }
664
+ this.window.reset();
665
+ if (good >= 2) {
666
+ this.network = "wifi";
667
+ this.state = "watching";
668
+ this.landedOnHotspotAt = null;
669
+ this.recordSwitch("wifi", true, "a known Wi-Fi with internet was in range");
670
+ this.decide("back on Wi-Fi with internet");
671
+ return;
672
+ }
673
+ const ip = this.currentIp();
674
+ let reason;
675
+ const osName = this.deps.platform === "win32" ? "Windows" : "macOS";
676
+ let rejoined = false;
677
+ if (this.onHotspot(ip))
678
+ reason = `${osName} rejoined the hotspot (no better network in range)`;
679
+ else {
680
+ reason = ip ? `the Wi-Fi ${osName} chose has no internet` : "no network was joined";
681
+ const outcome = await this.join(settings.hotspotSsid);
682
+ rejoined = outcome === "joined";
683
+ if (!rejoined)
684
+ reason += `; rejoining the hotspot failed: ${outcome}`;
685
+ }
686
+ this.network = classifyNetwork(this.currentIp(), this.learnedNetwork());
687
+ this.recordSwitch("wifi", false, reason);
688
+ if (this.network !== "hotspot" && rejoined) {
689
+ // Rejoined, but on a network that could not be remembered (see learn()): it reads as Wi-Fi.
690
+ this.state = "watching";
691
+ this.landedOnHotspotAt = null;
692
+ this.step(`${reason}; rejoined the hotspot, but its network cannot be told apart from Wi-Fi; watching it as Wi-Fi`);
693
+ return;
694
+ }
695
+ this.state = this.network === "hotspot" ? "on-hotspot" : "join-failed";
696
+ this.backOff(reason);
697
+ }
698
+ /**
699
+ * Stay on the hotspot until the next scheduled point (never doubling; see RETURN_SCHEDULE_MINUTES).
700
+ * A point already passed — a slow tick, a long gap attempt — is skipped for the next one at least
701
+ * MIN_SWITCH_INTERVAL_MS out, so a failed try never lands on top of the one just made.
702
+ */
703
+ backOff(reason) {
704
+ const landed = this.landedOnHotspotAt ?? this.now();
705
+ this.nextReturnAt = nextReturnPoint(landed, this.now());
706
+ this.lastLogged = "";
707
+ this.decide(`staying on the hotspot (${reason}); next try in ${Math.round((this.nextReturnAt - this.now()) / 1000)}s`);
708
+ }
709
+ recordSwitch(to, ok, reason) {
710
+ this.lastSwitch = { at: new Date(this.now()).toISOString(), to, ok, reason };
711
+ }
712
+ }