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
@@ -14,8 +14,12 @@
14
14
  // PATCH/DELETE /api/google-accounts/:id rename, pause/resume, clear a cooldown; remove
15
15
  // GET /api/update whether a newer version is published, and how this copy was installed
16
16
  // POST /api/update start `clauderipple update` in the background (it restarts this router)
17
+ // GET /api/power lid-closed awake + hotspot failover state (power.ts)
18
+ // PUT /api/power change awake mode/until, tether on/off (saved), pin (in memory)
19
+ // POST /api/power/setup start `clauderipple awake setup [--ssid]` (macOS password dialog); GET polls it
17
20
  // GET /* static files from packages/ui (the GUI itself)
18
21
  import crypto from "node:crypto";
22
+ import { phoneRequest } from "./phone.js";
19
23
  import fs from "node:fs";
20
24
  import net from "node:net";
21
25
  import os from "node:os";
@@ -23,7 +27,7 @@ import path from "node:path";
23
27
  import http from "node:http";
24
28
  import { execFile, spawn } from "node:child_process";
25
29
  import { fileURLToPath } from "node:url";
26
- import { adminPort, headerValueProblem, homeDir, searchProviderFor, validate } from "./config.js";
30
+ import { adminPort, headerValueProblem, homeDir, searchProviderFor, ssidProblem, validate } from "./config.js";
27
31
  export { adminPort } from "./config.js";
28
32
  import { generatedAgents } from "./agents.js";
29
33
  import { isJudgeOnlyModel } from "./model-policy.js";
@@ -51,12 +55,15 @@ import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
51
55
  import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount, setPreferredClaudeAccount } from "./providers/anthropic-accounts.js";
52
56
  import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
53
57
  import { exhaustedForMs, IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
58
+ import { googleWindowsFromGroups } from "./providers/google/usage.js";
59
+ import { isOpenCodeGo } from "./providers/opencode-usage.js";
54
60
  import { ANTIGRAVITY_WARNING, staticAntigravityModels } from "./providers/google/antigravity.js";
55
61
  import { readGoogleAccounts, removeGoogleAccount, summarize as summarizeGoogleAccount, updateGoogleAccount } from "./providers/google/accounts.js";
56
62
  import { GoogleOAuthSession } from "./providers/google/login.js";
57
63
  import { GROK_AUTH_HINT, grokAuthPath, grokHome, readGrokToken, sharedGrokAuth } from "./providers/grok/auth.js";
58
64
  import { GROK_DEFAULT_URL, GROK_EFFORT_LEVELS, GROK_FALLBACK_MODELS, grokModelsFromListing } from "./providers/grok/catalog.js";
59
65
  import { GROK_WARNING, grokHeaders } from "./providers/grok/index.js";
66
+ import { parsePowerChange, persistPowerChange } from "./power.js";
60
67
  const MAX_BODY = 1024 * 1024;
61
68
  /** `/api/image` carries reference images inline, so it gets more room than a config save. */
62
69
  const MAX_IMAGE_BODY = 32 * 1024 * 1024;
@@ -100,6 +107,145 @@ async function claudeAccountUsage(deps) {
100
107
  }
101
108
  return rows;
102
109
  }
110
+ /** A `{used_percent, window_minutes, reset_at}` snapshot window as `/api/limits` reads it, or null. */
111
+ function limitWindowFrom(w) {
112
+ if (!w || typeof w !== "object" || Array.isArray(w))
113
+ return null;
114
+ const o = w;
115
+ const used = o.used_percent;
116
+ if (typeof used !== "number" || !Number.isFinite(used))
117
+ return null;
118
+ const minutes = typeof o.window_minutes === "number" && o.window_minutes > 0 ? o.window_minutes : undefined;
119
+ // A reset already in milliseconds would otherwise park a window for decades; the other readers
120
+ // normalise the same way (chatgpt windowResetMs).
121
+ 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;
122
+ return { used_percent: used, ...(minutes !== undefined ? { window_minutes: minutes } : {}), ...(reset !== undefined ? { reset_at: reset } : {}) };
123
+ }
124
+ /** The kind a five-hour/weekly/other window takes, by its length in minutes. */
125
+ function windowKind(minutes) {
126
+ return minutes === 300 ? "five_hour" : minutes === 10080 ? "seven_day" : "window";
127
+ }
128
+ /** One Claude subscription account's plan windows: the two main windows and the scoped weeklies. */
129
+ function claudeLimitWindows(quota) {
130
+ const out = [];
131
+ const limits = quota.rate_limits;
132
+ for (const w of [limits?.primary, limits?.secondary]) {
133
+ const win = limitWindowFrom(w);
134
+ if (win)
135
+ 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 } : {}) });
136
+ }
137
+ const scoped = quota.scoped;
138
+ if (Array.isArray(scoped))
139
+ for (const s of scoped) {
140
+ if (!s || typeof s !== "object" || Array.isArray(s))
141
+ continue;
142
+ const entry = s;
143
+ const win = limitWindowFrom(entry);
144
+ if (!win || typeof entry.name !== "string" || !entry.name)
145
+ continue;
146
+ 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 } : {}) });
147
+ }
148
+ return out;
149
+ }
150
+ /** The Claude Code login's plan windows as one account (the row shows Claude as a single group). */
151
+ async function anthropicLimitAccounts(deps) {
152
+ let usage;
153
+ try {
154
+ usage = (await deps.claudeUsage?.()) ?? {};
155
+ }
156
+ catch {
157
+ return [];
158
+ }
159
+ const quota = usage.current ?? Object.values(usage)[0];
160
+ if (!quota)
161
+ return [];
162
+ const windows = claudeLimitWindows(quota);
163
+ if (!windows.length)
164
+ return [];
165
+ const at = typeof quota.at === "number" ? quota.at : Date.now();
166
+ return [{ account: 0, windows, at }];
167
+ }
168
+ /** Every ChatGPT account's windows, paused and needs-login accounts left out and unnumbered. */
169
+ function chatgptLimitAccounts(accounts) {
170
+ const kept = accounts.filter((a) => a.state !== "paused" && a.state !== "needs-login");
171
+ const out = [];
172
+ kept.forEach((account, index) => {
173
+ const quota = account.quota;
174
+ if (!quota || typeof quota.at !== "number")
175
+ return;
176
+ const limits = quota.rate_limits;
177
+ const windows = [];
178
+ for (const w of [limits?.primary, limits?.secondary]) {
179
+ const win = limitWindowFrom(w);
180
+ if (win)
181
+ 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 } : {}) });
182
+ }
183
+ if (windows.length)
184
+ out.push({ account: index, windows, at: quota.at });
185
+ });
186
+ return out;
187
+ }
188
+ /** Every Antigravity account's windows (Gemini's own plus the third-party ones), paused left out. */
189
+ async function googleLimitAccounts(deps, name) {
190
+ const google = deps.google?.();
191
+ if (!google)
192
+ return [];
193
+ const statuses = google.accounts()[name] ?? [];
194
+ const groups = (await google.usage?.(name)) ?? {};
195
+ const kept = statuses.filter((a) => a.state !== "paused" && a.state !== "needs-login");
196
+ const at = Date.now();
197
+ // The third-party (Claude and GPT) windows only when this provider has such a model selected:
198
+ // beside Gemini's own they made four figures, two of them a pool nobody here spends (2026-10-20).
199
+ const provider = deps.config().providers[name];
200
+ const models = provider && "models" in provider && Array.isArray(provider.models) ? provider.models : [];
201
+ const thirdParty = models.some((m) => /^(claude-|gpt-oss(-|$))/i.test(typeof m === "string" ? m : m.id));
202
+ const out = [];
203
+ kept.forEach((account, index) => {
204
+ const quotient = groups[account.id];
205
+ if (!quotient || !quotient.length)
206
+ return;
207
+ const windows = googleWindowsFromGroups(quotient).filter((w) => thirdParty || w.kind !== "scoped");
208
+ if (windows.length)
209
+ out.push({ account: index, windows, at });
210
+ });
211
+ return out;
212
+ }
213
+ /** Every provider whose quota can be read, in one shape, for `GET /api/limits`. */
214
+ async function buildLimits(deps) {
215
+ const providers = [];
216
+ const chatgpt = deps.chatgpt?.();
217
+ const accounts = chatgpt?.accounts?.() ?? {};
218
+ for (const [name, p] of Object.entries(deps.config().providers)) {
219
+ if (p.limits === false)
220
+ continue;
221
+ if (p.type === "anthropic" && p.auth === "claude-code") {
222
+ const rows = await anthropicLimitAccounts(deps);
223
+ if (rows.length)
224
+ providers.push({ provider: name, type: "anthropic", title: "Claude", accounts: rows });
225
+ }
226
+ else if (p.type === "chatgpt") {
227
+ const rows = chatgptLimitAccounts(accounts[name] ?? []);
228
+ if (rows.length)
229
+ providers.push({ provider: name, type: "chatgpt", title: "GPT", accounts: rows });
230
+ }
231
+ else if (p.type === "google" && p.auth === "antigravity") {
232
+ const rows = await googleLimitAccounts(deps, name);
233
+ if (rows.length)
234
+ providers.push({ provider: name, type: "google", title: "Gemini", accounts: rows });
235
+ }
236
+ else if (p.type === "openai-compatible" && isOpenCodeGo(p)) {
237
+ const windows = (await deps.opencodeUsage?.(name)) ?? [];
238
+ if (windows.length)
239
+ providers.push({ provider: name, type: "opencode-go", title: "Go", accounts: [{ account: 0, windows: windows.map((w) => ({ ...w })), at: Date.now() }] });
240
+ }
241
+ else if (p.type === "grok") {
242
+ const windows = (await deps.grokUsage?.(name)) ?? [];
243
+ if (windows.length)
244
+ providers.push({ provider: name, type: "grok", title: "Grok", accounts: [{ account: 0, windows: windows.map((w) => ({ ...w })), at: Date.now() }] });
245
+ }
246
+ }
247
+ return { providers };
248
+ }
103
249
  // The CLI's `--effort` takes the same five; Opus, Sonnet and Haiku 5.5 answered `xhigh` (requests.jsonl, 2026-10-14, #56).
104
250
  const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
105
251
  const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
@@ -1030,10 +1176,12 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
1030
1176
  const checkModel = presetProbeModel(body.preset) ?? models[0]?.id ?? body.probeModel ?? "test";
1031
1177
  // 16 tokens, as on Responses: some vendors refuse a smaller cap outright (B.ai: "max_tokens must be
1032
1178
  // greater than 2", #57), which read as a failed connection though the key and URL were right.
1179
+ // Streamed, as every translated turn is: the status arrives with the headers, while a reasoning
1180
+ // model answering whole can take past the timeout (Hive: 0.3s streamed, 3–23s whole, 2026-10-18).
1033
1181
  const checkBody = responses
1034
- ? { model: checkModel, max_output_tokens: 16, stream: false, store: false, input: "hi" }
1182
+ ? { model: checkModel, max_output_tokens: 16, stream: true, store: false, input: "hi" }
1035
1183
  : openai
1036
- ? { model: checkModel, max_tokens: 16, stream: false, messages: [{ role: "user", content: "hi" }] }
1184
+ ? { model: checkModel, max_tokens: 16, stream: true, messages: [{ role: "user", content: "hi" }] }
1037
1185
  : { model: checkModel, max_tokens: 16, messages: [{ role: "user", content: "hi" }] };
1038
1186
  const label = responses ? "responses endpoint" : openai ? "chat completions endpoint" : "messages endpoint";
1039
1187
  try {
@@ -1051,8 +1199,10 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
1051
1199
  const auth = response.status === 403 && refusedByPlan(refusal) ? "not-entitled" : "bad-key";
1052
1200
  return { ok: false, auth, models, error: `${label} returned ${response.status}: ${refusal}` };
1053
1201
  }
1054
- if (response.ok)
1202
+ if (response.ok) {
1203
+ await response.body?.cancel().catch(() => { });
1055
1204
  return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
1205
+ }
1056
1206
  const detail = snippet(await response.text());
1057
1207
  // A model-validation refusal still demonstrates that the endpoint reached the provider and the key was accepted.
1058
1208
  if (refusedForModel(response.status, detail)) {
@@ -1441,6 +1591,28 @@ async function pickerModels(deps) {
1441
1591
  }
1442
1592
  return { models: await claudeModelList(deps), source: "fallback" };
1443
1593
  }
1594
+ /**
1595
+ * Each model's context window, for a client that reads a session's context use without the CLI's own
1596
+ * figure (the phone, for a Claude app session). Claude's from Anthropic's catalog, the CLI's injected
1597
+ * models from their config (else `cli.autoCompactWindow`), providers' from theirs. Unknown is absent.
1598
+ */
1599
+ async function contextWindows(deps) {
1600
+ const cfg = deps.config();
1601
+ const windows = {};
1602
+ const put = (id, value) => {
1603
+ if (typeof value === "number" && Number.isFinite(value) && value > 0 && windows[id] === undefined)
1604
+ windows[id] = Math.floor(value);
1605
+ };
1606
+ for (const model of await claudeModelList(deps))
1607
+ put(model.id, model.contextWindow);
1608
+ for (const model of cfg.cli.extraModels)
1609
+ put(model.model, model.contextWindow ?? cfg.cli.autoCompactWindow);
1610
+ for (const provider of Object.values(cfg.providers)) {
1611
+ for (const model of ("models" in provider && provider.models) || [])
1612
+ put(model.id, model.contextWindow);
1613
+ }
1614
+ return { windows };
1615
+ }
1444
1616
  function tailLines(file, n) {
1445
1617
  if (!fs.existsSync(file))
1446
1618
  return "";
@@ -1502,6 +1674,8 @@ export function startAdmin(deps) {
1502
1674
  const scores = new ModelScoreCatalog({ home: homeDir(), warn: message => deps.log.warn(message), ...(deps.modelScoresFetch ? { fetch: deps.modelScoresFetch } : {}) });
1503
1675
  // Set again once the socket is bound (port 0 in tests); the Origin check below compares against it.
1504
1676
  let boundPort = adminPort(deps.config());
1677
+ /** The dashboard's `awake setup` run in progress (or the last one), for GET /api/power/setup. */
1678
+ let powerSetup = { running: false };
1505
1679
  const server = http.createServer((req, res) => {
1506
1680
  void handle(req, res);
1507
1681
  });
@@ -1535,6 +1709,28 @@ export function startAdmin(deps) {
1535
1709
  sendJson(res, 403, { error: "cross-site request refused" });
1536
1710
  return;
1537
1711
  }
1712
+ if (pathname.startsWith("/api/phone/")) {
1713
+ res.setHeader("cache-control", "no-store");
1714
+ // Pairing is a credential read: guard GETs too, including DNS-rebinding Host values.
1715
+ if (crossSitePost(req) || req.headers["sec-fetch-site"] === "cross-site" ||
1716
+ !new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`]).has(req.headers.host ?? "")) {
1717
+ sendJson(res, 403, { error: "same-origin phone control required" });
1718
+ return;
1719
+ }
1720
+ let body;
1721
+ if (method === "POST" && pathname === "/api/phone/settings") {
1722
+ try {
1723
+ body = (await readBody(req, 4096, new Error("phone settings too large"))).toString("utf8");
1724
+ }
1725
+ catch {
1726
+ sendJson(res, 413, { error: "phone settings too large" });
1727
+ return;
1728
+ }
1729
+ }
1730
+ const result = await phoneRequest(pathname.slice("/api/phone/".length), method, body, deps.phone);
1731
+ sendJson(res, result.status, result.body);
1732
+ return;
1733
+ }
1538
1734
  if (pathname === "/api/shutdown" && method === "POST") {
1539
1735
  if (!deps.shutdown) {
1540
1736
  sendJson(res, 501, { error: "shutdown not available" });
@@ -1546,6 +1742,114 @@ export function startAdmin(deps) {
1546
1742
  deps.shutdown();
1547
1743
  return;
1548
1744
  }
1745
+ if (pathname === "/api/remote" && method === "GET") {
1746
+ if (!deps.remote) {
1747
+ sendJson(res, 501, { error: "remote tunnels not available" });
1748
+ return;
1749
+ }
1750
+ sendJson(res, 200, { hosts: deps.remote.status() });
1751
+ return;
1752
+ }
1753
+ if (pathname === "/api/remote/reload" && method === "POST") {
1754
+ if (!deps.remote) {
1755
+ sendJson(res, 501, { error: "remote tunnels not available" });
1756
+ return;
1757
+ }
1758
+ sendJson(res, 200, { hosts: deps.remote.reload() });
1759
+ return;
1760
+ }
1761
+ if (pathname === "/api/power" && (method === "GET" || method === "PUT")) {
1762
+ if (!deps.power) {
1763
+ sendJson(res, 501, { error: "power control not available" });
1764
+ return;
1765
+ }
1766
+ if (method === "GET") {
1767
+ sendJson(res, 200, deps.power.status());
1768
+ return;
1769
+ }
1770
+ let parsed;
1771
+ try {
1772
+ parsed = JSON.parse((await readBody(req, 4096)).toString("utf8"));
1773
+ }
1774
+ catch {
1775
+ sendJson(res, 400, { error: "invalid JSON" });
1776
+ return;
1777
+ }
1778
+ const change = parsePowerChange(parsed);
1779
+ if (typeof change === "string") {
1780
+ sendJson(res, 400, { error: change });
1781
+ return;
1782
+ }
1783
+ if (persistPowerChange(deps.configFile, change)) {
1784
+ deps.log.info(`admin: power saved (${JSON.stringify({ ...(change.awake ? { awake: change.awake } : {}), ...(change.tether?.enabled !== undefined ? { tether: { enabled: change.tether.enabled } } : {}) })})`);
1785
+ }
1786
+ if (change.tether?.pin !== undefined)
1787
+ deps.power.pin(change.tether.pin);
1788
+ await deps.power.poke();
1789
+ sendJson(res, 200, deps.power.status());
1790
+ return;
1791
+ }
1792
+ // The one privileged step, from the dashboard: the CLI's `awake setup`, which shows macOS's own
1793
+ // password dialog. It runs in the background and is polled, like the ChatGPT sign-in: the user
1794
+ // may take minutes to type, and the app's window gives up on a response with no headers after
1795
+ // 4s (packages/app/src/protocol.ts) — a reply held open until the end would read as a dead router.
1796
+ if (pathname === "/api/power/setup" && (method === "GET" || method === "POST")) {
1797
+ if (!deps.power) {
1798
+ sendJson(res, 501, { error: "power control not available" });
1799
+ return;
1800
+ }
1801
+ if (method === "GET") {
1802
+ sendJson(res, 200, powerSetup);
1803
+ return;
1804
+ }
1805
+ let parsed;
1806
+ try {
1807
+ const text = (await readBody(req, 4096)).toString("utf8");
1808
+ parsed = text.trim() ? JSON.parse(text) : {};
1809
+ }
1810
+ catch {
1811
+ sendJson(res, 400, { error: "invalid JSON" });
1812
+ return;
1813
+ }
1814
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1815
+ sendJson(res, 400, { error: "expected an object" });
1816
+ return;
1817
+ }
1818
+ const extra = Object.keys(parsed).find((key) => key !== "ssid");
1819
+ if (extra) {
1820
+ sendJson(res, 400, { error: `unknown field "${extra}"` });
1821
+ return;
1822
+ }
1823
+ const ssid = parsed.ssid;
1824
+ if (ssid !== undefined) {
1825
+ // Checked here too so a bad name is refused before a password dialog, not after it.
1826
+ const problem = ssidProblem(ssid);
1827
+ if (problem) {
1828
+ sendJson(res, 400, { error: problem });
1829
+ return;
1830
+ }
1831
+ }
1832
+ if (powerSetup.running) {
1833
+ sendJson(res, 409, { error: "setup is already running", running: true });
1834
+ return;
1835
+ }
1836
+ powerSetup = { running: true, startedAt: new Date().toISOString() };
1837
+ const args = ["awake", "setup", ...(typeof ssid === "string" ? ["--ssid", ssid] : [])];
1838
+ deps.log.info(`admin: awake setup via GUI -> started${typeof ssid === "string" ? ` (hotspot "${ssid}")` : ""}`);
1839
+ const power = deps.power;
1840
+ // The CLI's own dialog times out at 5 min; this leaves it room to say so itself.
1841
+ void (deps.runCli ?? runCli)(args, 330_000).then((result) => ({ ok: result.ok, output: result.output }), (error) => ({ ok: false, output: errorText(error) })).then(async (result) => {
1842
+ powerSetup = { ...powerSetup, running: false, finishedAt: new Date().toISOString(), ...result };
1843
+ deps.log.info(`admin: awake setup via GUI -> ${result.ok ? "ok" : "failed"}`);
1844
+ // Re-read whether the rule is in place now, so the next GET /api/power says so.
1845
+ try {
1846
+ await power.poke();
1847
+ }
1848
+ catch { /* the next tick does it */ }
1849
+ });
1850
+ sendJson(res, 202, { started: true });
1851
+ return;
1852
+ }
1549
1853
  // Liveness is answering at all; readiness is 200 only when a request can actually go through.
1550
1854
  if (pathname === "/readyz" && method === "GET") {
1551
1855
  const readiness = (await buildStatus(deps)).readiness;
@@ -1559,6 +1863,11 @@ export function startAdmin(deps) {
1559
1863
  sendJson(res, 200, await buildStatus(deps, { refresh }));
1560
1864
  return;
1561
1865
  }
1866
+ // One shape for every provider whose quota can be read, for the status bar's limits row.
1867
+ if (pathname === "/api/limits" && method === "GET") {
1868
+ sendJson(res, 200, await buildLimits(deps));
1869
+ return;
1870
+ }
1562
1871
  if (pathname === "/api/mod-settings" && method === "GET") {
1563
1872
  const snapshot = readModSettings();
1564
1873
  // 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 +1906,10 @@ export function startAdmin(deps) {
1597
1906
  sendJson(res, 200, await pickerModels(deps));
1598
1907
  return;
1599
1908
  }
1909
+ if (pathname === "/api/context-windows" && method === "GET") {
1910
+ sendJson(res, 200, await contextWindows(deps));
1911
+ return;
1912
+ }
1600
1913
  if (pathname === "/api/effort-levels" && method === "GET") {
1601
1914
  sendJson(res, 200, effortLevels(deps.config()));
1602
1915
  return;
@@ -2294,7 +2607,20 @@ export function startAdmin(deps) {
2294
2607
  return;
2295
2608
  }
2296
2609
  if (pathname === "/api/requests/summary" && method === "GET") {
2297
- const seconds = Number(new URL(url, "http://x").searchParams.get("since") ?? 3600);
2610
+ const query = new URL(url, "http://x").searchParams;
2611
+ const fromParam = query.get("from");
2612
+ if (fromParam !== null) {
2613
+ const from = Number(fromParam);
2614
+ const toParam = query.get("to");
2615
+ const to = toParam === null ? Date.now() : Number(toParam);
2616
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from > to) {
2617
+ sendJson(res, 400, { error: "from and to must be epoch milliseconds with from <= to" });
2618
+ return;
2619
+ }
2620
+ sendJson(res, 200, { ...deps.requests.usage(from, to), from, to });
2621
+ return;
2622
+ }
2623
+ const seconds = Number(query.get("since") ?? 3600);
2298
2624
  const safeSeconds = Number.isFinite(seconds) ? Math.min(31_536_000, Math.max(0, seconds)) : 3600;
2299
2625
  sendJson(res, 200, deps.requests.summary(Date.now() - safeSeconds * 1000));
2300
2626
  return;