clauderipple 0.9.4 → 0.10.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 (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -16,6 +16,7 @@
16
16
  // POST /api/update start `clauderipple update` in the background (it restarts this router)
17
17
  // GET /* static files from packages/ui (the GUI itself)
18
18
  import crypto from "node:crypto";
19
+ import { phoneRequest } from "./phone.js";
19
20
  import fs from "node:fs";
20
21
  import net from "node:net";
21
22
  import os from "node:os";
@@ -51,6 +52,8 @@ import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
51
52
  import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount, setPreferredClaudeAccount } from "./providers/anthropic-accounts.js";
52
53
  import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
53
54
  import { exhaustedForMs, IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
55
+ import { googleWindowsFromGroups } from "./providers/google/usage.js";
56
+ import { isOpenCodeGo } from "./providers/opencode-usage.js";
54
57
  import { ANTIGRAVITY_WARNING, staticAntigravityModels } from "./providers/google/antigravity.js";
55
58
  import { readGoogleAccounts, removeGoogleAccount, summarize as summarizeGoogleAccount, updateGoogleAccount } from "./providers/google/accounts.js";
56
59
  import { GoogleOAuthSession } from "./providers/google/login.js";
@@ -100,6 +103,140 @@ async function claudeAccountUsage(deps) {
100
103
  }
101
104
  return rows;
102
105
  }
106
+ /** A `{used_percent, window_minutes, reset_at}` snapshot window as `/api/limits` reads it, or null. */
107
+ function limitWindowFrom(w) {
108
+ if (!w || typeof w !== "object" || Array.isArray(w))
109
+ return null;
110
+ const o = w;
111
+ const used = o.used_percent;
112
+ if (typeof used !== "number" || !Number.isFinite(used))
113
+ return null;
114
+ const minutes = typeof o.window_minutes === "number" && o.window_minutes > 0 ? o.window_minutes : undefined;
115
+ // A reset already in milliseconds would otherwise park a window for decades; the other readers
116
+ // normalise the same way (chatgpt windowResetMs).
117
+ const reset = typeof o.reset_at === "number" && Number.isFinite(o.reset_at) ? Math.round(o.reset_at > 1e12 ? o.reset_at / 1000 : o.reset_at) : undefined;
118
+ return { used_percent: used, ...(minutes !== undefined ? { window_minutes: minutes } : {}), ...(reset !== undefined ? { reset_at: reset } : {}) };
119
+ }
120
+ /** The kind a five-hour/weekly/other window takes, by its length in minutes. */
121
+ function windowKind(minutes) {
122
+ return minutes === 300 ? "five_hour" : minutes === 10080 ? "seven_day" : "window";
123
+ }
124
+ /** One Claude subscription account's plan windows: the two main windows and the scoped weeklies. */
125
+ function claudeLimitWindows(quota) {
126
+ const out = [];
127
+ const limits = quota.rate_limits;
128
+ for (const w of [limits?.primary, limits?.secondary]) {
129
+ const win = limitWindowFrom(w);
130
+ if (win)
131
+ out.push({ kind: windowKind(win.window_minutes), used_percent: win.used_percent, ...(win.window_minutes !== undefined ? { window_minutes: win.window_minutes } : {}), ...(win.reset_at !== undefined ? { reset_at: win.reset_at } : {}) });
132
+ }
133
+ const scoped = quota.scoped;
134
+ if (Array.isArray(scoped))
135
+ for (const s of scoped) {
136
+ if (!s || typeof s !== "object" || Array.isArray(s))
137
+ continue;
138
+ const entry = s;
139
+ const win = limitWindowFrom(entry);
140
+ if (!win || typeof entry.name !== "string" || !entry.name)
141
+ continue;
142
+ out.push({ kind: "scoped", label: entry.name, used_percent: win.used_percent, ...(win.window_minutes !== undefined ? { window_minutes: win.window_minutes } : {}), ...(win.reset_at !== undefined ? { reset_at: win.reset_at } : {}) });
143
+ }
144
+ return out;
145
+ }
146
+ /** The Claude Code login's plan windows as one account (the row shows Claude as a single group). */
147
+ async function anthropicLimitAccounts(deps) {
148
+ let usage;
149
+ try {
150
+ usage = (await deps.claudeUsage?.()) ?? {};
151
+ }
152
+ catch {
153
+ return [];
154
+ }
155
+ const quota = usage.current ?? Object.values(usage)[0];
156
+ if (!quota)
157
+ return [];
158
+ const windows = claudeLimitWindows(quota);
159
+ if (!windows.length)
160
+ return [];
161
+ const at = typeof quota.at === "number" ? quota.at : Date.now();
162
+ return [{ account: 0, windows, at }];
163
+ }
164
+ /** Every ChatGPT account's windows, paused and needs-login accounts left out and unnumbered. */
165
+ function chatgptLimitAccounts(accounts) {
166
+ const kept = accounts.filter((a) => a.state !== "paused" && a.state !== "needs-login");
167
+ const out = [];
168
+ kept.forEach((account, index) => {
169
+ const quota = account.quota;
170
+ if (!quota || typeof quota.at !== "number")
171
+ return;
172
+ const limits = quota.rate_limits;
173
+ const windows = [];
174
+ for (const w of [limits?.primary, limits?.secondary]) {
175
+ const win = limitWindowFrom(w);
176
+ if (win)
177
+ windows.push({ kind: windowKind(win.window_minutes), used_percent: win.used_percent, ...(win.window_minutes !== undefined ? { window_minutes: win.window_minutes } : {}), ...(win.reset_at !== undefined ? { reset_at: win.reset_at } : {}) });
178
+ }
179
+ if (windows.length)
180
+ out.push({ account: index, windows, at: quota.at });
181
+ });
182
+ return out;
183
+ }
184
+ /** Every Antigravity account's windows (Gemini's own plus the third-party ones), paused left out. */
185
+ async function googleLimitAccounts(deps, name) {
186
+ const google = deps.google?.();
187
+ if (!google)
188
+ return [];
189
+ const statuses = google.accounts()[name] ?? [];
190
+ const groups = (await google.usage?.(name)) ?? {};
191
+ const kept = statuses.filter((a) => a.state !== "paused" && a.state !== "needs-login");
192
+ const at = Date.now();
193
+ const out = [];
194
+ kept.forEach((account, index) => {
195
+ const quotient = groups[account.id];
196
+ if (!quotient || !quotient.length)
197
+ return;
198
+ const windows = googleWindowsFromGroups(quotient);
199
+ if (windows.length)
200
+ out.push({ account: index, windows, at });
201
+ });
202
+ return out;
203
+ }
204
+ /** Every provider whose quota can be read, in one shape, for `GET /api/limits`. */
205
+ async function buildLimits(deps) {
206
+ const providers = [];
207
+ const chatgpt = deps.chatgpt?.();
208
+ const accounts = chatgpt?.accounts?.() ?? {};
209
+ for (const [name, p] of Object.entries(deps.config().providers)) {
210
+ if (p.limits === false)
211
+ continue;
212
+ if (p.type === "anthropic" && p.auth === "claude-code") {
213
+ const rows = await anthropicLimitAccounts(deps);
214
+ if (rows.length)
215
+ providers.push({ provider: name, type: "anthropic", title: "Claude", accounts: rows });
216
+ }
217
+ else if (p.type === "chatgpt") {
218
+ const rows = chatgptLimitAccounts(accounts[name] ?? []);
219
+ if (rows.length)
220
+ providers.push({ provider: name, type: "chatgpt", title: "GPT", accounts: rows });
221
+ }
222
+ else if (p.type === "google" && p.auth === "antigravity") {
223
+ const rows = await googleLimitAccounts(deps, name);
224
+ if (rows.length)
225
+ providers.push({ provider: name, type: "google", title: "Gemini", accounts: rows });
226
+ }
227
+ else if (p.type === "openai-compatible" && isOpenCodeGo(p)) {
228
+ const windows = (await deps.opencodeUsage?.(name)) ?? [];
229
+ if (windows.length)
230
+ providers.push({ provider: name, type: "opencode-go", title: "Go", accounts: [{ account: 0, windows: windows.map((w) => ({ ...w })), at: Date.now() }] });
231
+ }
232
+ else if (p.type === "grok") {
233
+ const windows = (await deps.grokUsage?.(name)) ?? [];
234
+ if (windows.length)
235
+ providers.push({ provider: name, type: "grok", title: "Grok", accounts: [{ account: 0, windows: windows.map((w) => ({ ...w })), at: Date.now() }] });
236
+ }
237
+ }
238
+ return { providers };
239
+ }
103
240
  // The CLI's `--effort` takes the same five; Opus, Sonnet and Haiku 5.5 answered `xhigh` (requests.jsonl, 2026-10-14, #56).
104
241
  const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
105
242
  const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
@@ -1030,10 +1167,12 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
1030
1167
  const checkModel = presetProbeModel(body.preset) ?? models[0]?.id ?? body.probeModel ?? "test";
1031
1168
  // 16 tokens, as on Responses: some vendors refuse a smaller cap outright (B.ai: "max_tokens must be
1032
1169
  // greater than 2", #57), which read as a failed connection though the key and URL were right.
1170
+ // Streamed, as every translated turn is: the status arrives with the headers, while a reasoning
1171
+ // model answering whole can take past the timeout (Hive: 0.3s streamed, 3–23s whole, 2026-10-18).
1033
1172
  const checkBody = responses
1034
- ? { model: checkModel, max_output_tokens: 16, stream: false, store: false, input: "hi" }
1173
+ ? { model: checkModel, max_output_tokens: 16, stream: true, store: false, input: "hi" }
1035
1174
  : openai
1036
- ? { model: checkModel, max_tokens: 16, stream: false, messages: [{ role: "user", content: "hi" }] }
1175
+ ? { model: checkModel, max_tokens: 16, stream: true, messages: [{ role: "user", content: "hi" }] }
1037
1176
  : { model: checkModel, max_tokens: 16, messages: [{ role: "user", content: "hi" }] };
1038
1177
  const label = responses ? "responses endpoint" : openai ? "chat completions endpoint" : "messages endpoint";
1039
1178
  try {
@@ -1051,8 +1190,10 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
1051
1190
  const auth = response.status === 403 && refusedByPlan(refusal) ? "not-entitled" : "bad-key";
1052
1191
  return { ok: false, auth, models, error: `${label} returned ${response.status}: ${refusal}` };
1053
1192
  }
1054
- if (response.ok)
1193
+ if (response.ok) {
1194
+ await response.body?.cancel().catch(() => { });
1055
1195
  return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
1196
+ }
1056
1197
  const detail = snippet(await response.text());
1057
1198
  // A model-validation refusal still demonstrates that the endpoint reached the provider and the key was accepted.
1058
1199
  if (refusedForModel(response.status, detail)) {
@@ -1441,6 +1582,28 @@ async function pickerModels(deps) {
1441
1582
  }
1442
1583
  return { models: await claudeModelList(deps), source: "fallback" };
1443
1584
  }
1585
+ /**
1586
+ * Each model's context window, for a client that reads a session's context use without the CLI's own
1587
+ * figure (the phone, for a Claude app session). Claude's from Anthropic's catalog, the CLI's injected
1588
+ * models from their config (else `cli.autoCompactWindow`), providers' from theirs. Unknown is absent.
1589
+ */
1590
+ async function contextWindows(deps) {
1591
+ const cfg = deps.config();
1592
+ const windows = {};
1593
+ const put = (id, value) => {
1594
+ if (typeof value === "number" && Number.isFinite(value) && value > 0 && windows[id] === undefined)
1595
+ windows[id] = Math.floor(value);
1596
+ };
1597
+ for (const model of await claudeModelList(deps))
1598
+ put(model.id, model.contextWindow);
1599
+ for (const model of cfg.cli.extraModels)
1600
+ put(model.model, model.contextWindow ?? cfg.cli.autoCompactWindow);
1601
+ for (const provider of Object.values(cfg.providers)) {
1602
+ for (const model of ("models" in provider && provider.models) || [])
1603
+ put(model.id, model.contextWindow);
1604
+ }
1605
+ return { windows };
1606
+ }
1444
1607
  function tailLines(file, n) {
1445
1608
  if (!fs.existsSync(file))
1446
1609
  return "";
@@ -1535,6 +1698,28 @@ export function startAdmin(deps) {
1535
1698
  sendJson(res, 403, { error: "cross-site request refused" });
1536
1699
  return;
1537
1700
  }
1701
+ if (pathname.startsWith("/api/phone/")) {
1702
+ res.setHeader("cache-control", "no-store");
1703
+ // Pairing is a credential read: guard GETs too, including DNS-rebinding Host values.
1704
+ if (crossSitePost(req) || req.headers["sec-fetch-site"] === "cross-site" ||
1705
+ !new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`]).has(req.headers.host ?? "")) {
1706
+ sendJson(res, 403, { error: "same-origin phone control required" });
1707
+ return;
1708
+ }
1709
+ let body;
1710
+ if (method === "POST" && pathname === "/api/phone/settings") {
1711
+ try {
1712
+ body = (await readBody(req, 4096, new Error("phone settings too large"))).toString("utf8");
1713
+ }
1714
+ catch {
1715
+ sendJson(res, 413, { error: "phone settings too large" });
1716
+ return;
1717
+ }
1718
+ }
1719
+ const result = await phoneRequest(pathname.slice("/api/phone/".length), method, body, deps.phone);
1720
+ sendJson(res, result.status, result.body);
1721
+ return;
1722
+ }
1538
1723
  if (pathname === "/api/shutdown" && method === "POST") {
1539
1724
  if (!deps.shutdown) {
1540
1725
  sendJson(res, 501, { error: "shutdown not available" });
@@ -1546,6 +1731,22 @@ export function startAdmin(deps) {
1546
1731
  deps.shutdown();
1547
1732
  return;
1548
1733
  }
1734
+ if (pathname === "/api/remote" && method === "GET") {
1735
+ if (!deps.remote) {
1736
+ sendJson(res, 501, { error: "remote tunnels not available" });
1737
+ return;
1738
+ }
1739
+ sendJson(res, 200, { hosts: deps.remote.status() });
1740
+ return;
1741
+ }
1742
+ if (pathname === "/api/remote/reload" && method === "POST") {
1743
+ if (!deps.remote) {
1744
+ sendJson(res, 501, { error: "remote tunnels not available" });
1745
+ return;
1746
+ }
1747
+ sendJson(res, 200, { hosts: deps.remote.reload() });
1748
+ return;
1749
+ }
1549
1750
  // Liveness is answering at all; readiness is 200 only when a request can actually go through.
1550
1751
  if (pathname === "/readyz" && method === "GET") {
1551
1752
  const readiness = (await buildStatus(deps)).readiness;
@@ -1559,6 +1760,11 @@ export function startAdmin(deps) {
1559
1760
  sendJson(res, 200, await buildStatus(deps, { refresh }));
1560
1761
  return;
1561
1762
  }
1763
+ // One shape for every provider whose quota can be read, for the status bar's limits row.
1764
+ if (pathname === "/api/limits" && method === "GET") {
1765
+ sendJson(res, 200, await buildLimits(deps));
1766
+ return;
1767
+ }
1562
1768
  if (pathname === "/api/mod-settings" && method === "GET") {
1563
1769
  const snapshot = readModSettings();
1564
1770
  // Filled here as well as on save, so a ladder with empty rungs (Auto migrated, or never set) reads whole to every surface.
@@ -1597,6 +1803,10 @@ export function startAdmin(deps) {
1597
1803
  sendJson(res, 200, await pickerModels(deps));
1598
1804
  return;
1599
1805
  }
1806
+ if (pathname === "/api/context-windows" && method === "GET") {
1807
+ sendJson(res, 200, await contextWindows(deps));
1808
+ return;
1809
+ }
1600
1810
  if (pathname === "/api/effort-levels" && method === "GET") {
1601
1811
  sendJson(res, 200, effortLevels(deps.config()));
1602
1812
  return;
@@ -2294,7 +2504,20 @@ export function startAdmin(deps) {
2294
2504
  return;
2295
2505
  }
2296
2506
  if (pathname === "/api/requests/summary" && method === "GET") {
2297
- const seconds = Number(new URL(url, "http://x").searchParams.get("since") ?? 3600);
2507
+ const query = new URL(url, "http://x").searchParams;
2508
+ const fromParam = query.get("from");
2509
+ if (fromParam !== null) {
2510
+ const from = Number(fromParam);
2511
+ const toParam = query.get("to");
2512
+ const to = toParam === null ? Date.now() : Number(toParam);
2513
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from > to) {
2514
+ sendJson(res, 400, { error: "from and to must be epoch milliseconds with from <= to" });
2515
+ return;
2516
+ }
2517
+ sendJson(res, 200, { ...deps.requests.usage(from, to), from, to });
2518
+ return;
2519
+ }
2520
+ const seconds = Number(query.get("since") ?? 3600);
2298
2521
  const safeSeconds = Number.isFinite(seconds) ? Math.min(31_536_000, Math.max(0, seconds)) : 3600;
2299
2522
  sendJson(res, 200, deps.requests.summary(Date.now() - safeSeconds * 1000));
2300
2523
  return;
@@ -7,6 +7,7 @@ import fs from "node:fs";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
9
  import { PRESETS } from "./presets.js";
10
+ import { hostProblem, portProblem } from "./remote-host.js";
10
11
  /**
11
12
  * The provider's own key, re-sent under the header another wire on the same account expects.
12
13
  *
@@ -323,6 +324,8 @@ export function validate(c) {
323
324
  errors.push(`provider ${name}: identity must be true or false`);
324
325
  if (shared.instructionsAppend !== undefined && typeof shared.instructionsAppend !== "string")
325
326
  errors.push(`provider ${name}: instructionsAppend must be a string`);
327
+ if (shared.limits !== undefined && typeof shared.limits !== "boolean")
328
+ errors.push(`provider ${name}: limits must be true or false`);
326
329
  // Ids key the router's cooldown and quarantine state, so a duplicate would make two credentials
327
330
  // share one health record and take each other down.
328
331
  const pool = p.credentials;
@@ -465,6 +468,33 @@ export function validate(c) {
465
468
  errors.push("cli.limitWorkerTools must be true or false");
466
469
  if (c.listen.openaiPort !== undefined && !(c.listen.openaiPort >= 0 && c.listen.openaiPort < 65536))
467
470
  errors.push("listen.openaiPort out of range");
471
+ if (c.remote !== undefined) {
472
+ const hosts = c.remote?.hosts;
473
+ if (!Array.isArray(hosts))
474
+ errors.push("remote.hosts must be a list");
475
+ else {
476
+ const seen = new Set();
477
+ hosts.forEach((entry, i) => {
478
+ const h = entry;
479
+ if (!h || typeof h !== "object") {
480
+ errors.push(`remote.hosts ${i} must be an object`);
481
+ return;
482
+ }
483
+ // Handed to ssh as an argument: the same check `remote add` applies, so a hand edit cannot
484
+ // slip an option such as -oProxyCommand into the router's own ssh.
485
+ const hostError = hostProblem(h.host);
486
+ if (hostError)
487
+ errors.push(`remote.hosts ${i}: ${hostError}`);
488
+ else if (seen.has(h.host))
489
+ errors.push(`remote.hosts ${i}: "${String(h.host)}" is listed twice`);
490
+ else
491
+ seen.add(h.host);
492
+ const portError = portProblem(h.port);
493
+ if (portError)
494
+ errors.push(`remote.hosts ${i}: ${portError}`);
495
+ });
496
+ }
497
+ }
468
498
  return errors;
469
499
  }
470
500
  export class ConfigStore {
@@ -21,6 +21,8 @@ import { OpenAiIngress } from "./ingress/server.js";
21
21
  import { ObservedClaudeCodeAuth } from "./providers/anthropic-observed.js";
22
22
  import { VERSION } from "./version.js";
23
23
  import { allowLongSilences } from "./providers/retry.js";
24
+ import { assetsFingerprint, readLocalAssets, spawnSsh, syncRemoteAssets } from "./remote-host.js";
25
+ import { RemoteTunnels, proxyConnectHost } from "./remote-tunnel.js";
24
26
  // Startup timing. Claude Desktop routes *all* its traffic through us in picker mode, so every
25
27
  // second before the socket is open is a second the app cannot reach anything (ERR_PROXY_-
26
28
  // CONNECTION_FAILED, observed 2026-09-14 after a reboot: 26s between exec and listening).
@@ -31,6 +33,8 @@ const since = (t) => Date.now() - t;
31
33
  const home = homeDir();
32
34
  const logFile = process.env.CLAUDERIPPLE_NO_LOGFILE ? null : path.join(home, "logs", "router.log");
33
35
  let log = null;
36
+ // Created after listen(); the config callback below runs before that and finds it null.
37
+ let tunnels = null;
34
38
  const store = new ConfigStore(undefined, (c, errors) => {
35
39
  for (const e of errors)
36
40
  (log ?? console).warn(`config: ${e}`);
@@ -51,6 +55,9 @@ const store = new ConfigStore(undefined, (c, errors) => {
51
55
  catch (e) {
52
56
  (log ?? console).warn(`agent files: ${e.message}`);
53
57
  }
58
+ // Remote hosts get the regenerated agent files too, and a hand edit of `remote.hosts` takes effect.
59
+ tunnels?.reconcile(c.remote?.hosts ?? []);
60
+ tunnels?.assetsChanged();
54
61
  });
55
62
  const cfg0 = store.get();
56
63
  log = new Logger(logFile, cfg0.log.maxBytes, cfg0.log.keep, !!process.env.CLAUDERIPPLE_ECHO || !logFile);
@@ -121,6 +128,10 @@ function beginDrain(reason) {
121
128
  }
122
129
  for (const sig of ["SIGINT", "SIGTERM"])
123
130
  process.on(sig, () => beginDrain(sig));
131
+ // Tunnels go with the process, not with the start of a drain: a remote session's in-flight model
132
+ // call travels through one, and the drain exists to let it finish. Every exit path ends here
133
+ // (drain, the health self-exit, a fatal startup error); a SIGKILL is covered by the pid record.
134
+ process.on("exit", () => tunnels?.stopAll());
124
135
  const statsEvery = 5 * 60 * 1000;
125
136
  setInterval(() => {
126
137
  const s = proxy.stats;
@@ -138,6 +149,24 @@ proxy
138
149
  ` (startup ${since(T_EXEC)}ms: node ${T_MODULE - T_EXEC}, config ${T_CONFIG - T_MODULE}, listen ${since(T_CONFIG)})`);
139
150
  const ingressPort = await ingress.listen();
140
151
  log.info(`clauderipple OpenAI ingress listening on 127.0.0.1:${ingressPort}`);
152
+ // Reverse tunnels to remote (SSH) hosts, after listen() so their far end has something to reach.
153
+ const assets = () => readLocalAssets({ home, agentDir: defaultAgentDir() });
154
+ tunnels = new RemoteTunnels({
155
+ log: log,
156
+ proxyTarget: () => ({ host: proxyConnectHost(store.get().listen.host), port: store.get().listen.port }),
157
+ pidFile: path.join(home, "remote-tunnels.json"),
158
+ sync: (host) => syncRemoteAssets(spawnSsh, host, assets()),
159
+ fingerprint: () => {
160
+ try {
161
+ return assetsFingerprint(assets());
162
+ }
163
+ catch {
164
+ return "";
165
+ }
166
+ },
167
+ });
168
+ tunnels.cleanupStale();
169
+ tunnels.reconcile(c.remote?.hosts ?? []);
141
170
  // Quota only arrives on GPT response headers, so a quiet day left the status 11 hours stale
142
171
  // (2026-09-20). Ask once now, after listen(), and never wait for the answer.
143
172
  proxy.chatgptRefreshAll();
@@ -161,6 +190,7 @@ proxy
161
190
  google: () => ({
162
191
  accounts: () => proxy.googleAccounts(),
163
192
  models: (name) => proxy.googleFetchModels(name),
193
+ usage: (name) => proxy.googleUsage(name),
164
194
  clearCooldown: (id) => proxy.googleClearCooldown(id),
165
195
  recheckVerification: (id) => proxy.googleRecheckVerification(id),
166
196
  }),
@@ -169,9 +199,18 @@ proxy
169
199
  observedClaudeCodeAuth,
170
200
  claudeUsage: () => proxy.claudeUsage.snapshot(),
171
201
  claudeModels: () => proxy.claudeModels.list(),
202
+ opencodeUsage: (name) => proxy.opencodeLimits(name),
203
+ grokUsage: (name) => proxy.grokLimits(name),
172
204
  image: (req, signal) => proxy.generateImage(req, signal),
173
205
  modelCatalog: () => loadModelCatalog(),
174
206
  shutdown: () => beginDrain("shutdown requested"),
207
+ remote: {
208
+ status: () => tunnels.status(),
209
+ reload: () => {
210
+ tunnels.reconcile(store.get().remote?.hosts ?? []);
211
+ return tunnels.status();
212
+ },
213
+ },
175
214
  };
176
215
  const admin = await startAdmin(adminDeps);
177
216
  log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
@@ -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
+ }
@@ -199,6 +199,9 @@ export const PRESETS = [
199
199
  authHeader: "authorization-bearer",
200
200
  wire: "responses",
201
201
  sessionHeader: "x-opencode-session",
202
+ // Measured 2026-10-14 on muse-spark-1.3-contributor: `include` and `summary` are accepted, the
203
+ // sealed item comes back on a tool-bearing turn, and replaying it in place answers 200.
204
+ sealedReasoning: true,
202
205
  modelsUrl: "https://opencode.ai/zen/go/v1/models",
203
206
  modelsAuthHeader: "authorization-bearer",
204
207
  // Every wire below is from the endpoint table OpenCode publishes per model
@@ -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