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.
Files changed (78) hide show
  1. package/CHANGELOG.md +51 -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 +39 -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/index.js +184 -31
  39. package/dist/cli/src/launchd.js +29 -20
  40. package/dist/cli/src/launcher.js +215 -0
  41. package/dist/cli/src/power.js +182 -0
  42. package/dist/cli/src/registration.js +157 -0
  43. package/dist/cli/src/schtasks.js +2 -0
  44. package/dist/cli/src/settings.js +24 -0
  45. package/dist/cli/src/tray.js +5 -2
  46. package/dist/mod/hooks/i18n.ts +237 -237
  47. package/dist/mod/hooks/register.tsx +4 -5
  48. package/dist/mod/hooks/session.ts +6 -2
  49. package/dist/mod/hooks/ui/agents.tsx +81 -36
  50. package/dist/mod/hooks/ui/view.ts +8 -5
  51. package/dist/mod/tests/ui.test.tsx +46 -13
  52. package/dist/mod/types/index.d.ts +0 -2
  53. package/dist/remote/src/bridge-client.js +45 -4
  54. package/dist/router/src/admin.js +105 -2
  55. package/dist/router/src/awake-win.js +590 -0
  56. package/dist/router/src/awake.js +432 -0
  57. package/dist/router/src/config.js +64 -0
  58. package/dist/router/src/health.js +86 -3
  59. package/dist/router/src/hold.js +107 -0
  60. package/dist/router/src/index.js +69 -6
  61. package/dist/router/src/ingress/server.js +26 -4
  62. package/dist/router/src/power.js +107 -0
  63. package/dist/router/src/providers/google/antigravity.js +73 -10
  64. package/dist/router/src/providers/google/index.js +168 -17
  65. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  66. package/dist/router/src/providers/google/transport.js +1 -1
  67. package/dist/router/src/providers/google/usage.js +71 -26
  68. package/dist/router/src/providers/retry.js +4 -1
  69. package/dist/router/src/proxy.js +50 -21
  70. package/dist/router/src/tether-win.js +211 -0
  71. package/dist/router/src/tether.js +712 -0
  72. package/dist/router/src/version.js +1 -1
  73. package/dist/ui/app.js +649 -2
  74. package/dist/ui/i18n.js +494 -77
  75. package/dist/ui/index.html +9 -1
  76. package/dist/ui/style.css +138 -0
  77. package/docs/ARCHITECTURE.md +294 -6
  78. 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
+ }
@@ -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 { BUNDLE_ID, removeBundle, writeBundle } from "./bundle.js";
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
- fs.writeFileSync(path.join(home, "paths.json"), JSON.stringify({
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
- if (installedRuntime.packaged || isWindows || isLinux) {
215
- const plist = installAgent({
216
- program: installedRuntime.node,
217
- args: [installedRuntime.router],
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
- const result = args.includes("--install") ? installTrayRuntime() : await startTray();
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);
@@ -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
- return path.join(os.homedir(), "Library", "LaunchAgents", `${launchdLabel()}.plist`);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -60,49 +62,56 @@ ${environment}
60
62
  </plist>
61
63
  `;
62
64
  }
63
- function launchctl(args) {
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
- const out = execFileSync("launchctl", args, { stdio: ["ignore", "pipe", "pipe"] }).toString();
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
- launchctl(["bootout", domain(), file]); // ignore result: may not be loaded
83
- const r = launchctl(["bootstrap", domain(), file]);
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
- launchctl(["bootout", domain(), file]);
101
+ run(["bootout", domain(), file]);
93
102
  fs.unlinkSync(file);
94
103
  return true;
95
104
  }
96
- export function agentState() {
97
- const r = launchctl(["print", `${domain()}/${launchdLabel()}`]);
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 launchctl(args).ok;
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) && launchctl(["bootstrap", domain(), file]).ok)
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 = launchctl(["print", `${domain()}/${launchdLabel()}`]);
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);