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
package/dist/ui/app.js CHANGED
@@ -291,19 +291,48 @@ async function refreshHealth() {
291
291
  if (clientsLoaded) renderClients();
292
292
  if (modState.settings) renderModNotice();
293
293
  const details = $("#health-details");
294
- details.replaceChildren(
294
+ modRedraw(details, [
295
295
  versionRow(),
296
+ autoUpdateRow(),
296
297
  // Which files answer, and since when: the only way to see that an update actually replaced the router.
297
298
  ...(status.runtime ? [cell(t("health.runtime"), null, [el("span", { class: "cell-sub mono", text: status.runtime.router || "?" }), new Date(status.runtime.startedAt).toLocaleString()])] : []),
298
299
  el("div", { class: "row" }, [el("span", { class: "k", text: t("health.routes") }), el("span", { class: "v", text: String(status.routes) })]),
299
300
  el("div", { class: "row" }, [el("span", { class: "k", text: t("health.cli") }), el("span", { class: "v", text: status.cliVersion })]),
300
- );
301
+ ]);
301
302
  }
302
303
 
303
304
  // The update check runs on a click, not with the 5-second refresh: it asks the npm registry. What an
304
305
  // update means depends on how this copy was installed, so the router's CLI decides and runs it; the
305
306
  // router restarts on the new version and the served-files check above reloads this page.
306
307
  let updateState = { busy: false, check: null };
308
+ let autoUpdateSaving = false;
309
+ function autoUpdateRow() {
310
+ const control = modSwitch("auto-update", status.autoUpdate?.enabled !== false, t("update.auto"), toggleAutoUpdate);
311
+ control.querySelector("input").disabled = autoUpdateSaving || !status.autoUpdate;
312
+ return cell(t("update.auto"), control, t("update.autoHelp"));
313
+ }
314
+ async function toggleAutoUpdate(enabled) {
315
+ if (autoUpdateSaving) return;
316
+ autoUpdateSaving = true;
317
+ const input = document.querySelector('[data-key="auto-update"]');
318
+ if (input) input.disabled = true;
319
+ try {
320
+ // Do not use latestConfig's stale fallback for an automatic-install preference.
321
+ const next = await api("/api/config");
322
+ next.autoUpdate = enabled;
323
+ await configRequest(next);
324
+ currentConfig = next;
325
+ if (status.autoUpdate) status.autoUpdate.enabled = enabled;
326
+ toast(t("common.saved"));
327
+ } catch (error) {
328
+ toast(t("common.saveFailed"), true, error.message);
329
+ } finally {
330
+ autoUpdateSaving = false;
331
+ await refreshHealth();
332
+ const current = document.querySelector('[data-key="auto-update"]');
333
+ if (current) { current.disabled = !status.autoUpdate; current.checked = status.autoUpdate?.enabled !== false; }
334
+ }
335
+ }
307
336
  function versionRow() {
308
337
  const check = updateState.check;
309
338
  // The version reads as the row's label; what the update check found sits under it, the action on the right.
@@ -867,11 +896,25 @@ function providerCannotSearch(name, model) {
867
896
  return !(Array.isArray(entry.webSearchModels) && entry.webSearchModels.includes(id));
868
897
  }
869
898
 
899
+ // The web search row names a provider as well as a model: one id can be offered by two providers
900
+ // and only one of them may be able to run a search. JSON keeps the two apart whatever they contain.
901
+ function webSearchChoice(provider, model) { return JSON.stringify([provider, model]); }
902
+ function parseWebSearchChoice(value) {
903
+ if (!value) return null;
904
+ try {
905
+ const [provider, model] = JSON.parse(value);
906
+ return typeof provider === "string" && typeof model === "string" ? { provider, model } : null;
907
+ } catch { return null; }
908
+ }
909
+
870
910
  function renderModelSlots() {
871
911
  const rows = $("#client-model-slots");
872
912
  if (!rows) return;
873
913
  const chosen = (currentConfig.cli && currentConfig.cli.models) || {};
874
- rows.replaceChildren(...MODEL_SLOTS.map((slot) => {
914
+ // With a web search backend set, the search is intercepted before routing and never reaches
915
+ // `smallFast`, so the "no web search here" label there would be false.
916
+ const searchIntercepted = Boolean(currentConfig.webSearch);
917
+ const nodes = MODEL_SLOTS.map((slot) => {
875
918
  const select = el("select", {});
876
919
  select.appendChild(el("option", { value: "", text: t("slots.slotDefault") }));
877
920
  for (const group of groupedModels(currentConfig)) for (const model of group.models) {
@@ -879,7 +922,7 @@ function renderModelSlots() {
879
922
  if (chosen[slot] === model.id) option.selected = true;
880
923
  // `smallFast` is what a WebSearch runs on, so an inability to search is fatal there and
881
924
  // merely worth knowing everywhere else.
882
- if (slot === "smallFast" && providerCannotSearch(group.name, model.id)) {
925
+ if (slot === "smallFast" && !searchIntercepted && providerCannotSearch(group.name, model.id)) {
883
926
  option.textContent += ` — ${t("slots.noWebSearch")}`;
884
927
  }
885
928
  select.appendChild(option);
@@ -894,7 +937,55 @@ function renderModelSlots() {
894
937
  select.dataset.slot = slot;
895
938
  select.setAttribute("aria-label", t(`slots.slot.${slot}`));
896
939
  return cell(t(`slots.slot.${slot}`), select, t(`slots.slotHelp.${slot}`), "slot-row");
897
- }));
940
+ });
941
+ nodes.push(renderWebSearchSlot());
942
+ rows.replaceChildren(...nodes);
943
+ }
944
+
945
+ // The backend `cfg.webSearch` names answers the search instead of the `smallFast` slot. Only the
946
+ // router knows which models it can actually search with — that comes from `/api/status`, never
947
+ // from a model's name — so an unsearchable model is not offered here at all.
948
+ function renderWebSearchSlot() {
949
+ const saved = currentConfig.webSearch || null;
950
+ const select = el("select", {});
951
+ select.appendChild(el("option", { value: "", text: t("slots.webSearchFollow") }));
952
+ let found = false;
953
+ for (const group of groupedModels(currentConfig)) for (const model of group.models) {
954
+ if (providerCannotSearch(group.name, model.id)) continue;
955
+ // A Claude account searches on its own when routed to, but `serveWebSearch` has no backend for
956
+ // that provider kind: named here, the search would quietly fall back to `smallFast`.
957
+ if (currentConfig.providers[group.name] && currentConfig.providers[group.name].type === "anthropic") continue;
958
+ const option = el("option", { value: webSearchChoice(group.name, model.id), text: `${labelOf(model)} · ${group.name}` });
959
+ if (saved && saved.provider === group.name && saved.model === model.id) { option.selected = true; found = true; }
960
+ select.appendChild(option);
961
+ }
962
+ // A backend the config names but no provider can run any more must not read as "follows the
963
+ // small, fast model" — that would silently change where the search goes.
964
+ if (saved && !found) {
965
+ const orphan = el("option", { value: webSearchChoice(saved.provider, saved.model), text: `${saved.provider} / ${saved.model} (?)` });
966
+ orphan.selected = true;
967
+ select.appendChild(orphan);
968
+ }
969
+ select.onchange = () => void saveWebSearch(select.value);
970
+ select.dataset.webSearch = "true";
971
+ select.setAttribute("aria-label", t("slots.webSearch"));
972
+ return cell(t("slots.webSearch"), select, t("slots.webSearchHelp"), "slot-row");
973
+ }
974
+
975
+ async function saveWebSearch(value) {
976
+ if (!currentConfig) return;
977
+ const next = clone(await latestConfig());
978
+ const picked = parseWebSearchChoice(value);
979
+ // `webSearch` is the only setting this row owns; an existing `maxResults` survives the change.
980
+ const maxResults = (next.webSearch && next.webSearch.maxResults) ?? (currentConfig.webSearch && currentConfig.webSearch.maxResults);
981
+ if (picked) next.webSearch = { provider: picked.provider, model: picked.model, ...(maxResults !== undefined ? { maxResults } : {}) };
982
+ else delete next.webSearch;
983
+ try {
984
+ await configRequest(next);
985
+ currentConfig = next;
986
+ renderModelSlots();
987
+ toast(t("slots.webSearchSaved"));
988
+ } catch (error) { toast(t("common.saveFailed"), true, error.message); }
898
989
  }
899
990
 
900
991
  async function saveModelSlots() {
@@ -904,15 +995,17 @@ async function saveModelSlots() {
904
995
  // remove the env key, and the router tells "clear this" from "leave this alone" by whether the
905
996
  // slot is present at all — so dropping empty values here would make the choice a silent no-op.
906
997
  const models = {};
907
- for (const select of $all("#client-model-slots select")) models[select.dataset.slot] = select.value;
998
+ // Only the three slot rows: the web search row below them writes `webSearch`, not a slot.
999
+ for (const select of $all("#client-model-slots select[data-slot]")) models[select.dataset.slot] = select.value;
908
1000
  next.cli = { ...(next.cli || {}), models };
909
1001
  try {
910
1002
  await configRequest(next);
911
1003
  currentConfig = next;
912
1004
  // Saying "saved" over a choice that will make every search fail is the same silent no-op this
913
1005
  // screen was already guilty of once. The label on the option warns before the fact; this catches
914
- // a session that already had it selected.
915
- const owners = models.smallFast ? providersOffering(next, models.smallFast) : [];
1006
+ // a session that already had it selected. With a web search backend set the search never
1007
+ // reaches `smallFast`, so there is nothing to warn about.
1008
+ const owners = models.smallFast && !next.webSearch ? providersOffering(next, models.smallFast) : [];
916
1009
  if (owners.length && providerCannotSearch(owners[0], models.smallFast)) toast(t("slots.noWebSearchWarn"), true);
917
1010
  else toast(t("slots.slotSaved"));
918
1011
  } catch (error) { toast(t("common.saveFailed"), true, error.message); }
@@ -1039,6 +1132,36 @@ async function showPhoneQr(button) {
1039
1132
  } catch { if (generation === phoneQrGeneration) toast(t("common.loadFailed"), true); }
1040
1133
  finally { button.disabled = false; }
1041
1134
  }
1135
+ // Ripple Remote in the stores. iPhone links to the storefront of the dashboard's language, as the
1136
+ // README headers do; Android is a closed test on Google Play, joined through Discord, so no Play link.
1137
+ const REMOTE_STOREFRONT = { ko: "kr", en: "us", zh: "us", ja: "jp", es: "es", pt: "br" };
1138
+ /** Read when drawn, not at load: this block is also evaluated on its own by the phone tests. */
1139
+ function remoteAppStoreUrl() { return `https://apps.apple.com/${REMOTE_STOREFRONT[CURRENT_LANG] || "us"}/app/id6823063731`; }
1140
+ const COMMUNITY_URL = "https://discord.gg/R8ZgGSNHjw";
1141
+ function externalLink(href, cls, children) { return el("a", { class: cls, href, target: "_blank", rel: "noopener noreferrer" }, children); }
1142
+ function phonePaired(data) { return Boolean(data && data.hostRunning && data.phones && data.phones.length); }
1143
+ function remoteAndroidNote(cls) { return el("span", { class: `${cls} remote-android` }, [t("phone.promo.android"), " · ", externalLink(COMMUNITY_URL, "login-link", [t("phone.promo.androidLink")])]); }
1144
+ /** No phone paired yet: what the app does that nothing else does, the store, and how the QR below fits in. */
1145
+ function remotePromo(data) {
1146
+ const points = ["models", "hours", "private"].map(key => el("li", {}, [el("strong", { text: t(`phone.promo.${key}`) }), " ", t(`phone.promo.${key}Body`)]));
1147
+ const phoneGlyph = svgIcon("0 0 20 20", ["M7 2h6a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z", "M8.5 14.8h3"]);
1148
+ const download = svgIcon("0 0 20 20", ["M10 3.5v9", "M6.2 9l3.8 3.8L13.8 9", "M4.5 16.5h11"], "remote-store-icon");
1149
+ // Its own lang, so Korean wraps between words in a browser too (the app sets it on <html>).
1150
+ return el("section", { class: "conn-card remote-promo", lang: CURRENT_LANG }, [
1151
+ el("div", { class: "conn-hero" }, [el("div", { class: "conn-icon" }, [phoneGlyph]), el("div", { class: "conn-hero-text" }, [
1152
+ el("p", { class: "remote-eyebrow", text: t("phone.promo.eyebrow") }),
1153
+ el("h2", { class: "conn-title", text: t("phone.promo.title") }),
1154
+ el("p", { class: "conn-lead", text: t("phone.promo.lead") }),
1155
+ ])]),
1156
+ el("ul", { class: "remote-points" }, points),
1157
+ el("div", { class: "remote-get" }, [externalLink(remoteAppStoreUrl(), "btn large remote-store", [download, t("phone.promo.appStore")]), remoteAndroidNote("hint")]),
1158
+ el("p", { class: "remote-next", text: t(data.hostRunning ? "phone.promo.nextQr" : "phone.promo.nextHost") }),
1159
+ ]);
1160
+ }
1161
+ /** A phone is paired already: one quiet row for the next phone instead of the pitch. */
1162
+ function remoteGetApp() {
1163
+ return section(null, [cell(t("phone.promo.compact"), externalLink(remoteAppStoreUrl(), "btn secondary compact", [t("phone.promo.appStoreShort")]), [remoteAndroidNote("cell-sub")])]);
1164
+ }
1042
1165
  function renderPhoneStatus(data) {
1043
1166
  const rows = [cell(t("phone.host"), badge(data.hostRunning ? "ok" : "warn", t(data.hostRunning ? "phone.running" : "phone.stopped")))];
1044
1167
  if (data.hostRunning) rows.push(cell(t("phone.mode"), el("span", { text: t(data.mode === "relay" ? "phone.relayStatus" : data.mode === "direct" ? "phone.directStatus" : "phone.localStatus") }), `${data.bind}:${data.port} · ${data.version}`),
@@ -1106,8 +1229,8 @@ function phoneControls(data) {
1106
1229
  });
1107
1230
  const phones = section(t("phone.phones"), []);
1108
1231
  $(".group", phones).id = "phone-phones";
1109
- return [section(t("phone.pairing"), [cell(t("phone.scan"), qr), el("div", { id: "phone-qr", class: "phone-qr" })], [t("phone.qrWarning"), !data.reachableFromPhone ? t("phone.unreachable") : null]),
1110
- phoneSettings(data), phoneRestart(), phones, section(null, [rotate], t("phone.sharedKey"))];
1232
+ return [section(t("phone.pairing"), [cell(t("phone.scan"), qr, t("phone.scanHelp")), el("div", { id: "phone-qr", class: "phone-qr" })], [t("phone.qrWarning"), !data.reachableFromPhone ? t("phone.unreachable") : null]),
1233
+ phoneSettings(data), phoneRestart(), phones, phonePaired(data) ? remoteGetApp() : null, section(null, [rotate], t("phone.sharedKey"))].filter(Boolean);
1111
1234
  }
1112
1235
  async function refreshPhone(rebuild = false) {
1113
1236
  if (!phoneActive() || offline()) return;
@@ -1115,11 +1238,12 @@ async function refreshPhone(rebuild = false) {
1115
1238
  try {
1116
1239
  const data = await api("/api/phone/status");
1117
1240
  if (generation !== phoneGeneration || !phoneActive()) return;
1118
- if (rebuild || !phoneSnapshot || phoneSnapshot.hostRunning !== data.hostRunning) {
1241
+ // Pairing the first phone (or unpairing the last) swaps the pitch for its one-row form, so it rebuilds too.
1242
+ if (rebuild || !phoneSnapshot || phoneSnapshot.hostRunning !== data.hostRunning || phonePaired(phoneSnapshot) !== phonePaired(data)) {
1119
1243
  hidePhoneQr();
1120
1244
  const state = section(t("phone.status"), []);
1121
1245
  $(".group", state).id = "phone-status";
1122
- $("#phone-content").replaceChildren(state, ...(data.hostRunning ? phoneControls(data) : []));
1246
+ $("#phone-content").replaceChildren(...(phonePaired(data) ? [] : [remotePromo(data)]), state, ...(data.hostRunning ? phoneControls(data) : []));
1123
1247
  }
1124
1248
  phoneSnapshot = data;
1125
1249
  renderPhoneStatus(data);
@@ -3263,6 +3387,11 @@ const MOD_CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"];
3263
3387
  const MOD_CLAUDE_IDS = { haiku: "claude-haiku-5-5", sonnet: "claude-sonnet-5-5", opus: "claude-opus-5-5", fable: "claude-fable-5-1" };
3264
3388
  // The four workers, low to max (the mod's rung ids: light, standard, hard, plan).
3265
3389
  const MOD_RUNGS = ["light", "standard", "hard", "plan"];
3390
+ // A domain ladder has two levels: the light pair (light+standard) and the heavy pair (hard+plan).
3391
+ const MOD_DOMAIN_LEVELS = [
3392
+ { key: "light", rungs: ["light", "standard"] },
3393
+ { key: "heavy", rungs: ["hard", "plan"] },
3394
+ ];
3266
3395
  const modClaude = (model, effort) => ({ target: { agent: "general-purpose", model }, effort });
3267
3396
  /** The mod's defaults (packages/mod/hooks/settings.ts), for a router that has stored nothing yet. */
3268
3397
  const MOD_DEFAULTS = {
@@ -3274,7 +3403,7 @@ const MOD_DEFAULTS = {
3274
3403
  hard: [{ target: { agent: "gpt-6-astra" }, effort: "high" }], plan: [modClaude("opus", "high")],
3275
3404
  } },
3276
3405
  },
3277
- allow: [], allowKnown: [], modes: [], providers: {},
3406
+ allow: [], allowKnown: [], modes: [], delegation: "normal", providers: {},
3278
3407
  fallback: { agent: "general-purpose", model: "sonnet" },
3279
3408
  keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
3280
3409
  compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
@@ -3637,6 +3766,16 @@ function modRungs() {
3637
3766
  const general = modState.settings.rules.general;
3638
3767
  return general && general.mode === "ladder" && general.rungs ? general.rungs : {};
3639
3768
  }
3769
+ /** A domain ladder folded into two levels: light+standard hold one list, hard+plan another.
3770
+ * The upper rung of each pair wins; an empty upper rung falls back to the lower. */
3771
+ function modTwoLevelRungs(rungs) {
3772
+ const out = {};
3773
+ const light = rungs.standard && rungs.standard.length ? rungs.standard : rungs.light;
3774
+ const heavy = rungs.plan && rungs.plan.length ? rungs.plan : rungs.hard;
3775
+ if (light && light.length) { out.light = clone(light); out.standard = clone(light); }
3776
+ if (heavy && heavy.length) { out.hard = clone(heavy); out.plan = clone(heavy); }
3777
+ return out;
3778
+ }
3640
3779
  /** One menu of `model · effort` for the ticked models, effort limited to what each model takes; the session's model too. */
3641
3780
  function modPickSelect(key, step, shown, label, onPick) {
3642
3781
  const choices = modChoices().filter((choice) => choice.value === "general-purpose" || modTicked(choice));
@@ -3657,38 +3796,101 @@ function modPickSelect(key, step, shown, label, onPick) {
3657
3796
  onPick(modTargetOf(value.slice(0, bar)), value.slice(bar + 1));
3658
3797
  }, "rung-model");
3659
3798
  }
3660
- function modRungRows(mode) {
3799
+ /** @typedef {"general" | "ui" | "backend"} WorkerRuleKey */
3800
+ /** Domain selection is local UI state, independently retained for each saved worker set.
3801
+ * @type {Map<string, WorkerRuleKey>}
3802
+ */
3803
+ const modRuleTabs = new Map();
3804
+ /** Save only this set's optional rule; deleting it makes the set follow its own general ladder.
3805
+ * @param {import("../mod/types").Mode | undefined} mode
3806
+ * @param {"ui" | "backend"} key
3807
+ * @param {import("../mod/types").Rule | undefined} rule
3808
+ */
3809
+ function modSetDomainRule(mode, key, rule) {
3810
+ const domainRules = { ...(mode || modState.settings).domainRules };
3811
+ if (rule) domainRules[key] = rule;
3812
+ else delete domainRules[key];
3813
+ const change = { domainRules: Object.keys(domainRules).length ? domainRules : undefined };
3814
+ if (mode) modUpdateMode(mode.id, change);
3815
+ else modPatch(change);
3816
+ }
3817
+ /** @param {import("../mod/types").Mode} [mode] */
3818
+ function modWorkerRows(mode) {
3819
+ const id = mode ? mode.id : "base";
3820
+ const key = modRuleTabs.get(id) || "general";
3821
+ const rule = key === "general" ? null : (mode || modState.settings).domainRules?.[key];
3822
+ const rows = [cell(t("mod.workerDomain"), modSegmented(`worker-domain:${id}`, [
3823
+ { value: "general", label: t("mod.domain.general") },
3824
+ { value: "ui", label: t("mod.domain.ui") },
3825
+ { value: "backend", label: t("mod.domain.backend") },
3826
+ ], key, t("mod.workerDomain"), (value) => { modRuleTabs.set(id, value); modRefresh(); }), null, "wide")];
3827
+ if (key !== "general") rows.push(cell(t("mod.domainSeparate"), modSwitch(`worker-separate:${id}:${key}`, !!rule, t("mod.domainSeparate"), (on) => {
3828
+ // Switching on copies the set's general rungs, folded the same way a domain ladder is.
3829
+ modSetDomainRule(mode, key, on ? { mode: "ladder", rungs: modTwoLevelRungs(mode ? mode.rungs : modRungs()) } : undefined);
3830
+ }), rule ? null : t("mod.domainFollowsGeneral"), "wide"));
3831
+ if (key === "general" || rule) rows.push(...modRungRows(mode, key));
3832
+ return rows;
3833
+ }
3834
+ /** One rung row: the ordered model·effort list of a rung, with reorder, remove and add.
3835
+ * @param {import("../mod/types").Mode | undefined} mode
3836
+ * @param {WorkerRuleKey} ruleKey
3837
+ * @param {string} rowKey the row's own key (its part in the data-keys)
3838
+ * @param {string} name the row's label
3839
+ * @param {string} hintKey the i18n key of the line under it
3840
+ * @param {import("../mod/types").Step[]} steps the list it shows
3841
+ * @param {(next: import("../mod/types").Step[]) => void} save writes the list back
3842
+ */
3843
+ function modRungRow(mode, ruleKey, rowKey, name, hintKey, steps, save) {
3844
+ const key = `worker:${mode ? mode.id : "base"}:${ruleKey}:${rowKey}`;
3845
+ const rows = steps.map((step, index) => {
3846
+ const choice = modChoiceOf(step.target);
3847
+ const shown = modNearest(step.effort, modLadderOf(choice));
3848
+ const select = modPickSelect(`${key}:${index}`, step, shown, `${name} ${index + 1}`, (target, effort) => save(steps.map((item, at) => at === index ? { target, effort } : item)));
3849
+ const move = (delta) => { const next = [...steps]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; save(next); };
3850
+ const button = (suffix, text, label, disabled, onclick) => el("button", { type: "button", class: "btn secondary compact", "data-key": `${key}:${suffix}`, text, "aria-label": label, ...(disabled ? { disabled: "" } : {}), onclick });
3851
+ return el("li", { class: "rung-item" }, [
3852
+ select,
3853
+ el("div", { class: "rung-actions" }, [
3854
+ button("up", "↑", t("mod.modeMoveUp"), index === 0, () => move(-1)),
3855
+ button("down", "↓", t("mod.modeMoveDown"), index === steps.length - 1, () => move(1)),
3856
+ button("remove", "✕", t("common.remove"), steps.length <= 1, () => save(steps.filter((_, at) => at !== index))),
3857
+ ]),
3858
+ choice && !choice.usable ? el("small", { class: "warn-text", text: t("mod.rungUnusable") }) : null,
3859
+ ]);
3860
+ });
3861
+ const add = el("button", { type: "button", class: "btn secondary compact", "data-key": `worker-add:${mode ? mode.id : "base"}:${ruleKey}:${rowKey}`, text: t("mod.modeAddModel"), onclick: () => {
3862
+ const choice = modChoices().find((item) => item.value !== "general-purpose" && modTicked(item)) || modChoices().at(-1);
3863
+ save([...steps, { target: modTargetOf(choice.value), effort: modNearest("medium", modLadderOf(choice)) }]);
3864
+ } });
3865
+ return cell(el("span", { class: "cell-label strong", text: name }), el("div", { class: "rung-list" }, [el("ol", {}, rows), add]), modCap(t(hintKey)), "wide rung-row");
3866
+ }
3867
+ /** The same ordered-rung editor for the selected rule in Base or a saved mode: the general ladder
3868
+ * keeps its four rungs, a domain ladder its two levels.
3869
+ * @param {import("../mod/types").Mode | undefined} mode
3870
+ * @param {WorkerRuleKey} [ruleKey]
3871
+ */
3872
+ function modRungRows(mode, ruleKey = "general") {
3873
+ if (ruleKey !== "general") {
3874
+ const rungs = (mode || modState.settings).domainRules[ruleKey].rungs;
3875
+ return MOD_DOMAIN_LEVELS.map((level) => {
3876
+ // The pair's upper rung shows the list; an edit writes it to both rungs of the pair.
3877
+ const upper = level.rungs[1];
3878
+ const steps = rungs[upper] && rungs[upper].length ? rungs[upper] : (rungs[level.rungs[0]] || []);
3879
+ const save = (next) => {
3880
+ const updated = { ...rungs, [level.rungs[0]]: clone(next), [upper]: clone(next) };
3881
+ modSetDomainRule(mode, ruleKey, { mode: "ladder", rungs: updated });
3882
+ };
3883
+ return modRungRow(mode, ruleKey, level.key, t(`mod.domainLevel.${level.key}`), `mod.domainHint.${ruleKey}.${level.key}`, steps, save);
3884
+ });
3885
+ }
3661
3886
  const rungs = mode ? mode.rungs : modRungs();
3662
3887
  return MOD_RUNGS.map((rung) => {
3663
- const steps = rungs[rung] || [];
3664
- const name = t(`mod.rung.${rung}`);
3665
3888
  const save = (next) => {
3666
3889
  const updated = { ...rungs, [rung]: next };
3667
3890
  if (mode) modUpdateMode(mode.id, { rungs: updated });
3668
3891
  else modSetRule("general", { mode: "ladder", rungs: updated });
3669
3892
  };
3670
- const rows = steps.map((step, index) => {
3671
- const choice = modChoiceOf(step.target);
3672
- const shown = modNearest(step.effort, modLadderOf(choice));
3673
- const key = `worker:${mode ? mode.id : "base"}:${rung}:${index}`;
3674
- const select = modPickSelect(key, step, shown, `${name} ${index + 1}`, (target, effort) => save(steps.map((item, at) => at === index ? { target, effort } : item)));
3675
- const move = (delta) => { const next = [...steps]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; save(next); };
3676
- const button = (suffix, text, label, disabled, onclick) => el("button", { type: "button", class: "btn secondary compact", "data-key": `${key}:${suffix}`, text, "aria-label": label, ...(disabled ? { disabled: "" } : {}), onclick });
3677
- return el("li", { class: "rung-item" }, [
3678
- select,
3679
- el("div", { class: "rung-actions" }, [
3680
- button("up", "↑", t("mod.modeMoveUp"), index === 0, () => move(-1)),
3681
- button("down", "↓", t("mod.modeMoveDown"), index === steps.length - 1, () => move(1)),
3682
- button("remove", "✕", t("common.remove"), steps.length <= 1, () => save(steps.filter((_, at) => at !== index))),
3683
- ]),
3684
- choice && !choice.usable ? el("small", { class: "warn-text", text: t("mod.rungUnusable") }) : null,
3685
- ]);
3686
- });
3687
- const add = el("button", { type: "button", class: "btn secondary compact", "data-key": `worker-add:${mode ? mode.id : "base"}:${rung}`, text: t("mod.modeAddModel"), onclick: () => {
3688
- const choice = modChoices().find((item) => item.value !== "general-purpose" && modTicked(item)) || modChoices().at(-1);
3689
- save([...steps, { target: modTargetOf(choice.value), effort: modNearest("medium", modLadderOf(choice)) }]);
3690
- } });
3691
- return cell(el("span", { class: "cell-label strong", text: name }), el("div", { class: "rung-list" }, [el("ol", {}, rows), add]), modCap(t(`mod.rungHint.${rung}`)), "wide rung-row");
3893
+ return modRungRow(mode, ruleKey, rung, t(`mod.rung.${rung}`), `mod.rungHint.${rung}`, rungs[rung] || [], save);
3692
3894
  });
3693
3895
  }
3694
3896
  /** Said once after settings that still held Auto or field rules are read: they became the four workers. */
@@ -3769,7 +3971,7 @@ function modNewMode() {
3769
3971
  const add = () => {
3770
3972
  const name = input.value.trim();
3771
3973
  if (!name) { toast(t("mod.modeNameRequired"), true); input.focus(); return; }
3772
- const mode = { id: `mode-${crypto.randomUUID()}`, name, rungs: clone(modRungs()), delegation: "normal", until: "none" };
3974
+ const mode = { id: `mode-${crypto.randomUUID()}`, name, rungs: clone(modRungs()), ...(modState.settings.domainRules ? { domainRules: clone(modState.settings.domainRules) } : {}), delegation: "normal", until: "none" };
3773
3975
  modModeTab = mode.id;
3774
3976
  modPatch({ modes: [...modState.settings.modes, mode] });
3775
3977
  };
@@ -3821,7 +4023,7 @@ function modModeSection(mode) {
3821
4023
  const endHelp = kind === "reset" ? t("mod.modeResetHelp", { provider: modResetLabel(mode.until.reset) }) : null;
3822
4024
  return modSection(null, [
3823
4025
  cell(t("mod.modeName"), name),
3824
- ...modRungRows(mode),
4026
+ ...modWorkerRows(mode),
3825
4027
  cell(t("mod.modeDelegation"), modSegmented("mode-delegation", [{ value: "normal", label: t("mod.modeNormal") }, { value: "eager", label: t("mod.modeEager") }], mode.delegation, t("mod.modeDelegation"), (delegation) => modUpdateMode(mode.id, { delegation })), null, "wide"),
3826
4028
  cell(t("mod.modeUntil"), el("div", { class: "mode-until" }, endControls), endHelp, "wide"),
3827
4029
  cell(t("mod.modeActive"), [
@@ -3842,7 +4044,10 @@ function modWorkersCard() {
3842
4044
  modModeTabs(),
3843
4045
  el("div", { id: "worker-tab-panel", role: "tabpanel" }, modModeTab === "+" ? [modNewMode()] : modModeTab ? [modModeSection(s.modes.find((mode) => mode.id === modModeTab))] : [
3844
4046
  modSection(t("mod.models"), modTickRows(), [t("mod.modelsHelp")]),
3845
- modSection(t("mod.fourWorkers"), modRungRows(), [t("mod.modeOrderHelp")]),
4047
+ modSection(t("mod.fourWorkers"), modWorkerRows(), [t("mod.modeOrderHelp")]),
4048
+ modSection(null, [
4049
+ cell(t("mod.modeDelegation"), modSegmented("base-delegation", [{ value: "normal", label: t("mod.modeNormal") }, { value: "eager", label: t("mod.modeEager") }], s.delegation || "normal", t("mod.modeDelegation"), (delegation) => modPatch({ delegation })), t("mod.baseDelegationHelp"), "wide"),
4050
+ ]),
3846
4051
  s.activeMode ? modSection(null, [cell(t("mod.modeActive"), el("button", { type: "button", class: "btn secondary compact", "data-key": "mode-base-on", text: t("mod.modeUseBase"), onclick: () => modSetActive("") }))]) : null,
3847
4052
  ].filter(Boolean)),
3848
4053
  ]);
@@ -4213,7 +4418,7 @@ async function refreshRequests() {
4213
4418
  $("#logs-range-caption").textContent = usageCaption(range, lastSummary && lastSummary.earliest);
4214
4419
  const summaryPath = range ? `/api/requests/summary?from=${range.from}&to=${range.to}` : "/api/requests/summary?since=3600";
4215
4420
  const suffix = provider ? `&provider=${encodeURIComponent(provider)}` : "";
4216
- const [records, summary] = await Promise.allSettled([api(`/api/requests?n=200${suffix}&kind=messages`), api(summaryPath), loadConfiguredProviders()]);
4421
+ const [records, summary] = await Promise.allSettled([api(`/api/requests?n=200${suffix}&kind=messages,image`), api(summaryPath), loadConfiguredProviders()]);
4217
4422
  if (generation !== requestsGeneration) return;
4218
4423
  // Each half keeps its last good contents when its own call fails.
4219
4424
  if (summary.status === "fulfilled") {