clauderipple 0.2.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 (71) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/LICENSE +674 -0
  3. package/README.ko.md +328 -0
  4. package/README.md +372 -0
  5. package/bin/clauderipple.js +12 -0
  6. package/dist/app/assets/trayDownTemplate.png +0 -0
  7. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  8. package/dist/app/assets/trayTemplate.png +0 -0
  9. package/dist/app/assets/trayTemplate@2x.png +0 -0
  10. package/dist/app/assets/trayWarnTemplate.png +0 -0
  11. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  12. package/dist/app/assets/trayWin.png +0 -0
  13. package/dist/app/assets/trayWin@2x.png +0 -0
  14. package/dist/app/assets/trayWinDown.png +0 -0
  15. package/dist/app/assets/trayWinDown@2x.png +0 -0
  16. package/dist/app/assets/trayWinWarn.png +0 -0
  17. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  18. package/dist/app/dist/main.js +518 -0
  19. package/dist/cli/src/browser.js +21 -0
  20. package/dist/cli/src/bundle.js +51 -0
  21. package/dist/cli/src/certs.js +33 -0
  22. package/dist/cli/src/claude-auth.js +112 -0
  23. package/dist/cli/src/codex.js +172 -0
  24. package/dist/cli/src/gen-certs.js +7 -0
  25. package/dist/cli/src/hooks/agent-title.js +160 -0
  26. package/dist/cli/src/index.js +489 -0
  27. package/dist/cli/src/launchd.js +183 -0
  28. package/dist/cli/src/picker.js +166 -0
  29. package/dist/cli/src/probe.js +55 -0
  30. package/dist/cli/src/runtime.js +62 -0
  31. package/dist/cli/src/schtasks.js +134 -0
  32. package/dist/cli/src/settings.js +142 -0
  33. package/dist/cli/src/supervisor.js +100 -0
  34. package/dist/cli/src/tray.js +85 -0
  35. package/dist/router/src/admin.js +945 -0
  36. package/dist/router/src/bootstrap.js +80 -0
  37. package/dist/router/src/certs.js +65 -0
  38. package/dist/router/src/compat.js +172 -0
  39. package/dist/router/src/config.js +179 -0
  40. package/dist/router/src/health.js +45 -0
  41. package/dist/router/src/identity.js +51 -0
  42. package/dist/router/src/index.js +144 -0
  43. package/dist/router/src/ingress/models.js +29 -0
  44. package/dist/router/src/ingress/server.js +400 -0
  45. package/dist/router/src/ingress/translate.js +457 -0
  46. package/dist/router/src/log.js +81 -0
  47. package/dist/router/src/picker.js +74 -0
  48. package/dist/router/src/presets.js +267 -0
  49. package/dist/router/src/providers/anthropic-observed.js +88 -0
  50. package/dist/router/src/providers/anthropic-token-file.js +48 -0
  51. package/dist/router/src/providers/anthropic.js +203 -0
  52. package/dist/router/src/providers/chatgpt/auth.js +226 -0
  53. package/dist/router/src/providers/chatgpt/index.js +274 -0
  54. package/dist/router/src/providers/chatgpt/sse.js +28 -0
  55. package/dist/router/src/providers/chatgpt/translate.js +393 -0
  56. package/dist/router/src/providers/claude-oauth.js +252 -0
  57. package/dist/router/src/providers/openai/index.js +193 -0
  58. package/dist/router/src/providers/openai/translate.js +504 -0
  59. package/dist/router/src/proxy.js +724 -0
  60. package/dist/router/src/redact.js +43 -0
  61. package/dist/router/src/requestlog.js +346 -0
  62. package/dist/router/src/routing.js +113 -0
  63. package/dist/router/src/version.js +8 -0
  64. package/dist/router/src/x509.js +203 -0
  65. package/dist/ui/app.js +1228 -0
  66. package/dist/ui/i18n.js +95 -0
  67. package/dist/ui/index.html +104 -0
  68. package/dist/ui/presets-fallback.js +61 -0
  69. package/dist/ui/style.css +347 -0
  70. package/docs/ARCHITECTURE.md +441 -0
  71. package/package.json +66 -0
@@ -0,0 +1,945 @@
1
+ // Local admin API + static GUI. Binds 127.0.0.1 only, never touches the proxy port.
2
+ //
3
+ // GET /api/status snapshot for the Health screen
4
+ // GET /api/config raw config.json
5
+ // PUT /api/config validate + atomically save config.json (router picks it up via mtime)
6
+ // GET /api/logs?n= tail of router.log
7
+ // POST /api/chatgpt-login begin ChatGPT browser login without holding the request open
8
+ // GET /api/chatgpt-login ChatGPT browser login state and credential status
9
+ // GET /* static files from packages/ui (the GUI itself)
10
+ import fs from "node:fs";
11
+ import net from "node:net";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import http from "node:http";
15
+ import { execFile } from "node:child_process";
16
+ import { fileURLToPath } from "node:url";
17
+ import { homeDir, validate } from "./config.js";
18
+ import { PRESETS } from "./presets.js";
19
+ import { resolveCompatibleCaps } from "./compat.js";
20
+ import { ClaudeCodeAuthStore, nativeAnthropicHeaders } from "./providers/anthropic.js";
21
+ import { codexEnabled, codexHome } from "../../cli/src/codex.js";
22
+ import { openBrowser } from "../../cli/src/browser.js";
23
+ import { caTrusted, currentAppProxy } from "../../cli/src/picker.js";
24
+ import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
25
+ import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
26
+ const MAX_BODY = 1024 * 1024;
27
+ const here = path.dirname(fileURLToPath(import.meta.url));
28
+ const STARTED_AT = new Date().toISOString();
29
+ // packages/router/src → packages/ui in a checkout; dist/router/src → dist/ui in an npm install,
30
+ // where the build copies the dashboard so this one path serves both layouts.
31
+ const UI_ROOT = path.resolve(here, "../../ui");
32
+ const MIME = {
33
+ ".html": "text/html; charset=utf-8",
34
+ ".js": "text/javascript; charset=utf-8",
35
+ ".css": "text/css; charset=utf-8",
36
+ ".json": "application/json; charset=utf-8",
37
+ ".svg": "image/svg+xml",
38
+ ".png": "image/png",
39
+ ".ico": "image/x-icon",
40
+ };
41
+ const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "max"];
42
+ const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
43
+ const CHATGPT_LUNA_EFFORT_LEVELS = [...CHATGPT_DEFAULT_EFFORT_LEVELS, "ultra"];
44
+ let chatgptLogin = { running: false };
45
+ /** The Claude subscription sign-in in progress (or the last one), for GET /api/claude-oauth. */
46
+ let claudeOAuth = null;
47
+ /** Shared catalog for the GUI: model-specific values override provider defaults. */
48
+ export function effortLevels(cfg) {
49
+ const providers = {
50
+ anthropic: { default: ANTHROPIC_EFFORT_LEVELS },
51
+ };
52
+ for (const [name, provider] of Object.entries(cfg.providers)) {
53
+ if (provider.type === "chatgpt") {
54
+ providers[name] = {
55
+ default: CHATGPT_DEFAULT_EFFORT_LEVELS,
56
+ models: {
57
+ "gpt-5.6-luna": CHATGPT_LUNA_EFFORT_LEVELS,
58
+ "gpt-5.6-terra": CHATGPT_DEFAULT_EFFORT_LEVELS,
59
+ "gpt-5.6-sol": CHATGPT_DEFAULT_EFFORT_LEVELS,
60
+ "gpt-6-astra": CHATGPT_DEFAULT_EFFORT_LEVELS,
61
+ },
62
+ };
63
+ continue;
64
+ }
65
+ const modelLevels = Object.fromEntries((provider.models ?? [])
66
+ .filter((model) => model.effortLevels !== undefined)
67
+ .map((model) => [model.id, [...model.effortLevels]]));
68
+ if (provider.type === "openai-compatible") {
69
+ providers[name] = {
70
+ default: provider.caps?.reasoning === "effort" ? (provider.caps.effortLevels ?? []) : [],
71
+ ...(Object.keys(modelLevels).length ? { models: modelLevels } : {}),
72
+ };
73
+ continue;
74
+ }
75
+ if (provider.type === "anthropic") {
76
+ providers[name] = { default: ANTHROPIC_EFFORT_LEVELS };
77
+ continue;
78
+ }
79
+ const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
80
+ providers[name] = {
81
+ default: resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking } : undefined, provider.caps).effortLevels,
82
+ ...(Object.keys(modelLevels).length ? { models: modelLevels } : {}),
83
+ };
84
+ }
85
+ return { providers };
86
+ }
87
+ const CLAUDE_MODEL_FALLBACK = [
88
+ { id: "claude-fable-5-1", name: "Fable 5.1" },
89
+ { id: "claude-opus-5", name: "Opus 5" },
90
+ { id: "claude-sonnet-5", name: "Sonnet 5" },
91
+ { id: "claude-haiku-4-5", name: "Haiku 4.5" },
92
+ { id: "claude-fable-5", name: "Fable 5" },
93
+ { id: "claude-opus-4-8", name: "Opus 4.8" },
94
+ { id: "claude-opus-4-7", name: "Opus 4.7" },
95
+ { id: "claude-opus-4-6", name: "Opus 4.6" },
96
+ { id: "claude-sonnet-4-6", name: "Sonnet 4.6" },
97
+ ];
98
+ export function adminPort(cfg) {
99
+ return cfg.admin?.port ?? cfg.listen.port + 1;
100
+ }
101
+ function settingsPath() {
102
+ return process.env.CLAUDE_SETTINGS_PATH ?? path.join(os.homedir(), ".claude", "settings.json");
103
+ }
104
+ function readSettingsEnv() {
105
+ try {
106
+ const text = fs.readFileSync(settingsPath(), "utf8");
107
+ if (text.trim() === "")
108
+ return {};
109
+ const s = JSON.parse(text);
110
+ const env = s.env ?? {};
111
+ const out = {};
112
+ if (env.HTTPS_PROXY !== undefined)
113
+ out.HTTPS_PROXY = env.HTTPS_PROXY;
114
+ if (env.NODE_EXTRA_CA_CERTS !== undefined)
115
+ out.NODE_EXTRA_CA_CERTS = env.NODE_EXTRA_CA_CERTS;
116
+ return out;
117
+ }
118
+ catch {
119
+ return {};
120
+ }
121
+ }
122
+ /** Whether ClaudeRipple's agent-title hook is registered in settings.json (see packages/cli/src/settings.ts). */
123
+ function agentTitleHookEnabled() {
124
+ try {
125
+ const s = JSON.parse(fs.readFileSync(settingsPath(), "utf8"));
126
+ return (s.hooks?.PreToolUse ?? []).some((e) => e._clauderipple === "agent-title");
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ /** Newest mtime of the served GUI files; the page reloads itself when this changes (an open window would otherwise run stale JS). */
133
+ function uiRevision() {
134
+ try {
135
+ const files = ["index.html", "app.js", "style.css", "i18n.js", "presets-fallback.js"];
136
+ return String(Math.max(...files.map((f) => { try {
137
+ return fs.statSync(path.join(UI_ROOT, f)).mtimeMs;
138
+ }
139
+ catch {
140
+ return 0;
141
+ } })));
142
+ }
143
+ catch {
144
+ return "0";
145
+ }
146
+ }
147
+ function cliVersion() {
148
+ const dir = path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code");
149
+ try {
150
+ const versions = fs
151
+ .readdirSync(dir)
152
+ .filter((d) => /^\d+\.\d+\.\d+$/.test(d))
153
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
154
+ return versions.length ? versions[versions.length - 1] : "none";
155
+ }
156
+ catch {
157
+ return "n/a";
158
+ }
159
+ }
160
+ function tcpReachable(hostname, port, timeoutMs = 2000) {
161
+ return new Promise((resolveP) => {
162
+ const sock = net.connect({ host: hostname, port });
163
+ const timer = setTimeout(() => {
164
+ sock.destroy();
165
+ resolveP(false);
166
+ }, timeoutMs);
167
+ sock.once("connect", () => {
168
+ clearTimeout(timer);
169
+ sock.destroy();
170
+ resolveP(true);
171
+ });
172
+ sock.once("error", () => {
173
+ clearTimeout(timer);
174
+ resolveP(false);
175
+ });
176
+ });
177
+ }
178
+ /**
179
+ * Whether a chatgpt provider has usable credentials, from the files, so the answer is right before
180
+ * the first request is ever made. "auto" accepts either our own login or a Codex CLI one.
181
+ */
182
+ export function chatgptSignedIn(mode) {
183
+ const own = fs.existsSync(path.join(homeDir(), "chatgpt-auth.json"));
184
+ const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
185
+ return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
186
+ }
187
+ async function buildStatus(deps) {
188
+ const cfg = deps.config();
189
+ const providers = {};
190
+ await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
191
+ const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.url;
192
+ let reachable = false;
193
+ try {
194
+ const u = new URL(url);
195
+ reachable = await tcpReachable(u.hostname, Number(u.port) || (u.protocol === "https:" ? 443 : 80));
196
+ }
197
+ catch {
198
+ reachable = false;
199
+ }
200
+ providers[name] = {
201
+ url,
202
+ type: p.type,
203
+ reachable,
204
+ // Reaching the host says nothing about being able to use it: a chatgpt provider with no
205
+ // credentials is not "connected", and calling it that sends the user off believing it works.
206
+ ...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
207
+ ...(p.type === "anthropic"
208
+ ? {
209
+ authSource: p.auth === "claude-code" ? claudeAuthStore(deps).describeSource() : null,
210
+ signedIn: p.auth === "claude-code" ? (readClaudeAuthFile(homeDir())?.source ?? null) : null,
211
+ }
212
+ : {}),
213
+ };
214
+ }));
215
+ const chatgpt = deps.chatgpt?.() ?? { quota: {}, auth: {} };
216
+ const signedIn = {};
217
+ for (const [name, p] of Object.entries(cfg.providers)) {
218
+ if (p.type !== "chatgpt")
219
+ continue;
220
+ signedIn[name] = chatgptSignedIn(p.auth);
221
+ }
222
+ const env = readSettingsEnv();
223
+ const wantProxy = `http://127.0.0.1:${cfg.listen.port}`;
224
+ const picker = deps.picker?.() ?? { enabled: false, hosts: [], last: null };
225
+ // Readiness is not liveness: this process answering says nothing about whether a Claude request
226
+ // can go through. In picker mode a missing certificate trust or app proxy entry looks, from the
227
+ // app, exactly like a dead router. Each problem is a code the tray and the GUI can name.
228
+ const problems = [];
229
+ if (env.HTTPS_PROXY !== wantProxy)
230
+ problems.push("settings");
231
+ if (deps.health() > 0)
232
+ problems.push("upstream");
233
+ if (picker.enabled) {
234
+ const trust = pickerTrust();
235
+ if (!trust.caTrusted)
236
+ problems.push("picker-ca");
237
+ if (!trust.appProxy)
238
+ problems.push("picker-proxy");
239
+ }
240
+ for (const [name, p] of Object.entries(providers))
241
+ if (p.reachable === false)
242
+ problems.push(`provider:${name}`);
243
+ return {
244
+ version: deps.version,
245
+ readiness: { ready: problems.length === 0, problems },
246
+ // Which files this process is running, so the app can tell an old router from its own after
247
+ // an update: a zip unpacked next to the previous install left the old one serving (2026-09-15).
248
+ runtime: { node: process.execPath, router: process.argv[1] ?? null, startedAt: STARTED_AT },
249
+ home: homeDir(),
250
+ listen: cfg.listen,
251
+ upstream: cfg.upstream,
252
+ adminPort: adminPort(cfg),
253
+ stats: deps.stats(),
254
+ consecutiveUpstreamFailures: deps.health(),
255
+ routes: Object.keys(cfg.routes).length,
256
+ providers,
257
+ settings: {
258
+ HTTPS_PROXY: env.HTTPS_PROXY ?? null,
259
+ NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS ?? null,
260
+ pointsAtRouter: env.HTTPS_PROXY === wantProxy,
261
+ },
262
+ cliVersion: cliVersion(),
263
+ chatgpt: { ...chatgpt, signedIn },
264
+ picker,
265
+ agentTitle: agentTitleHookEnabled(),
266
+ pickerModels: cfg.cli.extraModels.map((m) => m.name || m.model),
267
+ uiRevision: uiRevision(),
268
+ };
269
+ }
270
+ /** Certificate trust and the app's proxy entry cost a subprocess each; the tray polls every 5s, so remember them for a minute. */
271
+ let pickerTrustMemo = null;
272
+ function pickerTrust() {
273
+ const now = Date.now();
274
+ if (pickerTrustMemo && now - pickerTrustMemo.at < 60_000)
275
+ return pickerTrustMemo.value;
276
+ let value = { caTrusted: false, appProxy: false };
277
+ try {
278
+ value = { caTrusted: caTrusted(), appProxy: currentAppProxy().ours };
279
+ }
280
+ catch {
281
+ // Unknown counts as not ready; the next poll tries again.
282
+ }
283
+ pickerTrustMemo = { at: now, value };
284
+ return value;
285
+ }
286
+ /** Run the ClaudeRipple CLI with the Node that installed us (`<home>/paths.json`, written by `install`). */
287
+ function runCli(args, timeout = 180_000) {
288
+ return new Promise((resolveP) => {
289
+ let node = process.execPath;
290
+ // Same walk either way; the extension says which layout this file was loaded from.
291
+ let cli = path.resolve(here, `../../cli/src/index${path.extname(fileURLToPath(import.meta.url))}`);
292
+ let runtimeEnv = {};
293
+ try {
294
+ const p = JSON.parse(fs.readFileSync(path.join(homeDir(), "paths.json"), "utf8"));
295
+ if (p.node)
296
+ node = p.node;
297
+ if (p.env)
298
+ runtimeEnv = p.env;
299
+ if (p.cli)
300
+ cli = p.cli;
301
+ }
302
+ catch {
303
+ /* not installed via the CLI: fall back to our own node + repo layout */
304
+ }
305
+ execFile(node, [cli, ...args], { env: { ...process.env, ...runtimeEnv, CLAUDERIPPLE_HOME: homeDir() }, timeout }, (err, stdout, stderr) => {
306
+ resolveP({ ok: !err, output: `${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim() });
307
+ });
308
+ });
309
+ }
310
+ function readBody(req) {
311
+ return new Promise((resolveP, reject) => {
312
+ const chunks = [];
313
+ let n = 0;
314
+ req.on("data", (c) => {
315
+ n += c.length;
316
+ if (n > MAX_BODY) {
317
+ reject(new Error("body too large"));
318
+ req.destroy();
319
+ return;
320
+ }
321
+ chunks.push(c);
322
+ });
323
+ req.on("end", () => resolveP(Buffer.concat(chunks)));
324
+ req.on("error", reject);
325
+ });
326
+ }
327
+ function sendJson(res, status, body) {
328
+ const out = JSON.stringify(body);
329
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": String(Buffer.byteLength(out)) });
330
+ res.end(out);
331
+ }
332
+ function headerValue(headers) {
333
+ if (!headers)
334
+ return null;
335
+ for (const [name, value] of Object.entries(headers)) {
336
+ const lower = name.toLowerCase();
337
+ if ((lower === "x-api-key" || lower === "authorization") && typeof value === "string")
338
+ return { name, value };
339
+ }
340
+ return null;
341
+ }
342
+ function authHeaders(source, kind) {
343
+ if (!source)
344
+ return {};
345
+ if (kind === "x-api-key")
346
+ return { "x-api-key": source.value };
347
+ if (kind === "authorization-bearer")
348
+ return { authorization: source.value.startsWith("Bearer ") ? source.value : `Bearer ${source.value}` };
349
+ return { [source.name]: source.value };
350
+ }
351
+ function messagesUrl(base) {
352
+ return `${base.replace(/\/+$/, "")}/v1/messages`;
353
+ }
354
+ function codexState() {
355
+ const home = codexHome();
356
+ const configPath = path.join(home, "config.toml");
357
+ try {
358
+ const text = fs.readFileSync(configPath, "utf8");
359
+ return { enabled: text.length > 0 && codexEnabled(home), codexHome: home, configPath };
360
+ }
361
+ catch {
362
+ return { enabled: false, codexHome: home, configPath };
363
+ }
364
+ }
365
+ function claudeAuthStore(deps) {
366
+ return new ClaudeCodeAuthStore(undefined, { home: homeDir(), ...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}) });
367
+ }
368
+ async function probeAnthropicApiKey(apiKey, probeFetch = fetchWithTimeout) {
369
+ const models = CLAUDE_MODEL_FALLBACK;
370
+ const label = "messages endpoint";
371
+ try {
372
+ const response = await probeFetch("https://api.anthropic.com/v1/messages", {
373
+ method: "POST",
374
+ headers: { "content-type": "application/json", ...nativeAnthropicHeaders({ type: "anthropic", auth: "api-key", apiKey }) },
375
+ body: JSON.stringify({ model: models[0].id, max_tokens: 1, messages: [{ role: "user", content: "hi" }] }),
376
+ });
377
+ if (response.status === 401 || response.status === 403)
378
+ return { ok: false, auth: "bad-key", models, error: `${label} returned ${response.status}: ${snippet(await response.text())}` };
379
+ if (response.ok)
380
+ return { ok: true, auth: "ok", models };
381
+ const detail = snippet(await response.text());
382
+ if (response.status === 400 && /model.{0,80}(not.?found|invalid|unsupported|does not exist)|unknown.{0,20}model/i.test(detail))
383
+ return { ok: true, auth: "ok", models };
384
+ if (response.status === 402 || /insufficient|balance|credit|quota|billing/i.test(detail))
385
+ return { ok: true, auth: "ok", models, error: `no-credits: ${response.status} ${detail}` };
386
+ return { ok: false, auth: response.status >= 500 ? "unreachable" : "unknown", models, error: `${label} returned ${response.status}: ${detail}` };
387
+ }
388
+ catch (e) {
389
+ return { ok: false, auth: "unreachable", models, error: `${label}: ${errorText(e)}` };
390
+ }
391
+ }
392
+ function probeClaudeCodeAuth(deps) {
393
+ const source = claudeAuthStore(deps).describeSource();
394
+ // Our own sign-in is reported separately: a Claude Desktop session outranks it, and without this
395
+ // the screen would answer a finished sign-in with the source it was already showing.
396
+ return { ok: source !== null, auth: source ? "ok" : "missing", source, signedIn: readClaudeAuthFile(homeDir())?.source ?? null, models: CLAUDE_MODEL_FALLBACK };
397
+ }
398
+ function chatCompletionsUrl(base) {
399
+ return `${base.replace(/\/+$/, "")}/chat/completions`;
400
+ }
401
+ function errorText(error) {
402
+ return error instanceof Error ? error.message : String(error);
403
+ }
404
+ function parsedModels(value) {
405
+ const data = value && typeof value === "object" && Array.isArray(value.data) ? value.data : [];
406
+ return data.flatMap((item) => {
407
+ if (!item || typeof item !== "object")
408
+ return [];
409
+ const r = item;
410
+ if (typeof r.id !== "string")
411
+ return [];
412
+ const supported = Array.isArray(r.supported_parameters) && r.supported_parameters.every((value) => typeof value === "string")
413
+ ? r.supported_parameters
414
+ : undefined;
415
+ return [{
416
+ id: r.id,
417
+ ...(typeof r.name === "string" ? { name: r.name } : {}),
418
+ ...(supported ? { effortLevels: supported.includes("reasoning_effort") ? ["low", "medium", "high"] : [] } : {}),
419
+ }];
420
+ });
421
+ }
422
+ function snippet(text) {
423
+ return text.replace(/\s+/g, " ").trim().slice(0, 200);
424
+ }
425
+ async function fetchWithTimeout(url, init) {
426
+ return fetch(url, { ...init, signal: AbortSignal.timeout(8_000) });
427
+ }
428
+ async function probeProvider(body) {
429
+ const source = headerValue(body.headers);
430
+ let models = [];
431
+ let modelsError;
432
+ if (body.modelsUrl) {
433
+ try {
434
+ const response = await fetchWithTimeout(body.modelsUrl, { headers: authHeaders(source, body.modelsAuthHeader) });
435
+ if (response.status === 401 || response.status === 403)
436
+ return { ok: false, auth: "bad-key", models: [], error: `models endpoint returned ${response.status}` };
437
+ if (response.ok)
438
+ models = parsedModels(await response.json());
439
+ else
440
+ modelsError = `models endpoint returned ${response.status}`;
441
+ }
442
+ catch (e) {
443
+ modelsError = `models endpoint: ${errorText(e)}`;
444
+ }
445
+ }
446
+ const openai = body.type === "openai-compatible";
447
+ const checkUrl = openai ? chatCompletionsUrl(body.url) : messagesUrl(body.url);
448
+ const checkBody = openai
449
+ ? { model: models[0]?.id ?? body.probeModel ?? "test", max_tokens: 1, stream: false, messages: [{ role: "user", content: "hi" }] }
450
+ : { model: models[0]?.id ?? body.probeModel ?? "test", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
451
+ const label = openai ? "chat completions endpoint" : "messages endpoint";
452
+ try {
453
+ const response = await fetchWithTimeout(checkUrl, {
454
+ method: "POST",
455
+ headers: { "content-type": "application/json", ...authHeaders(source) },
456
+ body: JSON.stringify(checkBody),
457
+ });
458
+ if (response.status === 401 || response.status === 403)
459
+ return { ok: false, auth: "bad-key", models, error: `${label} returned ${response.status}: ${snippet(await response.text())}` };
460
+ if (response.ok)
461
+ return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
462
+ const detail = snippet(await response.text());
463
+ // A model-validation 400 still demonstrates that the endpoint reached the provider and the key was accepted.
464
+ if (response.status === 400 && /model.{0,80}(not.?found|invalid|unsupported|does not exist)|unknown.{0,20}model/i.test(detail)) {
465
+ return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
466
+ }
467
+ // Key accepted but the account cannot pay: report auth ok so the user tops up instead of re-checking the key.
468
+ if (response.status === 402 || /insufficient|balance|credit|quota|billing/i.test(detail)) {
469
+ return { ok: true, auth: "ok", models, error: `no-credits: ${response.status} ${detail}` };
470
+ }
471
+ return { ok: false, auth: response.status >= 500 ? "unreachable" : "unknown", models, error: `${label} returned ${response.status}: ${detail}` };
472
+ }
473
+ catch (e) {
474
+ const message = `${label}: ${errorText(e)}`;
475
+ return { ok: false, auth: "unreachable", models, error: modelsError ? `${modelsError}; ${message}` : message };
476
+ }
477
+ }
478
+ function pickerModels(deps) {
479
+ const last = deps.picker?.().last;
480
+ if (last && typeof last === "object" && Array.isArray(last.surfaces)) {
481
+ const entries = last.surfaces
482
+ .filter((surface) => surface.id === "code" || surface.id === "ccd")
483
+ .flatMap((surface) => (Array.isArray(surface.entries) ? surface.entries : []))
484
+ .flatMap((entry) => {
485
+ if (!entry || typeof entry !== "object")
486
+ return [];
487
+ const r = entry;
488
+ return typeof r.id === "string" ? [{ id: r.id, name: typeof r.name === "string" ? r.name : r.id }] : [];
489
+ });
490
+ // The surfaces carry the same catalog, so "code" + "ccd" lists every model twice. And the
491
+ // snapshot is taken *after* injection, so our own entries are in it — offering GPT ids as
492
+ // mapping *sources* is meaningless, they are what a source maps to.
493
+ const injected = new Set(deps.config().cli.extraModels.map((m) => m.model));
494
+ const seen = new Set();
495
+ const models = entries.filter((e) => {
496
+ if (injected.has(e.id) || seen.has(e.id))
497
+ return false;
498
+ seen.add(e.id);
499
+ return true;
500
+ });
501
+ if (models.length > 0)
502
+ return { models, source: "picker" };
503
+ }
504
+ return { models: CLAUDE_MODEL_FALLBACK, source: "fallback" };
505
+ }
506
+ function tailLines(file, n) {
507
+ if (!fs.existsSync(file))
508
+ return "";
509
+ const text = fs.readFileSync(file, "utf8");
510
+ const lines = text.split("\n");
511
+ // last line is usually "" from trailing \n; keep behavior simple and predictable
512
+ if (lines.length && lines[lines.length - 1] === "")
513
+ lines.pop();
514
+ return lines.slice(-n).join("\n") + (lines.length ? "\n" : "");
515
+ }
516
+ function safeStaticPath(urlPath) {
517
+ const decoded = decodeURIComponent(urlPath.split("?")[0] ?? "/");
518
+ const rel = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
519
+ const full = path.resolve(UI_ROOT, rel);
520
+ const rootWithSep = UI_ROOT.endsWith(path.sep) ? UI_ROOT : UI_ROOT + path.sep;
521
+ if (full !== UI_ROOT && !full.startsWith(rootWithSep))
522
+ return null; // path traversal guard
523
+ return full;
524
+ }
525
+ function serveStatic(urlPath, res) {
526
+ const full = safeStaticPath(urlPath);
527
+ if (!full) {
528
+ sendJson(res, 400, { error: "invalid path" });
529
+ return;
530
+ }
531
+ fs.stat(full, (err, st) => {
532
+ if (err || !st.isFile()) {
533
+ // SPA-ish fallback: unknown paths under / serve index.html so the GUI can route itself
534
+ if (!path.extname(full)) {
535
+ const index = path.join(UI_ROOT, "index.html");
536
+ fs.readFile(index, (err2, data) => {
537
+ if (err2) {
538
+ sendJson(res, 404, { error: "not found" });
539
+ return;
540
+ }
541
+ res.writeHead(200, { "content-type": MIME[".html"] });
542
+ res.end(data);
543
+ });
544
+ return;
545
+ }
546
+ sendJson(res, 404, { error: "not found" });
547
+ return;
548
+ }
549
+ const ext = path.extname(full);
550
+ const type = MIME[ext] ?? "application/octet-stream";
551
+ fs.readFile(full, (err2, data) => {
552
+ if (err2) {
553
+ sendJson(res, 500, { error: "read failed" });
554
+ return;
555
+ }
556
+ res.writeHead(200, { "content-type": type, "content-length": String(data.length) });
557
+ res.end(data);
558
+ });
559
+ });
560
+ }
561
+ export function startAdmin(deps) {
562
+ // Set again once the socket is bound (port 0 in tests); the Origin check below compares against it.
563
+ let boundPort = adminPort(deps.config());
564
+ const server = http.createServer((req, res) => {
565
+ void handle(req, res);
566
+ });
567
+ /**
568
+ * Binding to 127.0.0.1 keeps other machines out, but not the browser on this one: any page the
569
+ * user visits can POST here, and a simple request (no custom header, no JSON content type) is
570
+ * not stopped by CORS — the response is unreadable, the side effect still happens. That is how
571
+ * a web page could turn the picker off, or shut the router down and take Claude Desktop with it.
572
+ *
573
+ * A browser always sends Origin on a cross-site POST, so requiring it to be our own origin is
574
+ * enough. Clients that are not browsers (the CLI, the tray app) send none and are let through.
575
+ */
576
+ function crossSitePost(req) {
577
+ const origin = req.headers.origin;
578
+ if (!origin)
579
+ return false;
580
+ const allowed = new Set([`http://127.0.0.1:${boundPort}`, `http://localhost:${boundPort}`]);
581
+ return !allowed.has(origin);
582
+ }
583
+ async function handle(req, res) {
584
+ const url = req.url ?? "/";
585
+ const method = req.method ?? "GET";
586
+ const pathname = url.split("?")[0] ?? "/";
587
+ try {
588
+ if (method !== "GET" && method !== "HEAD" && crossSitePost(req)) {
589
+ deps.log.warn(`admin: refused ${method} ${pathname} from origin ${String(req.headers.origin)}`);
590
+ sendJson(res, 403, { error: "cross-site request refused" });
591
+ return;
592
+ }
593
+ if (pathname === "/api/shutdown" && method === "POST") {
594
+ if (!deps.shutdown) {
595
+ sendJson(res, 501, { error: "shutdown not available" });
596
+ return;
597
+ }
598
+ // Answer before draining: the caller needs to know the request was accepted, and the
599
+ // drain can outlive the connection (it waits for in-flight model calls, up to 90s).
600
+ sendJson(res, 202, { draining: true });
601
+ deps.shutdown();
602
+ return;
603
+ }
604
+ // Liveness is answering at all; readiness is 200 only when a request can actually go through.
605
+ if (pathname === "/readyz" && method === "GET") {
606
+ const readiness = (await buildStatus(deps)).readiness;
607
+ if (!readiness.ready)
608
+ res.setHeader("retry-after", "5");
609
+ sendJson(res, readiness.ready ? 200 : 503, readiness);
610
+ return;
611
+ }
612
+ if (pathname === "/api/status" && method === "GET") {
613
+ sendJson(res, 200, await buildStatus(deps));
614
+ return;
615
+ }
616
+ if (pathname === "/api/presets" && method === "GET") {
617
+ sendJson(res, 200, { presets: PRESETS });
618
+ return;
619
+ }
620
+ if (pathname === "/api/claude-models" && method === "GET") {
621
+ sendJson(res, 200, pickerModels(deps));
622
+ return;
623
+ }
624
+ if (pathname === "/api/effort-levels" && method === "GET") {
625
+ sendJson(res, 200, effortLevels(deps.config()));
626
+ return;
627
+ }
628
+ if (pathname === "/api/providers/probe" && method === "POST") {
629
+ let raw;
630
+ try {
631
+ raw = await readBody(req);
632
+ }
633
+ catch (e) {
634
+ sendJson(res, 400, { error: e.message });
635
+ return;
636
+ }
637
+ let parsed;
638
+ try {
639
+ parsed = JSON.parse(raw.toString("utf8"));
640
+ }
641
+ catch {
642
+ sendJson(res, 400, { error: "invalid JSON" });
643
+ return;
644
+ }
645
+ if (!parsed || typeof parsed !== "object") {
646
+ sendJson(res, 400, { error: "expected provider probe object" });
647
+ return;
648
+ }
649
+ const probe = parsed;
650
+ if (probe.type === "anthropic") {
651
+ if (probe.auth === "claude-code") {
652
+ sendJson(res, 200, probeClaudeCodeAuth(deps));
653
+ return;
654
+ }
655
+ if (probe.auth === "api-key") {
656
+ const apiKey = typeof probe.apiKey === "string" && probe.apiKey.length > 0 ? probe.apiKey : process.env.ANTHROPIC_API_KEY;
657
+ if (apiKey) {
658
+ sendJson(res, 200, await probeAnthropicApiKey(apiKey, deps.probeFetch));
659
+ return;
660
+ }
661
+ }
662
+ sendJson(res, 400, { error: "expected {type: 'anthropic', auth: 'claude-code'|'api-key', apiKey?: string}" });
663
+ return;
664
+ }
665
+ if (probe.type === "chatgpt") {
666
+ const statuses = Object.values(deps.chatgpt?.().auth ?? {});
667
+ const signed = chatgptSignedIn(typeof probe.auth === "string" ? probe.auth : undefined);
668
+ sendJson(res, 200, {
669
+ ok: signed,
670
+ auth: signed ? (statuses[0] ?? "ok") : "missing",
671
+ ...(signed ? {} : { error: "no ChatGPT credentials: sign in from the tray menu, or install and sign in to the Codex CLI" }),
672
+ models: [
673
+ { id: "gpt-5.6-terra", name: "GPT-5.6 Terra" },
674
+ { id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
675
+ { id: "gpt-5.6-luna", name: "GPT-5.6 Luna" },
676
+ { id: "gpt-6-astra", name: "GPT-6 Astra" },
677
+ ],
678
+ });
679
+ return;
680
+ }
681
+ if ((probe.type !== "anthropic-compatible" && probe.type !== "openai-compatible") ||
682
+ typeof probe.url !== "string" ||
683
+ !/^https?:\/\//.test(probe.url) ||
684
+ (probe.headers !== undefined && (!probe.headers || typeof probe.headers !== "object" || Array.isArray(probe.headers) || Object.values(probe.headers).some((v) => typeof v !== "string"))) ||
685
+ (probe.modelsUrl !== undefined && typeof probe.modelsUrl !== "string") ||
686
+ (probe.modelsAuthHeader !== undefined && typeof probe.modelsAuthHeader !== "string")) {
687
+ sendJson(res, 400, { error: "expected {type: 'anthropic-compatible'|'openai-compatible', url: http(s) URL, headers?: Record<string,string>, modelsUrl?: string, modelsAuthHeader?: string}" });
688
+ return;
689
+ }
690
+ const result = await probeProvider({
691
+ type: probe.type,
692
+ url: probe.url,
693
+ ...(probe.headers ? { headers: probe.headers } : {}),
694
+ ...(probe.modelsUrl ? { modelsUrl: probe.modelsUrl } : {}),
695
+ ...(probe.modelsAuthHeader ? { modelsAuthHeader: probe.modelsAuthHeader } : {}),
696
+ ...(typeof probe.probeModel === "string" ? { probeModel: probe.probeModel } : {}),
697
+ });
698
+ sendJson(res, 200, result);
699
+ return;
700
+ }
701
+ if (pathname === "/api/config" && method === "GET") {
702
+ sendJson(res, 200, deps.config());
703
+ return;
704
+ }
705
+ if (pathname === "/api/config" && method === "PUT") {
706
+ let body;
707
+ try {
708
+ body = await readBody(req);
709
+ }
710
+ catch (e) {
711
+ sendJson(res, 400, { error: e.message });
712
+ return;
713
+ }
714
+ let parsed;
715
+ try {
716
+ parsed = JSON.parse(body.toString("utf8"));
717
+ }
718
+ catch (e) {
719
+ sendJson(res, 400, { errors: [`invalid JSON: ${e.message}`] });
720
+ return;
721
+ }
722
+ const errors = validate(parsed);
723
+ if (errors.length > 0) {
724
+ sendJson(res, 400, { errors });
725
+ return;
726
+ }
727
+ const tmp = `${deps.configFile}.tmp-${process.pid}-${Date.now()}`;
728
+ fs.mkdirSync(path.dirname(deps.configFile), { recursive: true });
729
+ fs.writeFileSync(tmp, JSON.stringify(parsed, null, 2) + "\n");
730
+ fs.renameSync(tmp, deps.configFile);
731
+ deps.log.info(`admin: config saved via GUI (${Object.keys(parsed.routes).length} routes, ${Object.keys(parsed.providers).length} providers)`);
732
+ sendJson(res, 200, { ok: true });
733
+ return;
734
+ }
735
+ if (pathname === "/api/codex" && method === "GET") {
736
+ sendJson(res, 200, codexState());
737
+ return;
738
+ }
739
+ if (pathname === "/api/codex" && method === "POST") {
740
+ let enabled;
741
+ try {
742
+ enabled = JSON.parse((await readBody(req)).toString("utf8")).enabled;
743
+ }
744
+ catch {
745
+ sendJson(res, 400, { error: "invalid JSON" });
746
+ return;
747
+ }
748
+ if (typeof enabled !== "boolean") {
749
+ sendJson(res, 400, { error: "expected {enabled: boolean}" });
750
+ return;
751
+ }
752
+ const r = await (deps.runCli ?? runCli)(["codex", enabled ? "on" : "off"]);
753
+ deps.log.info(`admin: codex ${enabled ? "on" : "off"} via GUI -> ${r.ok ? "ok" : "failed"}`);
754
+ sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
755
+ return;
756
+ }
757
+ if (pathname === "/api/chatgpt-login" && method === "GET") {
758
+ const provider = Object.values(deps.config().providers).find((candidate) => candidate.type === "chatgpt");
759
+ sendJson(res, 200, { ...chatgptLogin, signedIn: chatgptSignedIn(provider?.auth) });
760
+ return;
761
+ }
762
+ if (pathname === "/api/chatgpt-login" && method === "POST") {
763
+ if (chatgptLogin.running) {
764
+ sendJson(res, 200, { running: true });
765
+ return;
766
+ }
767
+ chatgptLogin = { running: true, startedAt: new Date().toISOString() };
768
+ deps.log.info("admin: chatgpt-login via GUI -> started");
769
+ void (deps.runCli ?? runCli)(["login"], 330_000).then((result) => {
770
+ chatgptLogin = { ...chatgptLogin, running: false, finishedAt: new Date().toISOString(), ok: result.ok, output: result.output };
771
+ deps.log.info(`admin: chatgpt-login via GUI -> ${result.ok ? "ok" : "failed"}`);
772
+ }, (error) => {
773
+ chatgptLogin = { ...chatgptLogin, running: false, finishedAt: new Date().toISOString(), ok: false, output: errorText(error) };
774
+ deps.log.info("admin: chatgpt-login via GUI -> failed");
775
+ });
776
+ sendJson(res, 200, { started: true });
777
+ return;
778
+ }
779
+ // Claude subscription sign-in of our own (browser, PKCE). The GUI starts it, polls the state,
780
+ // and pastes the code when the loopback port could not be used. Tokens never leave the router.
781
+ if (pathname === "/api/claude-oauth" && method === "GET") {
782
+ const state = { ...(claudeOAuth?.snapshot ?? { running: false, url: null, manual: false, startedAt: null, finishedAt: null, ok: null, error: null }), source: claudeAuthStore(deps).describeSource() };
783
+ sendJson(res, 200, state);
784
+ return;
785
+ }
786
+ if (pathname === "/api/claude-oauth" && method === "POST") {
787
+ if (claudeOAuth?.snapshot.running) {
788
+ sendJson(res, 200, claudeOAuth.snapshot);
789
+ return;
790
+ }
791
+ let manual = false;
792
+ try {
793
+ const body = (await readBody(req)).toString("utf8");
794
+ manual = body.length > 0 && JSON.parse(body).manual === true;
795
+ }
796
+ catch {
797
+ sendJson(res, 400, { error: "invalid JSON" });
798
+ return;
799
+ }
800
+ const session = new ClaudeOAuthSession({ home: homeDir(), manual, ...(deps.claudeOAuthFetch ? { fetch: deps.claudeOAuthFetch } : {}) });
801
+ claudeOAuth = session;
802
+ const { url, manual: needsCode } = await session.start();
803
+ const opened = deps.openBrowser ? deps.openBrowser(url) : openBrowser(url);
804
+ deps.log.info(`admin: claude sign-in via GUI -> started (${needsCode ? "paste the code" : "loopback callback"}, browser ${opened ? "opened" : "not opened"})`);
805
+ void session.result.then(() => deps.log.info("admin: claude sign-in via GUI -> ok"), (error) => deps.log.info(`admin: claude sign-in via GUI -> failed: ${error.message}`));
806
+ sendJson(res, 200, { ...session.snapshot, opened });
807
+ return;
808
+ }
809
+ if (pathname === "/api/claude-oauth/code" && method === "POST") {
810
+ const session = claudeOAuth;
811
+ if (!session?.snapshot.running) {
812
+ sendJson(res, 409, { error: "no Claude sign-in is waiting for a code" });
813
+ return;
814
+ }
815
+ let code = "";
816
+ try {
817
+ code = String(JSON.parse((await readBody(req)).toString("utf8")).code ?? "");
818
+ }
819
+ catch {
820
+ sendJson(res, 400, { error: "invalid JSON" });
821
+ return;
822
+ }
823
+ await session.submitCode(code);
824
+ await session.result.catch(() => { });
825
+ sendJson(res, session.snapshot.ok ? 200 : 400, session.snapshot);
826
+ return;
827
+ }
828
+ if (pathname === "/api/claude-oauth/cancel" && method === "POST") {
829
+ claudeOAuth?.cancel();
830
+ sendJson(res, 200, claudeOAuth?.snapshot ?? { running: false });
831
+ return;
832
+ }
833
+ if (pathname === "/api/claude-login" && method === "POST") {
834
+ const r = await (deps.runCli ?? runCli)(["claude-login"], 200_000);
835
+ deps.log.info(`admin: claude-login via GUI -> ${r.ok ? "ok" : "failed"}`);
836
+ sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
837
+ return;
838
+ }
839
+ if (pathname === "/api/claude-logout" && method === "POST") {
840
+ const r = await (deps.runCli ?? runCli)(["claude-logout"]);
841
+ deps.log.info(`admin: claude-logout via GUI -> ${r.ok ? "ok" : "failed"}`);
842
+ sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
843
+ return;
844
+ }
845
+ if (pathname === "/api/picker" && method === "POST") {
846
+ // Runs `clauderipple picker on|off` (keychain trust, app Config Library, config flag).
847
+ // macOS shows its keychain password dialog in the user's session; we never see the password.
848
+ let body;
849
+ try {
850
+ body = await readBody(req);
851
+ }
852
+ catch (e) {
853
+ sendJson(res, 400, { error: e.message });
854
+ return;
855
+ }
856
+ let enabled;
857
+ try {
858
+ enabled = JSON.parse(body.toString("utf8")).enabled;
859
+ }
860
+ catch {
861
+ sendJson(res, 400, { error: "invalid JSON" });
862
+ return;
863
+ }
864
+ if (typeof enabled !== "boolean") {
865
+ sendJson(res, 400, { error: "expected {enabled: boolean}" });
866
+ return;
867
+ }
868
+ const r = await runCli(["picker", enabled ? "on" : "off"]);
869
+ deps.log.info(`admin: picker ${enabled ? "on" : "off"} via GUI -> ${r.ok ? "ok" : "failed"}`);
870
+ sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
871
+ return;
872
+ }
873
+ if (pathname === "/api/agent-title" && method === "POST") {
874
+ // Registers/removes the PreToolUse hook that prefixes subagent titles with the real model.
875
+ let enabled;
876
+ try {
877
+ enabled = JSON.parse((await readBody(req)).toString("utf8")).enabled;
878
+ }
879
+ catch {
880
+ sendJson(res, 400, { error: "invalid JSON" });
881
+ return;
882
+ }
883
+ if (typeof enabled !== "boolean") {
884
+ sendJson(res, 400, { error: "expected {enabled: boolean}" });
885
+ return;
886
+ }
887
+ const r = await runCli(["agent-title", enabled ? "on" : "off"]);
888
+ deps.log.info(`admin: agent-title ${enabled ? "on" : "off"} via GUI -> ${r.ok ? "ok" : "failed"}`);
889
+ sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
890
+ return;
891
+ }
892
+ if (pathname === "/api/requests" && method === "GET") {
893
+ const query = new URL(url, "http://x").searchParams;
894
+ const requested = Number(query.get("n") ?? 200);
895
+ const n = Number.isFinite(requested) ? Math.min(2000, Math.max(1, Math.floor(requested))) : 200;
896
+ const provider = query.get("provider") || undefined;
897
+ const kindValue = query.get("kind");
898
+ const kind = kindValue === "messages" || kindValue === "count_tokens" || kindValue === "other" ? kindValue : undefined;
899
+ sendJson(res, 200, { requests: deps.requests.list(n, { ...(provider ? { provider } : {}), ...(kind ? { kind } : {}) }) });
900
+ return;
901
+ }
902
+ if (pathname === "/api/requests/summary" && method === "GET") {
903
+ const seconds = Number(new URL(url, "http://x").searchParams.get("since") ?? 3600);
904
+ const safeSeconds = Number.isFinite(seconds) ? Math.min(31_536_000, Math.max(0, seconds)) : 3600;
905
+ sendJson(res, 200, deps.requests.summary(Date.now() - safeSeconds * 1000));
906
+ return;
907
+ }
908
+ if (pathname === "/api/logs" && method === "GET") {
909
+ const n = Number(new URL(url, "http://x").searchParams.get("n") ?? 200) || 200;
910
+ const file = path.join(homeDir(), "logs", "router.log");
911
+ const text = tailLines(file, n);
912
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
913
+ res.end(text);
914
+ return;
915
+ }
916
+ if (method === "GET") {
917
+ serveStatic(pathname, res);
918
+ return;
919
+ }
920
+ sendJson(res, 404, { error: "not found" });
921
+ }
922
+ catch (e) {
923
+ deps.log.error(`admin error: ${e.stack ?? e}`);
924
+ if (!res.headersSent)
925
+ sendJson(res, 500, { error: e.message });
926
+ else
927
+ res.destroy();
928
+ }
929
+ }
930
+ const port = boundPort;
931
+ return new Promise((resolveP, reject) => {
932
+ server.once("error", reject);
933
+ server.listen(port, "127.0.0.1", () => {
934
+ server.off("error", reject);
935
+ const addr = server.address();
936
+ boundPort = addr && typeof addr === "object" ? addr.port : port;
937
+ resolveP({
938
+ port: boundPort,
939
+ close() {
940
+ server.close();
941
+ },
942
+ });
943
+ });
944
+ });
945
+ }