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.
- package/CHANGELOG.md +118 -0
- package/README.es.md +45 -0
- package/README.ja.md +17 -0
- package/README.ko.md +31 -0
- package/README.md +70 -0
- package/README.pt-BR.md +45 -0
- package/README.zh-CN.md +17 -0
- package/dist/app/assets/icon.icns +0 -0
- package/dist/app/assets/icon.ico +0 -0
- package/dist/app/assets/icon.png +0 -0
- package/dist/app/assets/trayDirectTemplate.png +0 -0
- package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDirect.png +0 -0
- package/dist/app/assets/trayWinDirect@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/claude-desktop.js +67 -0
- package/dist/app/dist/main.js +423 -110
- package/dist/app/dist/preload.js +21 -0
- package/dist/app/dist/protocol.js +121 -0
- package/dist/app/dist/status-model.js +87 -0
- package/dist/app/dist/window.js +133 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/awake-setup.js +314 -0
- package/dist/cli/src/bundle.js +9 -5
- package/dist/cli/src/direct.js +159 -0
- package/dist/cli/src/doctor.js +205 -0
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +227 -31
- package/dist/cli/src/launchd.js +29 -20
- package/dist/cli/src/launcher.js +215 -0
- package/dist/cli/src/power.js +182 -0
- package/dist/cli/src/registration.js +157 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +50 -40
- package/dist/cli/src/settings.js +26 -1
- package/dist/cli/src/tray.js +5 -2
- package/dist/mod/hooks/i18n.ts +473 -227
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +237 -32
- package/dist/mod/hooks/session.ts +102 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +903 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +120 -4
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +408 -27
- package/dist/mod/types/index.d.ts +66 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +336 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +331 -5
- package/dist/router/src/awake-win.js +590 -0
- package/dist/router/src/awake.js +432 -0
- package/dist/router/src/config.js +94 -0
- package/dist/router/src/health.js +86 -3
- package/dist/router/src/hold.js +107 -0
- package/dist/router/src/index.js +108 -6
- package/dist/router/src/ingress/server.js +26 -4
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/power.js +107 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/google/antigravity.js +73 -10
- package/dist/router/src/providers/google/index.js +191 -14
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
- package/dist/router/src/providers/google/transport.js +1 -1
- package/dist/router/src/providers/google/usage.js +302 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/translate.js +13 -2
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/providers/retry.js +4 -1
- package/dist/router/src/proxy.js +371 -261
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/tether-win.js +211 -0
- package/dist/router/src/tether.js +712 -0
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +1083 -59
- package/dist/ui/i18n.js +670 -69
- package/dist/ui/index.html +33 -6
- package/dist/ui/style.css +177 -0
- package/docs/ARCHITECTURE.md +402 -14
- package/package.json +8 -2
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// Keep a laptop awake with its lid closed while Claude works: macOS here, Windows in awake-win.ts
|
|
2
|
+
// (the decision, journal and controller below are shared; each OS is an AwakeBackend).
|
|
3
|
+
//
|
|
4
|
+
// macOS: the only mechanism that holds a closed MacBook awake on battery or without an external display is
|
|
5
|
+
// `pmset -a disablesleep 1`, run as root. It is undocumented (absent from `man pmset`) but it is what
|
|
6
|
+
// every working open-source tool uses (Insomnia, MacMiniMode, Sleepless, Capsomnia, WiFiGuard), and
|
|
7
|
+
// `pmset -g` reports it as `SleepDisabled`. Root comes from a narrow sudoers rule installed once by
|
|
8
|
+
// `clauderipple awake setup`; here it is only ever `sudo -n`, which fails instead of prompting.
|
|
9
|
+
//
|
|
10
|
+
// Rules this module keeps (docs/ARCHITECTURE.md "Lid-closed awake and hotspot failover"):
|
|
11
|
+
// - The bit is set only while it is wanted, and a journal (`<home>/awake.json`) records that we
|
|
12
|
+
// set it, so a router that died with it set clears it on its next start.
|
|
13
|
+
// - Battery below the floor, a thermal warning, or any error reading either forces it off.
|
|
14
|
+
// - Turning it off with the lid closed (and nothing else holding the Mac awake) sends the Mac to
|
|
15
|
+
// sleep, because clearing the bit alone leaves a bagged Mac running (MacMiniMode does the same).
|
|
16
|
+
// - The router's exit does not touch the bit: a restart's successor reconciles within one tick,
|
|
17
|
+
// and clearing it here then setting it again a second later is exactly the off-then-on window in
|
|
18
|
+
// which a closed lid could sleep mid-work. `clauderipple stop`/`uninstall` clear it.
|
|
19
|
+
//
|
|
20
|
+
// Every command goes through an injected `Runner`, never a shell, so tests run against fakes.
|
|
21
|
+
import { execFile } from "node:child_process";
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { WinAwakeBackend } from "./awake-win.js";
|
|
25
|
+
export const PMSET = "/usr/bin/pmset";
|
|
26
|
+
export const SUDO = "/usr/bin/sudo";
|
|
27
|
+
export const IOREG = "/usr/sbin/ioreg";
|
|
28
|
+
export const SUDOERS_FILE = "/etc/sudoers.d/clauderipple";
|
|
29
|
+
export function execRunner() {
|
|
30
|
+
return (file, args, opts = {}) => new Promise((resolve) => {
|
|
31
|
+
execFile(file, args, {
|
|
32
|
+
timeout: opts.timeoutMs ?? 10_000,
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
35
|
+
env: opts.env ?? { ...process.env, LC_ALL: "C" },
|
|
36
|
+
windowsHide: true,
|
|
37
|
+
...(opts.verbatim ? { windowsVerbatimArguments: true } : {}),
|
|
38
|
+
}, (error, stdout, stderr) => {
|
|
39
|
+
if (!error)
|
|
40
|
+
return resolve({ code: 0, stdout, stderr });
|
|
41
|
+
// A non-zero exit carries its status as a number; a spawn failure or timeout does not.
|
|
42
|
+
const code = error.code;
|
|
43
|
+
resolve({ code: typeof code === "number" ? code : -1, stdout: stdout ?? "", stderr: stderr || error.message });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `pmset -g batt`:
|
|
49
|
+
* Now drawing from 'AC Power'
|
|
50
|
+
* -InternalBattery-0 (id=7995491) 55%; AC attached; not charging present: true
|
|
51
|
+
* A Mac without a battery prints no InternalBattery line: percent null, never "on battery".
|
|
52
|
+
*/
|
|
53
|
+
export function parseBattery(text) {
|
|
54
|
+
const source = /Now drawing from '([^']+)'/.exec(text)?.[1] ?? "";
|
|
55
|
+
const pct = /InternalBattery[^\n]*?\t?(\d{1,3})%/.exec(text)?.[1];
|
|
56
|
+
return { percent: pct === undefined ? null : Number(pct), onBattery: /battery/i.test(source) };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* `pmset -g therm`. Measured with nothing recorded:
|
|
60
|
+
* Note: No thermal warning level has been recorded
|
|
61
|
+
* When a level is recorded pmset prints "Thermal warning level set to N." (any N > 0 is a warning).
|
|
62
|
+
* Output that says neither is "unknown": reported, not acted on, because a wording change in a new
|
|
63
|
+
* macOS must not switch the feature off for good. A failed command is an error, which is acted on.
|
|
64
|
+
*/
|
|
65
|
+
export function parseThermal(text) {
|
|
66
|
+
const level = /thermal warning level set to (\d+)/i.exec(text)?.[1];
|
|
67
|
+
if (level !== undefined)
|
|
68
|
+
return Number(level) > 0 ? "warning" : "normal";
|
|
69
|
+
if (/no thermal warning level has been recorded/i.test(text))
|
|
70
|
+
return "normal";
|
|
71
|
+
return "unknown";
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `ioreg -r -k AppleClamshellState -d 4` (no privilege needed):
|
|
75
|
+
* | "AppleClamshellCausesSleep" = No
|
|
76
|
+
* | "AppleClamshellState" = Yes
|
|
77
|
+
* | "SleepDisabled" = No
|
|
78
|
+
* `AppleClamshellCausesSleep` is No while an external display keeps a closed Mac in closed-display
|
|
79
|
+
* mode — the state in which a closed lid must never be put to sleep by us.
|
|
80
|
+
*/
|
|
81
|
+
export function parseClamshell(text) {
|
|
82
|
+
const flag = (key) => {
|
|
83
|
+
const m = new RegExp(`"${key}"\\s*=\\s*(Yes|No)`).exec(text);
|
|
84
|
+
return m ? m[1] === "Yes" : null;
|
|
85
|
+
};
|
|
86
|
+
return { lidClosed: flag("AppleClamshellState"), causesSleep: flag("AppleClamshellCausesSleep"), sleepDisabled: flag("SleepDisabled") };
|
|
87
|
+
}
|
|
88
|
+
// ---- The decision -----------------------------------------------------------------------------
|
|
89
|
+
export const WORKING_GRACE_MS = 10 * 60_000;
|
|
90
|
+
/** Whether the Mac should be held awake now, and the one-line reason. Pure. */
|
|
91
|
+
export function decideAwake(i) {
|
|
92
|
+
const { mode, until, batteryFloorPercent } = i.settings;
|
|
93
|
+
if (mode === "off")
|
|
94
|
+
return { wanted: false, reason: "off" };
|
|
95
|
+
const untilMs = until ? Date.parse(until) : NaN;
|
|
96
|
+
if (!Number.isNaN(untilMs) && i.now >= untilMs)
|
|
97
|
+
return { wanted: false, reason: `until ${until} has passed` };
|
|
98
|
+
let reason;
|
|
99
|
+
if (mode === "on")
|
|
100
|
+
reason = "on";
|
|
101
|
+
else if (i.inFlight > 0)
|
|
102
|
+
reason = `working: ${i.inFlight} model call${i.inFlight === 1 ? "" : "s"} running`;
|
|
103
|
+
else if (i.lastActivityAt !== undefined && i.now - i.lastActivityAt < WORKING_GRACE_MS)
|
|
104
|
+
reason = `working: last model call ${Math.round((i.now - i.lastActivityAt) / 1000)}s ago`;
|
|
105
|
+
else
|
|
106
|
+
return { wanted: false, reason: "idle: no model call in the last 10 min" };
|
|
107
|
+
// Guards only matter when something wants the Mac awake.
|
|
108
|
+
if (i.error)
|
|
109
|
+
return { wanted: false, reason: `error: ${i.error}` };
|
|
110
|
+
if (i.battery?.onBattery && i.battery.percent !== null && i.battery.percent < batteryFloorPercent) {
|
|
111
|
+
return { wanted: false, reason: `battery ${i.battery.percent}% is below ${batteryFloorPercent}%` };
|
|
112
|
+
}
|
|
113
|
+
if (i.thermal === "warning")
|
|
114
|
+
return { wanted: false, reason: "thermal warning" };
|
|
115
|
+
return { wanted: true, reason };
|
|
116
|
+
}
|
|
117
|
+
function journalLid(v) {
|
|
118
|
+
const l = v;
|
|
119
|
+
if (!l || typeof l.scheme !== "string" || !/^[0-9a-f-]{36}$/i.test(l.scheme))
|
|
120
|
+
return undefined;
|
|
121
|
+
const ok = (n) => typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= 3;
|
|
122
|
+
return ok(l.ac) && ok(l.dc) ? { scheme: l.scheme.toLowerCase(), ac: l.ac, dc: l.dc } : undefined;
|
|
123
|
+
}
|
|
124
|
+
export function readJournal(file) {
|
|
125
|
+
try {
|
|
126
|
+
const j = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
127
|
+
if (typeof j.applied !== "boolean")
|
|
128
|
+
return null;
|
|
129
|
+
const lid = journalLid(j.lid);
|
|
130
|
+
return {
|
|
131
|
+
applied: j.applied,
|
|
132
|
+
since: String(j.since ?? ""),
|
|
133
|
+
reason: String(j.reason ?? ""),
|
|
134
|
+
...(typeof j.lastActivityAt === "number" ? { lastActivityAt: j.lastActivityAt } : {}),
|
|
135
|
+
...(lid ? { lid } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function writeJournal(file, j) {
|
|
143
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
144
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
145
|
+
fs.writeFileSync(tmp, JSON.stringify(j) + "\n", { mode: 0o600 });
|
|
146
|
+
fs.renameSync(tmp, file);
|
|
147
|
+
}
|
|
148
|
+
/** `sudo -n` refused: the rule is missing or does not cover this exact command. */
|
|
149
|
+
export function sudoRefused(r) {
|
|
150
|
+
return r.code !== 0 && /password is required|not allowed|sudoers|sorry, user|a terminal is required/i.test(r.stderr + r.stdout);
|
|
151
|
+
}
|
|
152
|
+
export const AWAKE_TICK_MS = 15_000;
|
|
153
|
+
/** Pause between clearing the bit and reading whether a closed lid now causes sleep. */
|
|
154
|
+
export const SLEEPNOW_SETTLE_MS = 3_000;
|
|
155
|
+
export class AwakeController {
|
|
156
|
+
applied = false;
|
|
157
|
+
reason = "not started";
|
|
158
|
+
battery = null;
|
|
159
|
+
thermal = null;
|
|
160
|
+
lidClosed = null;
|
|
161
|
+
sudoFailed = false;
|
|
162
|
+
lastActivityAt;
|
|
163
|
+
journaledActivityAt;
|
|
164
|
+
busy = null;
|
|
165
|
+
cancel = null;
|
|
166
|
+
lastLogged = "";
|
|
167
|
+
deps;
|
|
168
|
+
now;
|
|
169
|
+
sleep;
|
|
170
|
+
backend;
|
|
171
|
+
constructor(deps) {
|
|
172
|
+
this.deps = deps;
|
|
173
|
+
this.now = deps.now ?? Date.now;
|
|
174
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
175
|
+
const j = readJournal(deps.journalFile);
|
|
176
|
+
// Reconcile starts from what the last process recorded: if it set the bit and died (or was
|
|
177
|
+
// restarted), we own it now and the first tick keeps or clears it.
|
|
178
|
+
if (j) {
|
|
179
|
+
this.applied = j.applied;
|
|
180
|
+
this.lastActivityAt = j.lastActivityAt;
|
|
181
|
+
this.journaledActivityAt = j.lastActivityAt;
|
|
182
|
+
}
|
|
183
|
+
const log = deps.log;
|
|
184
|
+
if (deps.platform === "darwin") {
|
|
185
|
+
this.backend = new MacAwakeBackend({ run: deps.run, sleep: this.sleep, log, fileExists: deps.fileExists ?? ((f) => fs.existsSync(f)) });
|
|
186
|
+
}
|
|
187
|
+
else if (deps.platform === "win32") {
|
|
188
|
+
this.backend = new WinAwakeBackend({ run: deps.run, sleep: this.sleep, now: this.now, log, save: () => this.persist(true), ...(deps.winHelper ? { helper: deps.winHelper } : {}) }, j);
|
|
189
|
+
}
|
|
190
|
+
else
|
|
191
|
+
this.backend = null;
|
|
192
|
+
}
|
|
193
|
+
get supported() {
|
|
194
|
+
return this.backend !== null;
|
|
195
|
+
}
|
|
196
|
+
/** First tick now (the reconcile), then every 15s. A no-op where unsupported. */
|
|
197
|
+
async start() {
|
|
198
|
+
if (!this.supported) {
|
|
199
|
+
this.reason = "unsupported on this platform";
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
await this.tick();
|
|
203
|
+
const schedule = this.deps.schedule ?? ((fn, ms) => {
|
|
204
|
+
const t = setInterval(fn, ms);
|
|
205
|
+
t.unref();
|
|
206
|
+
return () => clearInterval(t);
|
|
207
|
+
});
|
|
208
|
+
this.cancel = schedule(() => void this.tick(), AWAKE_TICK_MS);
|
|
209
|
+
}
|
|
210
|
+
stop() {
|
|
211
|
+
this.cancel?.();
|
|
212
|
+
this.cancel = null;
|
|
213
|
+
}
|
|
214
|
+
status() {
|
|
215
|
+
const s = this.deps.settings();
|
|
216
|
+
return {
|
|
217
|
+
mode: s.mode,
|
|
218
|
+
applied: this.applied,
|
|
219
|
+
reason: this.reason,
|
|
220
|
+
setup: this.backend ? this.backend.setup(this.sudoFailed) : "unsupported",
|
|
221
|
+
battery: this.battery,
|
|
222
|
+
thermal: this.thermal,
|
|
223
|
+
lidClosed: this.lidClosed,
|
|
224
|
+
until: s.until ?? null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/** One evaluation. Concurrent callers share the running one. */
|
|
228
|
+
tick() {
|
|
229
|
+
if (!this.supported)
|
|
230
|
+
return Promise.resolve();
|
|
231
|
+
if (!this.busy)
|
|
232
|
+
this.busy = this.evaluate().finally(() => (this.busy = null));
|
|
233
|
+
return this.busy;
|
|
234
|
+
}
|
|
235
|
+
/** A fresh evaluation after a settings change: one already running read the old settings. */
|
|
236
|
+
async refresh() {
|
|
237
|
+
if (this.busy)
|
|
238
|
+
await this.busy;
|
|
239
|
+
// A refresh follows `awake setup` or a settings change: a sudo refusal from before the setup
|
|
240
|
+
// must not keep saying "needs setup" while the mode is off and nothing calls pmset to retry.
|
|
241
|
+
this.sudoFailed = false;
|
|
242
|
+
await this.tick();
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The router is exiting. Only the journal is written (with the latest activity, so a successor
|
|
246
|
+
* remembers the working window); the bit stays as it is. See the header comment for why.
|
|
247
|
+
*/
|
|
248
|
+
beforeExit() {
|
|
249
|
+
if (!this.supported)
|
|
250
|
+
return;
|
|
251
|
+
this.noteActivity();
|
|
252
|
+
this.persist(true);
|
|
253
|
+
// Windows: the execution-state child exits with stdin closed (it would with the process anyway);
|
|
254
|
+
// the lid action stays as journaled for the successor, like the macOS bit.
|
|
255
|
+
this.backend?.shutdown();
|
|
256
|
+
}
|
|
257
|
+
noteActivity() {
|
|
258
|
+
const a = this.deps.activity();
|
|
259
|
+
const latest = Math.max(a.lastStartedAt ?? 0, a.lastEndedAt ?? 0, a.inFlight > 0 ? this.now() : 0);
|
|
260
|
+
if (latest > 0 && (this.lastActivityAt === undefined || latest > this.lastActivityAt))
|
|
261
|
+
this.lastActivityAt = latest;
|
|
262
|
+
}
|
|
263
|
+
persist(force = false) {
|
|
264
|
+
// The activity time is written at most once a minute: it only has to survive a restart.
|
|
265
|
+
const stale = this.lastActivityAt !== undefined && (this.journaledActivityAt === undefined || this.lastActivityAt - this.journaledActivityAt >= 60_000);
|
|
266
|
+
if (!force && !stale)
|
|
267
|
+
return;
|
|
268
|
+
try {
|
|
269
|
+
const prev = readJournal(this.deps.journalFile);
|
|
270
|
+
writeJournal(this.deps.journalFile, {
|
|
271
|
+
applied: this.applied,
|
|
272
|
+
since: prev && prev.applied === this.applied ? prev.since : new Date(this.now()).toISOString(),
|
|
273
|
+
reason: this.reason,
|
|
274
|
+
...(this.lastActivityAt !== undefined ? { lastActivityAt: this.lastActivityAt } : {}),
|
|
275
|
+
...(this.backend?.journalExtra() ?? {}),
|
|
276
|
+
});
|
|
277
|
+
this.journaledActivityAt = this.lastActivityAt;
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
this.deps.log.warn(`awake: cannot write journal: ${e.message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
logOnce(line) {
|
|
284
|
+
if (line === this.lastLogged)
|
|
285
|
+
return;
|
|
286
|
+
this.lastLogged = line;
|
|
287
|
+
this.deps.log.info(line);
|
|
288
|
+
}
|
|
289
|
+
async evaluate() {
|
|
290
|
+
const settings = this.deps.settings();
|
|
291
|
+
this.noteActivity();
|
|
292
|
+
const activity = this.deps.activity();
|
|
293
|
+
const backend = this.backend;
|
|
294
|
+
// Nothing to watch: no reads, no processes, unless a hold we took still has to be released.
|
|
295
|
+
if (settings.mode === "off" && !this.applied) {
|
|
296
|
+
this.reason = "off";
|
|
297
|
+
this.battery = null;
|
|
298
|
+
this.thermal = null;
|
|
299
|
+
this.lidClosed = null;
|
|
300
|
+
backend.shutdown();
|
|
301
|
+
this.persist();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const decide = (g) => decideAwake({
|
|
305
|
+
settings,
|
|
306
|
+
now: this.now(),
|
|
307
|
+
inFlight: activity.inFlight,
|
|
308
|
+
...(this.lastActivityAt !== undefined ? { lastActivityAt: this.lastActivityAt } : {}),
|
|
309
|
+
battery: g?.battery ?? null,
|
|
310
|
+
thermal: g?.thermal ?? "unknown",
|
|
311
|
+
...(g?.error ? { error: g.error } : {}),
|
|
312
|
+
});
|
|
313
|
+
// Whether anything wants the machine awake before the guards are read: a backend may skip
|
|
314
|
+
// reads (on Windows, a whole child process) when nothing does.
|
|
315
|
+
const guards = await backend.readGuards(decide().wanted);
|
|
316
|
+
this.battery = guards.battery;
|
|
317
|
+
this.thermal = guards.thermal;
|
|
318
|
+
this.lidClosed = guards.lidClosed;
|
|
319
|
+
// What we set reads as undone: a reboot (the macOS bit does not survive one), another tool
|
|
320
|
+
// (Insomnia saw one reset it every 2s), or the user changing the Windows lid action. Take it as
|
|
321
|
+
// not applied, so a wanted state is set again below — re-asserting at most once per tick — and an
|
|
322
|
+
// unwanted one is not "cleared" twice.
|
|
323
|
+
if (this.applied && !(await backend.stillHeld())) {
|
|
324
|
+
this.logOnce("awake: sleep was re-enabled outside ClaudeRipple (reboot or another tool)");
|
|
325
|
+
this.applied = false;
|
|
326
|
+
}
|
|
327
|
+
const d = decide(guards);
|
|
328
|
+
this.reason = d.reason;
|
|
329
|
+
if (d.wanted && !this.applied)
|
|
330
|
+
await this.set(true, d.reason);
|
|
331
|
+
else if (!d.wanted && this.applied) {
|
|
332
|
+
if (await this.set(false, d.reason))
|
|
333
|
+
await backend.afterRelease();
|
|
334
|
+
}
|
|
335
|
+
this.persist();
|
|
336
|
+
}
|
|
337
|
+
/** Hold or release. The journal is written before holding and after releasing. */
|
|
338
|
+
async set(on, reason) {
|
|
339
|
+
const backend = this.backend;
|
|
340
|
+
if (on) {
|
|
341
|
+
this.applied = true;
|
|
342
|
+
this.persist(true);
|
|
343
|
+
}
|
|
344
|
+
const r = on ? await backend.hold() : await backend.release();
|
|
345
|
+
if (!r.ok) {
|
|
346
|
+
this.sudoFailed = r.refused;
|
|
347
|
+
if (on) {
|
|
348
|
+
this.applied = false;
|
|
349
|
+
this.persist(true);
|
|
350
|
+
}
|
|
351
|
+
this.reason = r.refused ? "needs setup: run `clauderipple awake setup` once" : r.message;
|
|
352
|
+
this.logOnce(`awake: could not ${on ? "disable" : "re-enable"} sleep (${reason}): ${this.reason}`);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
this.sudoFailed = false;
|
|
356
|
+
this.applied = on;
|
|
357
|
+
this.persist(true);
|
|
358
|
+
this.lastLogged = "";
|
|
359
|
+
this.deps.log.info(`awake: sleep ${on ? "disabled" : "re-enabled"} (${reason})`);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
class MacAwakeBackend {
|
|
364
|
+
deps;
|
|
365
|
+
clamshell = null;
|
|
366
|
+
constructor(deps) {
|
|
367
|
+
this.deps = deps;
|
|
368
|
+
}
|
|
369
|
+
setup(sudoFailed) {
|
|
370
|
+
return sudoFailed || !this.deps.fileExists(SUDOERS_FILE) ? "needs-setup" : "ok";
|
|
371
|
+
}
|
|
372
|
+
journalExtra() {
|
|
373
|
+
return {};
|
|
374
|
+
}
|
|
375
|
+
shutdown() { }
|
|
376
|
+
async readGuards() {
|
|
377
|
+
let error;
|
|
378
|
+
let battery = null;
|
|
379
|
+
let thermal = null;
|
|
380
|
+
const batt = await this.deps.run(PMSET, ["-g", "batt"]);
|
|
381
|
+
if (batt.code === 0)
|
|
382
|
+
battery = parseBattery(batt.stdout);
|
|
383
|
+
else
|
|
384
|
+
error = `pmset -g batt failed (${batt.code})`;
|
|
385
|
+
const therm = await this.deps.run(PMSET, ["-g", "therm"]);
|
|
386
|
+
if (therm.code === 0)
|
|
387
|
+
thermal = parseThermal(therm.stdout);
|
|
388
|
+
else
|
|
389
|
+
error ??= `pmset -g therm failed (${therm.code})`;
|
|
390
|
+
this.clamshell = await this.readClamshell();
|
|
391
|
+
return { battery, thermal, lidClosed: this.clamshell?.lidClosed ?? null, ...(error ? { error } : {}) };
|
|
392
|
+
}
|
|
393
|
+
async stillHeld() {
|
|
394
|
+
return this.clamshell?.sleepDisabled !== false;
|
|
395
|
+
}
|
|
396
|
+
async readClamshell() {
|
|
397
|
+
const r = await this.deps.run(IOREG, ["-r", "-k", "AppleClamshellState", "-d", "4"]);
|
|
398
|
+
return r.code === 0 ? parseClamshell(r.stdout) : null;
|
|
399
|
+
}
|
|
400
|
+
/** `sudo -n pmset -a disablesleep 1|0`. */
|
|
401
|
+
async disableSleep(on) {
|
|
402
|
+
const r = await this.deps.run(SUDO, ["-n", PMSET, "-a", "disablesleep", on ? "1" : "0"]);
|
|
403
|
+
if (r.code === 0)
|
|
404
|
+
return { ok: true };
|
|
405
|
+
return { ok: false, refused: sudoRefused(r), message: `pmset failed: ${(r.stderr || r.stdout).trim().slice(0, 200)}` };
|
|
406
|
+
}
|
|
407
|
+
hold() {
|
|
408
|
+
return this.disableSleep(true);
|
|
409
|
+
}
|
|
410
|
+
release() {
|
|
411
|
+
return this.disableSleep(false);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* With the bit cleared, a Mac whose lid is shut keeps running: nothing re-evaluates the lid. If the
|
|
415
|
+
* lid is closed and macOS says a closed lid now causes sleep (no external display holding it in
|
|
416
|
+
* closed-display mode), put it to sleep so a bagged Mac does not run on. Tried unprivileged first,
|
|
417
|
+
* then through the sudoers rule.
|
|
418
|
+
*/
|
|
419
|
+
async afterRelease() {
|
|
420
|
+
await this.deps.sleep(SLEEPNOW_SETTLE_MS);
|
|
421
|
+
const c = await this.readClamshell();
|
|
422
|
+
if (!c?.lidClosed || c.causesSleep !== true)
|
|
423
|
+
return;
|
|
424
|
+
this.deps.log.info("awake: lid is closed and nothing else holds the Mac awake; sleeping now");
|
|
425
|
+
const r = await this.deps.run(PMSET, ["sleepnow"]);
|
|
426
|
+
if (r.code === 0)
|
|
427
|
+
return;
|
|
428
|
+
const s = await this.deps.run(SUDO, ["-n", PMSET, "sleepnow"]);
|
|
429
|
+
if (s.code !== 0)
|
|
430
|
+
this.deps.log.warn(`awake: pmset sleepnow failed: ${(s.stderr || s.stdout || r.stderr).trim().slice(0, 200)}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -7,6 +7,7 @@ import fs from "node:fs";
|
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { PRESETS } from "./presets.js";
|
|
10
|
+
import { hostProblem, portProblem } from "./remote-host.js";
|
|
10
11
|
/**
|
|
11
12
|
* The provider's own key, re-sent under the header another wire on the same account expects.
|
|
12
13
|
*
|
|
@@ -98,6 +99,69 @@ export function searchProviderFor(provider, model) {
|
|
|
98
99
|
...(presetId ? { preset: presetId } : {}),
|
|
99
100
|
};
|
|
100
101
|
}
|
|
102
|
+
export const AWAKE_DEFAULTS = { mode: "off", batteryFloorPercent: 20 };
|
|
103
|
+
export const TETHER_DEFAULTS = { enabled: false, hotspotSsid: "", probeEverySec: 10 };
|
|
104
|
+
export function awakeSettings(c) {
|
|
105
|
+
return { ...AWAKE_DEFAULTS, ...(c.awake ?? {}) };
|
|
106
|
+
}
|
|
107
|
+
export function tetherSettings(c) {
|
|
108
|
+
const t = { ...TETHER_DEFAULTS, ...(c.tether ?? {}) };
|
|
109
|
+
// A config from before 2026-10-20 still carries `returnEverySec`; it loads fine (unknown keys are
|
|
110
|
+
// not refused) and is dropped here rather than carried into the controller's settings.
|
|
111
|
+
return { enabled: t.enabled, hotspotSsid: t.hotspotSsid, probeEverySec: t.probeEverySec };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A hotspot name is written into a sudoers rule and handed to `networksetup` as one argument, so it
|
|
115
|
+
* is held to a charset that is literal in both: letters, digits, single spaces, `. _ -`, and the
|
|
116
|
+
* straight and typographic apostrophes iPhone names use ("Name’s iPhone"). Nothing sudoers or fnmatch
|
|
117
|
+
* reads specially (`, : = \ * ? [ ] #` and quotes) can get through. Returns what is wrong, or null.
|
|
118
|
+
*/
|
|
119
|
+
export function ssidProblem(ssid) {
|
|
120
|
+
if (typeof ssid !== "string" || !ssid)
|
|
121
|
+
return "the hotspot name is empty";
|
|
122
|
+
if (Buffer.byteLength(ssid, "utf8") > 32)
|
|
123
|
+
return "a Wi-Fi name is at most 32 bytes";
|
|
124
|
+
if (!/^[A-Za-z0-9 ._'’-]+$/u.test(ssid))
|
|
125
|
+
return "the hotspot name may contain only letters, digits, spaces and . _ - ' ’";
|
|
126
|
+
if (/^ | $| /.test(ssid))
|
|
127
|
+
return "the hotspot name cannot start or end with a space or contain two in a row";
|
|
128
|
+
if (ssid.startsWith("-"))
|
|
129
|
+
return "the hotspot name cannot start with -";
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
function validatePower(c, errors) {
|
|
133
|
+
if (c.awake !== undefined) {
|
|
134
|
+
const a = c.awake;
|
|
135
|
+
if (!a || typeof a !== "object" || Array.isArray(a))
|
|
136
|
+
errors.push("awake must be an object");
|
|
137
|
+
else {
|
|
138
|
+
if (a.mode !== undefined && a.mode !== "off" && a.mode !== "working" && a.mode !== "on")
|
|
139
|
+
errors.push('awake.mode must be "off", "working" or "on"');
|
|
140
|
+
if (a.until !== undefined && (typeof a.until !== "string" || Number.isNaN(Date.parse(a.until))))
|
|
141
|
+
errors.push("awake.until must be an ISO date-time");
|
|
142
|
+
if (a.batteryFloorPercent !== undefined && !(typeof a.batteryFloorPercent === "number" && a.batteryFloorPercent >= 0 && a.batteryFloorPercent <= 100))
|
|
143
|
+
errors.push("awake.batteryFloorPercent must be a number from 0 to 100");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (c.tether !== undefined) {
|
|
147
|
+
const t = c.tether;
|
|
148
|
+
if (!t || typeof t !== "object" || Array.isArray(t))
|
|
149
|
+
errors.push("tether must be an object");
|
|
150
|
+
else {
|
|
151
|
+
if (t.enabled !== undefined && typeof t.enabled !== "boolean")
|
|
152
|
+
errors.push("tether.enabled must be true or false");
|
|
153
|
+
if (t.hotspotSsid !== undefined && t.hotspotSsid !== "") {
|
|
154
|
+
const problem = ssidProblem(t.hotspotSsid);
|
|
155
|
+
if (problem)
|
|
156
|
+
errors.push(`tether.hotspotSsid: ${problem}`);
|
|
157
|
+
}
|
|
158
|
+
if (t.probeEverySec !== undefined && !(typeof t.probeEverySec === "number" && t.probeEverySec >= 5 && t.probeEverySec <= 3600))
|
|
159
|
+
errors.push("tether.probeEverySec must be a number from 5 to 3600");
|
|
160
|
+
// `tether.returnEverySec` was dropped on 2026-10-20 (the way back is a fixed schedule now).
|
|
161
|
+
// A config still carrying it is left alone: unknown keys are not refused here.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
101
165
|
export const PICKER_HOSTS_DEFAULT = ["claude.ai"];
|
|
102
166
|
export function terminateHosts(c) {
|
|
103
167
|
const hosts = [c.upstream];
|
|
@@ -323,6 +387,8 @@ export function validate(c) {
|
|
|
323
387
|
errors.push(`provider ${name}: identity must be true or false`);
|
|
324
388
|
if (shared.instructionsAppend !== undefined && typeof shared.instructionsAppend !== "string")
|
|
325
389
|
errors.push(`provider ${name}: instructionsAppend must be a string`);
|
|
390
|
+
if (shared.limits !== undefined && typeof shared.limits !== "boolean")
|
|
391
|
+
errors.push(`provider ${name}: limits must be true or false`);
|
|
326
392
|
// Ids key the router's cooldown and quarantine state, so a duplicate would make two credentials
|
|
327
393
|
// share one health record and take each other down.
|
|
328
394
|
const pool = p.credentials;
|
|
@@ -465,6 +531,34 @@ export function validate(c) {
|
|
|
465
531
|
errors.push("cli.limitWorkerTools must be true or false");
|
|
466
532
|
if (c.listen.openaiPort !== undefined && !(c.listen.openaiPort >= 0 && c.listen.openaiPort < 65536))
|
|
467
533
|
errors.push("listen.openaiPort out of range");
|
|
534
|
+
if (c.remote !== undefined) {
|
|
535
|
+
const hosts = c.remote?.hosts;
|
|
536
|
+
if (!Array.isArray(hosts))
|
|
537
|
+
errors.push("remote.hosts must be a list");
|
|
538
|
+
else {
|
|
539
|
+
const seen = new Set();
|
|
540
|
+
hosts.forEach((entry, i) => {
|
|
541
|
+
const h = entry;
|
|
542
|
+
if (!h || typeof h !== "object") {
|
|
543
|
+
errors.push(`remote.hosts ${i} must be an object`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// Handed to ssh as an argument: the same check `remote add` applies, so a hand edit cannot
|
|
547
|
+
// slip an option such as -oProxyCommand into the router's own ssh.
|
|
548
|
+
const hostError = hostProblem(h.host);
|
|
549
|
+
if (hostError)
|
|
550
|
+
errors.push(`remote.hosts ${i}: ${hostError}`);
|
|
551
|
+
else if (seen.has(h.host))
|
|
552
|
+
errors.push(`remote.hosts ${i}: "${String(h.host)}" is listed twice`);
|
|
553
|
+
else
|
|
554
|
+
seen.add(h.host);
|
|
555
|
+
const portError = portProblem(h.port);
|
|
556
|
+
if (portError)
|
|
557
|
+
errors.push(`remote.hosts ${i}: ${portError}`);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
validatePower(c, errors);
|
|
468
562
|
return errors;
|
|
469
563
|
}
|
|
470
564
|
export class ConfigStore {
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
// stays bad (observed: 10h of ENOTFOUND, 11 remote-control workers lost). After N
|
|
3
3
|
// consecutive upstream *connect-level* failures with no success in between, exit
|
|
4
4
|
// with a distinctive code so the supervisor restarts a fresh process.
|
|
5
|
+
//
|
|
6
|
+
// But only when a fresh process would do better. When the counter trips, a child process (curl,
|
|
7
|
+
// with its own resolver and sockets) asks the internet first: if it gets through, this process is
|
|
8
|
+
// the broken part and it exits; if it does not, the machine itself is offline, a restart cannot
|
|
9
|
+
// help, and the process stays (logged once per outage; the counter re-arms). On 2026-10-20 a
|
|
10
|
+
// MacBook that lost Wi-Fi on the commute restarted the router 27 times in 18 minutes, and every
|
|
11
|
+
// restart threw away the hotspot failover's probe history before it could act (tether.ts).
|
|
12
|
+
// A check that cannot run (no curl) exits as before: the documented fix stays the default.
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { PROBE_URLS } from "./tether.js";
|
|
5
15
|
/** Only definite billing/quota exhaustion, never a bare rate limit or RESOURCE_EXHAUSTED.
|
|
6
16
|
* 402 is already `exhausted` in pool.ts; Codex's usage_limit_reached is named in
|
|
7
17
|
* providers/chatgpt/translate.ts. Other vendors share the explicit billing vocabulary.
|
|
@@ -51,26 +61,99 @@ export function isConnectError(e) {
|
|
|
51
61
|
const code = e?.code;
|
|
52
62
|
return typeof code === "string" && CONNECT_ERRORS.has(code);
|
|
53
63
|
}
|
|
64
|
+
/** curl exits that mean "the network did not get us there" (man curl, EXIT CODES). Any other is the check failing. */
|
|
65
|
+
const CURL_NETWORK_EXITS = new Set([5, 6, 7, 28, 35, 52, 55, 56]);
|
|
66
|
+
export const CONNECTIVITY_TIMEOUT_S = 5;
|
|
67
|
+
/** The curl the OS ships: macOS/Linux /usr/bin/curl, Windows 10 1803+ %SystemRoot%\System32\curl.exe. */
|
|
68
|
+
export function curlPath(platform, env = process.env) {
|
|
69
|
+
if (platform === "win32")
|
|
70
|
+
return path.win32.join(env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows", "System32", "curl.exe");
|
|
71
|
+
return "/usr/bin/curl";
|
|
72
|
+
}
|
|
73
|
+
async function curlProbe(run, curl, url, ok) {
|
|
74
|
+
// -q first: no ~/.curlrc. --noproxy: the answer must be the network's, never a proxy's (ours included).
|
|
75
|
+
const r = await run(curl, ["-q", "-s", "--noproxy", "*", "-m", String(CONNECTIVITY_TIMEOUT_S), "-w", "\n%{http_code}", url], { timeoutMs: (CONNECTIVITY_TIMEOUT_S + 5) * 1000 });
|
|
76
|
+
if (r.code === 0) {
|
|
77
|
+
const at = r.stdout.lastIndexOf("\n");
|
|
78
|
+
const status = Number(r.stdout.slice(at + 1).trim());
|
|
79
|
+
const body = at >= 0 ? r.stdout.slice(0, at) : "";
|
|
80
|
+
// Anything but the exact answer (a captive portal's page, a redirect) is not the internet either.
|
|
81
|
+
return ok(status, body) ? { probe: "ok", detail: `HTTP ${status}` } : { probe: "fail", detail: `unexpected HTTP ${status}` };
|
|
82
|
+
}
|
|
83
|
+
if (CURL_NETWORK_EXITS.has(r.code))
|
|
84
|
+
return { probe: "fail", detail: `curl exit ${r.code}` };
|
|
85
|
+
return { probe: "error", detail: `curl could not run (${r.code}: ${r.stderr.trim().slice(0, 120)})` };
|
|
86
|
+
}
|
|
87
|
+
/** The same two endpoints as the hotspot probe (tether.ts), asked by a child process. Never throws. */
|
|
88
|
+
export async function checkConnectivity(run, platform, env = process.env) {
|
|
89
|
+
const curl = curlPath(platform, env);
|
|
90
|
+
const [apple, google] = await Promise.all([
|
|
91
|
+
curlProbe(run, curl, PROBE_URLS.apple, (s, b) => s === 200 && b.includes("Success")),
|
|
92
|
+
curlProbe(run, curl, PROBE_URLS.google, (s) => s === 204),
|
|
93
|
+
]);
|
|
94
|
+
const detail = `captive.apple.com: ${apple.detail}, gstatic: ${google.detail}`;
|
|
95
|
+
if (apple.probe === "ok" || google.probe === "ok")
|
|
96
|
+
return { result: "online", detail };
|
|
97
|
+
if (apple.probe === "error" || google.probe === "error")
|
|
98
|
+
return { result: "unknown", detail };
|
|
99
|
+
return { result: "offline", detail };
|
|
100
|
+
}
|
|
54
101
|
export class UpstreamHealth {
|
|
55
102
|
consecutive = 0;
|
|
56
103
|
tripped = false;
|
|
104
|
+
/** An offline verdict was logged and no success has been seen since. */
|
|
105
|
+
offline = false;
|
|
106
|
+
/** Bumped by every success, so a check that raced one does not exit a process that works. */
|
|
107
|
+
successes = 0;
|
|
57
108
|
limit;
|
|
58
109
|
onTrip;
|
|
59
|
-
|
|
110
|
+
opts;
|
|
111
|
+
constructor(limit, onTrip, opts = {}) {
|
|
60
112
|
this.limit = limit;
|
|
61
113
|
this.onTrip = onTrip;
|
|
114
|
+
this.opts = opts;
|
|
62
115
|
}
|
|
63
116
|
success() {
|
|
64
117
|
this.consecutive = 0;
|
|
118
|
+
this.offline = false;
|
|
119
|
+
this.successes++;
|
|
65
120
|
}
|
|
66
121
|
failure(e) {
|
|
67
122
|
if (!isConnectError(e))
|
|
68
123
|
return;
|
|
69
124
|
this.consecutive++;
|
|
70
|
-
if (
|
|
71
|
-
|
|
125
|
+
if (this.tripped || this.consecutive < this.limit())
|
|
126
|
+
return;
|
|
127
|
+
this.tripped = true;
|
|
128
|
+
if (!this.opts.connectivity)
|
|
72
129
|
this.onTrip(this.consecutive);
|
|
130
|
+
else
|
|
131
|
+
void this.confirm(this.opts.connectivity);
|
|
132
|
+
}
|
|
133
|
+
async confirm(check) {
|
|
134
|
+
const before = this.successes;
|
|
135
|
+
let c;
|
|
136
|
+
try {
|
|
137
|
+
c = await check();
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
c = { result: "unknown", detail: `check failed: ${e.message}` };
|
|
73
141
|
}
|
|
142
|
+
if (this.successes !== before) {
|
|
143
|
+
// An upstream call got through while we asked: this process works. Re-arm.
|
|
144
|
+
this.tripped = false;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (c.result !== "offline") {
|
|
148
|
+
this.onTrip(this.consecutive, c);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!this.offline) {
|
|
152
|
+
this.offline = true;
|
|
153
|
+
this.opts.onOffline?.(this.consecutive, c);
|
|
154
|
+
}
|
|
155
|
+
this.consecutive = 0;
|
|
156
|
+
this.tripped = false;
|
|
74
157
|
}
|
|
75
158
|
get consecutiveFailures() {
|
|
76
159
|
return this.consecutive;
|