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,263 @@
1
+ // clauderipple remote add|remove|hosts — Claude Desktop sessions on a remote (SSH) host (issue #67).
2
+ //
3
+ // `add` prepares the remote over the user's own key-based ssh (CA certificate, settings.json env,
4
+ // agent files; remote-host.ts), records the host in config.json, and asks the running router to
5
+ // open the reverse tunnel (remote-tunnel.ts). `remove` undoes exactly that. The phone host keeps
6
+ // `remote install|uninstall|status|pair` and the bare `remote`; these verbs are new beside them.
7
+ import fs from "node:fs";
8
+ import { defaultAgentDir } from "../../router/src/agents.js";
9
+ import { ConfigStore, adminPort, configPath, homeDir } from "../../router/src/config.js";
10
+ import { DEFAULT_REMOTE_PORT, REMOTE_DIR, SETTINGS_BACKUP_SUFFIX, SshConnectError, assetLines, hostProblem, inspectRemote, planAgents, portProblem, readLocalAssets, remoteCaPath, remoteProxyUrl, runRemoteScript, script, settingsGuardLine, settingsWriteLines, spawnSsh, } from "../../router/src/remote-host.js";
11
+ import { refuseForeignProxy } from "./settings.js";
12
+ function parseSettings(text, host) {
13
+ if (text === null || text.trim() === "")
14
+ return {};
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(text);
18
+ }
19
+ catch (e) {
20
+ throw new Error(`${host}:~/.claude/settings.json is not valid JSON (${e.message}); fix it first — nothing was changed`);
21
+ }
22
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
23
+ throw new Error(`${host}:~/.claude/settings.json is not a JSON object; nothing was changed`);
24
+ return parsed;
25
+ }
26
+ function envOf(s) {
27
+ return { ...(s.env ?? {}) };
28
+ }
29
+ /**
30
+ * The remote settings.json with our two env keys set, or null when it already has them. Same rule
31
+ * as the local install (settings.ts): every other key is kept, and a different HTTPS_PROXY is
32
+ * refused unless forced.
33
+ */
34
+ export function mergeRemoteSettings(text, host, want, force) {
35
+ const s = parseSettings(text, host);
36
+ const env = envOf(s);
37
+ try {
38
+ refuseForeignProxy(env.HTTPS_PROXY, want.proxyUrl, force);
39
+ }
40
+ catch (e) {
41
+ throw new Error(`${host}: ${e.message}`);
42
+ }
43
+ const notes = [];
44
+ if (env.HTTPS_PROXY && env.HTTPS_PROXY !== want.proxyUrl)
45
+ notes.push(`replaced HTTPS_PROXY ${env.HTTPS_PROXY}`);
46
+ if (env.NODE_EXTRA_CA_CERTS && env.NODE_EXTRA_CA_CERTS !== want.caPath)
47
+ notes.push(`replaced NODE_EXTRA_CA_CERTS ${env.NODE_EXTRA_CA_CERTS}`);
48
+ if (env.HTTPS_PROXY === want.proxyUrl && env.NODE_EXTRA_CA_CERTS === want.caPath)
49
+ return { content: null, notes };
50
+ env.HTTPS_PROXY = want.proxyUrl;
51
+ env.NODE_EXTRA_CA_CERTS = want.caPath;
52
+ return { content: JSON.stringify({ ...s, env }, null, 2) + "\n", notes };
53
+ }
54
+ /** The remote settings.json without our two keys (only where they still hold our values), or null when unchanged. */
55
+ export function unmergeRemoteSettings(text, host, ours) {
56
+ if (text === null)
57
+ return { content: null, notes: [] };
58
+ const s = parseSettings(text, host);
59
+ const env = envOf(s);
60
+ const notes = [];
61
+ let changed = false;
62
+ for (const [key, value] of [["HTTPS_PROXY", ours.proxyUrl], ["NODE_EXTRA_CA_CERTS", ours.caPath]]) {
63
+ if (env[key] === value) {
64
+ delete env[key];
65
+ changed = true;
66
+ }
67
+ else if (env[key])
68
+ notes.push(`left ${key}=${env[key]} (not ours)`);
69
+ }
70
+ if (!changed)
71
+ return { content: null, notes };
72
+ const next = { ...s };
73
+ if (Object.keys(env).length === 0)
74
+ delete next.env;
75
+ else
76
+ next.env = env;
77
+ return { content: JSON.stringify(next, null, 2) + "\n", notes };
78
+ }
79
+ export function configuredHosts(configFile) {
80
+ try {
81
+ const raw = JSON.parse(fs.readFileSync(configFile, "utf8"));
82
+ const hosts = raw.remote?.hosts;
83
+ return Array.isArray(hosts) ? hosts.filter((h) => !!h && typeof h.host === "string" && typeof h.port === "number") : [];
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ }
89
+ /** Rewrite `remote.hosts` in config.json, everything else as it was; atomic, as the router hot-reloads it. */
90
+ function updateHosts(configFile, mutate) {
91
+ if (!fs.existsSync(configFile))
92
+ throw new Error(`no config at ${configFile}; run \`clauderipple install\` first`);
93
+ const raw = JSON.parse(fs.readFileSync(configFile, "utf8"));
94
+ const remote = (raw.remote && typeof raw.remote === "object" ? raw.remote : {});
95
+ const hosts = mutate(Array.isArray(remote.hosts) ? remote.hosts : []);
96
+ if (hosts.length > 0)
97
+ raw.remote = { ...remote, hosts };
98
+ else
99
+ delete raw.remote;
100
+ const tmp = `${configFile}.tmp-${process.pid}-${Date.now()}`;
101
+ fs.writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n");
102
+ fs.renameSync(tmp, configFile);
103
+ }
104
+ async function routerCall(adminUrl, method, pathname) {
105
+ if (adminUrl === null)
106
+ return { ok: false, why: "not asked" };
107
+ try {
108
+ const res = await fetch(`${adminUrl}${pathname}`, { method, signal: AbortSignal.timeout(5_000) });
109
+ if (res.status === 404 || res.status === 501)
110
+ return { ok: false, why: "the running router does not manage remote tunnels (an older version?); restart it with `clauderipple restart`" };
111
+ if (!res.ok)
112
+ return { ok: false, why: `the router answered HTTP ${res.status}` };
113
+ return { ok: true, hosts: (await res.json()).hosts ?? [] };
114
+ }
115
+ catch {
116
+ return { ok: false, why: "the router is not answering; the tunnel starts when it does (`clauderipple start`)" };
117
+ }
118
+ }
119
+ function defaultAdminUrl(configFile) {
120
+ return `http://127.0.0.1:${adminPort(new ConfigStore(configFile).get())}`;
121
+ }
122
+ function describe(s) {
123
+ return `${s.state}${s.state === "up" ? ` since ${s.since}` : ""}${s.lastError ? ` — ${s.lastError}` : ""}`;
124
+ }
125
+ function resolved(opts) {
126
+ const configFile = opts.configFile ?? configPath();
127
+ return {
128
+ run: opts.run ?? spawnSsh,
129
+ home: opts.home ?? homeDir(),
130
+ agentDir: opts.agentDir ?? defaultAgentDir(),
131
+ configFile,
132
+ adminUrl: opts.adminUrl === undefined ? defaultAdminUrl(configFile) : opts.adminUrl,
133
+ print: opts.print ?? ((line) => console.log(line)),
134
+ waitMs: opts.waitMs ?? 20_000,
135
+ };
136
+ }
137
+ function reachError(host, e) {
138
+ if (e instanceof SshConnectError) {
139
+ return new Error(`${e.message}\nClaudeRipple uses your own ssh with key-based login and never asks for a password. Set that up first ` +
140
+ `(for example \`ssh-copy-id ${host}\`), check that \`ssh ${host} true\` runs without a prompt, then run this again.`);
141
+ }
142
+ return e;
143
+ }
144
+ // ---- commands ------------------------------------------------------------------------------
145
+ export async function remoteAdd(host, o = {}) {
146
+ const problem = hostProblem(host);
147
+ if (problem)
148
+ throw new Error(problem);
149
+ const port = o.port ?? DEFAULT_REMOTE_PORT;
150
+ const portError = portProblem(port);
151
+ if (portError)
152
+ throw new Error(`--port: ${portError}`);
153
+ const r = resolved(o);
154
+ const assets = readLocalAssets({ home: r.home, agentDir: r.agentDir });
155
+ if (!fs.existsSync(r.configFile))
156
+ throw new Error(`no config at ${r.configFile}; run \`clauderipple install\` first`);
157
+ let state;
158
+ try {
159
+ state = await inspectRemote(r.run, host);
160
+ }
161
+ catch (e) {
162
+ throw reachError(host, e);
163
+ }
164
+ r.print(`✓ ${host}: key-based ssh works (home ${state.home})`);
165
+ const want = { proxyUrl: remoteProxyUrl(port), caPath: remoteCaPath(state.home) };
166
+ const settings = mergeRemoteSettings(state.settings, host, want, o.force === true);
167
+ const plan = planAgents(assets.agents.map((a) => a.name), state);
168
+ // The CA goes before the env that names it; the guard before both, so a refusal changes nothing.
169
+ const lines = settings.content !== null
170
+ ? [settingsGuardLine(state), ...assetLines(assets, plan, want), ...settingsWriteLines(state, settings.content)]
171
+ : assetLines(assets, plan, want);
172
+ await runRemoteScript(r.run, host, script(lines));
173
+ r.print(`✓ CA certificate copied to ${want.caPath} (the public certificate only)`);
174
+ if (settings.content !== null) {
175
+ r.print(`✓ ${host}:~/.claude/settings.json env: HTTPS_PROXY=${want.proxyUrl}, NODE_EXTRA_CA_CERTS=${want.caPath}`);
176
+ if (state.settings !== null && !state.backup)
177
+ r.print(` backup: ~/.claude/settings.json${SETTINGS_BACKUP_SUFFIX}`);
178
+ }
179
+ else
180
+ r.print(`✓ ${host}:~/.claude/settings.json already points at the tunnel`);
181
+ for (const n of settings.notes)
182
+ r.print(` note: ${n}`);
183
+ r.print(`✓ ${plan.write.length} agent file${plan.write.length === 1 ? "" : "s"} copied to ~/.claude/agents${plan.remove.length ? `, ${plan.remove.length} no longer generated removed` : ""}`);
184
+ for (const n of plan.skipped)
185
+ r.print(` note: ~/.claude/agents/${n}.md on ${host} is not ours; left alone`);
186
+ updateHosts(r.configFile, (hosts) => [...hosts.filter((h) => h.host !== host), { host, port }]);
187
+ r.print(`✓ ${host} saved in ${r.configFile}`);
188
+ const reload = await routerCall(r.adminUrl, "POST", "/api/remote/reload");
189
+ if (!reload.ok) {
190
+ if (r.adminUrl !== null)
191
+ r.print(` ${reload.why}`);
192
+ return;
193
+ }
194
+ // The tunnel comes up asynchronously; say how it went rather than that it was asked for.
195
+ const deadline = Date.now() + r.waitMs;
196
+ let last = reload.hosts.find((h) => h.host === host);
197
+ while (last?.state !== "up" && Date.now() < deadline) {
198
+ await new Promise((res) => setTimeout(res, 500));
199
+ const now = await routerCall(r.adminUrl, "GET", "/api/remote");
200
+ if (now.ok)
201
+ last = now.hosts.find((h) => h.host === host);
202
+ }
203
+ if (last?.state === "up")
204
+ r.print(`✓ tunnel up: 127.0.0.1:${port} on ${host} reaches ClaudeRipple. New Claude Desktop sessions on ${host} go through it.`);
205
+ else {
206
+ r.print(`✗ tunnel ${last ? describe(last) : "not started"}. The router keeps retrying; \`clauderipple remote hosts\` shows its state.`);
207
+ process.exitCode = 1;
208
+ }
209
+ }
210
+ export async function remoteRemove(host, o = {}) {
211
+ const problem = hostProblem(host);
212
+ if (problem)
213
+ throw new Error(problem);
214
+ const r = resolved(o);
215
+ const entry = configuredHosts(r.configFile).find((h) => h.host === host);
216
+ // The tunnel goes first, so nothing re-copies files while they are being removed.
217
+ if (entry) {
218
+ updateHosts(r.configFile, (hosts) => hosts.filter((h) => h.host !== host));
219
+ r.print(`✓ ${host} removed from ${r.configFile}`);
220
+ const reload = await routerCall(r.adminUrl, "POST", "/api/remote/reload");
221
+ r.print(reload.ok ? "✓ tunnel stopped" : ` ${reload.why}`);
222
+ }
223
+ else
224
+ r.print(` ${host} was not in ${r.configFile}; cleaning up the host anyway`);
225
+ let state;
226
+ try {
227
+ state = await inspectRemote(r.run, host);
228
+ }
229
+ catch (e) {
230
+ const err = reachError(host, e);
231
+ throw new Error(`${err.message}\nNothing on ${host} was changed. Run \`clauderipple remote remove ${host}\` again once it is reachable.`);
232
+ }
233
+ const record = state.record ?? { agents: [] };
234
+ const ours = { proxyUrl: record.proxyUrl ?? remoteProxyUrl(entry?.port ?? DEFAULT_REMOTE_PORT), caPath: record.caPath ?? remoteCaPath(state.home) };
235
+ const settings = unmergeRemoteSettings(state.settings, host, ours);
236
+ const lines = [];
237
+ if (settings.content !== null)
238
+ lines.push(...settingsWriteLines(state, settings.content));
239
+ lines.push(...planAgents([], { record, agentFiles: state.agentFiles }).remove.map((n) => `rm -f "$HOME/.claude/agents/${n}.md"`));
240
+ lines.push(`if [ -n "$HOME" ] && [ "$HOME" != "/" ]; then rm -rf "$HOME/${REMOTE_DIR}"; fi`);
241
+ await runRemoteScript(r.run, host, script(lines));
242
+ r.print(settings.content !== null ? `✓ ${host}:~/.claude/settings.json: HTTPS_PROXY and NODE_EXTRA_CA_CERTS removed (everything else kept)` : `✓ ${host}:~/.claude/settings.json had no ClaudeRipple keys`);
243
+ for (const n of settings.notes)
244
+ r.print(` note: ${n}`);
245
+ r.print(`✓ ${record.agents.length} agent file${record.agents.length === 1 ? "" : "s"} we copied removed, and ~/${REMOTE_DIR}`);
246
+ if (state.backup)
247
+ r.print(` the original settings.json is still in ~/.claude/settings.json${SETTINGS_BACKUP_SUFFIX}`);
248
+ }
249
+ export async function remoteHosts(o = {}) {
250
+ const r = resolved(o);
251
+ const hosts = configuredHosts(r.configFile);
252
+ if (hosts.length === 0) {
253
+ r.print("no remote (SSH) hosts. Add one with: clauderipple remote add <ssh-host> [--port N]");
254
+ return;
255
+ }
256
+ const live = await routerCall(r.adminUrl, "GET", "/api/remote");
257
+ const w = Math.max(...hosts.map((h) => h.host.length));
258
+ for (const h of hosts) {
259
+ const s = live.ok ? live.hosts.find((x) => x.host === h.host) : undefined;
260
+ r.print(`${h.host.padEnd(w)} 127.0.0.1:${h.port} ${s ? describe(s) : live.ok ? "not started" : live.why}`);
261
+ }
262
+ }
263
+ export const REMOTE_SSH_USAGE = "usage: clauderipple remote add <ssh-host> [--port N] [--force] | remote remove <ssh-host> | remote hosts";
@@ -31,6 +31,8 @@ function run(script) {
31
31
  env: windowsPowerShellEnv(),
32
32
  stdio: ["ignore", "pipe", "pipe"],
33
33
  windowsHide: true,
34
+ // A hung PowerShell must not hang doctor/repair, which the menu-bar app waits on.
35
+ timeout: 60_000,
34
36
  }).toString();
35
37
  return { ok: true, out: out.trim() };
36
38
  }
@@ -94,27 +96,32 @@ export function taskDefinitionMatches(value, expected) {
94
96
  * is not, because that is `stop` or the drain half of `restart`, which starts the task again.
95
97
  * A router that keeps dying immediately backs off instead of spinning.
96
98
  */
97
- function writeLauncher(opts) {
98
- const launcher = path.join(opts.home, "clauderipple-router.ps1");
99
+ export function renderLauncher(opts) {
99
100
  const logDir = path.join(opts.home, "logs");
100
- fs.mkdirSync(logDir, { recursive: true });
101
101
  const env = { CLAUDERIPPLE_HOME: opts.home, ...(opts.env ?? {}) };
102
- const lines = [
103
- "# Generated by `clauderipple install`. Supervises the router; see schtasks.ts.",
102
+ const quoteArg = (arg) => ps(`"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`);
103
+ // No `$ErrorActionPreference = 'Stop'`: one failed Start-Process (a locked log file, say) would end
104
+ // the loop and leave the router or host unsupervised; the loop retries instead.
105
+ return [
104
106
  ...Object.entries(env).map(([k, v]) => `$env:${k} = ${ps(v)}`),
105
- `$routerArgs = @(${opts.args.map((a) => ps(a)).join(", ")})`,
107
+ `$routerArgs = @(${opts.args.map(quoteArg).join(", ")})`,
106
108
  `$out = ${ps(path.join(logDir, "launcher.log"))}`,
107
109
  `$err = ${ps(path.join(logDir, "launcher.err.log"))}`,
108
- `$quickFailures = 0`,
109
- `while ($true) {`,
110
- ` $startedAt = Get-Date`,
110
+ "$quickFailures = 0",
111
+ "while ($true) {",
112
+ " $startedAt = Get-Date",
111
113
  ` $p = Start-Process -FilePath ${ps(opts.program)} -ArgumentList $routerArgs -WindowStyle Hidden -PassThru -Wait -RedirectStandardOutput $out -RedirectStandardError $err`,
112
- ` if ($p.ExitCode -eq 0) { break }`,
113
- ` if (((Get-Date) - $startedAt).TotalSeconds -lt 10) { $quickFailures++ } else { $quickFailures = 0 }`,
114
- ` Start-Sleep -Seconds $(if ($quickFailures -ge 5) { 60 } else { 2 })`,
115
- `}`,
116
- ];
117
- fs.writeFileSync(launcher, lines.join("\r\n") + "\r\n");
114
+ " if ($p.ExitCode -eq 0) { break }",
115
+ " if (((Get-Date) - $startedAt).TotalSeconds -lt 10) { $quickFailures++ } else { $quickFailures = 0 }",
116
+ " Start-Sleep -Seconds $(if ($quickFailures -ge 5) { 60 } else { 2 })",
117
+ "}",
118
+ ].join("\r\n") + "\r\n";
119
+ }
120
+ function writeLauncher(opts) {
121
+ const launcher = path.join(opts.home, `${opts.name ?? "clauderipple-router"}.ps1`);
122
+ const logDir = path.join(opts.home, "logs");
123
+ fs.mkdirSync(logDir, { recursive: true });
124
+ fs.writeFileSync(launcher, renderLauncher(opts));
118
125
  return launcher;
119
126
  }
120
127
  /**
@@ -123,9 +130,9 @@ function writeLauncher(opts) {
123
130
  * DisallowStartIfOnBatteries/StopIfGoingOnBatteries to false — so reading the switch names gave
124
131
  * null, and until #38 no existing task ever matched: every install re-registered.
125
132
  */
126
- export function existingTaskDefinition() {
133
+ export function existingTaskDefinition(name = taskName()) {
127
134
  const existing = run([
128
- `$t = Get-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction SilentlyContinue`,
135
+ `$t = Get-ScheduledTask -TaskName ${ps(name)} -ErrorAction SilentlyContinue`,
129
136
  `if ($null -eq $t) { 'null'; exit 0 }`,
130
137
  `$a = @($t.Actions); $p = $t.Principal; $tr = @($t.Triggers); $s = $t.Settings`,
131
138
  `[pscustomobject]@{ actionCount = $a.Count; execute = $a[0].Execute; arguments = $a[0].Arguments; userId = $p.UserId; runLevel = [int]$p.RunLevel; logonType = [int]$p.LogonType; triggerCount = $tr.Count; triggerType = $tr[0].CimClass.CimClassName; triggerUserId = $tr[0].UserId; restartCount = $s.RestartCount; restartInterval = [string]$s.RestartInterval; executionTimeLimit = [string]$s.ExecutionTimeLimit; multipleInstances = [int]$s.MultipleInstances; allowStartIfOnBatteries = $s.DisallowStartIfOnBatteries -eq $false; dontStopIfGoingOnBatteries = $s.StopIfGoingOnBatteries -eq $false } | ConvertTo-Json -Compress`,
@@ -141,7 +148,8 @@ export function existingTaskDefinition() {
141
148
  }
142
149
  }
143
150
  export function installAgent(opts) {
144
- const launcher = writeLauncher({ program: opts.program, args: opts.args ?? [], home: opts.home, ...(opts.env ? { env: opts.env } : {}) });
151
+ const name = opts.name ?? taskName();
152
+ const launcher = writeLauncher({ ...opts, args: opts.args ?? [] });
145
153
  const user = taskUser();
146
154
  const powershellArgs = `-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`;
147
155
  // PowerShell is started inside a headless console host. Started directly, it gets a console from
@@ -151,39 +159,41 @@ export function installAgent(opts) {
151
159
  // for `powershell.exe`, none for `conhost.exe --headless powershell.exe`, and both ran the script).
152
160
  const execute = "conhost.exe";
153
161
  const actionArgs = `--headless powershell.exe ${powershellArgs}`;
154
- const existing = existingTaskDefinition();
162
+ const existing = existingTaskDefinition(name);
155
163
  // Updating the generated launcher does not require re-registering an otherwise correct task.
156
164
  // Some Windows installations protect an existing per-user task's security descriptor so that
157
165
  // even the same unelevated user gets E_ACCESSDENIED from Register-ScheduledTask -Force. Treat the
158
166
  // verified existing definition as success; never silently accept a same-named foreign task.
159
167
  if (taskDefinitionMatches(existing, { execute, arguments: actionArgs, userId: user }))
160
168
  return launcher;
161
- const script = [
169
+ const script = renderTaskRegistration(name, user, execute, actionArgs);
170
+ const r = run(script);
171
+ if (!r.ok) {
172
+ // Retain the pre-headless task only when its entire definition matches.
173
+ if (taskDefinitionMatches(existing, { execute: "powershell.exe", arguments: powershellArgs, userId: user }))
174
+ return launcher;
175
+ throw new Error(`Register-ScheduledTask failed: ${r.out}`);
176
+ }
177
+ return launcher;
178
+ }
179
+ export function renderTaskRegistration(name, user, execute, actionArgs) {
180
+ return [
162
181
  `$ErrorActionPreference = 'Stop'`,
163
182
  `$a = New-ScheduledTaskAction -Execute ${ps(execute)} -Argument ${ps(actionArgs)}`,
164
183
  `$t = New-ScheduledTaskTrigger -AtLogOn -User ${ps(user)}`,
165
184
  // Limited: the router needs no elevation, and asking for it would put a UAC prompt at every logon.
166
185
  `$p = New-ScheduledTaskPrincipal -UserId ${ps(user)} -LogonType Interactive -RunLevel Limited`,
167
186
  `$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 99 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew`,
168
- `Register-ScheduledTask -TaskName ${ps(taskName())} -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null`,
187
+ `Register-ScheduledTask -TaskName ${ps(name)} -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null`,
169
188
  `'ok'`,
170
189
  ].join("; ");
171
- const r = run(script);
172
- if (!r.ok) {
173
- // The task from before #49 still starts and supervises the router, only with a window. On an
174
- // install that cannot re-register it (E_ACCESSDENIED, above), keeping it beats failing the update.
175
- if (taskDefinitionMatches(existing, { execute: "powershell.exe", arguments: powershellArgs, userId: user }))
176
- return launcher;
177
- throw new Error(`Register-ScheduledTask failed: ${r.out}`);
178
- }
179
- return launcher;
180
190
  }
181
- export function removeAgent() {
182
- const r = run(`Unregister-ScheduledTask -TaskName ${ps(taskName())} -Confirm:$false -ErrorAction Stop; 'ok'`);
191
+ export function removeAgent(name = taskName()) {
192
+ const r = run(`Unregister-ScheduledTask -TaskName ${ps(name)} -Confirm:$false -ErrorAction Stop; 'ok'`);
183
193
  return r.ok;
184
194
  }
185
- export function agentState() {
186
- const r = run(`(Get-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction Stop).State`);
195
+ export function agentState(name = taskName()) {
196
+ const r = run(`(Get-ScheduledTask -TaskName ${ps(name)} -ErrorAction Stop).State`);
187
197
  if (!r.ok)
188
198
  return "not-loaded";
189
199
  return /^Running/im.test(r.out) ? "running" : "loaded";
@@ -205,14 +215,14 @@ export function agentPid() {
205
215
  const n = Number(r.out.trim());
206
216
  return r.ok && Number.isFinite(n) && n > 0 ? n : null;
207
217
  }
208
- export function startAgent() {
209
- if (agentPid() !== null)
218
+ export function startAgent(name = taskName()) {
219
+ if (name === taskName() ? agentPid() !== null : agentState(name) === "running")
210
220
  return "already-running";
211
- const r = run(`Start-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction Stop; 'ok'`);
221
+ const r = run(`Start-ScheduledTask -TaskName ${ps(name)} -ErrorAction Stop; 'ok'`);
212
222
  return r.ok ? "started" : "failed";
213
223
  }
214
- export function stopAgent() {
215
- // Stop the task so the supervisor does not immediately start it again, then let the router drain.
216
- run(`Stop-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction SilentlyContinue`);
224
+ export function stopAgent(name = taskName()) {
225
+ // Host callers first request authenticated graceful shutdown and wait for its clean exit.
226
+ run(`Stop-ScheduledTask -TaskName ${ps(name)} -ErrorAction SilentlyContinue`);
217
227
  return true;
218
228
  }
@@ -36,7 +36,8 @@ export const MODEL_SLOT_ENV = {
36
36
  smallFast: "ANTHROPIC_SMALL_FAST_MODEL",
37
37
  subagent: "CLAUDE_CODE_SUBAGENT_MODEL",
38
38
  };
39
- function refuseForeignProxy(existing, proxyUrl, force) {
39
+ /** Also applied to a remote host's settings.json (remote.ts): one rule for whose proxy wins. */
40
+ export function refuseForeignProxy(existing, proxyUrl, force) {
40
41
  if (existing && existing !== proxyUrl && !force) {
41
42
  throw new Error(`settings.json env.HTTPS_PROXY is already "${existing}". Re-run with --force to replace it, or uninstall the other proxy first.`);
42
43
  }
@@ -156,6 +157,30 @@ export function removeProxyEnv(opts) {
156
157
  write(file, next);
157
158
  return { changed: true, backup: b, notes, wroteSlots: [] };
158
159
  }
160
+ /**
161
+ * Applies `mutate` to a copy of settings.json's env block and writes it back (with a backup) only
162
+ * when it reports a change. Every other key in the file is kept as it was; an emptied env block is
163
+ * dropped. For callers that own a precise set of keys, such as `direct on|off`.
164
+ */
165
+ export function editSettingsEnv(mutate) {
166
+ const file = settingsPath();
167
+ const s = readSettings(file);
168
+ const env = { ...(s.env ?? {}) };
169
+ if (!mutate(env))
170
+ return { changed: false, backup: null, notes: [], wroteSlots: [] };
171
+ const b = backup(file);
172
+ const next = { ...s };
173
+ if (Object.keys(env).length === 0)
174
+ delete next.env;
175
+ else
176
+ next.env = env;
177
+ write(file, next);
178
+ return { changed: true, backup: b, notes: [], wroteSlots: [] };
179
+ }
180
+ /** settings.json's whole env block (empty when the file or the block is missing). */
181
+ export function settingsEnv() {
182
+ return { ...(readSettings(settingsPath()).env ?? {}) };
183
+ }
159
184
  export function currentProxyEnv() {
160
185
  const env = readSettings(settingsPath()).env ?? {};
161
186
  const out = {};
@@ -58,10 +58,13 @@ export function trayEnv(env = process.env) {
58
58
  */
59
59
  export async function startTray(options = {}) {
60
60
  const current = runtime();
61
+ // Started by hand (the app icon, `clauderipple tray`), the window opens; started by the logon
62
+ // supervisor in the foreground, it stays in the tray. The login item launches Electron directly.
63
+ const env = { ...trayEnv(), ...((options.detached ?? true) ? { CLAUDERIPPLE_APP_SHOW: "1" } : {}) };
61
64
  if (current.packaged) {
62
65
  // The packaged app IS the tray: relaunching its own binary with no script is what opens it.
63
66
  // Our own CLI runs under ELECTRON_RUN_AS_NODE here, and inheriting it would relaunch Node.
64
- const child = spawn(current.node, [], { detached: true, stdio: "ignore", env: trayEnv() });
67
+ const child = spawn(current.node, [], { detached: true, stdio: "ignore", env });
65
68
  child.unref();
66
69
  return { ok: true, message: "✓ tray started" };
67
70
  }
@@ -85,7 +88,7 @@ export async function startTray(options = {}) {
85
88
  detached: options.detached ?? true,
86
89
  stdio: ["ignore", out, out],
87
90
  // Electron would otherwise re-enter as a plain Node process, since that is how the CLI runs.
88
- env: trayEnv(),
91
+ env,
89
92
  });
90
93
  fs.closeSync(out);
91
94
  if (options.detached ?? true)