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.
Files changed (50) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.es.md +48 -0
  3. package/README.ja.md +42 -0
  4. package/README.ko.md +39 -0
  5. package/README.md +44 -0
  6. package/README.pt-BR.md +46 -0
  7. package/README.zh-CN.md +40 -0
  8. package/dist/app/dist/claude-desktop.js +31 -0
  9. package/dist/app/dist/main.js +127 -8
  10. package/dist/app/dist/status-model.js +12 -0
  11. package/dist/app/dist/update-model.js +32 -0
  12. package/dist/app/dist/window.js +10 -3
  13. package/dist/app/package.json +1 -1
  14. package/dist/cli/src/index.js +5 -1
  15. package/dist/mod/hooks/i18n.ts +6 -0
  16. package/dist/mod/hooks/modes.ts +15 -8
  17. package/dist/mod/hooks/register.tsx +67 -19
  18. package/dist/mod/hooks/settings.js +37 -2
  19. package/dist/mod/hooks/settings.ts +31 -3
  20. package/dist/mod/hooks/ui/agents.tsx +1 -1
  21. package/dist/mod/hooks/ui/art.ts +8 -3
  22. package/dist/mod/hooks/ui/band.tsx +6 -2
  23. package/dist/mod/hooks/ui/flat.ts +11 -8
  24. package/dist/mod/hooks/ui/look.ts +3 -1
  25. package/dist/mod/hooks/ui/view.ts +2 -0
  26. package/dist/mod/hooks/workers.ts +8 -1
  27. package/dist/mod/tests/delegation.test.tsx +50 -0
  28. package/dist/mod/tests/register.test.tsx +138 -1
  29. package/dist/mod/tests/ui.test.tsx +6 -3
  30. package/dist/mod/types/index.d.ts +14 -0
  31. package/dist/router/src/admin.js +41 -76
  32. package/dist/router/src/autoupdate.js +299 -0
  33. package/dist/router/src/bootstrap.js +3 -2
  34. package/dist/router/src/cli-control.js +65 -0
  35. package/dist/router/src/config.js +70 -11
  36. package/dist/router/src/index.js +11 -1
  37. package/dist/router/src/picker.js +25 -1
  38. package/dist/router/src/presets.js +21 -13
  39. package/dist/router/src/providers/chatgpt/index.js +11 -0
  40. package/dist/router/src/providers/google/index.js +7 -2
  41. package/dist/router/src/providers/google/transport.js +16 -1
  42. package/dist/router/src/proxy.js +46 -6
  43. package/dist/router/src/requestlog.js +3 -2
  44. package/dist/router/src/version.js +1 -1
  45. package/dist/router/src/websearch.js +2 -2
  46. package/dist/ui/app.js +247 -42
  47. package/dist/ui/i18n.js +154 -18
  48. package/dist/ui/style.css +21 -0
  49. package/docs/ARCHITECTURE.md +61 -11
  50. package/package.json +1 -1
@@ -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 { execFile, spawn } from "node:child_process";
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
- if (![general, ...settings.modes].some(set => ladderNeedsFill(set.rungs)))
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 { rungs } = fillLadder(general.rungs, settings.allow, choices, settings.allowKnown);
562
- const modes = settings.modes.map(mode => ({ ...mode, rungs: fillLadder(mode.rungs, settings.allow, choices, settings.allowKnown).rungs }));
563
- return { ...settings, rules: { general: { mode: "ladder", rungs } }, modes };
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
- if (known?.wire)
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
- const running = updateStartedAt > 0 && Date.now() - updateStartedAt < UPDATE_RUN_MS;
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
- updateStartedAt = Date.now();
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
- const kind = kindValue === "messages" || kindValue === "count_tokens" || kindValue === "other" ? kindValue : undefined;
2606
- sendJson(res, 200, { requests: deps.requests.list(n, { ...(provider ? { provider } : {}), ...(kind ? { kind } : {}), ...(session ? { session } : {}) }) });
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
- return { model: id, name: `${named?.name ?? base}${effort ? ` · ${effort}` : ""}`, ...(named?.contextWindow ? { contextWindow: named.contextWindow } : {}) };
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
+ }