clauderipple 0.11.0 → 0.12.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.
- package/CHANGELOG.md +42 -0
- package/README.es.md +48 -0
- package/README.ja.md +42 -0
- package/README.ko.md +39 -0
- package/README.md +44 -0
- package/README.pt-BR.md +46 -0
- package/README.zh-CN.md +40 -0
- package/dist/app/dist/claude-desktop.js +31 -0
- package/dist/app/dist/main.js +127 -8
- package/dist/app/dist/status-model.js +12 -0
- package/dist/app/dist/update-model.js +32 -0
- package/dist/app/dist/window.js +10 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +5 -1
- package/dist/mod/hooks/i18n.ts +6 -0
- package/dist/mod/hooks/modes.ts +15 -8
- package/dist/mod/hooks/register.tsx +67 -19
- package/dist/mod/hooks/settings.js +37 -2
- package/dist/mod/hooks/settings.ts +31 -3
- package/dist/mod/hooks/ui/agents.tsx +1 -1
- package/dist/mod/hooks/ui/art.ts +8 -3
- package/dist/mod/hooks/ui/band.tsx +6 -2
- package/dist/mod/hooks/ui/flat.ts +11 -8
- package/dist/mod/hooks/ui/look.ts +3 -1
- package/dist/mod/hooks/ui/view.ts +2 -0
- package/dist/mod/hooks/workers.ts +8 -1
- package/dist/mod/tests/delegation.test.tsx +50 -0
- package/dist/mod/tests/register.test.tsx +138 -1
- package/dist/mod/tests/ui.test.tsx +6 -3
- package/dist/mod/types/index.d.ts +14 -0
- package/dist/router/src/admin.js +41 -76
- package/dist/router/src/autoupdate.js +299 -0
- package/dist/router/src/bootstrap.js +3 -2
- package/dist/router/src/cli-control.js +65 -0
- package/dist/router/src/config.js +70 -11
- package/dist/router/src/index.js +11 -1
- package/dist/router/src/picker.js +25 -1
- package/dist/router/src/presets.js +21 -13
- package/dist/router/src/providers/chatgpt/index.js +11 -0
- package/dist/router/src/providers/google/index.js +7 -2
- package/dist/router/src/providers/google/transport.js +16 -1
- package/dist/router/src/proxy.js +46 -6
- package/dist/router/src/requestlog.js +3 -2
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +2 -2
- package/dist/ui/app.js +247 -42
- package/dist/ui/i18n.js +154 -18
- package/dist/ui/style.css +21 -0
- package/docs/ARCHITECTURE.md +61 -11
- package/package.json +1 -1
package/dist/router/src/admin.js
CHANGED
|
@@ -25,7 +25,9 @@ import net from "node:net";
|
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import http from "node:http";
|
|
28
|
-
import {
|
|
28
|
+
import { runCli, startCliDetached, parseUpdateCheck } from "./cli-control.js";
|
|
29
|
+
import { AutoUpdater } from "./autoupdate.js";
|
|
30
|
+
export { parseUpdateCheck } from "./cli-control.js";
|
|
29
31
|
import { fileURLToPath } from "node:url";
|
|
30
32
|
import { adminPort, headerValueProblem, homeDir, searchProviderFor, ssidProblem, validate } from "./config.js";
|
|
31
33
|
export { adminPort } from "./config.js";
|
|
@@ -33,7 +35,7 @@ import { generatedAgents } from "./agents.js";
|
|
|
33
35
|
import { isJudgeOnlyModel } from "./model-policy.js";
|
|
34
36
|
import { ModSettingsInputError, readModSettings, validateSettings, validateSettingsSchema, writeModSettings } from "./mod-settings.js";
|
|
35
37
|
import { billingOf, claudeChoices, fillLadder, ladderNeedsFill } from "../../mod/hooks/ladder.js";
|
|
36
|
-
import { normalizeSettings } from "../../mod/hooks/settings.js";
|
|
38
|
+
import { normalizeSettings, twoLevelRungs } from "../../mod/hooks/settings.js";
|
|
37
39
|
import { chatPickerModels } from "./picker.js";
|
|
38
40
|
import { PRESETS } from "./presets.js";
|
|
39
41
|
import { resolveCompatibleCaps } from "./compat.js";
|
|
@@ -254,21 +256,6 @@ let chatgptLogin = { running: false };
|
|
|
254
256
|
let claudeOAuth = null;
|
|
255
257
|
/** The Google (Antigravity) sign-in in progress (or the last one), for GET /api/google-login. */
|
|
256
258
|
let googleOAuth = null;
|
|
257
|
-
/** When the GUI last started an update. It restarts this router, so a fresh process starts at 0. */
|
|
258
|
-
let updateStartedAt = 0;
|
|
259
|
-
const UPDATE_RUN_MS = 10 * 60 * 1000;
|
|
260
|
-
/** The last line of `clauderipple update --check --json`, or null when it said something else. */
|
|
261
|
-
export function parseUpdateCheck(output) {
|
|
262
|
-
try {
|
|
263
|
-
const check = JSON.parse(output.trim().split("\n").pop() ?? "");
|
|
264
|
-
if (typeof check.current !== "string" || typeof check.newer !== "boolean" || typeof check.kind !== "string")
|
|
265
|
-
return null;
|
|
266
|
-
return { current: check.current, latest: typeof check.latest === "string" ? check.latest : null, newer: check.newer, kind: check.kind };
|
|
267
|
-
}
|
|
268
|
-
catch {
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
259
|
/** Shared catalog for the GUI: model-specific values override provider defaults. */
|
|
273
260
|
export function effortLevels(cfg) {
|
|
274
261
|
const providers = {
|
|
@@ -554,13 +541,17 @@ function ladderChoices(options) {
|
|
|
554
541
|
*/
|
|
555
542
|
async function withFilledLadder(deps, scores, settings) {
|
|
556
543
|
const general = settings.rules.general;
|
|
557
|
-
|
|
544
|
+
const sets = [settings, ...settings.modes];
|
|
545
|
+
const domains = sets.flatMap(set => Object.values(set.domainRules ?? {}));
|
|
546
|
+
if (![general, ...settings.modes, ...domains].some(set => ladderNeedsFill(set.rungs)))
|
|
558
547
|
return settings;
|
|
559
548
|
try {
|
|
560
549
|
const choices = ladderChoices(await modOptions(deps, scores));
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
550
|
+
const fill = (rungs) => fillLadder(rungs, settings.allow, choices, settings.allowKnown).rungs;
|
|
551
|
+
// A domain ladder has two levels: the fold keeps each pair equal, before and after the fill.
|
|
552
|
+
const fillDomains = (rules) => Object.fromEntries(Object.entries(rules).map(([key, rule]) => [key, { mode: "ladder", rungs: twoLevelRungs(fill(twoLevelRungs(rule.rungs))) }]));
|
|
553
|
+
const modes = settings.modes.map(mode => ({ ...mode, rungs: fill(mode.rungs), ...(mode.domainRules ? { domainRules: fillDomains(mode.domainRules) } : {}) }));
|
|
554
|
+
return { ...settings, rules: { general: { mode: "ladder", rungs: fill(general.rungs) } }, ...(settings.domainRules ? { domainRules: fillDomains(settings.domainRules) } : {}), modes };
|
|
564
555
|
}
|
|
565
556
|
catch (error) {
|
|
566
557
|
deps.log.warn(`admin: filling the worker ladder failed: ${error.message}`);
|
|
@@ -646,6 +637,7 @@ async function buildStatus(deps, opts = {}) {
|
|
|
646
637
|
problems.push(`provider:${name}`);
|
|
647
638
|
return {
|
|
648
639
|
version: deps.version,
|
|
640
|
+
autoUpdate: deps.autoUpdater?.status() ?? { enabled: cfg.autoUpdate !== false, running: false },
|
|
649
641
|
readiness: { ready: problems.length === 0, problems },
|
|
650
642
|
// Which files this process is running, so the app can tell an old router from its own after
|
|
651
643
|
// an update: a zip unpacked next to the previous install left the old one serving (2026-09-15).
|
|
@@ -693,50 +685,6 @@ function pickerTrust() {
|
|
|
693
685
|
pickerTrustMemo = { at: now, value };
|
|
694
686
|
return value;
|
|
695
687
|
}
|
|
696
|
-
/** The ClaudeRipple CLI and the Node that installed us (`<home>/paths.json`, written by `install`). */
|
|
697
|
-
function cliInvocation() {
|
|
698
|
-
let node = process.execPath;
|
|
699
|
-
// Same walk either way; the extension says which layout this file was loaded from.
|
|
700
|
-
let cli = path.resolve(here, `../../cli/src/index${path.extname(fileURLToPath(import.meta.url))}`);
|
|
701
|
-
let runtimeEnv = {};
|
|
702
|
-
try {
|
|
703
|
-
const p = JSON.parse(fs.readFileSync(path.join(homeDir(), "paths.json"), "utf8"));
|
|
704
|
-
if (p.node)
|
|
705
|
-
node = p.node;
|
|
706
|
-
if (p.env)
|
|
707
|
-
runtimeEnv = p.env;
|
|
708
|
-
if (p.cli)
|
|
709
|
-
cli = p.cli;
|
|
710
|
-
}
|
|
711
|
-
catch {
|
|
712
|
-
/* not installed via the CLI: fall back to our own node + repo layout */
|
|
713
|
-
}
|
|
714
|
-
return { node, cli, env: { ...process.env, ...runtimeEnv, CLAUDERIPPLE_HOME: homeDir() } };
|
|
715
|
-
}
|
|
716
|
-
function runCli(args, timeout = 180_000) {
|
|
717
|
-
return new Promise((resolveP) => {
|
|
718
|
-
const { node, cli, env } = cliInvocation();
|
|
719
|
-
execFile(node, [cli, ...args], { env, timeout }, (err, stdout, stderr) => {
|
|
720
|
-
resolveP({ ok: !err, output: `${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim() });
|
|
721
|
-
});
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
/**
|
|
725
|
-
* Start the CLI in its own process group, writing to `log`. An update ends by restarting this
|
|
726
|
-
* router, and launchd stops a job by killing its whole process group — an ordinary child would die
|
|
727
|
-
* with the router halfway through the install that restarted it.
|
|
728
|
-
*/
|
|
729
|
-
function startCliDetached(args, log) {
|
|
730
|
-
const { node, cli, env } = cliInvocation();
|
|
731
|
-
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
732
|
-
const out = fs.openSync(log, "w");
|
|
733
|
-
try {
|
|
734
|
-
spawn(node, [cli, ...args], { env, detached: true, stdio: ["ignore", out, out], windowsHide: true }).unref();
|
|
735
|
-
}
|
|
736
|
-
finally {
|
|
737
|
-
fs.closeSync(out);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
688
|
/**
|
|
741
689
|
* Whether this provider can actually run a web search — measured capability, not an assumption from
|
|
742
690
|
* the vendor name. The rule is the router's own (`Proxy.canRunServerTools`, mirrored here because
|
|
@@ -1270,15 +1218,21 @@ function measureCandidates(p, modelId, catalogWire) {
|
|
|
1270
1218
|
const known = "preset" in p && p.preset
|
|
1271
1219
|
? PRESETS.find((entry) => entry.id === p.preset)?.fallbackModels?.find((m) => m.id === modelId)
|
|
1272
1220
|
: undefined;
|
|
1273
|
-
|
|
1221
|
+
// The Gemini wire is not one `capabilities.ts` speaks: it has its own endpoint shape and header,
|
|
1222
|
+
// so a model the preset records on it is not tried on the other three (a request there would
|
|
1223
|
+
// answer 404/protocol-error and read as a model the plan refuses). Its wire stays as declared.
|
|
1224
|
+
if (known?.wire && known.wire !== "google")
|
|
1274
1225
|
add({ wire: known.wire, url: known.url ?? ownUrl, ...(known.authHeader ? { authHeader: known.authHeader } : {}) });
|
|
1275
1226
|
add(own);
|
|
1276
1227
|
if (p.type === "openai-compatible" && p.preset) {
|
|
1277
1228
|
// One plan can serve several wires on one catalog. The preset's fallback list is the only place
|
|
1278
1229
|
// any of that mapping is written down, so it is what the remaining candidates are read from.
|
|
1279
1230
|
const preset = PRESETS.find((entry) => entry.id === p.preset);
|
|
1280
|
-
for (const model of preset?.fallbackModels ?? [])
|
|
1231
|
+
for (const model of preset?.fallbackModels ?? []) {
|
|
1232
|
+
if (model.wire === "google")
|
|
1233
|
+
continue;
|
|
1281
1234
|
add({ wire: model.wire ?? defaultWire, url: model.url ?? p.url, ...(model.authHeader ? { authHeader: model.authHeader } : {}) });
|
|
1235
|
+
}
|
|
1282
1236
|
}
|
|
1283
1237
|
else if (p.type === "openai-compatible") {
|
|
1284
1238
|
// No table to read: the two OpenAI wires this kind of provider could be serving.
|
|
@@ -1288,7 +1242,7 @@ function measureCandidates(p, modelId, catalogWire) {
|
|
|
1288
1242
|
else if (p.type === "anthropic-compatible" && p.preset) {
|
|
1289
1243
|
const preset = PRESETS.find((entry) => entry.id === p.preset);
|
|
1290
1244
|
for (const model of preset?.fallbackModels ?? [])
|
|
1291
|
-
if (model.wire)
|
|
1245
|
+
if (model.wire && model.wire !== "google")
|
|
1292
1246
|
add({ wire: model.wire, url: model.url ?? p.url, ...(model.authHeader ? { authHeader: model.authHeader } : {}) });
|
|
1293
1247
|
}
|
|
1294
1248
|
if (!known?.wire && catalogWire) {
|
|
@@ -1671,6 +1625,13 @@ function serveStatic(urlPath, res) {
|
|
|
1671
1625
|
});
|
|
1672
1626
|
}
|
|
1673
1627
|
export function startAdmin(deps) {
|
|
1628
|
+
// Standalone admin users share the same coordinator, but only the router entry starts scheduling.
|
|
1629
|
+
const updater = deps.autoUpdater ?? new AutoUpdater({
|
|
1630
|
+
home: homeDir(), version: deps.version, enabled: () => deps.config().autoUpdate !== false,
|
|
1631
|
+
activity: () => ({ inFlight: 0 }), log: deps.log,
|
|
1632
|
+
runCli: deps.runCli ?? runCli, startCliDetached: deps.startCliDetached ?? startCliDetached,
|
|
1633
|
+
});
|
|
1634
|
+
deps = { ...deps, autoUpdater: updater };
|
|
1674
1635
|
const scores = new ModelScoreCatalog({ home: homeDir(), warn: message => deps.log.warn(message), ...(deps.modelScoresFetch ? { fetch: deps.modelScoresFetch } : {}) });
|
|
1675
1636
|
// Set again once the socket is bound (port 0 in tests); the Origin check below compares against it.
|
|
1676
1637
|
let boundPort = adminPort(deps.config());
|
|
@@ -2485,12 +2446,13 @@ export function startAdmin(deps) {
|
|
|
2485
2446
|
// The CLI answers both: it knows how this copy was installed, which decides what an update is
|
|
2486
2447
|
// (the tray asks it the same way). A POST checks again rather than trusting the page.
|
|
2487
2448
|
const r = await (deps.runCli ?? runCli)(["update", "--check", "--json"], 30_000);
|
|
2488
|
-
const check = parseUpdateCheck(r.output);
|
|
2449
|
+
const check = r.ok ? parseUpdateCheck(r.output) : null;
|
|
2489
2450
|
if (!check) {
|
|
2490
2451
|
sendJson(res, 502, { error: "could not check for updates", output: r.output.slice(-2000) });
|
|
2491
2452
|
return;
|
|
2492
2453
|
}
|
|
2493
|
-
|
|
2454
|
+
updater.observe(check);
|
|
2455
|
+
const running = updater.running();
|
|
2494
2456
|
if (method === "GET") {
|
|
2495
2457
|
sendJson(res, 200, { ...check, running });
|
|
2496
2458
|
return;
|
|
@@ -2507,9 +2469,7 @@ export function startAdmin(deps) {
|
|
|
2507
2469
|
sendJson(res, 409, { error: "an update is already running", ...check, running });
|
|
2508
2470
|
return;
|
|
2509
2471
|
}
|
|
2510
|
-
|
|
2511
|
-
const log = path.join(homeDir(), "logs", "update.log");
|
|
2512
|
-
(deps.startCliDetached ?? startCliDetached)(["update"], log);
|
|
2472
|
+
const log = updater.install(check);
|
|
2513
2473
|
deps.log.info(`admin: update ${check.current} -> ${check.latest ?? "?"} started via GUI (output in ${log})`);
|
|
2514
2474
|
sendJson(res, 202, { started: true, ...check, log });
|
|
2515
2475
|
return;
|
|
@@ -2537,9 +2497,12 @@ export function startAdmin(deps) {
|
|
|
2537
2497
|
const abort = new AbortController();
|
|
2538
2498
|
res.on("close", () => { if (!res.writableFinished)
|
|
2539
2499
|
abort.abort(); });
|
|
2500
|
+
// The CLI's own session, so the request log shows which session generated the image.
|
|
2501
|
+
const session = req.headers["x-claude-code-session-id"];
|
|
2502
|
+
const meta = typeof session === "string" && /^[\w-]{1,128}$/.test(session) ? { session } : undefined;
|
|
2540
2503
|
const started = Date.now();
|
|
2541
2504
|
try {
|
|
2542
|
-
const image = await deps.image(parsed, abort.signal);
|
|
2505
|
+
const image = await deps.image(parsed, abort.signal, meta);
|
|
2543
2506
|
deps.log.info(`admin: image ${image.size ?? "?"} ${image.quality ?? "?"} ${image.format}, ${parsed.images?.length ?? 0} reference(s), ${image.data.length} bytes in ${Date.now() - started} ms`);
|
|
2544
2507
|
res.writeHead(200, {
|
|
2545
2508
|
"content-type": `image/${image.format}`,
|
|
@@ -2602,8 +2565,10 @@ export function startAdmin(deps) {
|
|
|
2602
2565
|
const provider = query.get("provider") || undefined;
|
|
2603
2566
|
const session = query.get("session") || undefined;
|
|
2604
2567
|
const kindValue = query.get("kind");
|
|
2605
|
-
|
|
2606
|
-
|
|
2568
|
+
// A comma-separated list so one call can ask for several kinds; unknown entries are ignored
|
|
2569
|
+
// and a list with nothing valid left means no filter.
|
|
2570
|
+
const kinds = kindValue === null ? [] : kindValue.split(",").filter((k) => k === "messages" || k === "count_tokens" || k === "other" || k === "image");
|
|
2571
|
+
sendJson(res, 200, { requests: deps.requests.list(n, { ...(provider ? { provider } : {}), ...(kinds.length ? { kind: kinds } : {}), ...(session ? { session } : {}) }) });
|
|
2607
2572
|
return;
|
|
2608
2573
|
}
|
|
2609
2574
|
if (pathname === "/api/requests/summary" && method === "GET") {
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The router, not the optional tray, owns update scheduling. All process/time boundaries are injected.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { writeJsonAtomic } from "./locked-file.js";
|
|
5
|
+
import { parseUpdateCheck } from "./cli-control.js";
|
|
6
|
+
const MINUTE = 60_000;
|
|
7
|
+
export const UPDATE_TIMING = { first: 10 * MINUTE, interval: 12 * 60 * MINUTE, jitter: 30 * MINUTE, idle: 10 * MINUTE, poll: 5 * MINUTE, backoff: 24 * 60 * MINUTE };
|
|
8
|
+
const realClock = {
|
|
9
|
+
now: Date.now, random: Math.random,
|
|
10
|
+
later(fn, ms) { const timer = setTimeout(fn, ms); timer.unref(); return () => clearTimeout(timer); },
|
|
11
|
+
};
|
|
12
|
+
function alive(pid) {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === "ESRCH")
|
|
19
|
+
return false;
|
|
20
|
+
return true; // Permission errors cannot prove that an installer stopped.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function isAttempt(value) {
|
|
24
|
+
if (!value || typeof value !== "object")
|
|
25
|
+
return false;
|
|
26
|
+
const a = value;
|
|
27
|
+
return typeof a.from === "string" && typeof a.to === "string" && typeof a.at === "number" && Number.isFinite(a.at)
|
|
28
|
+
&& (a.pid === undefined || (Number.isInteger(a.pid) && a.pid > 0));
|
|
29
|
+
}
|
|
30
|
+
function load(file) {
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === "ENOENT")
|
|
37
|
+
return { failures: {} };
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
if (!raw || typeof raw !== "object")
|
|
41
|
+
throw new Error("invalid update state");
|
|
42
|
+
const s = raw;
|
|
43
|
+
if (!s.failures || typeof s.failures !== "object" || Array.isArray(s.failures)
|
|
44
|
+
|| Object.values(s.failures).some((t) => typeof t !== "number" || !Number.isFinite(t))
|
|
45
|
+
|| (s.pending !== undefined && !isAttempt(s.pending)) || (s.completed !== undefined && !isAttempt(s.completed))
|
|
46
|
+
|| [s.available, s.kind, s.lastError].some((v) => v !== undefined && typeof v !== "string"))
|
|
47
|
+
throw new Error("invalid update state");
|
|
48
|
+
return { ...s, failures: s.failures };
|
|
49
|
+
}
|
|
50
|
+
export class AutoUpdater {
|
|
51
|
+
deps;
|
|
52
|
+
clock;
|
|
53
|
+
file;
|
|
54
|
+
startedAt;
|
|
55
|
+
saved = { failures: {} };
|
|
56
|
+
blocked = false;
|
|
57
|
+
active = false;
|
|
58
|
+
cancel;
|
|
59
|
+
waiting;
|
|
60
|
+
childRunning = false;
|
|
61
|
+
constructor(deps) {
|
|
62
|
+
this.deps = deps;
|
|
63
|
+
this.clock = deps.clock ?? realClock;
|
|
64
|
+
this.startedAt = this.clock.now();
|
|
65
|
+
this.file = path.join(deps.home, "auto-update.json");
|
|
66
|
+
try {
|
|
67
|
+
this.saved = load(this.file);
|
|
68
|
+
const pending = this.saved.pending;
|
|
69
|
+
if (pending?.to === deps.version) {
|
|
70
|
+
this.saved.completed = { ...pending, at: this.clock.now() };
|
|
71
|
+
delete this.saved.pending;
|
|
72
|
+
delete this.saved.available;
|
|
73
|
+
delete this.saved.lastError;
|
|
74
|
+
delete this.saved.failures[pending.to];
|
|
75
|
+
this.persist();
|
|
76
|
+
this.info(`completed ${pending.from} -> ${pending.to}`);
|
|
77
|
+
}
|
|
78
|
+
else if (pending && !(pending.pid && (deps.alive ?? alive)(pending.pid))) {
|
|
79
|
+
this.fail(pending, "previous update did not start the recorded target version");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
this.blocked = true; // Never overwrite unreadable state or install without durable intent.
|
|
84
|
+
this.saved.lastError = String(error);
|
|
85
|
+
this.info(`disabled installs: ${error}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
info(message) { this.deps.log.info(`auto-update: ${message}`); }
|
|
89
|
+
persist() {
|
|
90
|
+
try {
|
|
91
|
+
writeJsonAtomic(this.file, this.saved);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
this.blocked = true;
|
|
95
|
+
this.saved.lastError = String(error);
|
|
96
|
+
this.info(`cannot persist state; disabled installs: ${error}`);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
completedInstallerRunning() {
|
|
101
|
+
const completed = this.saved.completed;
|
|
102
|
+
if (!completed?.pid)
|
|
103
|
+
return false;
|
|
104
|
+
if ((this.deps.alive ?? alive)(completed.pid))
|
|
105
|
+
return true;
|
|
106
|
+
// The CLI may install the tray runtime AFTER the new router starts. Retire its PID once gone
|
|
107
|
+
// so a later, unrelated process reusing that PID cannot suppress updates indefinitely.
|
|
108
|
+
delete completed.pid;
|
|
109
|
+
try {
|
|
110
|
+
if (!this.blocked)
|
|
111
|
+
this.persist();
|
|
112
|
+
}
|
|
113
|
+
catch { /* persist logs and disables installs */ }
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
pendingRunning() {
|
|
117
|
+
const pending = this.saved.pending;
|
|
118
|
+
if (!pending)
|
|
119
|
+
return this.completedInstallerRunning();
|
|
120
|
+
if (this.childRunning || (pending.pid && (this.deps.alive ?? alive)(pending.pid)))
|
|
121
|
+
return true;
|
|
122
|
+
if (this.clock.now() - pending.at < UPDATE_TIMING.first)
|
|
123
|
+
return true;
|
|
124
|
+
this.fail(pending, "update did not restart the router at the target version");
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
status() {
|
|
128
|
+
const completed = this.saved.completed;
|
|
129
|
+
return {
|
|
130
|
+
enabled: this.deps.enabled(), running: !!this.saved.pending || this.completedInstallerRunning(),
|
|
131
|
+
...(this.saved.kind ? { kind: this.saved.kind } : {}),
|
|
132
|
+
...(this.saved.available ? { available: this.saved.available } : {}),
|
|
133
|
+
...(completed ? { lastUpdatedFrom: completed.from, lastUpdatedTo: completed.to, lastUpdatedAt: completed.at } : {}),
|
|
134
|
+
...(this.saved.lastError ? { lastError: this.saved.lastError } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
observe(check) {
|
|
138
|
+
this.saved.kind = check.kind;
|
|
139
|
+
if (check.newer && check.latest)
|
|
140
|
+
this.saved.available = check.latest;
|
|
141
|
+
else
|
|
142
|
+
delete this.saved.available;
|
|
143
|
+
if (!this.blocked)
|
|
144
|
+
this.persist();
|
|
145
|
+
}
|
|
146
|
+
running() { return this.pendingRunning(); }
|
|
147
|
+
/** Shared by POST /api/update and the scheduler. Persists intent BEFORE launching anything. */
|
|
148
|
+
install(check) {
|
|
149
|
+
if (this.blocked)
|
|
150
|
+
throw new Error("update state is unreadable; refusing to install");
|
|
151
|
+
if (check.kind !== "script" && check.kind !== "npm")
|
|
152
|
+
throw new Error(`install kind ${check.kind} cannot auto-install`);
|
|
153
|
+
if (!check.newer || !check.latest || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(check.latest))
|
|
154
|
+
throw new Error("no valid newer version to install");
|
|
155
|
+
if (this.pendingRunning())
|
|
156
|
+
throw new Error("an update is already running");
|
|
157
|
+
const attempt = { from: this.deps.version, to: check.latest, at: this.clock.now() };
|
|
158
|
+
const log = path.join(this.deps.home, "logs", "update.log");
|
|
159
|
+
this.saved.pending = attempt;
|
|
160
|
+
delete this.saved.lastError;
|
|
161
|
+
this.persist();
|
|
162
|
+
this.info(`install ${attempt.from} -> ${attempt.to}; output ${log}`);
|
|
163
|
+
let child;
|
|
164
|
+
try {
|
|
165
|
+
// Pin the version that was recorded, even if npm's latest tag changes while waiting for idle.
|
|
166
|
+
child = this.deps.startCliDetached(["update"], log, { CLAUDERIPPLE_PACKAGE: `clauderipple@${attempt.to}` });
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
this.fail(attempt, String(error));
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
if (child) {
|
|
173
|
+
this.childRunning = true;
|
|
174
|
+
void child.completion.then((result) => {
|
|
175
|
+
this.childRunning = false;
|
|
176
|
+
if (!result.ok)
|
|
177
|
+
this.fail(attempt, result.output);
|
|
178
|
+
else
|
|
179
|
+
this.info(`CLI finished ${attempt.to}; awaiting target router startup`);
|
|
180
|
+
}, (error) => {
|
|
181
|
+
this.childRunning = false;
|
|
182
|
+
this.fail(attempt, String(error));
|
|
183
|
+
}).catch((error) => {
|
|
184
|
+
this.blocked = true;
|
|
185
|
+
this.info(`cannot persist failure; disabled installs: ${error}`);
|
|
186
|
+
});
|
|
187
|
+
if (child.pid !== undefined) {
|
|
188
|
+
attempt.pid = child.pid;
|
|
189
|
+
try {
|
|
190
|
+
this.persist();
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
// The installer is already alive: never clear its intent and allow a second launch.
|
|
194
|
+
this.blocked = true;
|
|
195
|
+
this.saved.lastError = String(error);
|
|
196
|
+
this.info(`cannot persist installer PID; disabled installs: ${error}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return log;
|
|
201
|
+
}
|
|
202
|
+
fail(attempt, error) {
|
|
203
|
+
if (this.saved.pending?.at !== attempt.at || this.saved.pending.to !== attempt.to)
|
|
204
|
+
return;
|
|
205
|
+
this.saved.failures[attempt.to] = this.clock.now();
|
|
206
|
+
this.saved.lastError = error.slice(-2000);
|
|
207
|
+
delete this.saved.pending;
|
|
208
|
+
this.persist();
|
|
209
|
+
this.info(`failed ${attempt.to}; back off 24h: ${this.saved.lastError}`);
|
|
210
|
+
}
|
|
211
|
+
start() {
|
|
212
|
+
if (this.active)
|
|
213
|
+
return;
|
|
214
|
+
this.active = true;
|
|
215
|
+
this.info("first check in 10 minutes");
|
|
216
|
+
this.schedule(UPDATE_TIMING.first);
|
|
217
|
+
}
|
|
218
|
+
stop() { this.active = false; this.cancel?.(); this.waiting = undefined; this.info("scheduler stopped"); }
|
|
219
|
+
schedule(ms) {
|
|
220
|
+
this.cancel?.();
|
|
221
|
+
if (this.active)
|
|
222
|
+
this.cancel = this.clock.later(() => { void this.tick(); }, ms);
|
|
223
|
+
}
|
|
224
|
+
nextCycle() {
|
|
225
|
+
this.waiting = undefined;
|
|
226
|
+
const ms = UPDATE_TIMING.interval + (this.clock.random() * 2 - 1) * UPDATE_TIMING.jitter;
|
|
227
|
+
this.info(`next check in ${Math.round(ms / MINUTE)} minutes`);
|
|
228
|
+
this.schedule(ms);
|
|
229
|
+
}
|
|
230
|
+
async tick() {
|
|
231
|
+
try {
|
|
232
|
+
if (this.pendingRunning()) {
|
|
233
|
+
this.info("installer still running; skip cycle");
|
|
234
|
+
this.nextCycle();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!this.waiting) {
|
|
238
|
+
this.info("checking via CLI");
|
|
239
|
+
const result = await this.deps.runCli(["update", "--check", "--json"], 30_000);
|
|
240
|
+
if (!this.active)
|
|
241
|
+
return;
|
|
242
|
+
const check = result.ok ? parseUpdateCheck(result.output) : null;
|
|
243
|
+
if (!check)
|
|
244
|
+
throw new Error(`update check failed: ${result.output.slice(-1000)}`);
|
|
245
|
+
this.observe(check);
|
|
246
|
+
if (!check.newer || !check.latest) {
|
|
247
|
+
this.info("no newer version");
|
|
248
|
+
this.nextCycle();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
this.waiting = { check, until: this.clock.now() + UPDATE_TIMING.backoff };
|
|
252
|
+
}
|
|
253
|
+
const { check, until } = this.waiting;
|
|
254
|
+
if (this.blocked || !this.deps.enabled()) {
|
|
255
|
+
this.info("automatic installation disabled");
|
|
256
|
+
this.nextCycle();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (check.kind !== "script" && check.kind !== "npm") {
|
|
260
|
+
this.info(`skip install kind ${check.kind}`);
|
|
261
|
+
this.nextCycle();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const failedAt = this.saved.failures[check.latest];
|
|
265
|
+
if (failedAt !== undefined && this.clock.now() - failedAt < UPDATE_TIMING.backoff) {
|
|
266
|
+
this.info(`backoff ${check.latest}`);
|
|
267
|
+
this.nextCycle();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (this.clock.now() >= until) {
|
|
271
|
+
this.info("gave up waiting for idle after 24h");
|
|
272
|
+
this.nextCycle();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const activity = this.deps.activity();
|
|
276
|
+
const last = Math.max(this.startedAt, activity.lastStartedAt ?? 0, activity.lastEndedAt ?? 0);
|
|
277
|
+
if (activity.inFlight !== 0 || this.clock.now() - last < UPDATE_TIMING.idle) {
|
|
278
|
+
this.info("waiting for zero in-flight requests and 10 minutes idle; recheck in 5 minutes");
|
|
279
|
+
this.schedule(UPDATE_TIMING.poll);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.install(check);
|
|
283
|
+
this.nextCycle();
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
this.saved.lastError = String(error).slice(-2000);
|
|
287
|
+
this.info(`cycle failed: ${error}`);
|
|
288
|
+
try {
|
|
289
|
+
if (!this.blocked)
|
|
290
|
+
this.persist();
|
|
291
|
+
}
|
|
292
|
+
catch (saveError) {
|
|
293
|
+
this.blocked = true;
|
|
294
|
+
this.info(`cannot persist state: ${saveError}`);
|
|
295
|
+
}
|
|
296
|
+
this.nextCycle();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -7,7 +7,7 @@ import os from "node:os";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { agentAliases } from "./agents.js";
|
|
9
9
|
import { isJudgeOnlyModel } from "./model-policy.js";
|
|
10
|
-
import { chatPickerModels } from "./picker.js";
|
|
10
|
+
import { chatPickerModels, providerModelWindow } from "./picker.js";
|
|
11
11
|
export const BOOTSTRAP_PATH = "/api/claude_cli/bootstrap";
|
|
12
12
|
/**
|
|
13
13
|
* Model ids named in the user's agent definitions (~/.claude/agents/*.md frontmatter `model:`),
|
|
@@ -60,7 +60,8 @@ export function injectBootstrap(body, cfg, agentDirs) {
|
|
|
60
60
|
const [base, effort] = id.split("@");
|
|
61
61
|
const named = cfg.cli.extraModels.find((m) => m.model === base);
|
|
62
62
|
// An "<model>@<effort>" entry is the same model with a different effort, so it has the same window.
|
|
63
|
-
|
|
63
|
+
const window = named?.contextWindow ?? providerModelWindow({ ...cfg, aliases }, base ?? id);
|
|
64
|
+
return { model: id, name: `${named?.name ?? base}${effort ? ` · ${effort}` : ""}`, ...(window ? { contextWindow: window } : {}) };
|
|
64
65
|
});
|
|
65
66
|
const extra = chatPickerModels({ ...cfg, aliases, cli: { ...cfg.cli, extraModels: [...cfg.cli.extraModels, ...fromAgents] } });
|
|
66
67
|
const win = cfg.cli.autoCompactWindow;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Process boundary shared by dashboard actions and the router-owned updater.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { execFile, spawn } from "node:child_process";
|
|
6
|
+
import { homeDir } from "./config.js";
|
|
7
|
+
/** Only the final CLI output line is the machine-readable answer. */
|
|
8
|
+
export function parseUpdateCheck(output) {
|
|
9
|
+
try {
|
|
10
|
+
const check = JSON.parse(output.trim().split("\n").pop() ?? "");
|
|
11
|
+
if (!check || typeof check.current !== "string" || typeof check.newer !== "boolean" || typeof check.kind !== "string")
|
|
12
|
+
return null;
|
|
13
|
+
return { current: check.current, latest: typeof check.latest === "string" ? check.latest : null, newer: check.newer, kind: check.kind };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null; // An invalid external CLI response is reported by the caller.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** paths.json is written by install; a checkout has no such file. */
|
|
20
|
+
function cliInvocation() {
|
|
21
|
+
let node = process.execPath;
|
|
22
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
let cli = path.resolve(here, `../../cli/src/index${path.extname(fileURLToPath(import.meta.url))}`);
|
|
24
|
+
let runtimeEnv = {};
|
|
25
|
+
try {
|
|
26
|
+
const p = JSON.parse(fs.readFileSync(path.join(homeDir(), "paths.json"), "utf8"));
|
|
27
|
+
if (p.node)
|
|
28
|
+
node = p.node;
|
|
29
|
+
if (p.env)
|
|
30
|
+
runtimeEnv = p.env;
|
|
31
|
+
if (p.cli)
|
|
32
|
+
cli = p.cli;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code !== "ENOENT")
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return { node, cli, env: { ...process.env, ...runtimeEnv, CLAUDERIPPLE_HOME: homeDir() } };
|
|
39
|
+
}
|
|
40
|
+
export function runCli(args, timeout = 180_000) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const { node, cli, env } = cliInvocation();
|
|
43
|
+
execFile(node, [cli, ...args], { env, timeout }, (error, stdout, stderr) => {
|
|
44
|
+
resolve({ ok: !error, output: `${stdout}${stderr}${error ? `\n${error.message}` : ""}`.trim() });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Separate process group: launchd must not kill the installer when it drains this router. */
|
|
49
|
+
export function startCliDetached(args, log, extraEnv = {}) {
|
|
50
|
+
const { node, cli, env } = cliInvocation();
|
|
51
|
+
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
52
|
+
const out = fs.openSync(log, "w");
|
|
53
|
+
try {
|
|
54
|
+
const child = spawn(node, [cli, ...args], { env: { ...env, ...extraEnv }, detached: true, stdio: ["ignore", out, out], windowsHide: true });
|
|
55
|
+
const completion = new Promise((resolve) => {
|
|
56
|
+
child.once("error", (error) => resolve({ ok: false, output: error.message }));
|
|
57
|
+
child.once("exit", (code, signal) => resolve({ ok: code === 0, output: `update exited ${code ?? signal}; see ${log}` }));
|
|
58
|
+
});
|
|
59
|
+
child.unref();
|
|
60
|
+
return { ...(child.pid === undefined ? {} : { pid: child.pid }), completion };
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
fs.closeSync(out);
|
|
64
|
+
}
|
|
65
|
+
}
|