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,107 @@
1
+ // Hold new model calls for a moment, without refusing them.
2
+ //
3
+ // The way back from the iPhone hotspot to Wi-Fi (tether.ts) drops the link for a few seconds, so it
4
+ // waits for a quiet moment. With Claude and subagents working back-to-back there is none, and on
5
+ // 2026-10-20 the return was postponed from 19:22:54 until the hotspot itself went away. So after a
6
+ // grace period the tether controller makes the gap instead: it engages this gate, waits for the
7
+ // calls already running to finish, switches, and releases the gate.
8
+ //
9
+ // While the gate is engaged, a NEW model call (the proxy's /v1/messages, the OpenAI ingress's
10
+ // Responses/Chat calls) waits before its body is read: nothing is written to it, it is not refused
11
+ // and not answered 503 — zero vanished requests is an acceptance metric. It goes ahead when the gate
12
+ // is released or after HOLD_MAX_MS, whichever comes first. Non-model traffic is never held.
13
+ //
14
+ // Release is guaranteed three ways: the owner releases in a `finally`, a failsafe timer set at
15
+ // engage() releases on its own, and close() (the router's drain) releases and refuses to engage again.
16
+ /**
17
+ * The longest any one call waits at the gate; then it proceeds as if there were none. Longer than the
18
+ * tether's drain wait plus its switch (tether.ts RETURN_DRAIN_WAIT_MS), shorter than the ~180s after
19
+ * which Claude Code gave up on a call with no answer yet (2026-10-20).
20
+ */
21
+ export const HOLD_MAX_MS = 120_000;
22
+ const realTimers = {
23
+ set: (fn, ms) => {
24
+ const t = setTimeout(fn, ms);
25
+ t.unref();
26
+ return t;
27
+ },
28
+ clear: (t) => clearTimeout(t),
29
+ };
30
+ export class HoldGate {
31
+ reasonText = null;
32
+ failsafe = null;
33
+ isClosed = false;
34
+ waiters = new Set();
35
+ maxHoldMs;
36
+ timers;
37
+ now;
38
+ constructor(opts = {}) {
39
+ this.maxHoldMs = opts.maxHoldMs ?? HOLD_MAX_MS;
40
+ this.timers = opts.timers ?? realTimers;
41
+ this.now = opts.now ?? Date.now;
42
+ }
43
+ get engaged() {
44
+ return this.reasonText !== null;
45
+ }
46
+ get reason() {
47
+ return this.reasonText;
48
+ }
49
+ /** Calls waiting at the gate right now. */
50
+ get waiting() {
51
+ return this.waiters.size;
52
+ }
53
+ get closed() {
54
+ return this.isClosed;
55
+ }
56
+ /**
57
+ * Start holding new calls. `failsafeMs` releases the gate by itself if its owner never does.
58
+ * False after close(): a draining router holds nothing.
59
+ */
60
+ engage(reason, failsafeMs) {
61
+ if (this.isClosed)
62
+ return false;
63
+ this.reasonText = reason;
64
+ if (this.failsafe !== null)
65
+ this.timers.clear(this.failsafe);
66
+ this.failsafe = this.timers.set(() => {
67
+ this.failsafe = null;
68
+ this.release();
69
+ }, failsafeMs);
70
+ return true;
71
+ }
72
+ /** Let every held call go ahead. Idempotent. Returns how many were waiting. */
73
+ release() {
74
+ if (this.failsafe !== null)
75
+ this.timers.clear(this.failsafe);
76
+ this.failsafe = null;
77
+ this.reasonText = null;
78
+ const all = [...this.waiters];
79
+ for (const wake of all)
80
+ wake();
81
+ return all.length;
82
+ }
83
+ /** Release, and never engage again (the router is draining or exiting). */
84
+ close() {
85
+ this.isClosed = true;
86
+ return this.release();
87
+ }
88
+ /** Resolves at once when the gate is open; else when it is released or after HOLD_MAX_MS. */
89
+ wait() {
90
+ if (this.reasonText === null)
91
+ return Promise.resolve({ ms: 0, timedOut: false });
92
+ const t0 = this.now();
93
+ return new Promise((resolve) => {
94
+ let timer = null;
95
+ const done = (timedOut) => {
96
+ if (!this.waiters.delete(wake))
97
+ return;
98
+ if (timer !== null)
99
+ this.timers.clear(timer);
100
+ resolve({ ms: this.now() - t0, timedOut });
101
+ };
102
+ const wake = () => done(false);
103
+ this.waiters.add(wake);
104
+ timer = this.timers.set(() => done(true), this.maxHoldMs);
105
+ });
106
+ }
107
+ }
@@ -8,11 +8,15 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { codexEnabled, followCodexCache, writeCodexCatalog } from "../../cli/src/codex.js";
10
10
  import { ingressModels } from "./ingress/models.js";
11
- import { ConfigStore, configPath, homeDir, terminateHosts } from "./config.js";
11
+ import { ConfigStore, awakeSettings, configPath, homeDir, terminateHosts, tetherSettings } from "./config.js";
12
+ import { AwakeController, execRunner } from "./awake.js";
13
+ import { TetherController } from "./tether.js";
14
+ import { powerPlatform } from "./power.js";
12
15
  import { defaultAgentDir, syncAgentFiles } from "./agents.js";
13
16
  import { CertStore } from "./certs.js";
14
17
  import { Logger } from "./log.js";
15
- import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE } from "./health.js";
18
+ import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE, checkConnectivity } from "./health.js";
19
+ import { HoldGate } from "./hold.js";
16
20
  import { Proxy } from "./proxy.js";
17
21
  import { adoptNewClaudeModels, backfillContextWindows, measureUnsettledModels, startAdmin } from "./admin.js";
18
22
  import { loadModelCatalog } from "./catalog.js";
@@ -21,6 +25,8 @@ import { OpenAiIngress } from "./ingress/server.js";
21
25
  import { ObservedClaudeCodeAuth } from "./providers/anthropic-observed.js";
22
26
  import { VERSION } from "./version.js";
23
27
  import { allowLongSilences } from "./providers/retry.js";
28
+ import { assetsFingerprint, readLocalAssets, spawnSsh, syncRemoteAssets } from "./remote-host.js";
29
+ import { RemoteTunnels, proxyConnectHost } from "./remote-tunnel.js";
24
30
  // Startup timing. Claude Desktop routes *all* its traffic through us in picker mode, so every
25
31
  // second before the socket is open is a second the app cannot reach anything (ERR_PROXY_-
26
32
  // CONNECTION_FAILED, observed 2026-09-14 after a reboot: 26s between exec and listening).
@@ -31,6 +37,8 @@ const since = (t) => Date.now() - t;
31
37
  const home = homeDir();
32
38
  const logFile = process.env.CLAUDERIPPLE_NO_LOGFILE ? null : path.join(home, "logs", "router.log");
33
39
  let log = null;
40
+ // Created after listen(); the config callback below runs before that and finds it null.
41
+ let tunnels = null;
34
42
  const store = new ConfigStore(undefined, (c, errors) => {
35
43
  for (const e of errors)
36
44
  (log ?? console).warn(`config: ${e}`);
@@ -51,6 +59,9 @@ const store = new ConfigStore(undefined, (c, errors) => {
51
59
  catch (e) {
52
60
  (log ?? console).warn(`agent files: ${e.message}`);
53
61
  }
62
+ // Remote hosts get the regenerated agent files too, and a hand edit of `remote.hosts` takes effect.
63
+ tunnels?.reconcile(c.remote?.hosts ?? []);
64
+ tunnels?.assetsChanged();
54
65
  });
55
66
  const cfg0 = store.get();
56
67
  log = new Logger(logFile, cfg0.log.maxBytes, cfg0.log.keep, !!process.env.CLAUDERIPPLE_ECHO || !logFile);
@@ -81,15 +92,56 @@ function premintLeaves() {
81
92
  }
82
93
  }
83
94
  }
84
- const health = new UpstreamHealth(() => store.get().health.maxConsecutiveUpstreamFailures, (n) => {
85
- log.error(`${n} consecutive upstream connect failures; exiting ${EXIT_UPSTREAM_UNREACHABLE} for the supervisor to restart`);
95
+ // One gate for the proxy and the ingress: the tether controller holds new model calls at it while
96
+ // it switches the network back to Wi-Fi (hold.ts). Closed when the router drains or exits.
97
+ const hold = new HoldGate();
98
+ const powerRunner = execRunner();
99
+ const health = new UpstreamHealth(() => store.get().health.maxConsecutiveUpstreamFailures, (n, c) => {
100
+ const why = !c ? "" : c.result === "online" ? ` while this machine is online (${c.detail}): this process is stuck` : ` and the connectivity check could not tell (${c.detail})`;
101
+ log.error(`${n} consecutive upstream connect failures${why}; exiting ${EXIT_UPSTREAM_UNREACHABLE} for the supervisor to restart`);
102
+ hold.close();
86
103
  setTimeout(() => process.exit(EXIT_UPSTREAM_UNREACHABLE), 100);
104
+ }, {
105
+ // A fresh process (curl) asks first; a machine that is offline is not helped by a restart.
106
+ connectivity: () => checkConnectivity(powerRunner, process.platform),
107
+ onOffline: (n, c) => log.warn(`${n} consecutive upstream connect failures, but this machine is offline too (${c.detail}); not restarting`),
87
108
  });
88
109
  const observedClaudeCodeAuth = new ObservedClaudeCodeAuth(path.join(home, "claude-auth-observed.json"));
89
110
  // Before any provider fetches: a reasoning model may stay silent past fetch's 300s default.
90
111
  allowLongSilences();
91
- const proxy = new Proxy({ config: () => store.get(), log, certs, health, home, requests, observedClaudeCodeAuth });
92
- const ingress = new OpenAiIngress({ config: () => store.get(), log, requests, home, observedClaudeCodeAuth, chatgpt: (name) => proxy.chatgptForCodex(name) });
112
+ const proxy = new Proxy({ config: () => store.get(), log, certs, health, home, requests, observedClaudeCodeAuth, hold });
113
+ const ingress = new OpenAiIngress({ config: () => store.get(), log, requests, home, observedClaudeCodeAuth, chatgpt: (name) => proxy.chatgptForCodex(name), hold });
114
+ // Lid-closed awake and hotspot failover (macOS and Windows; no-ops elsewhere, off until configured).
115
+ // They read Claude's activity from the two servers' counters. Started after listen(), below.
116
+ const activity = () => {
117
+ const p = proxy.stats;
118
+ const i = ingress.stats;
119
+ const latest = (a, b) => (a === undefined ? b : b === undefined ? a : Math.max(a, b));
120
+ const lastStartedAt = latest(p.lastMessageStartedAt, i.lastMessageStartedAt);
121
+ const lastEndedAt = latest(p.lastMessageEndedAt, i.lastMessageEndedAt);
122
+ return {
123
+ inFlight: p.messagesInFlight + i.messagesInFlight,
124
+ ...(lastStartedAt !== undefined ? { lastStartedAt } : {}),
125
+ ...(lastEndedAt !== undefined ? { lastEndedAt } : {}),
126
+ };
127
+ };
128
+ const awake = new AwakeController({
129
+ platform: process.platform,
130
+ run: powerRunner,
131
+ settings: () => awakeSettings(store.get()),
132
+ activity,
133
+ journalFile: path.join(home, "awake.json"),
134
+ log: { info: (m) => log.info(m), warn: (m) => log.warn(m) },
135
+ });
136
+ const tether = new TetherController({
137
+ platform: process.platform,
138
+ run: powerRunner,
139
+ settings: () => tetherSettings(store.get()),
140
+ activity,
141
+ log: { info: (m) => log.info(m), warn: (m) => log.warn(m) },
142
+ hold,
143
+ stateFile: path.join(home, "tether.json"),
144
+ });
93
145
  process.on("uncaughtException", (e) => log.error(`uncaught ${e.stack ?? e}`));
94
146
  process.on("unhandledRejection", (e) => log.error(`unhandled ${e?.stack ?? e}`));
95
147
  const DRAIN_MS = 90_000; // new model calls are refused during drain, so this is the longest single call we wait for; `clauderipple restart` waits 120s
@@ -108,6 +160,8 @@ function beginDrain(reason) {
108
160
  draining = true;
109
161
  log.info(`${reason}: draining, proxy=${proxy.stats.messagesInFlight} ingress=${ingress.stats.messagesInFlight} model calls`);
110
162
  let lastReported = -1;
163
+ // Each drain() closes the shared hold gate first (hold.ts): a call held for a network switch is
164
+ // let go, refused with the retryable 503 like any new call, and waited for.
111
165
  void Promise.all([
112
166
  proxy.drain(DRAIN_MS, () => { }),
113
167
  ingress.drain(DRAIN_MS, () => { }),
@@ -116,11 +170,20 @@ function beginDrain(reason) {
116
170
  const left = proxy.stats.messagesInFlight + ingress.stats.messagesInFlight;
117
171
  if (left !== lastReported)
118
172
  log.info(`drain complete, exiting (model calls still open: ${left})`);
173
+ // The sleep bit is left as it is (awake.ts header): the successor reconciles it within one
174
+ // tick, and the journal carries the working window across the restart.
175
+ tether.stop();
176
+ awake.stop();
177
+ awake.beforeExit();
119
178
  setTimeout(() => process.exit(0), 200).unref();
120
179
  });
121
180
  }
122
181
  for (const sig of ["SIGINT", "SIGTERM"])
123
182
  process.on(sig, () => beginDrain(sig));
183
+ // Tunnels go with the process, not with the start of a drain: a remote session's in-flight model
184
+ // call travels through one, and the drain exists to let it finish. Every exit path ends here
185
+ // (drain, the health self-exit, a fatal startup error); a SIGKILL is covered by the pid record.
186
+ process.on("exit", () => tunnels?.stopAll());
124
187
  const statsEvery = 5 * 60 * 1000;
125
188
  setInterval(() => {
126
189
  const s = proxy.stats;
@@ -138,6 +201,24 @@ proxy
138
201
  ` (startup ${since(T_EXEC)}ms: node ${T_MODULE - T_EXEC}, config ${T_CONFIG - T_MODULE}, listen ${since(T_CONFIG)})`);
139
202
  const ingressPort = await ingress.listen();
140
203
  log.info(`clauderipple OpenAI ingress listening on 127.0.0.1:${ingressPort}`);
204
+ // Reverse tunnels to remote (SSH) hosts, after listen() so their far end has something to reach.
205
+ const assets = () => readLocalAssets({ home, agentDir: defaultAgentDir() });
206
+ tunnels = new RemoteTunnels({
207
+ log: log,
208
+ proxyTarget: () => ({ host: proxyConnectHost(store.get().listen.host), port: store.get().listen.port }),
209
+ pidFile: path.join(home, "remote-tunnels.json"),
210
+ sync: (host) => syncRemoteAssets(spawnSsh, host, assets()),
211
+ fingerprint: () => {
212
+ try {
213
+ return assetsFingerprint(assets());
214
+ }
215
+ catch {
216
+ return "";
217
+ }
218
+ },
219
+ });
220
+ tunnels.cleanupStale();
221
+ tunnels.reconcile(c.remote?.hosts ?? []);
141
222
  // Quota only arrives on GPT response headers, so a quiet day left the status 11 hours stale
142
223
  // (2026-09-20). Ask once now, after listen(), and never wait for the answer.
143
224
  proxy.chatgptRefreshAll();
@@ -161,6 +242,7 @@ proxy
161
242
  google: () => ({
162
243
  accounts: () => proxy.googleAccounts(),
163
244
  models: (name) => proxy.googleFetchModels(name),
245
+ usage: (name) => proxy.googleUsage(name),
164
246
  clearCooldown: (id) => proxy.googleClearCooldown(id),
165
247
  recheckVerification: (id) => proxy.googleRecheckVerification(id),
166
248
  }),
@@ -169,12 +251,32 @@ proxy
169
251
  observedClaudeCodeAuth,
170
252
  claudeUsage: () => proxy.claudeUsage.snapshot(),
171
253
  claudeModels: () => proxy.claudeModels.list(),
254
+ opencodeUsage: (name) => proxy.opencodeLimits(name),
255
+ grokUsage: (name) => proxy.grokLimits(name),
172
256
  image: (req, signal) => proxy.generateImage(req, signal),
173
257
  modelCatalog: () => loadModelCatalog(),
174
258
  shutdown: () => beginDrain("shutdown requested"),
259
+ remote: {
260
+ status: () => tunnels.status(),
261
+ reload: () => {
262
+ tunnels.reconcile(store.get().remote?.hosts ?? []);
263
+ return tunnels.status();
264
+ },
265
+ },
266
+ power: {
267
+ status: () => ({ platform: powerPlatform(process.platform), awake: awake.status(), tether: tether.status() }),
268
+ pin: (on) => tether.setPin(on),
269
+ poke: async () => {
270
+ tether.kick();
271
+ await awake.refresh();
272
+ },
273
+ },
175
274
  };
176
275
  const admin = await startAdmin(adminDeps);
177
276
  log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
277
+ // The first awake tick is the reconcile: a bit a previous process set is kept or cleared now.
278
+ void awake.start().catch((e) => log.warn(`awake: ${e.message}`));
279
+ tether.start();
178
280
  setImmediate(premintLeaves);
179
281
  // A minute in, once start-up traffic has settled: first the catalogue's context windows are
180
282
  // written into models already saved without one (a one-shot pass), then a few one-token requests
@@ -131,6 +131,8 @@ export class OpenAiIngress {
131
131
  claudeAccounts;
132
132
  deps;
133
133
  draining = false;
134
+ /** Model calls waiting at the hold gate (not yet in `stats`): drain() waits for them too. */
135
+ held = 0;
134
136
  constructor(deps) {
135
137
  this.deps = deps;
136
138
  this.claudeAccounts = deps.claudeAccounts ?? new ClaudeAccountAuthPool({
@@ -166,9 +168,13 @@ export class OpenAiIngress {
166
168
  /** Stop new connections, reject new model work on keep-alive sockets, then close after model calls drain. */
167
169
  async drain(maxMs, onProgress) {
168
170
  this.draining = true;
171
+ // As in the proxy: a call held for a network switch goes on now, meets the 503, and is waited for.
172
+ const released = this.deps.hold?.close() ?? 0;
173
+ if (released)
174
+ this.deps.log.info(`OPENAI drain: released ${released} model call(s) held for a network switch`);
169
175
  this.server.close();
170
176
  const start = Date.now();
171
- while (this.stats.messagesInFlight > 0 && Date.now() - start < maxMs) {
177
+ while ((this.stats.messagesInFlight > 0 || this.held > 0) && Date.now() - start < maxMs) {
172
178
  onProgress?.(this.stats.messagesInFlight);
173
179
  await new Promise((resolveP) => setTimeout(resolveP, 250));
174
180
  }
@@ -187,11 +193,25 @@ export class OpenAiIngress {
187
193
  const path = (req.url ?? "/").split("?")[0] ?? "/";
188
194
  const method = req.method ?? "GET";
189
195
  const isCall = method === "POST" && (path === "/v1/responses" || path === "/v1/responses/compact" || path === "/v1/chat/completions");
196
+ // As in the proxy (hold.ts): during a network switch a new model call waits here, unread and
197
+ // uncounted, then goes ahead. Draining refuses below instead.
198
+ const hold = this.deps.hold;
199
+ if (isCall && hold?.engaged && !this.draining) {
200
+ this.held++;
201
+ const held = await hold.wait().finally(() => this.held--);
202
+ this.deps.log.info(`OPENAI HOLD ${method} ${path} waited ${(held.ms / 1000).toFixed(1)}s${held.timedOut ? " (longest hold reached; going ahead)" : ""}`);
203
+ if (req.socket?.destroyed) {
204
+ this.deps.log.warn(`OPENAI HOLD ${method} ${path}: the client left while held`);
205
+ return;
206
+ }
207
+ }
190
208
  this.stats.started++;
191
209
  this.stats.inFlight++;
192
- if (isCall)
193
- this.stats.messagesInFlight++;
194
210
  const started = Date.now();
211
+ if (isCall) {
212
+ this.stats.messagesInFlight++;
213
+ this.stats.lastMessageStartedAt = started;
214
+ }
195
215
  let record = { kind: "messages", source: "-", target: "-", provider: "-", stream: false };
196
216
  let completed = false;
197
217
  const finish = (status, bytes, extra = {}) => {
@@ -199,8 +219,10 @@ export class OpenAiIngress {
199
219
  return;
200
220
  completed = true;
201
221
  this.stats.inFlight--;
202
- if (isCall)
222
+ if (isCall) {
203
223
  this.stats.messagesInFlight--;
224
+ this.stats.lastMessageEndedAt = Date.now();
225
+ }
204
226
  if (status >= 200 && status < 400)
205
227
  this.stats.completed++;
206
228
  else
@@ -170,9 +170,13 @@ export function effortFromRequest(value) {
170
170
  return effort ?? undefined;
171
171
  }
172
172
  /** Convert an OpenAI Responses request to a cache-friendly Anthropic Messages request. */
173
- /** Claude models that accept `output_config.effort` (measured 2026-09-13: Haiku 4.5 answers 400 "does not support the effort parameter"). */
173
+ /**
174
+ * Claude models that accept `output_config.effort` (measured 2026-09-13: Haiku 4.5 answers 400 "does
175
+ * not support the effort parameter"). Mythos 5 and 5.1 list `effort` among their capabilities in
176
+ * Claude Code 2.1.295's model catalogue (read 2026-10-14).
177
+ */
174
178
  export function claudeSupportsEffort(model) {
175
- return /^claude-(opus|sonnet|fable)-(4-[6-9]|5)(-|$)/.test(model);
179
+ return /^claude-(opus|sonnet|fable|mythos)-(4-[6-9]|5)(-|$)/.test(model);
176
180
  }
177
181
  /**
178
182
  * With a borrowed Claude Code login, Anthropic accepts the request only when the system prompt is
@@ -0,0 +1,82 @@
1
+ // Admin → host control bridge. Never forwards arbitrary URLs, headers or redirects.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { DEFAULT_PORT, remoteHome } from "../../remote/src/config.js";
6
+ import { fileURLToPath } from "node:url";
7
+ const INSTALL_HINT = "clauderipple remote install";
8
+ function target(dir) {
9
+ let port = DEFAULT_PORT;
10
+ try {
11
+ const config = JSON.parse(fs.readFileSync(path.join(dir, "host.json"), "utf8"));
12
+ if (config.port !== undefined) {
13
+ if (typeof config.port !== "number" || !Number.isInteger(config.port) || config.port < 1 || config.port > 65535)
14
+ throw new Error("invalid host port");
15
+ port = config.port;
16
+ }
17
+ }
18
+ catch (error) {
19
+ if (error.code !== "ENOENT")
20
+ throw error;
21
+ }
22
+ const secret = fs.readFileSync(path.join(dir, "local.secret"), "utf8").trim();
23
+ if (secret.length < 32 || !/^[\w-]+$/.test(secret))
24
+ throw new Error("invalid local secret");
25
+ return { port, secret };
26
+ }
27
+ function execute(file, args) {
28
+ return new Promise((resolve, reject) => execFile(file, args, { timeout: 150_000, windowsHide: true }, error => error ? reject(error) : resolve()));
29
+ }
30
+ export async function phoneRequest(route, method, body, deps = {}) {
31
+ if (route === "restart" && method === "POST") {
32
+ const uid = deps.uid ?? process.getuid?.();
33
+ const command = uid === undefined ? "npm run remote -- install" : `launchctl kickstart -k gui/${uid}/com.clauderipple.host`;
34
+ if ((deps.platform ?? process.platform) !== "darwin") {
35
+ const script = fileURLToPath(new URL(`../../remote/src/main${path.extname(import.meta.url)}`, import.meta.url));
36
+ try {
37
+ await (deps.exec ?? execute)(process.execPath, [script, "install"]);
38
+ return { status: 200, body: { ok: true } };
39
+ }
40
+ catch {
41
+ return { status: 502, body: { error: "host service install/restart failed", command: INSTALL_HINT } };
42
+ }
43
+ }
44
+ if (uid === undefined)
45
+ return { status: 500, body: { error: "host restart unavailable" } };
46
+ try {
47
+ await (deps.exec ?? execute)("launchctl", ["kickstart", "-k", `gui/${uid}/com.clauderipple.host`]);
48
+ return { status: 200, body: { ok: true } };
49
+ }
50
+ catch {
51
+ return { status: 502, body: { error: "host restart failed", command } };
52
+ }
53
+ }
54
+ if (!((method === "GET" && (route === "status" || route === "pairing")) || (method === "POST" && (route === "settings" || route === "rotate")))) {
55
+ return { status: 404, body: { error: "unknown phone endpoint or method" } };
56
+ }
57
+ let response;
58
+ try {
59
+ const { port, secret } = target(deps.home ?? remoteHome());
60
+ response = await (deps.fetch ?? fetch)(`http://127.0.0.1:${port}/local/${route}`, {
61
+ method, headers: { "x-ripple-local": secret, "content-type": "application/json" },
62
+ ...(body !== undefined ? { body } : {}), redirect: "error", signal: AbortSignal.timeout(5000),
63
+ });
64
+ }
65
+ catch {
66
+ return route === "status"
67
+ ? { status: 200, body: { hostRunning: false, hint: INSTALL_HINT } }
68
+ : { status: 503, body: { error: "phone host unavailable", hint: INSTALL_HINT } };
69
+ }
70
+ // A live old host or rejected local secret is not a stopped host. Report the boundary failure.
71
+ if (!response.ok)
72
+ return { status: response.status === 400 ? 400 : 502, body: { error: `host control HTTP ${response.status}; update the host if its local API is unavailable` } };
73
+ try {
74
+ const data = await response.json();
75
+ if (!data || typeof data !== "object" || Array.isArray(data))
76
+ throw new Error("invalid host response");
77
+ return { status: 200, body: route === "status" ? { ...data, hostRunning: true } : data };
78
+ }
79
+ catch {
80
+ return { status: 502, body: { error: "invalid host control response" } };
81
+ }
82
+ }
@@ -0,0 +1,107 @@
1
+ // The admin contract for lid-closed awake + hotspot failover (`GET|PUT /api/power`).
2
+ //
3
+ // GET → { platform: "darwin"|"win32"|"linux", awake: AwakeStatus, tether: TetherStatus }
4
+ // (`platform` lets a client word things per OS: "Mac" or "PC", the Windows hints. Anything
5
+ // that is neither macOS nor Windows reads "linux", where both features are `unsupported`.)
6
+ // PUT { awake?: { mode?: "off"|"working"|"on", until?: ISO string | null },
7
+ // tether?: { enabled?: boolean, pin?: boolean } }
8
+ // mode/until/enabled are saved to config.json (the router picks the file up by mtime, like
9
+ // every other settings save); `pin` is in-memory only — it pauses switching until unpinned or
10
+ // until the router restarts.
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ export function powerPlatform(p) {
14
+ return p === "darwin" || p === "win32" ? p : "linux";
15
+ }
16
+ /** Validates a PUT body. Returns the change, or what is wrong with it. */
17
+ export function parsePowerChange(raw) {
18
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
19
+ return "expected an object";
20
+ const body = raw;
21
+ const out = {};
22
+ for (const key of Object.keys(body))
23
+ if (key !== "awake" && key !== "tether")
24
+ return `unknown field "${key}"`;
25
+ if (body.awake !== undefined) {
26
+ const a = body.awake;
27
+ if (!a || typeof a !== "object" || Array.isArray(a))
28
+ return "awake must be an object";
29
+ for (const key of Object.keys(a))
30
+ if (key !== "mode" && key !== "until")
31
+ return `unknown field "awake.${key}"`;
32
+ const awake = {};
33
+ if (a.mode !== undefined) {
34
+ if (a.mode !== "off" && a.mode !== "working" && a.mode !== "on")
35
+ return 'awake.mode must be "off", "working" or "on"';
36
+ awake.mode = a.mode;
37
+ }
38
+ if (a.until !== undefined) {
39
+ if (a.until !== null && (typeof a.until !== "string" || Number.isNaN(Date.parse(a.until))))
40
+ return "awake.until must be an ISO date-time or null";
41
+ awake.until = a.until === null ? null : new Date(Date.parse(a.until)).toISOString();
42
+ }
43
+ out.awake = awake;
44
+ }
45
+ if (body.tether !== undefined) {
46
+ const t = body.tether;
47
+ if (!t || typeof t !== "object" || Array.isArray(t))
48
+ return "tether must be an object";
49
+ // The hotspot name is set by `awake setup` only: the sudoers rule names it.
50
+ for (const key of Object.keys(t))
51
+ if (key !== "enabled" && key !== "pin")
52
+ return `unknown field "tether.${key}"${key === "hotspotSsid" ? " (set it with `clauderipple awake setup --ssid`)" : ""}`;
53
+ const tether = {};
54
+ if (t.enabled !== undefined) {
55
+ if (typeof t.enabled !== "boolean")
56
+ return "tether.enabled must be true or false";
57
+ tether.enabled = t.enabled;
58
+ }
59
+ if (t.pin !== undefined) {
60
+ if (typeof t.pin !== "boolean")
61
+ return "tether.pin must be true or false";
62
+ tether.pin = t.pin;
63
+ }
64
+ out.tether = tether;
65
+ }
66
+ return out;
67
+ }
68
+ /**
69
+ * Applies the persistent part of a change to the config file as it is on disk (re-read, so a save
70
+ * made elsewhere meanwhile is not reverted) and writes it atomically. Returns whether it wrote.
71
+ */
72
+ export function persistPowerChange(configFile, change) {
73
+ const awake = change.awake ?? {};
74
+ const tether = change.tether ?? {};
75
+ if (awake.mode === undefined && awake.until === undefined && tether.enabled === undefined && tether.hotspotSsid === undefined)
76
+ return false;
77
+ let raw = {};
78
+ try {
79
+ raw = JSON.parse(fs.readFileSync(configFile, "utf8"));
80
+ }
81
+ catch (e) {
82
+ if (e.code !== "ENOENT")
83
+ throw e;
84
+ }
85
+ if (awake.mode !== undefined || awake.until !== undefined) {
86
+ const next = { ...(raw.awake ?? {}) };
87
+ if (awake.mode !== undefined)
88
+ next.mode = awake.mode;
89
+ if (awake.until === null)
90
+ delete next.until;
91
+ else if (awake.until !== undefined)
92
+ next.until = awake.until;
93
+ raw.awake = next;
94
+ }
95
+ if (tether.enabled !== undefined || tether.hotspotSsid !== undefined) {
96
+ raw.tether = {
97
+ ...(raw.tether ?? {}),
98
+ ...(tether.enabled !== undefined ? { enabled: tether.enabled } : {}),
99
+ ...(tether.hotspotSsid !== undefined ? { hotspotSsid: tether.hotspotSsid } : {}),
100
+ };
101
+ }
102
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
103
+ const tmp = `${configFile}.tmp-${process.pid}-${Date.now()}`;
104
+ fs.writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n");
105
+ fs.renameSync(tmp, configFile);
106
+ return true;
107
+ }
@@ -25,7 +25,39 @@ export function claudeUsageFromBody(body) {
25
25
  const secondary = win(b?.seven_day, WEEK);
26
26
  if (!primary && !secondary)
27
27
  return null;
28
- return { type: "claude.usage", rate_limits: { primary: primary ?? secondary, secondary: primary ? secondary : null }, at: Date.now() };
28
+ const scoped = scopedFromLimits(b?.limits);
29
+ return {
30
+ type: "claude.usage",
31
+ rate_limits: { primary: primary ?? secondary, secondary: primary ? secondary : null },
32
+ ...(scoped.length ? { scoped } : {}),
33
+ at: Date.now(),
34
+ };
35
+ }
36
+ /**
37
+ * Per-model weekly windows from the same body: entries of `limits` with kind `weekly_scoped`
38
+ * carry the model in `scope.model.display_name` (measured 2026-10-15:
39
+ * `{"kind":"weekly_scoped","group":"weekly","percent":2,…,"scope":{"model":{"id":null,
40
+ * "display_name":"Fable"},"surface":null},"is_active":false}`). They ride beside
41
+ * `rate_limits` (which the GUI and the tray read) so those readers keep working unchanged.
42
+ */
43
+ function scopedFromLimits(limits) {
44
+ if (!Array.isArray(limits))
45
+ return [];
46
+ const out = [];
47
+ for (const entry of limits) {
48
+ if (!entry || typeof entry !== "object" || entry.kind !== "weekly_scoped")
49
+ continue;
50
+ const name = entry.scope?.model?.display_name;
51
+ const percent = entry.percent;
52
+ if (typeof name !== "string" || !name || typeof percent !== "number" || !Number.isFinite(percent))
53
+ continue;
54
+ const w = { name, used_percent: percent, window_minutes: WEEK };
55
+ const at = typeof entry.resets_at === "string" ? Date.parse(entry.resets_at) : NaN;
56
+ if (Number.isFinite(at))
57
+ w.reset_at = Math.round(at / 1000);
58
+ out.push(w);
59
+ }
60
+ return out;
29
61
  }
30
62
  /**
31
63
  * Per-account snapshots, fetched at most once per `ttlMs` per account. The GUI polls the status