clauderipple 0.11.1 → 0.12.1

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 +46 -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/package.json +1 -1
  13. package/dist/cli/src/index.js +5 -1
  14. package/dist/mod/hooks/i18n.ts +6 -0
  15. package/dist/mod/hooks/modes.ts +15 -8
  16. package/dist/mod/hooks/register.tsx +72 -20
  17. package/dist/mod/hooks/settings.js +37 -2
  18. package/dist/mod/hooks/settings.ts +31 -3
  19. package/dist/mod/hooks/ui/agents.tsx +1 -1
  20. package/dist/mod/hooks/ui/art.ts +8 -3
  21. package/dist/mod/hooks/ui/band.tsx +6 -2
  22. package/dist/mod/hooks/ui/flat.ts +11 -8
  23. package/dist/mod/hooks/ui/look.ts +3 -1
  24. package/dist/mod/hooks/ui/view.ts +2 -0
  25. package/dist/mod/hooks/workers.ts +22 -1
  26. package/dist/mod/tests/delegation.test.tsx +50 -0
  27. package/dist/mod/tests/register.test.tsx +154 -1
  28. package/dist/mod/tests/ui.test.tsx +6 -3
  29. package/dist/mod/types/index.d.ts +14 -0
  30. package/dist/router/src/admin.js +41 -76
  31. package/dist/router/src/autoupdate.js +299 -0
  32. package/dist/router/src/bootstrap.js +3 -2
  33. package/dist/router/src/cli-control.js +65 -0
  34. package/dist/router/src/config.js +70 -11
  35. package/dist/router/src/index.js +11 -1
  36. package/dist/router/src/picker.js +25 -1
  37. package/dist/router/src/presets.js +21 -13
  38. package/dist/router/src/providers/chatgpt/index.js +11 -0
  39. package/dist/router/src/providers/google/index.js +7 -2
  40. package/dist/router/src/providers/google/transport.js +16 -1
  41. package/dist/router/src/proxy.js +847 -717
  42. package/dist/router/src/requestlog.js +3 -2
  43. package/dist/router/src/version.js +1 -1
  44. package/dist/router/src/webfetch.js +56 -0
  45. package/dist/router/src/websearch.js +2 -2
  46. package/dist/ui/app.js +247 -42
  47. package/dist/ui/i18n.js +160 -18
  48. package/dist/ui/style.css +21 -0
  49. package/docs/ARCHITECTURE.md +111 -14
  50. package/package.json +1 -1
@@ -17,6 +17,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
17
17
  const node_os_1 = __importDefault(require("node:os"));
18
18
  const node_path_1 = __importDefault(require("node:path"));
19
19
  const claude_desktop_1 = require("./claude-desktop");
20
+ const update_model_1 = require("./update-model");
20
21
  const protocol_1 = require("./protocol");
21
22
  const status_model_1 = require("./status-model");
22
23
  const window_1 = require("./window");
@@ -100,6 +101,8 @@ const STRINGS = {
100
101
  updateNow: "Update",
101
102
  later: "Later",
102
103
  updating: (v) => `Updating to ClaudeRipple ${v}…`,
104
+ autoUpdated: (v) => `Updated to ClaudeRipple ${v}`,
105
+ autoAvailable: (v) => `New version ${v} — click to update`,
103
106
  updateDone: (v) => `Updated to ClaudeRipple ${v}`,
104
107
  updateFailed: "The update did not finish",
105
108
  updateCheckFailed: "Could not check for updates",
@@ -182,6 +185,8 @@ const STRINGS = {
182
185
  updateNow: "업데이트",
183
186
  later: "나중에",
184
187
  updating: (v) => `ClaudeRipple ${v} 업데이트 중…`,
188
+ autoUpdated: (v) => `ClaudeRipple ${v}로 업데이트됨`,
189
+ autoAvailable: (v) => `새 버전 ${v} — 클릭해서 업데이트`,
185
190
  updateDone: (v) => `ClaudeRipple ${v} 업데이트 완료`,
186
191
  updateFailed: "업데이트를 완료하지 못했습니다",
187
192
  updateCheckFailed: "업데이트 정보를 확인하지 못했습니다",
@@ -264,6 +269,8 @@ const STRINGS = {
264
269
  updateNow: "更新",
265
270
  later: "稍后",
266
271
  updating: (v) => `正在更新到 ClaudeRipple ${v}…`,
272
+ autoUpdated: (v) => `已更新到 ClaudeRipple ${v}`,
273
+ autoAvailable: (v) => `新版本 ${v} — 点击更新`,
267
274
  updateDone: (v) => `已更新到 ClaudeRipple ${v}`,
268
275
  updateFailed: "更新未完成",
269
276
  updateCheckFailed: "无法检查更新",
@@ -346,6 +353,8 @@ const STRINGS = {
346
353
  updateNow: "アップデート",
347
354
  later: "後で",
348
355
  updating: (v) => `ClaudeRipple ${v} にアップデート中…`,
356
+ autoUpdated: (v) => `ClaudeRipple ${v} にアップデートしました`,
357
+ autoAvailable: (v) => `新バージョン ${v} — クリックして更新`,
349
358
  updateDone: (v) => `ClaudeRipple ${v} にアップデートしました`,
350
359
  updateFailed: "アップデートが完了しませんでした",
351
360
  updateCheckFailed: "アップデートを確認できませんでした",
@@ -428,6 +437,8 @@ const STRINGS = {
428
437
  updateNow: "Actualizar",
429
438
  later: "Más tarde",
430
439
  updating: (v) => `Actualizando a ClaudeRipple ${v}…`,
440
+ autoUpdated: (v) => `Actualizado a ClaudeRipple ${v}`,
441
+ autoAvailable: (v) => `Nueva versión ${v} — haz clic para actualizar`,
431
442
  updateDone: (v) => `Actualizado a ClaudeRipple ${v}`,
432
443
  updateFailed: "La actualización no se completó",
433
444
  updateCheckFailed: "No se pudo comprobar si hay actualizaciones",
@@ -510,6 +521,8 @@ const STRINGS = {
510
521
  updateNow: "Atualizar",
511
522
  later: "Depois",
512
523
  updating: (v) => `Atualizando para o ClaudeRipple ${v}…`,
524
+ autoUpdated: (v) => `Atualizado para o ClaudeRipple ${v}`,
525
+ autoAvailable: (v) => `Nova versão ${v} — clique para atualizar`,
513
526
  updateDone: (v) => `Atualizado para o ClaudeRipple ${v}`,
514
527
  updateFailed: "A atualização não foi concluída",
515
528
  updateCheckFailed: "Não foi possível verificar atualizações",
@@ -621,6 +634,7 @@ async function poll() {
621
634
  downSince = null;
622
635
  downNotified = false;
623
636
  answered = true;
637
+ void handleRouterUpdate(last).catch((error) => console.error(`update notification: ${error}`));
624
638
  void reconcileRouter(last);
625
639
  }
626
640
  catch (e) {
@@ -632,7 +646,7 @@ async function poll() {
632
646
  // the outage the notification is for. Neither is the direct mode, where the router being off
633
647
  // is the point — so doctor is asked first, and once per outage. With the window on screen the
634
648
  // user is already looking at the same news.
635
- if ((0, status_model_1.shouldNotifyDown)({ downForMs: Date.now() - downSince, graceMs: DOWN_NOTIFY_AFTER_MS, alreadyNotified: downNotified, updating, busy: !!rec.busy || (0, window_1.windowVisible)(), mode: rec.doctor?.mode ?? null })) {
649
+ if ((0, status_model_1.shouldNotifyDown)({ downForMs: Date.now() - downSince, graceMs: DOWN_NOTIFY_AFTER_MS, alreadyNotified: downNotified, updating: updating || Date.now() < routerUpdateUntil, busy: !!rec.busy || (0, window_1.windowVisible)(), mode: rec.doctor?.mode ?? null })) {
636
650
  downNotified = true;
637
651
  void refreshDoctor().then(() => {
638
652
  if (!last && rec.doctor?.mode !== "direct")
@@ -643,6 +657,12 @@ async function poll() {
643
657
  // Up or down changed: what doctor said about the rest (direct, mixed) may have changed with it.
644
658
  if (before !== null && before !== answered && !rec.busy)
645
659
  void refreshDoctor();
660
+ // Claude quit or reopened by hand has applied the change as surely as the button would.
661
+ if (rec.restartClaude && !rec.busy && Date.now() - restartCheckedAt >= RESTART_CHECK_MS) {
662
+ restartCheckedAt = Date.now();
663
+ if (!(0, status_model_1.restartStillOwed)(restartOwedSince, await (0, claude_desktop_1.claudeStartedAt)()))
664
+ rec.restartClaude = false;
665
+ }
646
666
  render();
647
667
  }
648
668
  // Held so the handlers are not collected with it before the user gets to the notification.
@@ -856,6 +876,11 @@ const rec = {
856
876
  result: null,
857
877
  restartClaude: false,
858
878
  };
879
+ /** When the connection last changed in a way Claude only picks up on its next start. */
880
+ let restartOwedSince = 0;
881
+ let restartCheckedAt = 0;
882
+ /** How often a pending "reopen Claude" step asks whether Claude was reopened some other way. */
883
+ const RESTART_CHECK_MS = 15_000;
859
884
  let doctorRun = null;
860
885
  /** One `doctor` at a time; callers that arrive while one runs share it. */
861
886
  function refreshDoctor() {
@@ -985,8 +1010,11 @@ async function runAction(action, args) {
985
1010
  ? (0, status_model_1.repairDetails)(out, r.output)
986
1011
  : [out?.detail ?? "", ...(ok ? [] : (0, status_model_1.tailLines)(out ? "" : r.output, 8))].filter(Boolean);
987
1012
  rec.result = { action, ok, timedOut: r.timedOut, details, at: Date.now() };
988
- if (ok && out?.restartClaude)
989
- rec.restartClaude = true;
1013
+ if (ok && out?.restartClaude) {
1014
+ // A Claude that is not running picks the change up when it next starts: nothing to ask for.
1015
+ restartOwedSince = Date.now();
1016
+ rec.restartClaude = (0, status_model_1.restartStillOwed)(restartOwedSince - 1, await (0, claude_desktop_1.claudeStartedAt)());
1017
+ }
990
1018
  if (out?.doctor) {
991
1019
  rec.doctor = out.doctor;
992
1020
  rec.doctorFailed = false;
@@ -1025,6 +1053,83 @@ async function restartClaude() {
1025
1053
  let availableUpdate = null;
1026
1054
  let updating = false;
1027
1055
  const UPDATE_CHECK_MS = 12 * 60 * 60 * 1000;
1056
+ let routerUpdateUntil = 0;
1057
+ let updateNotification = null;
1058
+ let updateSeen;
1059
+ const updateSeenFile = node_path_1.default.join(home, "update-notifications.json");
1060
+ function loadUpdateSeen() {
1061
+ try {
1062
+ return (0, update_model_1.readUpdateSeen)(JSON.parse(node_fs_1.default.readFileSync(updateSeenFile, "utf8")));
1063
+ }
1064
+ catch (error) {
1065
+ if (error.code !== "ENOENT")
1066
+ console.error(`update notification state: ${error}`);
1067
+ return { updated: [], available: [] };
1068
+ }
1069
+ }
1070
+ async function relaunchTray() {
1071
+ electron_1.app.releaseSingleInstanceLock();
1072
+ const result = await runCliResult(["tray"]);
1073
+ if (!result.ok) {
1074
+ // Keep a functioning tray if its replacement could not start.
1075
+ if (!electron_1.app.requestSingleInstanceLock())
1076
+ electron_1.app.quit();
1077
+ throw new Error(result.output || "could not relaunch tray");
1078
+ }
1079
+ electron_1.app.quit();
1080
+ }
1081
+ async function handleRouterUpdate(status) {
1082
+ if (status.autoUpdate?.running)
1083
+ routerUpdateUntil = Date.now() + 10 * 60_000;
1084
+ else
1085
+ routerUpdateUntil = 0;
1086
+ if (updating)
1087
+ return;
1088
+ updateSeen ??= loadUpdateSeen();
1089
+ const notice = (0, update_model_1.updateNotice)(status.version, electron_1.app.getVersion(), status.autoUpdate, updateSeen);
1090
+ if (!notice)
1091
+ return;
1092
+ // Memory dedup is immediate; disk dedup must succeed before a relaunch to avoid restart loops.
1093
+ updateSeen = (0, update_model_1.rememberUpdate)(updateSeen, notice);
1094
+ node_fs_1.default.mkdirSync(home, { recursive: true });
1095
+ const tmp = `${updateSeenFile}.${process.pid}.tmp`;
1096
+ try {
1097
+ node_fs_1.default.writeFileSync(tmp, JSON.stringify(updateSeen) + "\n", { mode: 0o600 });
1098
+ node_fs_1.default.renameSync(tmp, updateSeenFile);
1099
+ }
1100
+ finally {
1101
+ try {
1102
+ node_fs_1.default.unlinkSync(tmp);
1103
+ }
1104
+ catch (error) {
1105
+ if (error.code !== "ENOENT")
1106
+ throw error;
1107
+ }
1108
+ }
1109
+ if (notice.kind === "available") {
1110
+ const check = { current: status.version, latest: notice.version, newer: true, kind: status.autoUpdate?.kind ?? "unknown" };
1111
+ availableUpdate = check;
1112
+ if (electron_1.Notification.isSupported()) {
1113
+ updateNotification = new electron_1.Notification({ title: "ClaudeRipple", body: L.autoAvailable(notice.version), icon: notifyIcon() });
1114
+ updateNotification.on("click", () => { void offerUpdate(check).catch((error) => console.error(`offer update: ${error}`)); });
1115
+ updateNotification.show();
1116
+ }
1117
+ return;
1118
+ }
1119
+ if (electron_1.Notification.isSupported()) {
1120
+ updateNotification = new electron_1.Notification({ title: "ClaudeRipple", body: L.autoUpdated(notice.version), icon: notifyIcon() });
1121
+ updateNotification.show();
1122
+ }
1123
+ if (notice.relaunch) {
1124
+ updating = true;
1125
+ try {
1126
+ await relaunchTray();
1127
+ }
1128
+ finally {
1129
+ updating = false;
1130
+ }
1131
+ }
1132
+ }
1028
1133
  async function checkForUpdate() {
1029
1134
  const r = await runCliResult(["update", "--check", "--json"]);
1030
1135
  try {
@@ -1068,6 +1173,23 @@ async function offerUpdate(check) {
1068
1173
  const version = check.latest ?? "";
1069
1174
  if (electron_1.Notification.isSupported())
1070
1175
  new electron_1.Notification({ title: "ClaudeRipple", body: L.updating(version), icon: notifyIcon() }).show();
1176
+ if (last?.autoUpdate) {
1177
+ // Share the router's coordinator with its scheduler instead of racing a second installer.
1178
+ try {
1179
+ const response = await fetch(`${adminUrl()}api/update`, { method: "POST", signal: AbortSignal.timeout(35_000) });
1180
+ if (!response.ok)
1181
+ throw new Error(await response.text());
1182
+ routerUpdateUntil = Date.now() + 10 * 60_000;
1183
+ }
1184
+ catch (error) {
1185
+ await electron_1.dialog.showMessageBox({ type: "warning", message: L.updateFailed, detail: String(error) });
1186
+ }
1187
+ finally {
1188
+ updating = false;
1189
+ render();
1190
+ }
1191
+ return; // /api/status will confirm completion, notify and relaunch this tray.
1192
+ }
1071
1193
  const r = await runCliResult(["update"]);
1072
1194
  updating = false;
1073
1195
  if (!r.ok) {
@@ -1078,11 +1200,8 @@ async function offerUpdate(check) {
1078
1200
  availableUpdate = null;
1079
1201
  if (electron_1.Notification.isSupported())
1080
1202
  new electron_1.Notification({ title: "ClaudeRipple", body: L.updateDone(version), icon: notifyIcon() }).show();
1081
- // A new tray from the new files, then this one goes. The lock is let go first, or the new tray
1082
- // would see this one and leave.
1083
- electron_1.app.releaseSingleInstanceLock();
1084
- await runCliResult(["tray"]);
1085
- electron_1.app.quit();
1203
+ // A new tray from the new files, then this one goes.
1204
+ await relaunchTray();
1086
1205
  }
1087
1206
  // Offer setup only when there is no working installation at all. A developer who runs the router
1088
1207
  // from a source checkout keeps that; switching to the app's bundled runtime is a menu action.
@@ -7,6 +7,7 @@ exports.parseJsonOutput = parseJsonOutput;
7
7
  exports.tailLines = tailLines;
8
8
  exports.trayState = trayState;
9
9
  exports.shouldNotifyDown = shouldNotifyDown;
10
+ exports.restartStillOwed = restartStillOwed;
10
11
  exports.repairDetails = repairDetails;
11
12
  exports.routeRequest = routeRequest;
12
13
  /**
@@ -54,6 +55,17 @@ function trayState(health, doctor) {
54
55
  function shouldNotifyDown(o) {
55
56
  return !o.alreadyNotified && !o.updating && !o.busy && o.downForMs >= o.graceMs && o.mode !== "direct";
56
57
  }
58
+ /**
59
+ * Whether the "reopen Claude" step is still owed after a connection change at `changedAt`. Claude
60
+ * picks the change up on its next start, whoever restarts it: a Claude that is not running, or one
61
+ * that started after the change, owes nothing. Only an unknown start time keeps the step — the
62
+ * button used to be the only way to clear it, so reopening by hand left it on screen for good.
63
+ */
64
+ function restartStillOwed(changedAt, claudeStartedAt) {
65
+ if (claudeStartedAt === undefined)
66
+ return true;
67
+ return claudeStartedAt !== null && claudeStartedAt <= changedAt;
68
+ }
57
69
  /** Lines for the "Details" fold after a repair: each step as ✓/✗ with what it said. */
58
70
  function repairDetails(out, raw) {
59
71
  if (!out?.steps?.length)
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readUpdateSeen = readUpdateSeen;
4
+ exports.updateNotice = updateNotice;
5
+ exports.rememberUpdate = rememberUpdate;
6
+ function readUpdateSeen(value) {
7
+ if (!value || typeof value !== "object")
8
+ throw new Error("invalid update notification state");
9
+ const s = value;
10
+ if (!Array.isArray(s.updated) || !Array.isArray(s.available)
11
+ || [...s.updated, ...s.available].some((v) => typeof v !== "string"))
12
+ throw new Error("invalid update notification versions");
13
+ return { updated: s.updated, available: s.available };
14
+ }
15
+ /** No I/O or Electron: only confirmed completion is a reason to replace a running tray. */
16
+ function updateNotice(routerVersion, trayVersion, state, seen) {
17
+ if (!state)
18
+ return null;
19
+ if (!state.running && state.lastUpdatedTo === routerVersion && state.lastUpdatedFrom && state.lastUpdatedFrom !== routerVersion
20
+ && typeof state.lastUpdatedAt === "number" && Number.isFinite(state.lastUpdatedAt) && !seen.updated.includes(routerVersion)) {
21
+ return { kind: "updated", version: routerVersion, relaunch: trayVersion !== routerVersion && (state.kind === "script" || state.kind === "npm") };
22
+ }
23
+ const version = state.available;
24
+ const automatic = state.enabled !== false && (state.kind === "script" || state.kind === "npm");
25
+ if (version && version !== routerVersion && !state.running && !automatic && !seen.available.includes(version)) {
26
+ return { kind: "available", version };
27
+ }
28
+ return null;
29
+ }
30
+ function rememberUpdate(seen, notice) {
31
+ return { ...seen, [notice.kind]: [...new Set([...seen[notice.kind], notice.version])] };
32
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "type": "commonjs"
5
5
  }
@@ -146,9 +146,13 @@ async function image() {
146
146
  ...(images.length ? { images } : {}),
147
147
  };
148
148
  const port = adminPort(new ConfigStore(configPath()).get());
149
+ // Claude Code exports its session to Bash commands; the router logs it with the image so the
150
+ // request list shows which session spent the subscription.
151
+ const session = process.env.CLAUDE_CODE_SESSION_ID;
152
+ const headers = { "content-type": "application/json", ...(session && /^[\w-]{1,128}$/.test(session) ? { "x-claude-code-session-id": session } : {}) };
149
153
  let res;
150
154
  try {
151
- res = await fetch(`http://127.0.0.1:${port}/api/image`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
155
+ res = await fetch(`http://127.0.0.1:${port}/api/image`, { method: "POST", headers, body: JSON.stringify(body) });
152
156
  }
153
157
  catch {
154
158
  throw new Error(`the router is not answering on 127.0.0.1:${port}; start it with: clauderipple start`);
@@ -66,6 +66,7 @@ const EN = {
66
66
  'band.compact': 'Compact',
67
67
  'band.clear': 'Clear',
68
68
  'band.compactAlt': 'Compact — summarise the conversation to free context',
69
+ 'band.compactedTimes': 'compacted {count}×',
69
70
  'band.clearAlt': 'New start (/clear) — clear the conversation and start fresh',
70
71
  'band.log': 'Log',
71
72
  'band.agents': 'Agents',
@@ -563,6 +564,7 @@ const KO: Record<Key, string> = {
563
564
  'band.compact': '압축',
564
565
  'band.clear': '새로 시작',
565
566
  'band.compactAlt': '대화 압축 — 대화를 요약해 컨텍스트를 줄입니다',
567
+ 'band.compactedTimes': '{count}번 압축됨',
566
568
  'band.clearAlt': '새로 시작 (/clear) — 대화 내용을 비우고 새로 시작합니다',
567
569
  'band.log': '로그',
568
570
  'band.agents': '에이전트',
@@ -1058,6 +1060,7 @@ const ZH: Record<Key, string> = {
1058
1060
  'band.compact': '压缩',
1059
1061
  'band.clear': '新开',
1060
1062
  'band.compactAlt': '压缩对话 — 总结对话以释放上下文',
1063
+ 'band.compactedTimes': '已压缩 {count} 次',
1061
1064
  'band.clearAlt': '重新开始 (/clear) — 清空对话上下文,重新开始',
1062
1065
  'band.log': '日志',
1063
1066
  'band.agents': '代理',
@@ -1553,6 +1556,7 @@ const JA: Record<Key, string> = {
1553
1556
  'band.compact': '圧縮',
1554
1557
  'band.clear': '新しく開始',
1555
1558
  'band.compactAlt': '圧縮 — これまでの会話を要約してコンテキストを減らします',
1559
+ 'band.compactedTimes': '{count} 回圧縮済み',
1556
1560
  'band.clearAlt': '新しく開始 (/clear) — 会話の文脈を空にして新しく始めます',
1557
1561
  'band.log': 'ログ',
1558
1562
  'band.agents': 'エージェント',
@@ -2048,6 +2052,7 @@ const ES: Record<Key, string> = {
2048
2052
  'band.compact': 'Compactar',
2049
2053
  'band.clear': 'Reiniciar',
2050
2054
  'band.compactAlt': 'Compactar — resume la conversación para liberar contexto',
2055
+ 'band.compactedTimes': 'compactada {count} veces',
2051
2056
  'band.clearAlt': 'Empezar de nuevo (/clear) — vacía la conversación y empieza desde cero',
2052
2057
  'band.log': 'Registro',
2053
2058
  'band.agents': 'Agentes',
@@ -2543,6 +2548,7 @@ const PT: Record<Key, string> = {
2543
2548
  'band.compact': 'Compactar',
2544
2549
  'band.clear': 'Recomeçar',
2545
2550
  'band.compactAlt': 'Compactar — resume a conversa para liberar contexto',
2551
+ 'band.compactedTimes': 'compactada {count} vezes',
2546
2552
  'band.clearAlt': 'Começar de novo (/clear) — limpa a conversa e começa do zero',
2547
2553
  'band.log': 'Log',
2548
2554
  'band.agents': 'Agentes',
@@ -1,5 +1,6 @@
1
1
  // Operating modes in the session (docs/specs/2026-10-12-worker-tiers.md "Operating modes"): which
2
- // mode is on, when it ends by itself, and the eager-delegation note. Pure; register.tsx wires them.
2
+ // mode is on, when it ends by itself, and the eager-delegation note (Base's while no mode is on).
3
+ // Pure; register.tsx wires them.
3
4
 
4
5
  import type { Hot, LimitItem, Mode, Settings } from '../types'
5
6
 
@@ -18,21 +19,27 @@ export function modeNote(name: string): string {
18
19
  return `ClaudeRipple mode ${name}: delegate everything except judgment and final review to ripple:auto.`
19
20
  }
20
21
 
22
+ /** What it reads while no mode is on and Base is eager. */
23
+ const BASE_NOTE = 'ClaudeRipple: delegate everything except judgment and final review to ripple:auto.'
24
+
21
25
  /**
22
- * The eager note for this prompt: sent on the first prompt after the mode turned on, and again on
23
- * the first after a compaction (which clears `noted`), never on the turns between.
26
+ * The eager note for this prompt: Base's while no mode is on, the active mode's otherwise. Sent on
27
+ * the first prompt after that turned eager (a mode turning on, or Base made eager), and again on
28
+ * the first after a compaction (which clears `noted`), never on the turns between. When the
29
+ * effective delegation is not eager, nothing is said and `noted` becomes null, so making it eager
30
+ * again later sends the note afresh.
24
31
  */
25
- export function eagerNote(s: Pick<Settings, 'modes' | 'activeMode' | 'activeSince'>, noted: string | null): { note?: string; noted: string | null } {
32
+ export function eagerNote(s: Pick<Settings, 'modes' | 'activeMode' | 'activeSince' | 'delegation'>, noted: string | null): { note?: string; noted: string | null } {
26
33
  const mode = activeModeOf(s)
27
- const key = modeKey(s)
28
- if (!mode || mode.delegation !== 'eager') return { noted }
29
- return noted === key ? { noted } : { note: modeNote(mode.name), noted: key }
34
+ if ((mode?.delegation ?? s.delegation) !== 'eager') return { noted: null }
35
+ const key = mode ? modeKey(s) : 'base'
36
+ return noted === key ? { noted } : { note: mode ? modeNote(mode.name) : BASE_NOTE, noted: key }
30
37
  }
31
38
 
32
39
  /** Modes replace the base worker set, without changing fallback, allow-list or provider policy. */
33
40
  export function routingSettings(s: Settings): Settings {
34
41
  const mode = activeModeOf(s)
35
- return mode ? { ...s, rules: { general: { mode: 'ladder', rungs: mode.rungs } } } : s
42
+ return mode ? { ...s, rules: { general: { mode: 'ladder', rungs: mode.rungs } }, domainRules: mode.domainRules } : s
36
43
  }
37
44
 
38
45
  /** The provider whose quota reset ends the mode: 'anthropic' is Claude, any other a ChatGPT provider. */
@@ -1,14 +1,14 @@
1
1
  // Engine wiring lives here: imported logic and drawings never receive the engine interface.
2
2
  // Timers own background work; hooks only admit/rewrite a request and record its result.
3
3
  import { atom, read, update } from 'claude-code'
4
- import type { AgentSpawnInput, EngineInterface, ModelCompleteResult, ModelForkResult, Register, RenderElement, RenderInput, SessionRateLimit, ToolCallInput, TurnStepInput, TurnUsage } from 'claude-code'
4
+ import type { AgentSpawnInput, EngineInterface, ModelCompleteResult, ModelForkResult, Register, RenderElement, RenderInput, SessionCompactResult, SessionRateLimit, ToolCallInput, TurnStepInput, TurnUsage } from 'claude-code'
5
5
  import type { AgentRec, Effort, JudgeInput, JudgeKind, JudgeRun, LimitItem, ModSettingsSnapshot, Pick, Settings, StatusRequest, TierTarget, Verdict, WorkerCandidate, WorkerPick, WorkerVerdict } from '../types'
6
6
  import { MOD_SETTINGS_SCHEMA_VERSION, RUNG_EFFORT, applySettingsChanges, normalizeSettings, settingsChanges } from './settings'
7
7
  import { rungOf } from './ladder'
8
8
  import { debugText, langFrom, t } from './i18n'
9
9
  import type { Lang } from './i18n'
10
10
  import { EFFORT_JUDGE_SYSTEM, finalEffort, isCacheSafeModel, isShortFollowUp, jevEffortRequest, jevFailure, jevUsage, jevWorkerRequest, judgeContext, parseEffortVerdict, parseJevEffort, parseJevKey, parseJevWorker, shouldJudge } from './judge'
11
- import { AUTO_DESCRIPTION, WORKER_JUDGE_SYSTEM, agentProvider, candidateUsable, chatgptUsable, claudeCandidates, ladderOptionsFrom, ladderPick, parseWorkerVerdict, readMarker, routerModelOptions, spawnModel, spawnTitle, takesOver, TIER_MARKERS, tierOf, workerJudgeInput, zenJev } from './workers'
11
+ import { AUTO_DESCRIPTION, WORKER_JUDGE_SYSTEM, agentProvider, candidateUsable, chatgptUsable, claudeCandidates, domainVerdict, ladderOptionsFrom, ladderPick, parseWorkerVerdict, readMarker, routerModelOptions, spawnModel, spawnTitle, takesOver, TIER_MARKERS, tierOf, workerJudgeInput, workerRule, zenJev } from './workers'
12
12
  import { MARKER, agentModel, fixSpawn } from './worker'
13
13
  import { activeModeOf, chatgptProviders, claudeWindow, eagerNote, gptLimitsFrom, modeKey, modeOver, quotaWindows, resetProvider, routingSettings } from './modes'
14
14
  import type { QuotaWindow } from './modes'
@@ -86,6 +86,8 @@ const setupStep = atom({ plugin: 'ripple', key: 'setupStep' } as const, 1)
86
86
  const compactNote = atom({ plugin: 'ripple', key: 'compactNote' } as const, '')
87
87
  const effortMenuOpen = atom({ plugin: 'ripple', key: 'effortMenuOpen' } as const, false)
88
88
  const agentsOpen = atom({ plugin: 'ripple', key: 'agentsOpen' } as const, false)
89
+ /** Compactions of the main conversation that finished, for the Compact icon's count; /clear zeroes it. */
90
+ const compactions = atom({ plugin: 'ripple', key: 'compactions' } as const, 0)
89
91
  const agentsShowDone = atom({ plugin: 'ripple', key: 'agentsShowDone' } as const, false)
90
92
  const agentOpen = atom({ plugin: 'ripple', key: 'agentOpen' } as const, null)
91
93
  const agentRunsOpen = atom({ plugin: 'ripple', key: 'agentRunsOpen' } as const, [])
@@ -251,6 +253,16 @@ async function installSettings($: EngineInterface, s: Settings): Promise<void> {
251
253
  if (s.keepWarm && !previous.keepWarm) await update($, warm, value => ({ ...value, stopped: undefined }))
252
254
  // Settings caught up with this chat's own choice: it follows Settings again.
253
255
  if (await read($, warmHere) === s.keepWarm) await update($, warmHere, () => null)
256
+ // Setup was finished (here, in another chat, or the settings came back from the router): a setup
257
+ // card still open is stale, and the judge's key is looked up for the source now chosen. Both
258
+ // survive a reload, so after a router outage they asked for setup and a Jev key again although
259
+ // the router held both (2026-10-20).
260
+ if (s.setupDone && await read($, panel) === 'setup') {
261
+ await update($, panel, () => null)
262
+ await update($, settingsDraft, () => null)
263
+ await update($, settingsBase, () => null)
264
+ }
265
+ if (s.judge !== previous.judge || s.jevSource !== previous.jevSource || (s.judge === 'jev' && (await read($, jev)).source === null)) await jevKey($)
254
266
  }
255
267
  function settingsSnapshot(text: string): ModSettingsSnapshot {
256
268
  const value = object(JSON.parse(text))
@@ -295,6 +307,13 @@ async function syncSettings($: EngineInterface, admin: string): Promise<void> {
295
307
  await update($, settingsBase, () => null)
296
308
  }
297
309
  if (snapshot.stored && sync.migration) sync = await update($, settingsSync, value => ({ ...value, migration: null }))
310
+ // A router with no settings stored (a fresh or emptied home, or a stray test router that took the
311
+ // live one's launchd label, 2026-10-20) is not a reset: settings this chat holds from a finished
312
+ // setup are kept and written back, instead of taking defaults and asking for setup again.
313
+ if (!snapshot.stored && !sync.migration) {
314
+ const held = await read($, settings)
315
+ if (held !== null && normalizeSettings(held).setupDone) sync = await update($, settingsSync, value => ({ ...value, migrationChecked: true, migration: normalizeSettings(held) }))
316
+ }
298
317
  if (!sync.migrationChecked) {
299
318
  let migration: Settings | null = null
300
319
  if (!snapshot.stored) {
@@ -703,6 +722,8 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
703
722
  let rawVerdict: WorkerVerdict | null = null
704
723
  let judgeError: string | undefined
705
724
  let target: TierTarget | undefined
725
+ const mode = activeModeOf(s)
726
+ const routing = routingSettings(s)
706
727
  if (explicit) {
707
728
  // Agent names and their file-model ids are both valid explicit routing notes.
708
729
  const name = files.has(explicit.name) ? explicit.name : [...files].find(([, f]) => agentModel(f)?.replace(/@[^@]*$/, '').toLowerCase() === explicit.name)?.[0]
@@ -710,12 +731,21 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
710
731
  verdict = { domain: 'general', level: 'standard', effort: effortOf(explicit.level) ?? 'high', why: t(language, 'toasts.explicitMarker') }
711
732
  by = 'marker'
712
733
  } else if (tier) {
713
- // The main chat named the tier. The judge still rates the same brief, unawaited, so the log
714
- // can later say which of the two was right (see shadowJudge).
734
+ // A marker owns the tier. Only a set with domain ladders needs the shadow judge's domain
735
+ // before dispatch; otherwise leave the existing unawaited path (and latency) unchanged.
715
736
  const named = TIER_MARKERS[tier]!
716
737
  verdict = { domain: 'general', ...named, effort: RUNG_EFFORT[rungOf(named)], why: t(language, 'toasts.sessionTier', { tier }) }
717
738
  by = 'session'
718
- void shadowJudge($, s, id, { description: e.description, prompt: e.prompt })
739
+ const shadow = shadowJudge($, s, id, { description: e.description, prompt: e.prompt })
740
+ if (routing.domainRules?.ui || routing.domainRules?.backend) {
741
+ const judged = await shadow
742
+ if (judged?.verdict && 'domain' in judged.verdict) {
743
+ rawVerdict = judged.verdict
744
+ verdict.domain = judged.verdict.domain
745
+ }
746
+ ms = judged?.ms ?? 0
747
+ judgeError = judged?.error
748
+ }
719
749
  } else {
720
750
  const judged = await judge($, s, { description: e.description, prompt: e.prompt }, true)
721
751
  if (judged.verdict && 'domain' in judged.verdict) { rawVerdict = judged.verdict; verdict = judged.verdict; by = judged.by }
@@ -730,12 +760,15 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
730
760
  const providers = options && options.until > now ? Object.fromEntries(Object.entries(options.providers ?? {}).filter(([, p]) => p.exhaustedUntil === undefined || p.exhaustedUntil > now)) : {}
731
761
  const explicitCandidate = target ? pinnedCandidates.find(c => c.target.agent === target!.agent) ?? (target.agent === 'general-purpose' ? { target, model: target.model ?? 'sonnet', provider: (await read($, routerModels)).find(m => m.value.toLowerCase() === target!.model)?.provider ?? 'anthropic' } : undefined) : undefined
732
762
  if (explicitCandidate && s.providers[explicitCandidate.provider] === false) return { deny: t(language, 'workers.disabled', { provider: explicitCandidate.provider }) }
733
- // An active mode moves the tiers; an explicit marker still names its own model.
734
- const mode = activeModeOf(s)
735
- const routing = routingSettings(s)
736
- const resolved = explicitCandidate && candidateUsable(explicitCandidate, s, usable, providers)
763
+ // An active mode replaces the whole set; an explicit marker still names its own model. Design under
764
+ // the ui ladder takes the heavy level even past a tier marker: the main chat tiers by difficulty,
765
+ // and a design task is "standard" by that measure while its result depends on the model's taste.
766
+ const { rule, ladder } = workerRule(verdict.domain, routing)
767
+ const explicitUsable = !!explicitCandidate && candidateUsable(explicitCandidate, s, usable, providers)
768
+ const domained: ReturnType<typeof domainVerdict> = explicitUsable ? { verdict } : domainVerdict(verdict, rule)
769
+ const resolved = explicitUsable
737
770
  ? { target: target!, effort: verdict.effort }
738
- : ladderPick(verdict, routing.rules.general, routing, pinnedCandidates, usable, language, routing.allow, providers, routing.allowKnown)
771
+ : ladderPick(domained.verdict, ladder, routing, pinnedCandidates, usable, language, routing.allow, providers, routing.allowKnown)
739
772
  const selected = resolved.target
740
773
  if (!selected) return { deny: t(language, 'workers.none') }
741
774
  const fallbackReason = resolved.fallbackReason
@@ -743,10 +776,10 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
743
776
  const model = spawnModel(selected, verdict.effort, files.has(selected.agent) ? agentModel(files.get(selected.agent)!) : undefined)
744
777
  let prompt = e.prompt
745
778
  if (marker) prompt = prompt.replace(MARKER, '').replace(/^\s*\n/, '')
746
- const value: WorkerPick = { ...verdict, ...resolved, target: selected, by, ms, id, ...(fallbackReason ? { fallbackReason } : {}) }
779
+ const value: WorkerPick = { ...verdict, ...resolved, rule, target: selected, by, ms, id, ...(fallbackReason ? { fallbackReason } : {}) }
747
780
  // The verdict behind a pick is otherwise only in the agents panel; one debug line per pick keeps
748
781
  // it checkable: what the judge said, the rung it landed on and what ran.
749
- const entry = { task: e.description.slice(0, 200), domain: value.domain, level: value.level, kind: value.kind ?? 'do', sure: value.sure, rung: value.rung, position: value.position, skipped: value.skipped, target: value.target, model: model ?? null, effort: value.effort, by, ...(mode ? { mode: mode.name } : {}), ...(fallbackReason ? { fallbackReason } : {}) }
782
+ const entry = { task: e.description.slice(0, 200), domain: value.domain, level: value.level, kind: value.kind ?? 'do', sure: value.sure, rule: value.rule, ...(domained.raised ? { raised: domained.raised } : {}), rung: value.rung, position: value.position, skipped: value.skipped, target: value.target, model: model ?? null, effort: value.effort, by, ...(mode ? { mode: mode.name } : {}), ...(fallbackReason ? { fallbackReason } : {}) }
750
783
  await $.ui.log('ClaudeRipple worker pick: ' + JSON.stringify(entry), { to: 'debug' })
751
784
  // The brief itself is never logged (it can hold anything); its length is. To re-judge a pick,
752
785
  // its brief is in the session's own transcript under the same description.
@@ -760,15 +793,16 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
760
793
 
761
794
  /**
762
795
  * What the judge would have said of a brief the main chat already tiered: logged beside the pick
763
- * (same `id`), never awaited by the spawn, never acted on. Together with each pick's outcome it is
764
- * what decides later whether the judge stays.
796
+ * (same `id`). With domain ladders the spawn awaits this same result for its domain only; without
797
+ * them it never waits or acts on it. The marker's tier always stands.
765
798
  */
766
- async function shadowJudge($: EngineInterface, s: Settings, id: string, input: { description: string; prompt: string }): Promise<void> {
799
+ async function shadowJudge($: EngineInterface, s: Settings, id: string, input: { description: string; prompt: string }): Promise<Judged | undefined> {
767
800
  try {
768
801
  const judged = await judge($, s, input, true)
769
802
  const verdict = judged.verdict && 'domain' in judged.verdict ? judged.verdict : null
770
803
  await persistWorkerPick($, { type: 'shadow', pick: id, at: new Date(await $.clock.now()).toISOString(), verdict, rung: verdict ? rungOf(verdict) : null, judge: judged.by, ms: judged.ms, ...(judged.error ? { error: judged.error } : {}) })
771
- } catch (error) { await report($, 'shadow judge', error) }
804
+ return judged
805
+ } catch (error) { await report($, 'shadow judge', error); return undefined }
772
806
  }
773
807
 
774
808
  async function toggleAuto($: EngineInterface): Promise<void> {
@@ -1219,6 +1253,7 @@ async function dashboard($: EngineInterface, working: boolean): Promise<Dashboar
1219
1253
  limits: await read($, limits) ?? [],
1220
1254
  auto: s.effortAuto, handoff: await read($, advice) ? 'advised' : s.handoffButton === 'always' ? 'always' : 'off',
1221
1255
  agents: { live: list.filter(a => ['running', 'waiting'].includes(a.state)).length, total: list.length },
1256
+ compactions: await read($, compactions),
1222
1257
  logOpen: await read($, logOpen), agentsOpen: await read($, agentsOpen), effortMenu: await read($, effortMenuOpen),
1223
1258
  swampAt: s.swampAt, hide: s.hide, extras: s.extras, isWorking: working,
1224
1259
  ...(activeModeOf(s) ? { mode: activeModeOf(s)!.name } : {}),
@@ -1404,8 +1439,16 @@ export const register: Register = on => {
1404
1439
  await registerAuto($, await currentSettings($))
1405
1440
  startTimers($)
1406
1441
  // Setup keeps its draft across a reload; quick settings never keep one.
1407
- const open = await read($, panel)
1442
+ let open = await read($, panel)
1443
+ // A setup card kept from a reload while setup is done (left by a router outage) goes.
1444
+ if (open === 'setup' && (await currentSettings($)).setupDone) {
1445
+ await update($, panel, () => null)
1446
+ await update($, settingsDraft, () => null)
1447
+ await update($, settingsBase, () => null)
1448
+ open = null
1449
+ }
1408
1450
  if (open === 'setup') await rebaseDraft($)
1451
+ if ((await currentSettings($)).judge === 'jev' && (await read($, jev)).source === null) await jevKey($)
1409
1452
  if (open === 'settings') { await update($, settingsDraft, () => null); await update($, settingsBase, () => null); await jevKey($) }
1410
1453
  if (!(await currentSettings($)).setupDone && !open) await startPanel($, 'setup')
1411
1454
  } catch (error) { await report($, 'session.start', error) }
@@ -1426,6 +1469,7 @@ export const register: Register = on => {
1426
1469
  await update($, context, () => null)
1427
1470
  await update($, lastRealReplyAt, () => null)
1428
1471
  if (await read($, kept)) await update($, kept, () => null)
1472
+ await update($, compactions, () => 0)
1429
1473
  }
1430
1474
  if (e.model) await update($, modelIs, () => e.model!)
1431
1475
  await update($, paused, () => e.model ? !isCacheSafeModel(e.model) : false)
@@ -1632,11 +1676,19 @@ export const register: Register = on => {
1632
1676
  }
1633
1677
  })
1634
1678
  on('session.compact', async ($, e, next) => {
1679
+ // A finished compaction of the main conversation adds one to the Compact icon's count; a skipped
1680
+ // one, a subagent's own and the ahead-of-time precompute do not.
1681
+ const counted = async (result: SessionCompactResult): Promise<SessionCompactResult> => {
1682
+ try {
1683
+ if (!e.agentId && e.trigger !== 'precompute' && 'messages' in result) await update($, compactions, n => n + 1)
1684
+ } catch (error) { await report($, 'session.compact count', error) }
1685
+ return result
1686
+ }
1635
1687
  try {
1636
1688
  const language = await read($, lang)
1637
1689
  // The summary drops the eager note: the first prompt after it carries it again.
1638
1690
  if (!e.agentId && e.trigger !== 'precompute' && await read($, modeNoted) !== null) await update($, modeNoted, () => null)
1639
- if (e.agentId || (await currentSettings($)).compactWith !== 'haiku') return next(e)
1691
+ if (e.agentId || (await currentSettings($)).compactWith !== 'haiku') return next(e).then(counted)
1640
1692
  if (e.trigger === 'precompute') return { skip: t(language, 'commands.compactSkip') }
1641
1693
  const transcript = serializeTranscript(e.messages.length ? e.messages : await $.session.messages())
1642
1694
  if (!transcript) throw new Error(t(language, 'toasts.transcriptEmpty'))
@@ -1646,12 +1698,12 @@ export const register: Register = on => {
1646
1698
  await update($, context, () => null)
1647
1699
  if (await read($, kept)) await update($, kept, () => null)
1648
1700
  await update($, resultCard, () => ({ stage: 'compacted' }))
1649
- return compactResult(answer.text)
1701
+ return counted(compactResult(answer.text))
1650
1702
  } catch (error) {
1651
1703
  await $.ui.toast(t(await read($, lang), 'toasts.compactFallback'))
1652
1704
  await report($, 'session.compact', error)
1653
1705
  }
1654
- return next(e)
1706
+ return counted(await next(e))
1655
1707
  })
1656
1708
  on('command.run', { command: 'ripple' }, async ($, e, next) => {
1657
1709
  try {