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
@@ -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);
@@ -0,0 +1,215 @@
1
+ // `clauderipple app --install|--uninstall`: something to double-click that opens the menu-bar app.
2
+ //
3
+ // Installed from npm there is no application to open, and the menu-bar app is exactly what a
4
+ // person needs when the router is down (it offers repair and Claude's default connection). So a
5
+ // launcher runs `clauderipple tray --ensure`: fetch Electron once if it is missing, then start the
6
+ // tray the way `clauderipple tray` does.
7
+ //
8
+ // macOS ~/Applications/ClaudeRipple.app — Info.plist + a shell script, bundle id
9
+ // com.clauderipple.launcher (the router's own background bundle in ~/.clauderipple is
10
+ // com.clauderipple.router, the packaged app com.clauderipple.app; none collide)
11
+ // Windows Start Menu and Desktop shortcuts (.lnk, WScript.Shell) to a hidden PowerShell launcher
12
+ // Linux ~/.local/share/applications/clauderipple.desktop
13
+ //
14
+ // CLAUDERIPPLE_LAUNCHER_DIR replaces every target directory (tests, or a custom location).
15
+ import { execFileSync } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { windowsPowerShellEnv } from "./windows-powershell.js";
20
+ export const LAUNCHER_BUNDLE_ID = "com.clauderipple.launcher";
21
+ const APP_NAME = "ClaudeRipple";
22
+ const DESKTOP_MARK = "X-ClaudeRipple-Launcher=1";
23
+ function overrideDir() {
24
+ return process.env.CLAUDERIPPLE_LAUNCHER_DIR ?? null;
25
+ }
26
+ export function macAppPath() {
27
+ return path.join(overrideDir() ?? path.join(os.homedir(), "Applications"), `${APP_NAME}.app`);
28
+ }
29
+ export function desktopEntryPath() {
30
+ const data = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
31
+ return path.join(overrideDir() ?? path.join(data, "applications"), "clauderipple.desktop");
32
+ }
33
+ export function windowsTrayLauncherPath(home) {
34
+ return path.join(home, "clauderipple-tray.ps1");
35
+ }
36
+ /** POSIX single-quoted literal. */
37
+ function sh(value) {
38
+ return `'${value.replace(/'/g, `'\\''`)}'`;
39
+ }
40
+ /** PowerShell single-quoted literal; the only escape inside one is a doubled quote. */
41
+ function ps(value) {
42
+ return `'${value.replace(/'/g, "''")}'`;
43
+ }
44
+ /** A desktop-entry Exec argument (freedesktop Desktop Entry spec, "The Exec key"). */
45
+ function execArg(value) {
46
+ return `"${value.replace(/([\\"`$])/g, "\\$1").replace(/%/g, "%%")}"`;
47
+ }
48
+ function launchEnv(rt, home) {
49
+ return { CLAUDERIPPLE_HOME: home, ...rt.env };
50
+ }
51
+ /** The app icon (icon.icns / icon.png / icon.ico), committed in the app's assets and shipped in the npm package beside the tray. */
52
+ function findIcon(rt, file) {
53
+ for (const dir of [path.join(rt.repo, "packages", "app", "assets"), path.join(rt.repo, "dist", "app", "assets")]) {
54
+ const p = path.join(dir, file);
55
+ if (fs.existsSync(p))
56
+ return p;
57
+ }
58
+ return null;
59
+ }
60
+ export function macInfoPlist(version, icon) {
61
+ return `<?xml version="1.0" encoding="UTF-8"?>
62
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
63
+ <plist version="1.0">
64
+ <dict>
65
+ <key>CFBundleName</key><string>${APP_NAME}</string>
66
+ <key>CFBundleDisplayName</key><string>${APP_NAME}</string>
67
+ <key>CFBundleIdentifier</key><string>${LAUNCHER_BUNDLE_ID}</string>
68
+ <key>CFBundleVersion</key><string>${version}</string>
69
+ <key>CFBundleShortVersionString</key><string>${version}</string>
70
+ <key>CFBundlePackageType</key><string>APPL</string>
71
+ <key>CFBundleExecutable</key><string>${APP_NAME}</string>
72
+ ${icon ? " <key>CFBundleIconFile</key><string>icon</string>\n" : ""} <key>LSUIElement</key><true/>
73
+ </dict>
74
+ </plist>
75
+ `;
76
+ }
77
+ export function macLauncherScript(rt, home) {
78
+ const log = path.join(home, "logs", "launcher.log");
79
+ return [
80
+ "#!/bin/sh",
81
+ "# Opens the ClaudeRipple menu-bar app (generated by `clauderipple app --install`).",
82
+ ...Object.entries(launchEnv(rt, home)).map(([k, v]) => `export ${k}=${sh(v)}`),
83
+ `mkdir -p ${sh(path.dirname(log))}`,
84
+ `exec ${sh(rt.node)} ${sh(rt.cli)} tray --ensure >> ${sh(log)} 2>&1`,
85
+ "",
86
+ ].join("\n");
87
+ }
88
+ export function desktopEntry(rt, home) {
89
+ const env = Object.entries(launchEnv(rt, home)).map(([k, v]) => execArg(`${k}=${v}`));
90
+ const icon = findIcon(rt, "icon.png");
91
+ return [
92
+ "[Desktop Entry]",
93
+ "Type=Application",
94
+ `Name=${APP_NAME}`,
95
+ "Comment=ClaudeRipple menu-bar app: router status, repair, and Claude's default connection",
96
+ `Exec=env ${env.join(" ")} ${execArg(rt.node)} ${execArg(rt.cli)} tray --ensure`,
97
+ ...(icon ? [`Icon=${icon}`] : []),
98
+ "Terminal=false",
99
+ "Categories=Utility;Development;",
100
+ DESKTOP_MARK,
101
+ "",
102
+ ].join("\n");
103
+ }
104
+ export function windowsTrayLauncher(rt, home) {
105
+ const log = path.join(home, "logs", "launcher.log");
106
+ return [
107
+ ...Object.entries(launchEnv(rt, home)).map(([k, v]) => `$env:${k} = ${ps(v)}`),
108
+ `New-Item -ItemType Directory -Force -Path ${ps(path.dirname(log))} | Out-Null`,
109
+ `& ${ps(rt.node)} ${ps(rt.cli)} tray --ensure *>> ${ps(log)}`,
110
+ ].join("\r\n") + "\r\n";
111
+ }
112
+ /**
113
+ * Creates (or, with `remove`, deletes) the shortcuts and prints each path. Without an override the
114
+ * folders are the user's own Start Menu programs and Desktop, as Windows names them.
115
+ */
116
+ export function windowsShortcutScript(launcher, iconSource, dir, remove = false) {
117
+ const targets = dir
118
+ ? `@(${ps(path.join(dir, "Start Menu", `${APP_NAME}.lnk`))}, ${ps(path.join(dir, "Desktop", `${APP_NAME}.lnk`))})`
119
+ : `@((Join-Path ([Environment]::GetFolderPath('Programs')) '${APP_NAME}.lnk'), (Join-Path ([Environment]::GetFolderPath('Desktop')) '${APP_NAME}.lnk'))`;
120
+ if (remove) {
121
+ return [`$ErrorActionPreference = 'Stop'`, `foreach ($p in ${targets}) { if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force; $p } }`].join("; ");
122
+ }
123
+ // conhost --headless: started directly, PowerShell gets a console from the default terminal and
124
+ // a window stays on screen (#49, the same reason the router's scheduled task uses it).
125
+ const args = `--headless powershell.exe -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`;
126
+ return [
127
+ `$ErrorActionPreference = 'Stop'`,
128
+ `$ws = New-Object -ComObject WScript.Shell`,
129
+ `foreach ($p in ${targets}) { New-Item -ItemType Directory -Force -Path (Split-Path -Parent $p) | Out-Null; $s = $ws.CreateShortcut($p); $s.TargetPath = (Join-Path $env:SystemRoot 'System32\\conhost.exe'); $s.Arguments = ${ps(args)}; $s.IconLocation = ${ps(`${iconSource},0`)}; $s.Description = 'ClaudeRipple menu-bar app'; $s.Save(); $p }`,
130
+ ].join("; ");
131
+ }
132
+ function runPowerShell(script) {
133
+ return execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
134
+ env: windowsPowerShellEnv(),
135
+ stdio: ["ignore", "pipe", "pipe"],
136
+ windowsHide: true,
137
+ timeout: 30_000,
138
+ }).toString();
139
+ }
140
+ /** Bundle id of an existing .app, or null when it has none we can read. */
141
+ function bundleIdOf(app) {
142
+ try {
143
+ const plist = fs.readFileSync(path.join(app, "Contents", "Info.plist"), "utf8");
144
+ return /<key>CFBundleIdentifier<\/key>\s*<string>([^<]*)<\/string>/.exec(plist)?.[1] ?? null;
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ export function installLauncher(opts) {
151
+ const platform = opts.platform ?? process.platform;
152
+ if (opts.rt.packaged)
153
+ return { ok: true, paths: [], detail: "This is the standalone ClaudeRipple app, which opens the menu-bar app itself; no launcher needed." };
154
+ if (platform === "darwin") {
155
+ const app = macAppPath();
156
+ // The packaged app may sit there under the same name: never write into someone else's bundle.
157
+ if (fs.existsSync(app) && bundleIdOf(app) !== LAUNCHER_BUNDLE_ID) {
158
+ return { ok: false, paths: [], detail: `${app} already exists and is not ClaudeRipple's launcher; left it alone.` };
159
+ }
160
+ const contents = path.join(app, "Contents");
161
+ fs.mkdirSync(path.join(contents, "MacOS"), { recursive: true });
162
+ const icns = findIcon(opts.rt, "icon.icns");
163
+ if (icns) {
164
+ fs.mkdirSync(path.join(contents, "Resources"), { recursive: true });
165
+ fs.copyFileSync(icns, path.join(contents, "Resources", "icon.icns"));
166
+ }
167
+ fs.writeFileSync(path.join(contents, "Info.plist"), macInfoPlist(opts.version, icns !== null));
168
+ fs.writeFileSync(path.join(contents, "MacOS", APP_NAME), macLauncherScript(opts.rt, opts.home), { mode: 0o755 });
169
+ fs.chmodSync(path.join(contents, "MacOS", APP_NAME), 0o755);
170
+ return { ok: true, paths: [app], detail: `Open ${APP_NAME} from ${path.dirname(app)} (or Spotlight) to start the menu-bar app.` };
171
+ }
172
+ if (platform === "linux") {
173
+ const file = desktopEntryPath();
174
+ fs.mkdirSync(path.dirname(file), { recursive: true });
175
+ fs.writeFileSync(file, desktopEntry(opts.rt, opts.home), { mode: 0o755 });
176
+ return { ok: true, paths: [file], detail: `${APP_NAME} is in your applications menu.` };
177
+ }
178
+ if (platform === "win32") {
179
+ const launcher = windowsTrayLauncherPath(opts.home);
180
+ fs.mkdirSync(opts.home, { recursive: true });
181
+ fs.writeFileSync(launcher, windowsTrayLauncher(opts.rt, opts.home));
182
+ const out = (opts.powershell ?? runPowerShell)(windowsShortcutScript(launcher, findIcon(opts.rt, "icon.ico") ?? opts.rt.node, overrideDir()));
183
+ const paths = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
184
+ return { ok: true, paths, detail: `${APP_NAME} is in the Start Menu and on the Desktop.` };
185
+ }
186
+ return { ok: false, paths: [], detail: `no launcher for ${platform}; start the menu-bar app with: clauderipple tray` };
187
+ }
188
+ export function uninstallLauncher(opts) {
189
+ const platform = opts.platform ?? process.platform;
190
+ const removed = [];
191
+ if (platform === "darwin") {
192
+ const app = macAppPath();
193
+ if (fs.existsSync(app)) {
194
+ if (bundleIdOf(app) !== LAUNCHER_BUNDLE_ID)
195
+ return { ok: false, paths: [], detail: `${app} is not ClaudeRipple's launcher; left it alone.` };
196
+ fs.rmSync(app, { recursive: true, force: true });
197
+ removed.push(app);
198
+ }
199
+ }
200
+ else if (platform === "linux") {
201
+ const file = desktopEntryPath();
202
+ if (fs.existsSync(file)) {
203
+ if (!fs.readFileSync(file, "utf8").includes(DESKTOP_MARK))
204
+ return { ok: false, paths: [], detail: `${file} is not ClaudeRipple's launcher; left it alone.` };
205
+ fs.rmSync(file);
206
+ removed.push(file);
207
+ }
208
+ }
209
+ else if (platform === "win32") {
210
+ const out = (opts.powershell ?? runPowerShell)(windowsShortcutScript(windowsTrayLauncherPath(opts.home), opts.rt.node, overrideDir(), true));
211
+ removed.push(...out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
212
+ fs.rmSync(windowsTrayLauncherPath(opts.home), { force: true });
213
+ }
214
+ return { ok: true, paths: removed, detail: removed.length ? "launcher removed" : "no launcher was installed" };
215
+ }
@@ -0,0 +1,182 @@
1
+ // `clauderipple power | awake … | tether …` — lid-closed awake and hotspot failover from the
2
+ // terminal. Changes go through the running router's admin API (`PUT /api/power`), which saves them
3
+ // and applies them at once; with no router answering they are saved to config.json and apply when it
4
+ // starts (and `awake off` also clears a sleep bit the router left set).
5
+ import { persistPowerChange } from "../../router/src/power.js";
6
+ import { awakeRemove, awakeSetup, releaseAwake, releaseFailedHint } from "./awake-setup.js";
7
+ /** `HH:MM` → the next time the clock shows it (today, else tomorrow), as ISO. A full ISO time is accepted too. */
8
+ export function parseUntil(text, now) {
9
+ const m = /^(\d{1,2}):(\d{2})$/.exec(text);
10
+ if (!m) {
11
+ const t = Date.parse(text);
12
+ if (/^\d{4}-\d{2}-\d{2}T/.test(text) && !Number.isNaN(t))
13
+ return new Date(t).toISOString();
14
+ throw new Error(`--until takes HH:MM (24-hour), got "${text}"`);
15
+ }
16
+ const h = Number(m[1]);
17
+ const min = Number(m[2]);
18
+ if (h > 23 || min > 59)
19
+ throw new Error(`--until takes HH:MM (24-hour), got "${text}"`);
20
+ const at = new Date(now);
21
+ at.setHours(h, min, 0, 0);
22
+ if (at.getTime() <= now.getTime())
23
+ at.setDate(at.getDate() + 1);
24
+ return at.toISOString();
25
+ }
26
+ const clock = (iso) => new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
27
+ export function formatPower(s) {
28
+ const a = s.awake;
29
+ const t = s.tether;
30
+ const lines = [];
31
+ const setup = a.setup === "ok" ? "" : a.setup === "needs-setup" ? " · needs setup: clauderipple awake setup" : " · not supported here";
32
+ const machine = s.platform === "win32" ? "PC" : "Mac";
33
+ lines.push(`awake ${a.mode}${a.until ? ` until ${clock(a.until)}` : ""} · ${a.applied ? `holding the ${machine} awake` : "sleep allowed"} (${a.reason})${setup}`);
34
+ const facts = [];
35
+ if (a.battery)
36
+ facts.push(a.battery.percent === null ? "no battery" : `battery ${a.battery.percent}% ${a.battery.onBattery ? "(on battery)" : "(on power)"}`);
37
+ if (a.thermal)
38
+ facts.push(`thermal ${a.thermal}`);
39
+ if (a.lidClosed !== null)
40
+ facts.push(`lid ${a.lidClosed ? "closed" : "open"}`);
41
+ if (facts.length)
42
+ lines.push(` ${facts.join(" · ")}`);
43
+ if (!t.enabled)
44
+ lines.push(`tether off${t.state === "unsupported" ? " (not supported here)" : ""}`);
45
+ else {
46
+ lines.push(`tether on · ${t.network} · ${t.state}${t.pinned ? " (pinned)" : ""}${t.hotspotSsid ? ` · hotspot "${t.hotspotSsid}"` : ""}`);
47
+ if (t.lastProbe)
48
+ lines.push(` last probe ${t.lastProbe.ok ? "ok" : "FAILED"} at ${clock(t.lastProbe.at)}`);
49
+ if (t.lastSwitch)
50
+ lines.push(` last switch to ${t.lastSwitch.to} at ${clock(t.lastSwitch.at)}: ${t.lastSwitch.ok ? "ok" : "failed"} (${t.lastSwitch.reason})`);
51
+ }
52
+ if (t.hint && t.enabled)
53
+ lines.push(` → ${t.hint}`);
54
+ return lines;
55
+ }
56
+ export function adminPowerCall(port) {
57
+ return async (method, body) => {
58
+ try {
59
+ const res = await fetch(`http://127.0.0.1:${port}/api/power`, {
60
+ method,
61
+ signal: AbortSignal.timeout(15_000),
62
+ ...(body ? { headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : {}),
63
+ });
64
+ return { status: res.status, json: await res.json().catch(() => ({})) };
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ };
70
+ }
71
+ export const POWER_USAGE = `usage:
72
+ clauderipple power [--json]
73
+ clauderipple awake setup [--ssid <hotspot name>] [--remove]
74
+ clauderipple awake on|off|working [--until HH:MM]
75
+ clauderipple tether on|off`;
76
+ /** Runs `power`, `awake …` or `tether …`; returns the exit code. */
77
+ export async function powerCli(args, d) {
78
+ const [cmd, sub] = args;
79
+ const opt = (name) => {
80
+ const i = args.indexOf(`--${name}`);
81
+ return i >= 0 ? args[i + 1] : undefined;
82
+ };
83
+ const flag = (name) => args.includes(`--${name}`);
84
+ const platform = d.platform ?? process.platform;
85
+ const machine = platform === "win32" ? "PC" : "Mac";
86
+ const show = async () => {
87
+ const r = await d.call("GET");
88
+ if (!r) {
89
+ d.print("the router is not answering; start it with: clauderipple start");
90
+ return 1;
91
+ }
92
+ if (r.status !== 200) {
93
+ d.print(`error: ${r.json.error ?? `HTTP ${r.status}`}`);
94
+ return 1;
95
+ }
96
+ if (flag("json"))
97
+ d.print(JSON.stringify(r.json));
98
+ else
99
+ for (const line of formatPower(r.json))
100
+ d.print(line);
101
+ return 0;
102
+ };
103
+ /** Through the router when it answers; saved to the file otherwise. */
104
+ const change = async (c) => {
105
+ const r = await d.call("PUT", c);
106
+ if (r && r.status === 200) {
107
+ for (const line of formatPower(r.json))
108
+ d.print(line);
109
+ return 0;
110
+ }
111
+ if (r) {
112
+ d.print(`error: ${r.json.error ?? `HTTP ${r.status}`}`);
113
+ return 1;
114
+ }
115
+ persistPowerChange(d.configFile, c);
116
+ d.print("✓ saved to config.json; the router is not running, so it applies when it starts (clauderipple start)");
117
+ if (c.awake?.mode === "off") {
118
+ const released = await releaseAwake(d.run, d.rippleHome, "awake off (router not running)", platform);
119
+ if (released === "released")
120
+ d.print(platform === "win32" ? "✓ the lid-close action is back to yours (the router had left it on Do nothing)" : "✓ sleep re-enabled (the router had left it disabled)");
121
+ if (released === "failed")
122
+ d.print(`✗ could not re-enable sleep: ${releaseFailedHint(platform)}`);
123
+ }
124
+ return 0;
125
+ };
126
+ if (cmd === "power")
127
+ return show();
128
+ if (cmd === "awake") {
129
+ if (sub === undefined || sub === "status")
130
+ return show();
131
+ if (sub === "setup") {
132
+ if (flag("remove")) {
133
+ // Off first, so a running router stops re-applying while the rule goes away.
134
+ await change({ awake: { mode: "off" }, tether: { enabled: false } });
135
+ const r = await awakeRemove(d.setupDeps());
136
+ d.print(`${r.ok ? "✓" : "✗"} ${r.detail}`);
137
+ return r.ok ? 0 : 1;
138
+ }
139
+ const ssid = opt("ssid");
140
+ if (args.includes("--ssid") && (ssid === undefined || ssid.startsWith("--"))) {
141
+ d.print("usage: clauderipple awake setup --ssid <hotspot name>");
142
+ return 1;
143
+ }
144
+ const r = await awakeSetup(ssid !== undefined ? { ssid } : {}, d.setupDeps());
145
+ d.print(`${r.ok ? "✓" : "✗"} ${r.detail}`);
146
+ if (!r.ok)
147
+ return 1;
148
+ if (ssid !== undefined)
149
+ persistPowerChange(d.configFile, { tether: { hotspotSsid: ssid } });
150
+ d.print("");
151
+ d.print("Next:");
152
+ d.print(` clauderipple awake working keep the ${machine} awake (lid closed too) while Claude works, and 10 min after`);
153
+ d.print(" clauderipple awake on [--until HH:MM] keep it awake regardless");
154
+ if (ssid !== undefined)
155
+ d.print(" clauderipple tether on join the phone hotspot when Wi-Fi has no internet, and come back later");
156
+ d.print(`Keep a closed ${machine === "PC" ? "laptop" : "Mac"} on a hard, ventilated surface — never in a bag while it is held awake.`);
157
+ return 0;
158
+ }
159
+ if (sub === "on" || sub === "off" || sub === "working") {
160
+ const raw = opt("until");
161
+ let until = null;
162
+ if (raw !== undefined) {
163
+ if (sub === "off") {
164
+ d.print("--until applies to on and working");
165
+ return 1;
166
+ }
167
+ try {
168
+ until = parseUntil(raw, d.now());
169
+ }
170
+ catch (e) {
171
+ d.print(e.message);
172
+ return 1;
173
+ }
174
+ }
175
+ return change({ awake: { mode: sub, until } });
176
+ }
177
+ }
178
+ if (cmd === "tether" && (sub === "on" || sub === "off"))
179
+ return change({ tether: { enabled: sub === "on" } });
180
+ d.print(POWER_USAGE);
181
+ return 1;
182
+ }
@@ -0,0 +1,157 @@
1
+ // What `install` registers with the supervisor, as data — so `repair` can register exactly the
2
+ // same thing and `doctor` can tell whether what is registered is still this installation's.
3
+ //
4
+ // The failure this exists for (2026-10-20): the launchd plist named a program that no longer
5
+ // existed, so launchd could not start the router, Claude Desktop and Claude Code got
6
+ // ECONNREFUSED on everything, and the dashboard — served by the router — was gone with it.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { BUNDLE_ID, launcherPath, launcherScript, writeBundle } from "./bundle.js";
10
+ import * as launchd from "./launchd.js";
11
+ import * as schtasks from "./schtasks.js";
12
+ import * as systemd from "./systemd.js";
13
+ /**
14
+ * Windows and Linux register the runtime + router script directly; so does a packaged app. A macOS
15
+ * source checkout or npm install runs a generated bundle instead (bundle.ts). `platform` is a
16
+ * parameter only so this pure mapping can be tested for every platform; nothing here runs anything.
17
+ */
18
+ export function registration(rt, home, version, platform = process.platform, settingsPathEnv = process.env.CLAUDE_SETTINGS_PATH ?? null) {
19
+ const settingsEnv = settingsPathEnv ? { CLAUDE_SETTINGS_PATH: settingsPathEnv } : {};
20
+ if (rt.packaged || platform === "win32" || platform === "linux") {
21
+ return { program: rt.node, args: [rt.router], bundleId: "com.clauderipple.app", env: { ...rt.env, ...settingsEnv }, bundle: null };
22
+ }
23
+ return { program: launcherPath(home), args: [], bundleId: BUNDLE_ID, env: settingsEnv, bundle: { node: rt.node, script: rt.router, version } };
24
+ }
25
+ /** The single execution contract the GUI, admin API, hooks and supervisor read (`paths.json`). */
26
+ export function writePaths(rt, home) {
27
+ const file = path.join(home, "paths.json");
28
+ fs.mkdirSync(home, { recursive: true });
29
+ fs.writeFileSync(file, JSON.stringify({ node: rt.node, env: rt.env, cli: rt.cli, router: rt.router, hookScript: rt.hookScript, repo: rt.repo }, null, 2) + "\n");
30
+ return file;
31
+ }
32
+ /** Reads only the head of `file`: the program is often a 100MB binary, and only a script matters. */
33
+ function head(file) {
34
+ let fd = null;
35
+ try {
36
+ fd = fs.openSync(file, "r");
37
+ const buf = Buffer.alloc(4096);
38
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
39
+ return buf.subarray(0, n).toString("utf8");
40
+ }
41
+ catch {
42
+ return "";
43
+ }
44
+ finally {
45
+ if (fd !== null)
46
+ fs.closeSync(fd);
47
+ }
48
+ }
49
+ /**
50
+ * Whether everything the definition starts exists. A launcher script (the macOS bundle) is followed
51
+ * one step: its `exec "node" "router"` targets are what actually went missing when node moved.
52
+ */
53
+ export function targetsExist(program, args) {
54
+ if (!program || !fs.existsSync(program))
55
+ return false;
56
+ const paths = args.filter((a) => path.isAbsolute(a));
57
+ const text = head(program);
58
+ if (text.startsWith("#!")) {
59
+ const exec = /^exec\s+(.+)$/m.exec(text)?.[1] ?? "";
60
+ for (const m of exec.matchAll(/"([^"]+)"|'([^']+)'/g)) {
61
+ const p = m[1] ?? m[2];
62
+ if (path.isAbsolute(p))
63
+ paths.push(p);
64
+ }
65
+ }
66
+ return paths.every((p) => fs.existsSync(p));
67
+ }
68
+ function unescapeXml(s) {
69
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
70
+ }
71
+ /** ProgramArguments from a launchd plist, or [] when there are none. */
72
+ export function plistProgramArguments(xml) {
73
+ const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml)?.[1];
74
+ if (!array)
75
+ return [];
76
+ return [...array.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
77
+ }
78
+ /** ExecStart's words from a systemd unit, undoing the quoting renderUnit applies. */
79
+ export function unitExecStart(unit) {
80
+ const line = /^ExecStart=(.*)$/m.exec(unit)?.[1];
81
+ if (!line)
82
+ return [];
83
+ return [...line.matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)].map((m) => m[1] !== undefined ? m[1].replace(/\$\$/g, "$").replace(/%%/g, "%").replace(/\\(.)/g, "$1") : m[2]);
84
+ }
85
+ /** Program and arguments from the Windows launcher script renderLauncher writes. */
86
+ export function launcherProgram(ps1) {
87
+ const program = /-FilePath '((?:[^']|'')*)'/.exec(ps1)?.[1]?.replace(/''/g, "'");
88
+ if (!program)
89
+ return [];
90
+ const list = /^\$routerArgs = @\((.*)\)$/m.exec(ps1)?.[1] ?? "";
91
+ const args = [...list.matchAll(/'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'").replace(/^"(.*)"$/, "$1"));
92
+ return [program, ...args];
93
+ }
94
+ function readText(file) {
95
+ try {
96
+ return fs.readFileSync(file, "utf8");
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ function view(definition, registered, current, state, pid) {
103
+ const [program = null, ...args] = definition;
104
+ return { registered, program, args, programExists: targetsExist(program, args), state, pid, current };
105
+ }
106
+ /** launchd, with an injectable launchctl so tests never reach the real one. */
107
+ export function launchdSupervisor(reg, home, run = launchd.launchctl) {
108
+ const stdoutLog = path.join(home, "logs", "launchd.log");
109
+ const opts = { program: reg.program, args: reg.args, bundleId: reg.bundleId, home, env: reg.env };
110
+ return {
111
+ inspect() {
112
+ const text = readText(launchd.plistPath());
113
+ const bundleCurrent = !reg.bundle || readText(launcherPath(home)) === launcherScript({ home, node: reg.bundle.node, script: reg.bundle.script });
114
+ const current = text !== null && text === launchd.renderPlist({ ...opts, stdoutLog }) && bundleCurrent;
115
+ return view(text ? plistProgramArguments(text) : [], text !== null, current, launchd.agentState(run), launchd.agentPid(run));
116
+ },
117
+ register() {
118
+ if (reg.bundle)
119
+ writeBundle({ home, node: reg.bundle.node, script: reg.bundle.script, version: reg.bundle.version });
120
+ return launchd.installAgent(opts, run);
121
+ },
122
+ start: () => launchd.startAgent(run),
123
+ };
124
+ }
125
+ export function systemdSupervisor(reg, home) {
126
+ const opts = { program: reg.program, args: reg.args, home, env: reg.env };
127
+ return {
128
+ inspect() {
129
+ const text = readText(systemd.unitPath());
130
+ return view(text ? unitExecStart(text) : [], text !== null, text === systemd.renderUnit(opts), systemd.agentState(), systemd.agentPid());
131
+ },
132
+ register: () => systemd.installAgent(opts),
133
+ start: () => systemd.startAgent(),
134
+ };
135
+ }
136
+ export function schtasksSupervisor(reg, home) {
137
+ const opts = { program: reg.program, args: reg.args, home, env: reg.env };
138
+ const launcher = path.join(home, "clauderipple-router.ps1");
139
+ return {
140
+ inspect() {
141
+ const text = readText(launcher);
142
+ const registered = schtasks.existingTaskDefinition() !== null;
143
+ return view(text ? launcherProgram(text) : [], registered, registered && text === schtasks.renderLauncher(opts), schtasks.agentState(), schtasks.agentPid());
144
+ },
145
+ // installAgent keeps an existing task whose whole definition already matches (issue #9).
146
+ register: () => schtasks.installAgent(opts),
147
+ start: () => schtasks.startAgent(),
148
+ };
149
+ }
150
+ /** This machine's supervisor. Chosen by the real platform, never a spoofed one. */
151
+ export function nativeSupervisor(reg, home) {
152
+ if (process.platform === "win32")
153
+ return schtasksSupervisor(reg, home);
154
+ if (process.platform === "linux")
155
+ return systemdSupervisor(reg, home);
156
+ return launchdSupervisor(reg, home);
157
+ }