clauderipple 0.10.0 → 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 +51 -0
- package/README.es.md +45 -0
- package/README.ja.md +17 -0
- package/README.ko.md +31 -0
- package/README.md +39 -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/index.js +184 -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/schtasks.js +2 -0
- package/dist/cli/src/settings.js +24 -0
- package/dist/cli/src/tray.js +5 -2
- package/dist/mod/hooks/i18n.ts +237 -237
- package/dist/mod/hooks/register.tsx +4 -5
- package/dist/mod/hooks/session.ts +6 -2
- package/dist/mod/hooks/ui/agents.tsx +81 -36
- package/dist/mod/hooks/ui/view.ts +8 -5
- package/dist/mod/tests/ui.test.tsx +46 -13
- package/dist/mod/types/index.d.ts +0 -2
- package/dist/remote/src/bridge-client.js +45 -4
- package/dist/router/src/admin.js +105 -2
- package/dist/router/src/awake-win.js +590 -0
- package/dist/router/src/awake.js +432 -0
- package/dist/router/src/config.js +64 -0
- package/dist/router/src/health.js +86 -3
- package/dist/router/src/hold.js +107 -0
- package/dist/router/src/index.js +69 -6
- package/dist/router/src/ingress/server.js +26 -4
- package/dist/router/src/power.js +107 -0
- package/dist/router/src/providers/google/antigravity.js +73 -10
- package/dist/router/src/providers/google/index.js +168 -17
- 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 +71 -26
- package/dist/router/src/providers/retry.js +4 -1
- package/dist/router/src/proxy.js +50 -21
- package/dist/router/src/tether-win.js +211 -0
- package/dist/router/src/tether.js +712 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +649 -2
- package/dist/ui/i18n.js +494 -77
- package/dist/ui/index.html +9 -1
- package/dist/ui/style.css +138 -0
- package/docs/ARCHITECTURE.md +294 -6
- package/package.json +2 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// `clauderipple doctor` and `clauderipple repair`: what is wrong with the chain Claude depends on,
|
|
2
|
+
// and the idempotent fix. Both serve the menu-bar app, which has to work when the router — and
|
|
3
|
+
// with it the dashboard — is down: Claude Desktop and Claude Code send everything to 127.0.0.1
|
|
4
|
+
// and fail with ECONNREFUSED, and nothing the router serves can explain why (2026-10-20).
|
|
5
|
+
//
|
|
6
|
+
// Everything that reaches the machine is a dependency (`RecoveryDeps`), so tests run with a fake
|
|
7
|
+
// supervisor and temp files and never touch launchd, the user's settings or the live router.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ConfigStore, adminPort } from "../../router/src/config.js";
|
|
11
|
+
import { certPaths } from "./certs.js";
|
|
12
|
+
import { applyAppProxy, currentAppProxy } from "./picker.js";
|
|
13
|
+
import { applyProxyEnv, settingsEnv } from "./settings.js";
|
|
14
|
+
export async function fetchRouterStatus(adminUrl, timeoutMs) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(`${adminUrl}/api/status`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
return null;
|
|
19
|
+
const body = (await res.json());
|
|
20
|
+
return { version: typeof body.version === "string" ? body.version : null };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const sleepDefault = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
const LOOPBACK_PROXY = /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d+\/?$/;
|
|
28
|
+
export function proxyUrlFor(port) {
|
|
29
|
+
return `http://127.0.0.1:${port}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Claude Code's proxy, from settings.json env. `stale` is a proxy that is ours — our CA beside a
|
|
33
|
+
* loopback URL — on a port the config no longer listens on: the one case repair may rewrite.
|
|
34
|
+
*/
|
|
35
|
+
export function cliProxyState(env, proxyUrl, caPath) {
|
|
36
|
+
const url = env.HTTPS_PROXY || null;
|
|
37
|
+
if (!url)
|
|
38
|
+
return { proxy: "none", url: null, stale: false };
|
|
39
|
+
if (url === proxyUrl)
|
|
40
|
+
return { proxy: "ripple", url, stale: false };
|
|
41
|
+
return { proxy: "other", url, stale: env.NODE_EXTRA_CA_CERTS === caPath && LOOPBACK_PROXY.test(url) };
|
|
42
|
+
}
|
|
43
|
+
/** Claude Desktop's proxy, from the applied Config Library entry. An entry with no proxy is "none". */
|
|
44
|
+
export function desktopProxyState(ap, proxyUrl) {
|
|
45
|
+
const url = ap.egressProxyUrl;
|
|
46
|
+
if (!url)
|
|
47
|
+
return { proxy: "none", url: null, stale: false };
|
|
48
|
+
if (url === proxyUrl)
|
|
49
|
+
return { proxy: "ripple", url, stale: false };
|
|
50
|
+
return { proxy: "other", url, stale: ap.ours };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Claude Code always goes through the router in Ripple mode; Claude Desktop itself only when picker
|
|
54
|
+
* mode is on (the Code tab's sessions read settings.json either way). Nothing through us is
|
|
55
|
+
* "direct"; anything in between is "mixed".
|
|
56
|
+
*/
|
|
57
|
+
export function classifyMode(cli, desktop, pickerEnabled) {
|
|
58
|
+
const cliRipple = cli === "ripple";
|
|
59
|
+
const desktopRipple = desktop === "ripple";
|
|
60
|
+
if (cliRipple && (desktopRipple || !pickerEnabled))
|
|
61
|
+
return "ripple";
|
|
62
|
+
if (!cliRipple && !desktopRipple)
|
|
63
|
+
return "direct";
|
|
64
|
+
return "mixed";
|
|
65
|
+
}
|
|
66
|
+
function configFile(home) {
|
|
67
|
+
return path.join(home, "config.json");
|
|
68
|
+
}
|
|
69
|
+
async function snapshot(deps) {
|
|
70
|
+
const installed = fs.existsSync(configFile(deps.home));
|
|
71
|
+
const cfg = new ConfigStore(configFile(deps.home)).get();
|
|
72
|
+
const proxyUrl = proxyUrlFor(cfg.listen.port);
|
|
73
|
+
const adminUrl = `http://127.0.0.1:${adminPort(cfg)}`;
|
|
74
|
+
const caPath = certPaths(deps.home).caPem;
|
|
75
|
+
const status = await (deps.routerStatus ?? fetchRouterStatus)(adminUrl, 4_000);
|
|
76
|
+
const view = deps.supervisor.inspect();
|
|
77
|
+
const cli = cliProxyState(settingsEnv(), proxyUrl, caPath);
|
|
78
|
+
const desktop = desktopProxyState(currentAppProxy(), proxyUrl);
|
|
79
|
+
const pickerEnabled = cfg.picker?.enabled === true;
|
|
80
|
+
const mode = classifyMode(cli.proxy, desktop.proxy, pickerEnabled);
|
|
81
|
+
const problems = [];
|
|
82
|
+
const add = (code, message) => void problems.push({ code, message });
|
|
83
|
+
if (!installed)
|
|
84
|
+
add("not-installed", `ClaudeRipple is not installed here (no ${configFile(deps.home)}). Run: clauderipple install`);
|
|
85
|
+
if (!view.registered)
|
|
86
|
+
add("supervisor-missing", "The router is not registered to start at login, so nothing starts it. Repair registers it again.");
|
|
87
|
+
else if (!view.programExists)
|
|
88
|
+
add("supervisor-program-missing", `The router's startup entry points at a program that no longer exists (${view.program ?? "none"}). Repair points it at this installation.`);
|
|
89
|
+
else if (!view.current)
|
|
90
|
+
add("supervisor-other-install", `The router's startup entry belongs to another ClaudeRipple installation (${view.program}).`);
|
|
91
|
+
if (!status) {
|
|
92
|
+
add("router-down", `The router is not answering on ${adminUrl}. ${mode === "direct" ? "Claude is on its default connection, so it is not affected." : "Claude Desktop and Claude Code cannot connect while it is down."}`);
|
|
93
|
+
if (view.registered && view.state !== "running")
|
|
94
|
+
add("supervisor-not-running", "The router's background service is not running.");
|
|
95
|
+
}
|
|
96
|
+
else if (status.version && status.version !== deps.version) {
|
|
97
|
+
add("router-version-mismatch", `The running router is ${status.version}; this ClaudeRipple is ${deps.version}.`);
|
|
98
|
+
}
|
|
99
|
+
if (cli.stale)
|
|
100
|
+
add("cli-port-mismatch", `Claude Code is set to a ClaudeRipple port that is no longer used (${cli.url}; the router listens on ${proxyUrl}).`);
|
|
101
|
+
else if (cli.proxy === "other")
|
|
102
|
+
add("cli-foreign-proxy", `Claude Code goes through another proxy (${cli.url}), not ClaudeRipple.`);
|
|
103
|
+
if (desktop.stale)
|
|
104
|
+
add("desktop-port-mismatch", `Claude Desktop is set to a ClaudeRipple port that is no longer used (${desktop.url}; the router listens on ${proxyUrl}).`);
|
|
105
|
+
else if (desktop.proxy === "other")
|
|
106
|
+
add("desktop-foreign-proxy", `Claude Desktop goes through another proxy (${desktop.url}), not ClaudeRipple.`);
|
|
107
|
+
if (mode === "mixed")
|
|
108
|
+
add("mixed-mode", `Claude Code ${cli.proxy === "ripple" ? "goes" : "does not go"} through ClaudeRipple, Claude Desktop ${desktop.proxy === "ripple" ? "does" : "does not"}.`);
|
|
109
|
+
return {
|
|
110
|
+
report: {
|
|
111
|
+
mode,
|
|
112
|
+
router: { responding: status !== null, version: status?.version ?? null, pid: view.pid, url: proxyUrl, adminUrl },
|
|
113
|
+
supervisor: { registered: view.registered, program: view.program, programExists: view.programExists, state: view.state },
|
|
114
|
+
desktop: { proxy: desktop.proxy, url: desktop.url },
|
|
115
|
+
cli: { proxy: cli.proxy, url: cli.url },
|
|
116
|
+
problems,
|
|
117
|
+
},
|
|
118
|
+
installed,
|
|
119
|
+
cfg,
|
|
120
|
+
view,
|
|
121
|
+
caPath,
|
|
122
|
+
cliStale: cli.stale,
|
|
123
|
+
desktopStale: desktop.stale,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export async function doctor(deps) {
|
|
127
|
+
return (await snapshot(deps)).report;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Fixes what doctor finds, in order, and is safe to run again: re-register the supervisor for this
|
|
131
|
+
* installation, start it, wait for it to answer, and correct a Claude proxy setting that is ours but
|
|
132
|
+
* names an old port. It never switches between Ripple and direct mode (that is `direct on|off`) and
|
|
133
|
+
* never touches a proxy setting that is not ours.
|
|
134
|
+
*/
|
|
135
|
+
export async function repair(deps) {
|
|
136
|
+
const sleep = deps.sleep ?? sleepDefault;
|
|
137
|
+
const status = deps.routerStatus ?? fetchRouterStatus;
|
|
138
|
+
const steps = [];
|
|
139
|
+
const before = await snapshot(deps);
|
|
140
|
+
if (!before.installed) {
|
|
141
|
+
steps.push({ name: "config", ok: false, detail: `no ${configFile(deps.home)}; run \`clauderipple install\` first` });
|
|
142
|
+
return { ok: false, steps, doctor: before.report };
|
|
143
|
+
}
|
|
144
|
+
const responding = before.report.router.responding;
|
|
145
|
+
const view = before.view;
|
|
146
|
+
// A working router is never cut off for a definition that merely differs: re-registering unloads
|
|
147
|
+
// the running job, which drains it and drops every Claude connection (ARCHITECTURE §5).
|
|
148
|
+
const mustRegister = !view.registered || !view.programExists || (!view.current && !responding);
|
|
149
|
+
if (mustRegister) {
|
|
150
|
+
try {
|
|
151
|
+
const where = deps.supervisor.register();
|
|
152
|
+
steps.push({ name: "supervisor", ok: true, detail: `registered for this installation: ${where}` });
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
steps.push({ name: "supervisor", ok: false, detail: e.message });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
steps.push({
|
|
160
|
+
name: "supervisor",
|
|
161
|
+
ok: true,
|
|
162
|
+
detail: view.current ? "already registered for this installation" : `left as it is: the router answers, and its entry names another installation (${view.program}); run \`clauderipple install\` to move it`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (responding) {
|
|
166
|
+
steps.push({ name: "start", ok: true, detail: "the router is already answering" });
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const started = deps.supervisor.start();
|
|
170
|
+
steps.push({ name: "start", ok: started !== "failed", detail: started === "failed" ? "the supervisor could not start the router" : started === "already-running" ? "the supervisor reports it running" : "started" });
|
|
171
|
+
const adminUrl = before.report.router.adminUrl;
|
|
172
|
+
const budget = deps.healthBudgetMs ?? 20_000;
|
|
173
|
+
const t0 = Date.now();
|
|
174
|
+
let up = false;
|
|
175
|
+
while (!up) {
|
|
176
|
+
up = (await status(adminUrl, 3_000)) !== null;
|
|
177
|
+
if (up || Date.now() - t0 >= budget)
|
|
178
|
+
break;
|
|
179
|
+
await sleep(1_000);
|
|
180
|
+
}
|
|
181
|
+
steps.push({ name: "health", ok: up, detail: up ? `answering on ${adminUrl} after ${Math.round((Date.now() - t0) / 100) / 10}s` : `still not answering on ${adminUrl} after ${Math.round(budget / 1000)}s; see ${path.join(deps.home, "logs", "router.log")}` });
|
|
182
|
+
}
|
|
183
|
+
const proxyUrl = proxyUrlFor(before.cfg.listen.port);
|
|
184
|
+
if (before.cliStale) {
|
|
185
|
+
try {
|
|
186
|
+
// Ours by our CA beside a loopback URL (cliProxyState), so replacing it is not overriding anyone.
|
|
187
|
+
const edit = applyProxyEnv({ proxyUrl, caPath: before.caPath, force: true });
|
|
188
|
+
steps.push({ name: "cli-proxy", ok: true, detail: `Claude Code now points at ${proxyUrl}${edit.backup ? ` (backup: ${edit.backup})` : ""}` });
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
steps.push({ name: "cli-proxy", ok: false, detail: e.message });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (before.desktopStale) {
|
|
195
|
+
try {
|
|
196
|
+
applyAppProxy(proxyUrl);
|
|
197
|
+
steps.push({ name: "desktop-proxy", ok: true, detail: `Claude Desktop now points at ${proxyUrl} (quit and reopen it to apply)` });
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
steps.push({ name: "desktop-proxy", ok: false, detail: e.message });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const after = await doctor(deps);
|
|
204
|
+
return { ok: steps.every((s) => s.ok) && after.router.responding, steps, doctor: after };
|
|
205
|
+
}
|
package/dist/cli/src/index.js
CHANGED
|
@@ -15,7 +15,9 @@ import { adminPort } from "../../router/src/admin.js";
|
|
|
15
15
|
import { certsExist, certPaths, generateCerts } from "./certs.js";
|
|
16
16
|
import { applyProxyEnv, checkProxyEnv, currentProxyEnv, removeProxyEnv, settingsPath } from "./settings.js";
|
|
17
17
|
import { agentDefinitionPath, agentState, installAgent, isLinux, isSupported, isWindows, removeAgent, restartAgent, startAgent, stopAgent, supervisorName } from "./supervisor.js";
|
|
18
|
-
import {
|
|
18
|
+
import { removeBundle, writeBundle } from "./bundle.js";
|
|
19
|
+
import { nativeSupervisor, registration, writePaths } from "./registration.js";
|
|
20
|
+
import { installLauncher, uninstallLauncher } from "./launcher.js";
|
|
19
21
|
import { applyAppProxy, caTrusted, currentAppProxy, nssDb, removeAppProxy, trustCa, untrustCa } from "./picker.js";
|
|
20
22
|
import { runtime } from "./runtime.js";
|
|
21
23
|
import { codexOff, codexOn } from "./codex.js";
|
|
@@ -201,37 +203,17 @@ async function install() {
|
|
|
201
203
|
checkProxyEnv({ proxyUrl, force: flag("force") });
|
|
202
204
|
// Persist the single execution contract so the GUI, admin API, hooks, and supervisor all use
|
|
203
205
|
// this installation's runtime rather than any Node installation on the user's PATH.
|
|
204
|
-
|
|
205
|
-
node: installedRuntime.node,
|
|
206
|
-
env: installedRuntime.env,
|
|
207
|
-
cli: installedRuntime.cli,
|
|
208
|
-
router: installedRuntime.router,
|
|
209
|
-
hookScript: installedRuntime.hookScript,
|
|
210
|
-
repo: installedRuntime.repo,
|
|
211
|
-
}, null, 2) + "\n");
|
|
206
|
+
writePaths(installedRuntime, home);
|
|
212
207
|
// Windows and Linux register node + the router script directly (schtasks.ts writes its own .cmd
|
|
213
208
|
// launcher); a macOS source checkout gets a small .app so the agent shows a real name in Login Items.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
bundleId: "com.clauderipple.app",
|
|
219
|
-
home,
|
|
220
|
-
env: { ...installedRuntime.env, ...(process.env.CLAUDE_SETTINGS_PATH ? { CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH } : {}) },
|
|
221
|
-
});
|
|
222
|
-
console.log(`✓ ${supervisorName()} registered: ${plist}${isLinux ? "" : ' (shows as "ClaudeRipple" in Login Items)'}`);
|
|
223
|
-
}
|
|
224
|
-
else {
|
|
225
|
-
const launcher = writeBundle({ home, node: installedRuntime.node, script: installedRuntime.router, version: VERSION });
|
|
209
|
+
// registration.ts holds the mapping, so `repair` registers exactly the same thing.
|
|
210
|
+
const reg = registration(installedRuntime, home, VERSION);
|
|
211
|
+
if (reg.bundle) {
|
|
212
|
+
const launcher = writeBundle({ home, node: reg.bundle.node, script: reg.bundle.script, version: reg.bundle.version });
|
|
226
213
|
console.log(`✓ background item bundle written: ${path.dirname(path.dirname(path.dirname(launcher)))} (shows as "ClaudeRipple" in Login Items)`);
|
|
227
|
-
const plist = installAgent({
|
|
228
|
-
program: launcher,
|
|
229
|
-
bundleId: BUNDLE_ID,
|
|
230
|
-
home,
|
|
231
|
-
...(process.env.CLAUDE_SETTINGS_PATH ? { env: { CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH } } : {}),
|
|
232
|
-
});
|
|
233
|
-
console.log(`✓ ${supervisorName()} registered: ${plist}`);
|
|
234
214
|
}
|
|
215
|
+
const plist = installAgent({ program: reg.program, args: reg.args, bundleId: reg.bundleId, home, env: reg.env });
|
|
216
|
+
console.log(`✓ ${supervisorName()} registered: ${plist}${isLinux || reg.bundle ? "" : ' (shows as "ClaudeRipple" in Login Items)'}`);
|
|
235
217
|
// launchd starts the agent the moment it is bootstrapped (RunAtLoad); a scheduled task waits for
|
|
236
218
|
// its logon trigger, so the router would only appear after the next sign-in. Start it either way.
|
|
237
219
|
const started = startAgent();
|
|
@@ -257,9 +239,21 @@ async function install() {
|
|
|
257
239
|
console.log(edit.changed ? `✓ ${settingsPath()} updated (backup: ${edit.backup ?? "none"})` : `✓ ${settingsPath()} already correct`);
|
|
258
240
|
for (const n of edit.notes)
|
|
259
241
|
console.log(` note: ${n}`);
|
|
242
|
+
// Something to double-click for the menu-bar app, which is what offers repair when the router is
|
|
243
|
+
// down. Never fatal: the router and Claude's settings are already in place.
|
|
244
|
+
try {
|
|
245
|
+
const app = installLauncher({ rt: installedRuntime, home, version: VERSION });
|
|
246
|
+
if (app.paths.length)
|
|
247
|
+
console.log(`✓ menu-bar app launcher: ${app.paths.join(", ")}`);
|
|
248
|
+
else if (!app.ok)
|
|
249
|
+
console.log(` menu-bar app launcher not written: ${app.detail}`);
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
console.log(` menu-bar app launcher not written: ${e.message}`);
|
|
253
|
+
}
|
|
260
254
|
console.log("\nDone. New Claude Desktop Code sessions go through ClaudeRipple. Existing sessions pick up settings.json env changes on their next request.");
|
|
261
255
|
}
|
|
262
|
-
function uninstall() {
|
|
256
|
+
async function uninstall() {
|
|
263
257
|
const home = homeDir();
|
|
264
258
|
const cfg = new ConfigStore(configPath()).get();
|
|
265
259
|
// Stop the router before unregistering it. On macOS unloading the agent takes the process with
|
|
@@ -268,9 +262,19 @@ function uninstall() {
|
|
|
268
262
|
// Windows 11 arm64 VM).
|
|
269
263
|
if (stopAgent())
|
|
270
264
|
console.log("✓ router stopped");
|
|
265
|
+
// Before --purge can take the journal that says whether the router left sleep disabled.
|
|
266
|
+
await releaseHeldSleep("uninstalled");
|
|
271
267
|
const removed = removeAgent();
|
|
272
268
|
console.log(removed ? `✓ ${supervisorName()} removed (${agentDefinitionPath()})` : `✓ no ${supervisorName()} registered`);
|
|
273
269
|
removeBundle(home);
|
|
270
|
+
try {
|
|
271
|
+
const app = uninstallLauncher({ rt: installedRuntime, home, version: VERSION });
|
|
272
|
+
if (app.paths.length)
|
|
273
|
+
console.log(`✓ menu-bar app launcher removed (${app.paths.join(", ")})`);
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
console.log(` could not remove the menu-bar app launcher: ${e.message}`);
|
|
277
|
+
}
|
|
274
278
|
const edit = removeProxyEnv({ proxyUrl: proxyUrlFor(cfg.listen.port), caPath: certPaths(home).caPem });
|
|
275
279
|
console.log(edit.changed ? `✓ ${settingsPath()} restored (backup: ${edit.backup ?? "none"})` : `✓ ${settingsPath()} had no ClaudeRipple keys`);
|
|
276
280
|
for (const n of edit.notes)
|
|
@@ -443,12 +447,126 @@ async function update() {
|
|
|
443
447
|
if (!result.ok)
|
|
444
448
|
process.exitCode = 1;
|
|
445
449
|
}
|
|
450
|
+
// ---- Recovery: doctor, repair, direct, app ------------------------------------------------
|
|
451
|
+
//
|
|
452
|
+
// The menu-bar app calls these when the router is down and the dashboard with it. With --json each
|
|
453
|
+
// prints one JSON object on stdout and exits 0 whatever the diagnosis; a non-zero exit means the
|
|
454
|
+
// command itself could not run.
|
|
455
|
+
/** This machine's supervisor, registering this installation's runtime and files. */
|
|
456
|
+
function recoveryDeps() {
|
|
457
|
+
const home = homeDir();
|
|
458
|
+
const ops = nativeSupervisor(registration(installedRuntime, home, VERSION), home);
|
|
459
|
+
return {
|
|
460
|
+
home,
|
|
461
|
+
version: VERSION,
|
|
462
|
+
supervisor: {
|
|
463
|
+
inspect: () => ops.inspect(),
|
|
464
|
+
// paths.json is part of what install registers: the GUI and hooks run what it names.
|
|
465
|
+
register: () => {
|
|
466
|
+
writePaths(installedRuntime, home);
|
|
467
|
+
return ops.register();
|
|
468
|
+
},
|
|
469
|
+
start: () => ops.start(),
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function printDoctor(d) {
|
|
474
|
+
const sup = d.supervisor;
|
|
475
|
+
const rows = [
|
|
476
|
+
["mode", d.mode === "ripple" ? "ClaudeRipple" : d.mode === "direct" ? "Claude's default connection (direct)" : "mixed"],
|
|
477
|
+
["router", d.router.responding ? `answering${d.router.version ? ` (${d.router.version}${d.router.pid ? `, pid ${d.router.pid}` : ""})` : ""} on ${d.router.url}` : `NOT answering (${d.router.adminUrl})`],
|
|
478
|
+
[supervisorName(), sup.registered ? `registered · ${sup.state} · ${sup.programExists ? "program ok" : "PROGRAM MISSING"} (${sup.program ?? "-"})` : "NOT registered"],
|
|
479
|
+
["claude code", `${d.cli.proxy}${d.cli.url ? ` (${d.cli.url})` : ""}`],
|
|
480
|
+
["claude desktop", `${d.desktop.proxy}${d.desktop.url ? ` (${d.desktop.url})` : ""}`],
|
|
481
|
+
];
|
|
482
|
+
const w = Math.max(...rows.map((r) => r[0].length));
|
|
483
|
+
for (const [k, v] of rows)
|
|
484
|
+
console.log(`${k.padEnd(w)} ${v}`);
|
|
485
|
+
if (d.problems.length === 0)
|
|
486
|
+
console.log("\n✓ no problems found");
|
|
487
|
+
else {
|
|
488
|
+
console.log("\nproblems:");
|
|
489
|
+
for (const p of d.problems)
|
|
490
|
+
console.log(` ✗ ${p.message} [${p.code}]`);
|
|
491
|
+
console.log("\nFix what can be fixed with: clauderipple repair");
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function recovery() {
|
|
495
|
+
const json = flag("json");
|
|
496
|
+
const out = (value, text) => {
|
|
497
|
+
if (json)
|
|
498
|
+
console.log(JSON.stringify(value));
|
|
499
|
+
else {
|
|
500
|
+
text();
|
|
501
|
+
if ("ok" in value && value.ok === false)
|
|
502
|
+
process.exitCode = 1;
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
if (cmd === "doctor") {
|
|
506
|
+
const { doctor } = await import("./doctor.js");
|
|
507
|
+
const d = await doctor(recoveryDeps());
|
|
508
|
+
out(d, () => printDoctor(d));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (cmd === "repair") {
|
|
512
|
+
const { repair } = await import("./doctor.js");
|
|
513
|
+
const r = await repair(recoveryDeps());
|
|
514
|
+
out(r, () => {
|
|
515
|
+
for (const s of r.steps)
|
|
516
|
+
console.log(`${s.ok ? "✓" : "✗"} ${s.name}: ${s.detail}`);
|
|
517
|
+
console.log("");
|
|
518
|
+
printDoctor(r.doctor);
|
|
519
|
+
});
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (cmd === "direct") {
|
|
523
|
+
const sub = args[1];
|
|
524
|
+
if (sub !== "on" && sub !== "off")
|
|
525
|
+
throw new Error("usage: clauderipple direct on|off [--force] [--json]");
|
|
526
|
+
const { directOn, directOff } = await import("./direct.js");
|
|
527
|
+
const r = sub === "on" ? directOn(homeDir()) : await directOff(recoveryDeps(), { force: flag("force") });
|
|
528
|
+
out(r, () => {
|
|
529
|
+
console.log(`${r.ok ? "✓" : "✗"} ${r.detail}`);
|
|
530
|
+
if (r.ok)
|
|
531
|
+
console.log("Quit and reopen Claude Desktop, and start new Claude Code sessions: both read this at start.");
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (cmd === "app") {
|
|
536
|
+
const install = flag("install");
|
|
537
|
+
if (install === flag("uninstall"))
|
|
538
|
+
throw new Error("usage: clauderipple app --install|--uninstall [--json]");
|
|
539
|
+
const o = { rt: installedRuntime, home: homeDir(), version: VERSION };
|
|
540
|
+
const r = install ? installLauncher(o) : uninstallLauncher(o);
|
|
541
|
+
out(r, () => console.log(`${r.ok ? "✓" : "✗"} ${r.detail}${r.paths.length ? `\n ${r.paths.join("\n ")}` : ""}`));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* A router that exits leaves the lid-closed sleep bit as it was, for its successor to reconcile
|
|
546
|
+
* (router awake.ts). When it is stopped for good there is no successor, so clear it here.
|
|
547
|
+
*/
|
|
548
|
+
async function releaseHeldSleep(reason) {
|
|
549
|
+
const { releaseAwake, releaseFailedHint } = await import("./awake-setup.js");
|
|
550
|
+
const { execRunner } = await import("../../router/src/awake.js");
|
|
551
|
+
const r = await releaseAwake(execRunner(), homeDir(), reason);
|
|
552
|
+
if (r === "released")
|
|
553
|
+
console.log(process.platform === "win32" ? "✓ the lid-close action is back to yours (lid-closed awake had set it to Do nothing)" : "✓ sleep re-enabled (lid-closed awake was holding the Mac awake)");
|
|
554
|
+
if (r === "failed")
|
|
555
|
+
console.log(`✗ could not re-enable sleep: ${releaseFailedHint()}`);
|
|
556
|
+
}
|
|
446
557
|
function help() {
|
|
447
558
|
console.log(`clauderipple <command>
|
|
448
559
|
|
|
449
560
|
install [--port N] [--force] [--max-context-tokens N]
|
|
450
561
|
uninstall [--purge]
|
|
451
562
|
status
|
|
563
|
+
doctor [--json] what is wrong with the router, its background service and Claude's connection settings
|
|
564
|
+
repair [--json] fix what doctor finds: re-register and start the router, wait for it to answer
|
|
565
|
+
direct on|off [--force] [--json]
|
|
566
|
+
on: use Claude's default connection (Claude Desktop and Claude Code skip ClaudeRipple;
|
|
567
|
+
the router stays installed); off: go through ClaudeRipple again. Restart Claude after either.
|
|
568
|
+
app --install|--uninstall [--json]
|
|
569
|
+
a double-clickable launcher for the menu-bar app (~/Applications, Start Menu, app menu)
|
|
452
570
|
start | stop | restart
|
|
453
571
|
logs [-n N] [-f]
|
|
454
572
|
remote [install|uninstall|status|pair] run or manage the phone host (macOS, Windows, Linux systemd)
|
|
@@ -474,6 +592,14 @@ function help() {
|
|
|
474
592
|
agent-title on|off|status
|
|
475
593
|
prefix subagent titles with the real model and thinking depth ("Terra·high · …") via a Claude Code hook
|
|
476
594
|
mod on|off|status show where each request went above the Claude Code prompt, and the request log in a pane (/ripple-log), via a Claude Code mod
|
|
595
|
+
awake setup [--ssid <hotspot name>] [--remove]
|
|
596
|
+
allow ClaudeRipple to keep the laptop awake with the lid closed (and join that phone hotspot).
|
|
597
|
+
macOS: asks for your password once in macOS's own dialog, and removes Insomnia if it is installed;
|
|
598
|
+
Windows: no administrator step, only checks (power plan, Wi-Fi adapter, saved hotspot profile)
|
|
599
|
+
awake on|off|working [--until HH:MM]
|
|
600
|
+
working: awake while Claude works and 10 min after; on: always (until HH:MM); off: never
|
|
601
|
+
tether on|off join the phone hotspot when Wi-Fi has no internet, and go back to Wi-Fi when a known one works
|
|
602
|
+
power [--json] lid-closed awake and hotspot state
|
|
477
603
|
|
|
478
604
|
Home directory: ${homeDir()} (override with CLAUDERIPPLE_HOME)`);
|
|
479
605
|
}
|
|
@@ -520,11 +646,17 @@ try {
|
|
|
520
646
|
await install();
|
|
521
647
|
break;
|
|
522
648
|
case "uninstall":
|
|
523
|
-
uninstall();
|
|
649
|
+
await uninstall();
|
|
524
650
|
break;
|
|
525
651
|
case "status":
|
|
526
652
|
await status();
|
|
527
653
|
break;
|
|
654
|
+
case "doctor":
|
|
655
|
+
case "repair":
|
|
656
|
+
case "direct":
|
|
657
|
+
case "app":
|
|
658
|
+
await recovery();
|
|
659
|
+
break;
|
|
528
660
|
case "start": {
|
|
529
661
|
const r = startAgent();
|
|
530
662
|
console.log(r === "already-running" ? "already running" : r === "started" ? "started" : "start failed (is it installed?)");
|
|
@@ -535,6 +667,7 @@ try {
|
|
|
535
667
|
break;
|
|
536
668
|
case "stop":
|
|
537
669
|
console.log(stopAgent() ? "stopped (run `clauderipple start` or `install` to bring it back)" : "stop failed (not loaded?)");
|
|
670
|
+
await releaseHeldSleep("router stopped");
|
|
538
671
|
break;
|
|
539
672
|
case "logs":
|
|
540
673
|
logs();
|
|
@@ -684,6 +817,24 @@ try {
|
|
|
684
817
|
case "image":
|
|
685
818
|
await image();
|
|
686
819
|
break;
|
|
820
|
+
case "power":
|
|
821
|
+
case "awake":
|
|
822
|
+
case "tether": {
|
|
823
|
+
const { powerCli, adminPowerCall } = await import("./power.js");
|
|
824
|
+
const { defaultSetupDeps } = await import("./awake-setup.js");
|
|
825
|
+
const { execRunner } = await import("../../router/src/awake.js");
|
|
826
|
+
const run = execRunner();
|
|
827
|
+
process.exitCode = await powerCli(args, {
|
|
828
|
+
call: adminPowerCall(adminPort(new ConfigStore(configPath()).get())),
|
|
829
|
+
configFile: configPath(),
|
|
830
|
+
rippleHome: homeDir(),
|
|
831
|
+
run,
|
|
832
|
+
setupDeps: () => defaultSetupDeps(run, homeDir()),
|
|
833
|
+
print: (line) => console.log(line),
|
|
834
|
+
now: () => new Date(),
|
|
835
|
+
});
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
687
838
|
case "ui":
|
|
688
839
|
ui();
|
|
689
840
|
break;
|
|
@@ -691,7 +842,9 @@ try {
|
|
|
691
842
|
await update();
|
|
692
843
|
break;
|
|
693
844
|
case "tray": {
|
|
694
|
-
|
|
845
|
+
// --ensure is what the app launcher runs: fetch Electron once if it is missing, then start.
|
|
846
|
+
const ensured = args.includes("--ensure") && !installedRuntime.packaged ? installTrayRuntime() : null;
|
|
847
|
+
const result = args.includes("--install") ? installTrayRuntime() : ensured && !ensured.ok ? ensured : await startTray();
|
|
695
848
|
console.log(result.message);
|
|
696
849
|
if (!result.ok)
|
|
697
850
|
process.exit(1);
|
package/dist/cli/src/launchd.js
CHANGED
|
@@ -10,8 +10,10 @@ export const LABEL = "com.clauderipple.router";
|
|
|
10
10
|
export function launchdLabel() {
|
|
11
11
|
return process.env.CLAUDERIPPLE_LAUNCHD_LABEL ?? LABEL;
|
|
12
12
|
}
|
|
13
|
+
/** Test-only override (CLAUDERIPPLE_LAUNCH_AGENTS_DIR); production always uses ~/Library/LaunchAgents. */
|
|
13
14
|
export function plistPath() {
|
|
14
|
-
|
|
15
|
+
const dir = process.env.CLAUDERIPPLE_LAUNCH_AGENTS_DIR ?? path.join(os.homedir(), "Library", "LaunchAgents");
|
|
16
|
+
return path.join(dir, `${launchdLabel()}.plist`);
|
|
15
17
|
}
|
|
16
18
|
function esc(s) {
|
|
17
19
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -60,49 +62,56 @@ ${environment}
|
|
|
60
62
|
</plist>
|
|
61
63
|
`;
|
|
62
64
|
}
|
|
63
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The real launchctl, with a timeout: the menu-bar app runs doctor/repair and must get an answer.
|
|
67
|
+
* CLAUDERIPPLE_LAUNCHCTL is a test-only override naming a fake binary for end-to-end CLI tests.
|
|
68
|
+
*/
|
|
69
|
+
export const launchctl = (args) => {
|
|
64
70
|
try {
|
|
65
|
-
|
|
71
|
+
// bootout waits for the router to exit, and a router drains for up to 90s (ExitTimeOut 120):
|
|
72
|
+
// that one call keeps a budget the drain fits in, so `stop` never reports a drain as a failure.
|
|
73
|
+
const timeout = args[0] === "bootout" ? 150_000 : 20_000;
|
|
74
|
+
const out = execFileSync(process.env.CLAUDERIPPLE_LAUNCHCTL ?? "launchctl", args, { stdio: ["ignore", "pipe", "pipe"], timeout }).toString();
|
|
66
75
|
return { ok: true, out };
|
|
67
76
|
}
|
|
68
77
|
catch (e) {
|
|
69
78
|
const err = e;
|
|
70
|
-
return { ok: false, out: `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim() };
|
|
79
|
+
return { ok: false, out: `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim() || (err.message ?? "launchctl failed") };
|
|
71
80
|
}
|
|
72
|
-
}
|
|
81
|
+
};
|
|
73
82
|
function domain() {
|
|
74
83
|
return `gui/${process.getuid?.() ?? 501}`;
|
|
75
84
|
}
|
|
76
|
-
export function installAgent(opts) {
|
|
85
|
+
export function installAgent(opts, run = launchctl) {
|
|
77
86
|
const file = plistPath();
|
|
78
87
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
79
88
|
const stdoutLog = path.join(opts.home, "logs", "launchd.log");
|
|
80
89
|
fs.mkdirSync(path.dirname(stdoutLog), { recursive: true });
|
|
81
90
|
fs.writeFileSync(file, renderPlist({ ...opts, stdoutLog }));
|
|
82
|
-
|
|
83
|
-
const r =
|
|
91
|
+
run(["bootout", domain(), file]); // ignore result: may not be loaded
|
|
92
|
+
const r = run(["bootstrap", domain(), file]);
|
|
84
93
|
if (!r.ok)
|
|
85
94
|
throw new Error(`launchctl bootstrap failed: ${r.out}`);
|
|
86
95
|
return file;
|
|
87
96
|
}
|
|
88
|
-
export function removeAgent() {
|
|
97
|
+
export function removeAgent(run = launchctl) {
|
|
89
98
|
const file = plistPath();
|
|
90
99
|
if (!fs.existsSync(file))
|
|
91
100
|
return false;
|
|
92
|
-
|
|
101
|
+
run(["bootout", domain(), file]);
|
|
93
102
|
fs.unlinkSync(file);
|
|
94
103
|
return true;
|
|
95
104
|
}
|
|
96
|
-
export function agentState() {
|
|
97
|
-
const r =
|
|
105
|
+
export function agentState(run = launchctl) {
|
|
106
|
+
const r = run(["print", `${domain()}/${launchdLabel()}`]);
|
|
98
107
|
if (!r.ok)
|
|
99
108
|
return "not-loaded";
|
|
100
109
|
return /state = running/.test(r.out) ? "running" : "loaded";
|
|
101
110
|
}
|
|
102
111
|
/** `force` adds -k, which SIGKILLs a running instance ~5s after SIGTERM. Only restart wants that. */
|
|
103
|
-
export function kickstart(opts = {}) {
|
|
112
|
+
export function kickstart(opts = {}, run = launchctl) {
|
|
104
113
|
const args = ["kickstart", ...(opts.force ? ["-k"] : []), `${domain()}/${launchdLabel()}`];
|
|
105
|
-
return
|
|
114
|
+
return run(args).ok;
|
|
106
115
|
}
|
|
107
116
|
/**
|
|
108
117
|
* Idempotent start. A router that is already up (or still coming up) is left alone: `kickstart -k`
|
|
@@ -110,18 +119,18 @@ export function kickstart(opts = {}) {
|
|
|
110
119
|
* up killed it and started the wait over (observed 2026-09-14: three runs in the four minutes
|
|
111
120
|
* after login). Re-bootstraps the plist when the agent was unloaded by `clauderipple stop`.
|
|
112
121
|
*/
|
|
113
|
-
export function startAgent() {
|
|
114
|
-
if (agentState() === "running")
|
|
122
|
+
export function startAgent(run = launchctl) {
|
|
123
|
+
if (agentState(run) === "running")
|
|
115
124
|
return "already-running";
|
|
116
|
-
if (kickstart())
|
|
125
|
+
if (kickstart({}, run))
|
|
117
126
|
return "started";
|
|
118
127
|
const file = plistPath();
|
|
119
|
-
if (fs.existsSync(file) &&
|
|
128
|
+
if (fs.existsSync(file) && run(["bootstrap", domain(), file]).ok)
|
|
120
129
|
return "started";
|
|
121
130
|
return "failed";
|
|
122
131
|
}
|
|
123
|
-
export function agentPid() {
|
|
124
|
-
const r =
|
|
132
|
+
export function agentPid(run = launchctl) {
|
|
133
|
+
const r = run(["print", `${domain()}/${launchdLabel()}`]);
|
|
125
134
|
if (!r.ok)
|
|
126
135
|
return null;
|
|
127
136
|
const m = /^\s*pid = (\d+)/m.exec(r.out);
|