clauderipple 0.9.5 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +70 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +133 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/hooks/agent-title.js +7 -4
  39. package/dist/cli/src/index.js +227 -31
  40. package/dist/cli/src/launchd.js +29 -20
  41. package/dist/cli/src/launcher.js +215 -0
  42. package/dist/cli/src/power.js +182 -0
  43. package/dist/cli/src/registration.js +157 -0
  44. package/dist/cli/src/remote.js +263 -0
  45. package/dist/cli/src/schtasks.js +50 -40
  46. package/dist/cli/src/settings.js +26 -1
  47. package/dist/cli/src/tray.js +5 -2
  48. package/dist/mod/hooks/i18n.ts +473 -227
  49. package/dist/mod/hooks/judge.ts +35 -17
  50. package/dist/mod/hooks/modes.ts +56 -1
  51. package/dist/mod/hooks/register.tsx +237 -32
  52. package/dist/mod/hooks/session.ts +102 -1
  53. package/dist/mod/hooks/settings.js +7 -3
  54. package/dist/mod/hooks/settings.ts +9 -4
  55. package/dist/mod/hooks/stats.ts +25 -1
  56. package/dist/mod/hooks/ui/agents.tsx +903 -79
  57. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  58. package/dist/mod/hooks/ui/art.ts +33 -7
  59. package/dist/mod/hooks/ui/band.tsx +262 -81
  60. package/dist/mod/hooks/ui/flat.ts +265 -0
  61. package/dist/mod/hooks/ui/kit.tsx +70 -55
  62. package/dist/mod/hooks/ui/look.ts +129 -0
  63. package/dist/mod/hooks/ui/settings.tsx +71 -46
  64. package/dist/mod/hooks/ui/setup.tsx +15 -13
  65. package/dist/mod/hooks/ui/theme.ts +3 -0
  66. package/dist/mod/hooks/ui/tools.ts +124 -0
  67. package/dist/mod/hooks/ui/view.ts +120 -4
  68. package/dist/mod/hooks/workers.ts +33 -6
  69. package/dist/mod/tests/register.test.tsx +155 -11
  70. package/dist/mod/tests/ui.test.tsx +408 -27
  71. package/dist/mod/types/index.d.ts +66 -2
  72. package/dist/remote/package.json +28 -0
  73. package/dist/remote/src/away.js +85 -0
  74. package/dist/remote/src/bridge-client.js +336 -0
  75. package/dist/remote/src/composer-menu.js +11 -0
  76. package/dist/remote/src/config.js +102 -0
  77. package/dist/remote/src/desktop.js +1018 -0
  78. package/dist/remote/src/e2ee.js +126 -0
  79. package/dist/remote/src/e2ee.ts +126 -0
  80. package/dist/remote/src/eventlog.js +298 -0
  81. package/dist/remote/src/expo-push.js +127 -0
  82. package/dist/remote/src/folders.js +79 -0
  83. package/dist/remote/src/fsutil.js +39 -0
  84. package/dist/remote/src/handoff.js +38 -0
  85. package/dist/remote/src/host-service.js +168 -0
  86. package/dist/remote/src/images.js +215 -0
  87. package/dist/remote/src/local-control.js +138 -0
  88. package/dist/remote/src/main.js +264 -0
  89. package/dist/remote/src/permissions.js +124 -0
  90. package/dist/remote/src/power.js +76 -0
  91. package/dist/remote/src/processes.js +24 -0
  92. package/dist/remote/src/protocol.js +32 -0
  93. package/dist/remote/src/protocol.ts +284 -0
  94. package/dist/remote/src/push-content.js +32 -0
  95. package/dist/remote/src/push.js +134 -0
  96. package/dist/remote/src/receipts.js +111 -0
  97. package/dist/remote/src/relay.js +438 -0
  98. package/dist/remote/src/router.js +208 -0
  99. package/dist/remote/src/sdk-launcher.js +18 -0
  100. package/dist/remote/src/server.js +706 -0
  101. package/dist/remote/src/service.js +157 -0
  102. package/dist/remote/src/sessions.js +1800 -0
  103. package/dist/remote/src/sidebar-storage.js +211 -0
  104. package/dist/remote/src/sidebar.js +173 -0
  105. package/dist/remote/src/tool-details.js +205 -0
  106. package/dist/remote/src/tool-details.ts +153 -0
  107. package/dist/remote/src/worktree.js +99 -0
  108. package/dist/remote/web/app.ts +746 -0
  109. package/dist/remote/web/conn.ts +193 -0
  110. package/dist/remote/web/demo.ts +134 -0
  111. package/dist/remote/web/dom.ts +124 -0
  112. package/dist/remote/web/esc.ts +4 -0
  113. package/dist/remote/web/i18n.ts +374 -0
  114. package/dist/remote/web/icon.svg +1 -0
  115. package/dist/remote/web/index.html +24 -0
  116. package/dist/remote/web/manifest.webmanifest +10 -0
  117. package/dist/remote/web/mascot.ts +60 -0
  118. package/dist/remote/web/md.ts +78 -0
  119. package/dist/remote/web/notify.ts +55 -0
  120. package/dist/remote/web/pushstate.ts +27 -0
  121. package/dist/remote/web/secure.ts +109 -0
  122. package/dist/remote/web/sidebar.ts +106 -0
  123. package/dist/remote/web/style.css +311 -0
  124. package/dist/remote/web/sw.ts +48 -0
  125. package/dist/remote/web/timeline.ts +224 -0
  126. package/dist/remote/web/views.ts +323 -0
  127. package/dist/router/src/admin.js +331 -5
  128. package/dist/router/src/awake-win.js +590 -0
  129. package/dist/router/src/awake.js +432 -0
  130. package/dist/router/src/config.js +94 -0
  131. package/dist/router/src/health.js +86 -3
  132. package/dist/router/src/hold.js +107 -0
  133. package/dist/router/src/index.js +108 -6
  134. package/dist/router/src/ingress/server.js +26 -4
  135. package/dist/router/src/ingress/translate.js +6 -2
  136. package/dist/router/src/phone.js +82 -0
  137. package/dist/router/src/power.js +107 -0
  138. package/dist/router/src/providers/anthropic-usage.js +33 -1
  139. package/dist/router/src/providers/google/antigravity.js +73 -10
  140. package/dist/router/src/providers/google/index.js +191 -14
  141. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  142. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  143. package/dist/router/src/providers/google/transport.js +1 -1
  144. package/dist/router/src/providers/google/usage.js +302 -0
  145. package/dist/router/src/providers/grok/index.js +18 -0
  146. package/dist/router/src/providers/grok/usage.js +77 -0
  147. package/dist/router/src/providers/openai/translate.js +13 -2
  148. package/dist/router/src/providers/opencode-usage.js +116 -0
  149. package/dist/router/src/providers/retry.js +4 -1
  150. package/dist/router/src/proxy.js +371 -261
  151. package/dist/router/src/remote-host.js +320 -0
  152. package/dist/router/src/remote-tunnel.js +320 -0
  153. package/dist/router/src/requestlog.js +48 -39
  154. package/dist/router/src/tether-win.js +211 -0
  155. package/dist/router/src/tether.js +712 -0
  156. package/dist/router/src/usage-history.js +227 -0
  157. package/dist/router/src/version.js +1 -1
  158. package/dist/ui/app.js +1083 -59
  159. package/dist/ui/i18n.js +670 -69
  160. package/dist/ui/index.html +33 -6
  161. package/dist/ui/style.css +177 -0
  162. package/docs/ARCHITECTURE.md +402 -14
  163. package/package.json +8 -2
@@ -0,0 +1,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
+ }
@@ -25,13 +25,16 @@ import { fileURLToPath } from "node:url";
25
25
  const MARKER = /^\s*\[\[\s*(?:ripple|gpt)\s*:\s*([A-Za-z0-9.\-]+)\s*(?:@\s*([A-Za-z]+))?\s*\]\]/;
26
26
  const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
27
27
  const OUR_PREFIX = /^[^\s·]+(?:·[a-z]+)? · /;
28
- const CLAUDE_ID = /^claude-(opus|sonnet|haiku|fable)-(\d+(?:-\d+)*?)(?:-\d{8})?$/;
28
+ const CLAUDE_ID = /^claude-(opus|sonnet|haiku|fable|mythos)-(\d+(?:-\d+)*?)(?:-\d{8})?$/;
29
29
  /** The router's default `effortClamp` (config.ts), for when config.json cannot be read. */
30
30
  const DEFAULT_CLAMP = { ultra: "max" };
31
- /** A model's name in a title: the last word of its display name ("GPT-6.1 Sol" → "Sol"). */
31
+ /**
32
+ * A model's name in a title: the last word of its display name ("GPT-6.1 Sol" → "Sol"), without
33
+ * the vendor path a hosted id carries ("deepseek-ai/deepseek-v4.1-flash-1017" → "deepseek-v4.1-flash-1017").
34
+ */
32
35
  export function shortName(name) {
33
36
  const words = name.trim().split(/\s+/);
34
- return words[words.length - 1];
37
+ return words[words.length - 1].replace(/^.*\//, "");
35
38
  }
36
39
  function titleConfig() {
37
40
  const names = { "gpt-5.6-terra": "Terra", "gpt-5.6-sol": "Sol", "gpt-5.6-luna": "Luna", "gpt-6-astra": "Astra", "gpt-6-sol": "Sol", "gpt-6-luna": "Luna" };
@@ -84,7 +87,7 @@ export function fromAgentFile(subagentType, dirs) {
84
87
  }
85
88
  export function claudePretty(modelId) {
86
89
  const v = (modelId ?? "").trim().toLowerCase();
87
- if (["opus", "sonnet", "haiku", "fable"].includes(v))
90
+ if (["opus", "sonnet", "haiku", "fable", "mythos"].includes(v))
88
91
  return v[0].toUpperCase() + v.slice(1);
89
92
  const m = CLAUDE_ID.exec(v);
90
93
  return m ? m[1][0].toUpperCase() + m[1].slice(1) + m[2].replace(/-/g, ".") : null;
@@ -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,14 +447,134 @@ 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]
572
+ remote [install|uninstall|status|pair] run or manage the phone host (macOS, Windows, Linux systemd)
573
+ remote add <ssh-host> [--port N] [--force]
574
+ route Claude Desktop sessions that run on a remote (SSH) host through ClaudeRipple:
575
+ sets up the host over your key-based ssh and keeps a reverse tunnel open (remote port, default 18791)
576
+ remote remove <ssh-host> undo "remote add" on that host and stop its tunnel
577
+ remote hosts list remote (SSH) hosts and their tunnels (also shown by "remote status")
454
578
  config print the config file path
455
579
  ui open the dashboard in your browser
456
580
  tray [--install] start the menu-bar / tray app (--install fetches Electron, ~270MB, once)
@@ -468,20 +592,71 @@ function help() {
468
592
  agent-title on|off|status
469
593
  prefix subagent titles with the real model and thinking depth ("Terra·high · …") via a Claude Code hook
470
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
471
603
 
472
604
  Home directory: ${homeDir()} (override with CLAUDERIPPLE_HOME)`);
473
605
  }
474
606
  try {
475
607
  switch (cmd) {
608
+ case "remote": {
609
+ const sub = args[1];
610
+ // Remote (SSH) hosts for Claude Desktop sessions; every other `remote` verb is the phone host's.
611
+ if (sub === "add" || sub === "remove" || sub === "hosts") {
612
+ const { remoteAdd, remoteRemove, remoteHosts, REMOTE_SSH_USAGE } = await import("./remote.js");
613
+ const host = args[2];
614
+ if (sub === "hosts")
615
+ await remoteHosts();
616
+ else if (!host || host.startsWith("--"))
617
+ throw new Error(REMOTE_SSH_USAGE);
618
+ else if (sub === "add") {
619
+ const port = opt("port");
620
+ await remoteAdd(host, { ...(port !== undefined ? { port: Number(port) } : {}), force: flag("force") });
621
+ }
622
+ else
623
+ await remoteRemove(host);
624
+ break;
625
+ }
626
+ const script = path.resolve(here, "../../remote/src/main" + path.extname(fileURLToPath(import.meta.url)));
627
+ const { spawn } = await import("node:child_process");
628
+ const child = spawn(installedRuntime.node, [script, ...args.slice(1)], { stdio: "inherit", env: { ...process.env, ...installedRuntime.env } });
629
+ for (const signal of ["SIGTERM", "SIGINT"])
630
+ process.on(signal, () => child.kill(signal));
631
+ process.exitCode = await new Promise((resolve, reject) => {
632
+ child.once("error", reject);
633
+ child.once("exit", code => resolve(code ?? 1));
634
+ });
635
+ if (sub === "status") {
636
+ // Only when there are any, so the phone host's status reads exactly as before otherwise.
637
+ const { configuredHosts, remoteHosts } = await import("./remote.js");
638
+ if (configuredHosts(configPath()).length > 0) {
639
+ console.log("\nremote (SSH) hosts:");
640
+ await remoteHosts();
641
+ }
642
+ }
643
+ break;
644
+ }
476
645
  case "install":
477
646
  await install();
478
647
  break;
479
648
  case "uninstall":
480
- uninstall();
649
+ await uninstall();
481
650
  break;
482
651
  case "status":
483
652
  await status();
484
653
  break;
654
+ case "doctor":
655
+ case "repair":
656
+ case "direct":
657
+ case "app":
658
+ await recovery();
659
+ break;
485
660
  case "start": {
486
661
  const r = startAgent();
487
662
  console.log(r === "already-running" ? "already running" : r === "started" ? "started" : "start failed (is it installed?)");
@@ -492,6 +667,7 @@ try {
492
667
  break;
493
668
  case "stop":
494
669
  console.log(stopAgent() ? "stopped (run `clauderipple start` or `install` to bring it back)" : "stop failed (not loaded?)");
670
+ await releaseHeldSleep("router stopped");
495
671
  break;
496
672
  case "logs":
497
673
  logs();
@@ -641,6 +817,24 @@ try {
641
817
  case "image":
642
818
  await image();
643
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
+ }
644
838
  case "ui":
645
839
  ui();
646
840
  break;
@@ -648,7 +842,9 @@ try {
648
842
  await update();
649
843
  break;
650
844
  case "tray": {
651
- 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();
652
848
  console.log(result.message);
653
849
  if (!result.ok)
654
850
  process.exit(1);