clauderipple 0.11.1 → 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 (49) hide show
  1. package/CHANGELOG.md +34 -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 +67 -19
  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 +8 -1
  26. package/dist/mod/tests/delegation.test.tsx +50 -0
  27. package/dist/mod/tests/register.test.tsx +138 -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 +46 -6
  42. package/dist/router/src/requestlog.js +3 -2
  43. package/dist/router/src/version.js +1 -1
  44. package/dist/router/src/websearch.js +2 -2
  45. package/dist/ui/app.js +247 -42
  46. package/dist/ui/i18n.js +154 -18
  47. package/dist/ui/style.css +21 -0
  48. package/docs/ARCHITECTURE.md +61 -11
  49. package/package.json +1 -1
@@ -21,7 +21,7 @@ export const DEFAULTS = {
21
21
  hard: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }], plan: [claude('opus', 'high')],
22
22
  } },
23
23
  },
24
- allow: [], allowKnown: [], modes: [],
24
+ allow: [], allowKnown: [], modes: [], delegation: 'normal',
25
25
  providers: {},
26
26
  fallback: { agent: 'general-purpose', model: 'sonnet' },
27
27
  keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
@@ -130,6 +130,25 @@ function ladderRungs(raw) {
130
130
  }
131
131
  return rungs;
132
132
  }
133
+ /**
134
+ * A domain ladder folded into two levels: light and standard hold one list, hard and plan another.
135
+ * The upper rung of each pair wins (standard's list for the light pair, plan's for the heavy one);
136
+ * an empty upper rung falls back to the lower. The general ladder keeps its four rungs.
137
+ */
138
+ export function twoLevelRungs(rungs) {
139
+ const out = {};
140
+ const light = rungs.standard?.length ? rungs.standard : rungs.light;
141
+ const heavy = rungs.plan?.length ? rungs.plan : rungs.hard;
142
+ if (light?.length) {
143
+ out.light = copy(light);
144
+ out.standard = copy(light);
145
+ }
146
+ if (heavy?.length) {
147
+ out.hard = copy(heavy);
148
+ out.plan = copy(heavy);
149
+ }
150
+ return out;
151
+ }
133
152
  /**
134
153
  * The one rule left, the general ladder (the four workers). A ladder keeps its rungs; a pin from
135
154
  * before the ladder becomes one; Auto (experimental, removed 2026-10-12) becomes a ladder with no
@@ -153,6 +172,17 @@ function generalRule(raw) {
153
172
  }
154
173
  return copy(DEFAULTS.rules.general);
155
174
  }
175
+ /** Only optional domain ladders, never the retired per-field pin/Auto rules. */
176
+ function domainRules(raw) {
177
+ const source = object(raw);
178
+ const out = {};
179
+ for (const key of ['ui', 'backend']) {
180
+ // A domain ladder has two levels, so its four rungs are folded on the way in.
181
+ if (object(source[key]).mode === 'ladder')
182
+ out[key] = { mode: 'ladder', rungs: twoLevelRungs(generalRule(source[key]).rungs) };
183
+ }
184
+ return Object.keys(out).length ? out : undefined;
185
+ }
156
186
  /**
157
187
  * Auto's allow-list names worker agents. Settings saved before 2026-10-10 named providers there;
158
188
  * with the worker list known, a provider name becomes that provider's workers (names it does not
@@ -242,7 +272,8 @@ export function normalizeModes(raw, base = DEFAULTS.rules.general.rungs) {
242
272
  if (!id || !name || out.some(m => m.id === id))
243
273
  continue;
244
274
  const rungs = ladderRungs(v.rungs === undefined ? base : v.rungs);
245
- out.push({ id, name, rungs, delegation: v.delegation === 'eager' ? 'eager' : 'normal', until: modeUntil(v.until, rungs) });
275
+ const domains = domainRules(v.domainRules);
276
+ out.push({ id, name, rungs, ...(domains ? { domainRules: domains } : {}), delegation: v.delegation === 'eager' ? 'eager' : 'normal', until: modeUntil(v.until, rungs) });
246
277
  if (out.length === MODES_MAX)
247
278
  break;
248
279
  }
@@ -283,6 +314,7 @@ export function normalizeSettings(raw, workers) {
283
314
  result.extras = [...new Set(value.extras.filter((part) => typeof part === 'string' && EXTRAS.includes(part)))];
284
315
  const choices = {
285
316
  judge: ['haiku', 'jev', 'model'], jevSource: ['typesafe', 'zen'], bias: [-2, -1, 0, 1, 2], floor: EFFORTS,
317
+ delegation: ['normal', 'eager'],
286
318
  ceiling: EFFORTS, agentAuto: ['off', 'auto', 'all'], keepWarmHours: [1, 2, 4, 8], compactWith: ['haiku', 'session'],
287
319
  handoffAfter: ['continue', 'confirm', 'copy'], handoffButton: ['advised', 'always'], layout: ['dashboard', 'minimal'],
288
320
  appearance: ['auto', 'dark', 'light'], skin: SKINS, language: ['auto', 'ko', 'en', 'zh', 'ja', 'es', 'pt'],
@@ -301,6 +333,9 @@ export function normalizeSettings(raw, workers) {
301
333
  if (Array.isArray(value.hide))
302
334
  result.hide = [...new Set(value.hide.filter((part) => typeof part === 'string' && ['timer', 'reason', 'route', 'limits'].includes(part)))];
303
335
  result.fallback = target(value.fallback, DEFAULTS.fallback);
336
+ const domains = domainRules(value.domainRules);
337
+ if (domains)
338
+ result.domainRules = domains;
304
339
  result.modes = normalizeModes(value.modes, rules.general.rungs);
305
340
  // An active mode must be one of the modes; its start time stays only with it.
306
341
  if (typeof value.activeMode === 'string' && result.modes.some(m => m.id === value.activeMode)) {
@@ -1,4 +1,4 @@
1
- import type { Calibration, Domain, Effort, Level, Mode, Rule, Rung, Settings, SettingsChange, Skin, Step, TierTarget } from '../types/index.d.ts'
1
+ import type { Calibration, Domain, DomainRules, Effort, Level, Mode, Rule, Rung, Settings, SettingsChange, Skin, Step, TierTarget } from '../types/index.d.ts'
2
2
 
3
3
  /** Bump whenever a writer's normalization can lose another format's fields. */
4
4
  export const MOD_SETTINGS_SCHEMA_VERSION = 1
@@ -26,7 +26,7 @@ export const DEFAULTS: Settings = {
26
26
  hard: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }], plan: [claude('opus', 'high')],
27
27
  } },
28
28
  },
29
- allow: [], allowKnown: [], modes: [],
29
+ allow: [], allowKnown: [], modes: [], delegation: 'normal',
30
30
  providers: {},
31
31
  fallback: { agent: 'general-purpose', model: 'sonnet' },
32
32
  keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
@@ -134,6 +134,19 @@ function ladderRungs(raw: unknown): Rule['rungs'] {
134
134
  }
135
135
  return rungs
136
136
  }
137
+ /**
138
+ * A domain ladder folded into two levels: light and standard hold one list, hard and plan another.
139
+ * The upper rung of each pair wins (standard's list for the light pair, plan's for the heavy one);
140
+ * an empty upper rung falls back to the lower. The general ladder keeps its four rungs.
141
+ */
142
+ export function twoLevelRungs(rungs: Rule['rungs']): Rule['rungs'] {
143
+ const out: Rule['rungs'] = {}
144
+ const light = rungs.standard?.length ? rungs.standard : rungs.light
145
+ const heavy = rungs.plan?.length ? rungs.plan : rungs.hard
146
+ if (light?.length) { out.light = copy(light); out.standard = copy(light) }
147
+ if (heavy?.length) { out.hard = copy(heavy); out.plan = copy(heavy) }
148
+ return out
149
+ }
137
150
  /**
138
151
  * The one rule left, the general ladder (the four workers). A ladder keeps its rungs; a pin from
139
152
  * before the ladder becomes one; Auto (experimental, removed 2026-10-12) becomes a ladder with no
@@ -152,6 +165,17 @@ function generalRule(raw: unknown): Rule {
152
165
  return copy(DEFAULTS.rules.general)
153
166
  }
154
167
 
168
+ /** Only optional domain ladders, never the retired per-field pin/Auto rules. */
169
+ function domainRules(raw: unknown): DomainRules | undefined {
170
+ const source = object(raw)
171
+ const out: DomainRules = {}
172
+ for (const key of ['ui', 'backend'] as const) {
173
+ // A domain ladder has two levels, so its four rungs are folded on the way in.
174
+ if (object(source[key]).mode === 'ladder') out[key] = { mode: 'ladder', rungs: twoLevelRungs(generalRule(source[key]).rungs) }
175
+ }
176
+ return Object.keys(out).length ? out : undefined
177
+ }
178
+
155
179
  type Worker = { name: string; provider: string }
156
180
 
157
181
  /**
@@ -242,7 +266,8 @@ export function normalizeModes(raw: unknown, base: Rule['rungs'] = DEFAULTS.rule
242
266
  const name = typeof v.name === 'string' ? v.name.trim().slice(0, MODE_NAME_MAX) : ''
243
267
  if (!id || !name || out.some(m => m.id === id)) continue
244
268
  const rungs = ladderRungs(v.rungs === undefined ? base : v.rungs)
245
- out.push({ id, name, rungs, delegation: v.delegation === 'eager' ? 'eager' : 'normal', until: modeUntil(v.until, rungs) })
269
+ const domains = domainRules(v.domainRules)
270
+ out.push({ id, name, rungs, ...(domains ? { domainRules: domains } : {}), delegation: v.delegation === 'eager' ? 'eager' : 'normal', until: modeUntil(v.until, rungs) })
246
271
  if (out.length === MODES_MAX) break
247
272
  }
248
273
  return out
@@ -275,6 +300,7 @@ export function normalizeSettings(raw: unknown, workers?: readonly Worker[]): Se
275
300
  if (Array.isArray(value.extras)) result.extras = [...new Set(value.extras.filter((part): part is string => typeof part === 'string' && EXTRAS.includes(part)))]
276
301
  const choices = {
277
302
  judge: ['haiku', 'jev', 'model'], jevSource: ['typesafe', 'zen'], bias: [-2, -1, 0, 1, 2], floor: EFFORTS,
303
+ delegation: ['normal', 'eager'],
278
304
  ceiling: EFFORTS, agentAuto: ['off', 'auto', 'all'], keepWarmHours: [1, 2, 4, 8], compactWith: ['haiku', 'session'],
279
305
  handoffAfter: ['continue', 'confirm', 'copy'], handoffButton: ['advised', 'always'], layout: ['dashboard', 'minimal'],
280
306
  appearance: ['auto', 'dark', 'light'], skin: SKINS, language: ['auto', 'ko', 'en', 'zh', 'ja', 'es', 'pt'],
@@ -286,6 +312,8 @@ export function normalizeSettings(raw: unknown, workers?: readonly Worker[]): Se
286
312
  if (typeof value.swampAt === 'number' && Number.isFinite(value.swampAt) && value.swampAt >= 0 && value.swampAt <= 100) result.swampAt = value.swampAt
287
313
  if (Array.isArray(value.hide)) result.hide = [...new Set(value.hide.filter((part): part is string => typeof part === 'string' && ['timer', 'reason', 'route', 'limits'].includes(part)))]
288
314
  result.fallback = target(value.fallback, DEFAULTS.fallback)!
315
+ const domains = domainRules(value.domainRules)
316
+ if (domains) result.domainRules = domains
289
317
  result.modes = normalizeModes(value.modes, rules.general.rungs)
290
318
  // An active mode must be one of the modes; its start time stays only with it.
291
319
  if (typeof value.activeMode === 'string' && result.modes.some(m => m.id === value.activeMode)) {
@@ -93,7 +93,7 @@ function typeOf(agent: AgentRec, model: string): string {
93
93
  function whyLine(frame: Frame, agent: AgentRec): string {
94
94
  const pick = agent.pick
95
95
  if (!pick) return ''
96
- const parts = [t(frame.lang, 'agents.by', { name: t(frame.lang, BY[pick.by]) }) + ' · ' + t(frame.lang, 'agents.domainLevel', { domain: t(frame.lang, 'workers.' + pick.domain), level: t(frame.lang, 'settings.' + pick.level) }) + (pick.rung ? ' · ' + t(frame.lang, 'workers.rung.' + pick.rung) : '')]
96
+ const parts = [t(frame.lang, 'agents.by', { name: t(frame.lang, BY[pick.by]) }) + ' · ' + t(frame.lang, 'agents.domainLevel', { domain: t(frame.lang, 'workers.' + pick.domain), level: t(frame.lang, 'settings.' + pick.level) }) + (pick.rung ? ' · ' + (pick.rule ?? 'general') + '/' + t(frame.lang, 'workers.rung.' + pick.rung) : '')]
97
97
  if (pick.why) parts.push('“' + pick.why + '”')
98
98
  return parts.join(' · ')
99
99
  }
@@ -609,14 +609,19 @@ export function iconArt(icon: Icon, on: boolean, tone: Tone = 'violet'): string
609
609
 
610
610
  export const AGENTS_W = 26
611
611
 
612
- /** The agents icon with the live count in a lit disc on its shoulder, one width whatever the count. */
613
- export function agentsArt(count: number, on: boolean): string {
612
+ /** A control's icon with a count in a lit disc on its shoulder, one width whatever the count. */
613
+ export function countedArt(icon: Icon, count: number, on: boolean): string {
614
614
  const stroke = on ? TONE.violet.hi : SOFT
615
615
  const label = count > 9 ? '9+' : String(count)
616
616
  const badge = count > 0
617
617
  ? '<circle cx="20" cy="5.5" r="5.5" fill="#22d3ee"/><text x="20" y="8.6" text-anchor="middle" font-size="' + (label.length > 1 ? 7 : 8.5) + '" font-weight="750" fill="#04252e">' + label + '</text>'
618
618
  : ''
619
- return svg(AGENTS_W, 16, '<g transform="translate(0 .5)" fill="none" stroke="' + stroke + '" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round">' + ICON.agents + '</g>' + badge, '', lit(on))
619
+ return svg(AGENTS_W, 16, '<g transform="translate(0 .5)" fill="none" stroke="' + stroke + '" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round">' + ICON[icon] + '</g>' + badge, '', lit(on))
620
+ }
621
+
622
+ /** The agents icon with the live count on its shoulder. */
623
+ export function agentsArt(count: number, on: boolean): string {
624
+ return countedArt('agents', count, on)
620
625
  }
621
626
 
622
627
  /** A thin upright rule between the band's groups, 1 × 14 px in a 9 px slot. */
@@ -309,7 +309,7 @@ function controls(frame: Frame, view: DashboardView, actions: BandActions): Cont
309
309
  // The count has a slot of its own (desktop) or is always a number (terminal): no width change.
310
310
  { key: 'agents', label: t(L, 'band.agents') + (isRich(frame) ? '' : ' ' + (view.agents.live || '·')) + mark(view.agentsOpen), hotkey: 'g', onPress: actions.toggleAgents, on: view.agentsOpen, ...(isRich(frame) ? { count: view.agents.live > 0 ? String(view.agents.live) : '' } : {}) },
311
311
  // The desktop's Clear asks first (a card of its own, "clear" in bandPriority); Compact opens its note bar.
312
- ...(isRich(frame) ? [{ key: 'compact', label: t(L, 'band.compact'), hotkey: 'c', onPress: actions.compact, on: false }, { key: 'clear', label: t(L, 'band.clear'), hotkey: 'x', onPress: actions.clear, on: false }] : []),
312
+ ...(isRich(frame) ? [{ key: 'compact', label: t(L, 'band.compact'), hotkey: 'c', onPress: actions.compact, on: false, count: view.compactions > 0 ? String(view.compactions) : '' }, { key: 'clear', label: t(L, 'band.clear'), hotkey: 'x', onPress: actions.clear, on: false }] : []),
313
313
  // A word on the desktop: the host's 14 px font draws ⚙ as a small ring that reads as "o".
314
314
  { key: 'settings', label: isRich(frame) ? t(L, 'band.settings') : '⚙', hotkey: 's', onPress: actions.openSettings, on: false },
315
315
  )
@@ -786,7 +786,11 @@ function controlsDrawn(el: Els, frame: Frame, view: DashboardView, actions: Band
786
786
  // The live count sits on the icon's shoulder, in one picture of one width: no width change.
787
787
  out.push(iconControl(el, frame, 'agents', k.art.agents(live, view.agentsOpen || live > 0), AGENTS_W, live > 0 ? agentsLabel + ' ' + live : agentsLabel, t(L, 'band.agentsAlt', { count: live }), actions.toggleAgents, words.includes('agents') ? agentsLabel : undefined, view.agentsOpen ? k.ink : k.sub))
788
788
  const compactLabel = t(L, 'band.compact')
789
- out.push(iconControl(el, frame, 'compact', k.art.icon('compact', false), ICON_PX, compactLabel, t(L, 'band.compactAlt'), actions.compact, words.includes('compact') ? compactLabel : undefined))
789
+ // How many times the chat was compacted sits on the icon's shoulder, as the agents' live count
790
+ // does, in one picture of one width: the first compaction moves no neighbour.
791
+ const compactions = view.compactions
792
+ const compactTip = t(L, 'band.compactAlt') + (compactions > 0 ? ' · ' + t(L, 'band.compactedTimes', { count: compactions }) : '')
793
+ out.push(iconControl(el, frame, 'compact', k.art.counted('compact', compactions, false), AGENTS_W, compactions > 0 ? compactLabel + ' ' + compactions : compactLabel, compactTip, actions.compact, words.includes('compact') ? compactLabel : undefined))
790
794
  const clearLabel = t(L, 'band.clear')
791
795
  out.push(iconControl(el, frame, 'clear', k.art.icon('clear', false), ICON_PX, clearLabel, t(L, 'band.clearAlt'), actions.clear, words.includes('clear') ? clearLabel : undefined))
792
796
  const settingsLabel = t(L, 'band.settings')
@@ -8,7 +8,7 @@
8
8
  // laid over the pictures line up (kit.tsx). Pure: SVG only, so node tests can load it.
9
9
 
10
10
  import { AGENTS_W, CELL_PX, CLOSE_PX, CONTROL_PX, FLAME, GLYPH, ICON, ICON_PX, METER_H, METER_W, SWITCH_H, SWITCH_W, TILE_PX, esc, labelPx, r } from './art.ts'
11
- import type { Tone } from './art.ts'
11
+ import type { Icon, Tone } from './art.ts'
12
12
  import type { Paint } from './look.ts'
13
13
  import { svgDoc } from './theme.ts'
14
14
  import type { Appearance } from './theme.ts'
@@ -55,6 +55,14 @@ export function flatPictures(appearance: Appearance): Paint {
55
55
  /** A rule for `sel` that follows the theme: `light` on a light app, `dark` on a dark one. */
56
56
  const themed = (sel: string, light: string, dark: string): string =>
57
57
  appearance === 'dark' ? sel + '{' + dark + '}' : appearance === 'light' ? sel + '{' + light + '}' : sel + '{' + light + '}@media (prefers-color-scheme:dark){' + sel + '{' + dark + '}}'
58
+ /** A control's icon with a count in an accent disc on its shoulder, one width whatever the count. */
59
+ const counted = (icon: Icon, count: number, on: boolean): string => {
60
+ const text = count > 9 ? '9+' : String(count)
61
+ const badge = count > 0
62
+ ? '<circle cx="20" cy="5.5" r="5.5" fill="' + ACCENT + '"/><text x="20" y="8.6" text-anchor="middle" font-size="' + (text.length > 1 ? 7 : 8.5) + '" font-weight="750"' + fillOf('#fff') + '>' + text + '</text>'
63
+ : ''
64
+ return doc(AGENTS_W, 16, '<g transform="translate(0 .5)" fill="none" stroke="' + (on ? ACCENT : MUTED) + '" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round">' + ICON[icon] + '</g>' + badge)
65
+ }
58
66
 
59
67
  return {
60
68
  // The capsule is the app's own surface, as its prompt box is: white on a light app, its dark
@@ -203,13 +211,8 @@ export function flatPictures(appearance: Appearance): Paint {
203
211
  return doc(ICON_PX, ICON_PX, '<g fill="none" stroke="' + (on ? ACCENT : MUTED) + '" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round">' + ICON[icon] + '</g>')
204
212
  },
205
213
 
206
- agents: (count, on) => {
207
- const text = count > 9 ? '9+' : String(count)
208
- const badge = count > 0
209
- ? '<circle cx="20" cy="5.5" r="5.5" fill="' + ACCENT + '"/><text x="20" y="8.6" text-anchor="middle" font-size="' + (text.length > 1 ? 7 : 8.5) + '" font-weight="750"' + fillOf('#fff') + '>' + text + '</text>'
210
- : ''
211
- return doc(AGENTS_W, 16, '<g transform="translate(0 .5)" fill="none" stroke="' + (on ? ACCENT : MUTED) + '" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round">' + ICON.agents + '</g>' + badge)
212
- },
214
+ agents: (count, on) => counted('agents', count, on),
215
+ counted: (icon, count, on) => counted(icon, count, on),
213
216
 
214
217
  rule: () => doc(9, 16, '<rect x="4" y="1" width="1" height="14" rx=".5" fill="' + STROKE + '"/>'),
215
218
 
@@ -41,6 +41,8 @@ export type Paint = {
41
41
  gauge: (kind: string) => string
42
42
  icon: (icon: Icon, on: boolean, tone?: Tone) => string
43
43
  agents: (count: number, on: boolean) => string
44
+ /** An icon with a count on its shoulder (the chat's compactions). */
45
+ counted: (icon: Icon, count: number, on: boolean) => string
44
46
  rule: () => string
45
47
  tile: (art: TileArt) => string
46
48
  beads: (step: number, total: number) => { source: string; width: number }
@@ -70,7 +72,7 @@ const DEEP_PAINT: Paint = {
70
72
  sea: deep.seaArt, plate: deep.plateArt, switch: deep.switchArt, word: deep.wordArt, extraHover: deep.extraHoverArt,
71
73
  pill: deep.pillArt, segmented: deep.segmentedArt, slider: deep.sliderArt, well: deep.wellArt, close: deep.closeArt,
72
74
  meter: deep.meterArt, ring: deep.ringArt, clock: deep.clockArt, gauge: deep.gaugeArt, icon: deep.iconArt,
73
- agents: deep.agentsArt, rule: deep.ruleArt, tile: deep.tileArt, beads: deep.beadsArt,
75
+ agents: deep.agentsArt, counted: deep.countedArt, rule: deep.ruleArt, tile: deep.tileArt, beads: deep.beadsArt,
74
76
  }
75
77
 
76
78
  /** The deep: the capsule's edge a warm grey that reads on a light page and a dark one alike, lights white on the water. */
@@ -82,6 +82,8 @@ export type DashboardView = {
82
82
  /** Compact turns into a lit Handoff when advice says so, or always with handoffButton 'always'. */
83
83
  handoff: 'off' | 'advised' | 'always'
84
84
  agents: { live: number; total: number }
85
+ /** Times this chat was compacted (manual, automatic or by the band), shown on the Compact icon. */
86
+ compactions: number
85
87
  logOpen: boolean
86
88
  agentsOpen: boolean
87
89
  /** The manual effort row is open. */
@@ -1,4 +1,4 @@
1
- import type { Billing, Domain, Effort, LadderChoice, LadderOptions, Level, ModelScore, RouterModel, Rule, Rung, Settings, TierTarget, WorkerCandidate, WorkerVerdict } from '../types'
1
+ import type { Billing, Domain, Effort, LadderChoice, LadderOptions, Level, ModelScore, RouterModel, Rule, Rung, Settings, TierTarget, WorkerCandidate, WorkerPick, WorkerVerdict } from '../types'
2
2
  import { CLAUDE_WORKERS, DOMAINS, EFFORTS, LEVELS, candidateKey, isAllowed } from './settings'
3
3
  import { MARKER } from './worker'
4
4
  import { t } from './i18n'
@@ -164,6 +164,13 @@ function sameTarget(a: TierTarget, b: TierTarget): boolean { return a.agent ===
164
164
  function candidateOf(target: TierTarget, candidates: readonly WorkerCandidate[]): WorkerCandidate | undefined {
165
165
  return candidates.find(c => sameTarget(c.target, target)) ?? (target.agent === 'general-purpose' ? { target, model: target.model ?? 'sonnet', provider: candidates.find(c => c.model === baseModel(target.model ?? 'sonnet'))?.provider ?? 'anthropic' } : undefined)
166
166
  }
167
+ /** Domain overrides belong to the effective worker set; a missing override uses its general ladder. */
168
+ export function workerRule(domain: Domain, settings: Settings): { rule: WorkerPick['rule']; ladder: Rule } {
169
+ const key = domain === 'design' || domain === 'frontend' ? 'ui' : domain === 'backend' ? 'backend' : 'general'
170
+ const ladder = key === 'general' ? undefined : settings.domainRules?.[key]
171
+ return ladder ? { rule: key, ladder } : { rule: 'general', ladder: settings.rules.general }
172
+ }
173
+
167
174
  export type Resolution = { target?: TierTarget; effort: Effort; rung?: Rung; position?: number; skipped?: { rung: Rung; position: number; model: string; reason: string }[]; fallbackReason?: string }
168
175
 
169
176
  /** Claude Code's own agent with no model: the subagent runs on the session's model. */
@@ -0,0 +1,50 @@
1
+ // Base's delegation (docs/specs/2026-10-12-worker-tiers.md "Screens"): while no mode is on, the
2
+ // eager note is Base's; a mode that is on uses its own delegation instead.
3
+ import { expect, test } from 'claude-code/testing'
4
+ import { DEFAULTS, normalizeSettings } from '../hooks/settings'
5
+ import { eagerNote, modeNote } from '../hooks/modes'
6
+ import type { Mode, Settings } from '../types'
7
+
8
+ const BASE_NOTE = 'ClaudeRipple: delegate everything except judgment and final review to ripple:auto.'
9
+
10
+ function settings(patch: Partial<Settings> = {}): Settings {
11
+ return { ...normalizeSettings(null), ...patch }
12
+ }
13
+
14
+ const MODES: Mode[] = [
15
+ { id: 'calm', name: 'Calm', rungs: {}, delegation: 'normal', until: 'none' },
16
+ { id: 'burn', name: 'Burn', rungs: {}, delegation: 'eager', until: 'none' },
17
+ ]
18
+
19
+ test('Base eager notes the main chat once, then not again until a compaction clears it', () => {
20
+ const s = settings({ delegation: 'eager' })
21
+ expect(eagerNote(s, null)).toEqual({ note: BASE_NOTE, noted: 'base' })
22
+ expect(eagerNote(s, 'base')).toEqual({ noted: 'base' })
23
+ // The compaction clears `noted` (register.tsx), so the first prompt after it carries the note again.
24
+ expect(eagerNote(s, null).note).toBe(BASE_NOTE)
25
+ })
26
+
27
+ test('Base normal says nothing and clears what was noted, so eager again sends it afresh', () => {
28
+ const s = settings({ delegation: 'normal' })
29
+ expect(eagerNote(s, null)).toEqual({ noted: null })
30
+ expect(eagerNote(s, 'base')).toEqual({ noted: null })
31
+ })
32
+
33
+ test("a mode's delegation wins over Base, either way", () => {
34
+ // A normal mode on an eager Base: no note, and the earlier Base note is cleared.
35
+ const calmOnEagerBase = normalizeSettings({ delegation: 'eager', modes: MODES, activeMode: 'calm', activeSince: 5 })
36
+ expect(eagerNote(calmOnEagerBase, 'base')).toEqual({ noted: null })
37
+ // An eager mode on a normal Base: the mode's note, once per turning-on.
38
+ const burnOnNormalBase = normalizeSettings({ delegation: 'normal', modes: MODES, activeMode: 'burn', activeSince: 5 })
39
+ const first = eagerNote(burnOnNormalBase, null)
40
+ expect(first).toEqual({ note: modeNote('Burn'), noted: 'burn@5' })
41
+ expect(eagerNote(burnOnNormalBase, first.noted)).toEqual({ noted: 'burn@5' })
42
+ })
43
+
44
+ test('normalizeSettings keeps a stored delegation and defaults anything else to normal', () => {
45
+ expect(DEFAULTS.delegation).toBe('normal')
46
+ expect(normalizeSettings(null).delegation).toBe('normal')
47
+ expect(normalizeSettings({ delegation: 'loud' }).delegation).toBe('normal')
48
+ expect(normalizeSettings({ delegation: 'eager' }).delegation).toBe('eager')
49
+ expect(normalizeSettings(normalizeSettings({ delegation: 'eager' })).delegation).toBe('eager')
50
+ })
@@ -270,6 +270,37 @@ test('empty router uses defaults without writing when there is no legacy store',
270
270
  await ui.unmount()
271
271
  })
272
272
 
273
+ // 2026-10-20: a stray test router took the live router's launchd label for five minutes and answered
274
+ // with nothing stored; every chat took its defaults, opened setup and asked for the Jev key again,
275
+ // and kept asking after the real router came back.
276
+ test('a router that lost its settings gets this chat\'s back instead of opening setup', async ($, on) => {
277
+ const b = boundaries(on)
278
+ b.remote({ settings: normalizeSettings({ setupDone: true, language: 'pt', keepWarm: true }), revision: 7, stored: true })
279
+ http(on)
280
+ await start($)
281
+ expectLanguage(b, 'pt')
282
+ b.remote({ settings: normalizeSettings(null), revision: 0, stored: false })
283
+ await b.clock.advance(3000)
284
+ const ui = await band($)
285
+ expect(await ui.find({ type: 'Text', text: 'Welcome to ClaudeRipple' })).toBeUndefined()
286
+ expectLanguage(b, 'pt')
287
+ expect(b.router()).toMatchObject({ stored: true, settings: { setupDone: true, language: 'pt', keepWarm: true } })
288
+ await ui.unmount()
289
+ })
290
+
291
+ test('a setup card left open closes once the router says setup is done', async ($, on) => {
292
+ const b = boundaries(on)
293
+ b.remote({ settings: normalizeSettings(null), revision: 0, stored: false })
294
+ http(on)
295
+ await start($)
296
+ const ui = await band($)
297
+ expect(await ui.find({ type: 'Text', text: 'Welcome to ClaudeRipple' })).toBeDefined()
298
+ b.remote({ settings: normalizeSettings({ setupDone: true }), revision: 3, stored: true })
299
+ await b.clock.advance(3000)
300
+ expect(await ui.find({ type: 'Text', text: 'Welcome to ClaudeRipple' })).toBeUndefined()
301
+ await ui.unmount()
302
+ })
303
+
273
304
  test('legacy settings and setup completion migrate once; unrelated store keys survive', async ($, on) => {
274
305
  const b = boundaries(on)
275
306
  b.remote({ settings: normalizeSettings(null), revision: 0, stored: false })
@@ -494,7 +525,7 @@ test('ripple:auto is judged and rewritten to the light rung\'s agent at the rung
494
525
  expect(b.logs.length).toBe(1)
495
526
  expect(b.logs[0]!.startsWith('ClaudeRipple worker pick: ')).toBe(true)
496
527
  expect(JSON.parse(b.logs[0]!.slice('ClaudeRipple worker pick: '.length))).toEqual({
497
- task: 'Rename variable', domain: 'general', level: 'light', kind: 'do', sure: 0.9, rung: 'light', position: 1, skipped: [],
528
+ task: 'Rename variable', domain: 'general', level: 'light', kind: 'do', sure: 0.9, rule: 'general', rung: 'light', position: 1, skipped: [],
498
529
  target: { agent: 'deepseek-v4-1-flash' }, model: 'deepseek-v4.1-flash@max', effort: 'max', by: 'haiku',
499
530
  })
500
531
  const persisted = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!
@@ -621,6 +652,86 @@ for (const [tier, rung, agent, model] of [
621
652
  expect(b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').filter(line => line.includes('"outcome"')).length).toBe(1)
622
653
  })
623
654
 
655
+ for (const domain of ['design', 'frontend', 'backend', 'docs'] as const) test(`auto selects the ${domain} task's domain ladder or its own general fallback`, async ($, on) => {
656
+ const b = boundaries(on, { domainRules: {
657
+ ui: { mode: 'ladder', rungs: { light: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }] } },
658
+ backend: { mode: 'ladder', rungs: { light: [{ target: { agent: 'gpt-6-1-sol' }, effort: 'medium' }] } },
659
+ } })
660
+ http(on)
661
+ judgeStub(on, JSON.stringify({ domain, level: 'light', kind: 'do', effort: 'low', sure: 0.9, why: 'domain' }))
662
+ const sent = spawnStub(on)
663
+ await start($)
664
+ await $.agent.spawn(SPAWN)
665
+ const rule = domain === 'design' || domain === 'frontend' ? 'ui' : domain === 'backend' ? 'backend' : 'general'
666
+ expect(sent[0]?.subagentType).toBe(rule === 'ui' ? 'gpt-6-astra' : rule === 'backend' ? 'gpt-6-1-sol' : 'deepseek-v4-1-flash')
667
+ expect(JSON.parse(b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!)).toMatchObject({ rule, domain, rung: 'light' })
668
+ })
669
+
670
+ for (const domains of [false, true]) test(`tier marker ${domains ? 'awaits' : 'does not await'} one pending shadow judge with domain ladders ${domains ? 'on' : 'off'}`, async ($, on) => {
671
+ const b = boundaries(on, domains ? { domainRules: { ui: { mode: 'ladder', rungs: { light: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }] } } } } : {})
672
+ http(on)
673
+ let release!: () => void
674
+ const pending = new Promise<void>(resolve => { release = resolve })
675
+ let calls = 0
676
+ on('model.complete', async () => {
677
+ calls++
678
+ await pending
679
+ return { value: { isAnswered: true, text: JSON.stringify({ domain: 'frontend', level: 'deep', kind: 'plan', effort: 'max', sure: 0.1, why: 'conflicts with tier' }), usage: USAGE } }
680
+ })
681
+ const sent = spawnStub(on)
682
+ await start($)
683
+ const spawn = $.agent.spawn({ ...SPAWN, prompt: '[[ripple: light]]\nBuild the UI.' })
684
+ await b.clock.settle()
685
+ expect(calls).toBe(1)
686
+ expect(sent.length).toBe(domains ? 0 : 1)
687
+ release()
688
+ await spawn
689
+ await b.clock.settle()
690
+ expect(calls).toBe(1)
691
+ expect(sent[0]?.subagentType).toBe(domains ? 'gpt-6-astra' : 'deepseek-v4-1-flash')
692
+ const rows = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').map(line => JSON.parse(line))
693
+ const pick = rows.find(row => row.type === 'pick')
694
+ expect(pick).toMatchObject({ by: 'session', tier: 'light', rung: 'light', level: 'light', kind: 'do', rule: domains ? 'ui' : 'general', domain: domains ? 'frontend' : 'general' })
695
+ expect(pick.sure).toBeUndefined()
696
+ expect(rows.filter(row => row.type === 'shadow').length).toBe(1)
697
+ expect(rows.find(row => row.type === 'shadow')).toMatchObject({ pick: pick.id, rung: 'plan', verdict: { domain: 'frontend', sure: 0.1 } })
698
+ })
699
+
700
+ for (const error of ['api-error', 'aborted'] as const) test(`a marker retains its tier and uses general when its domain judge is ${error}`, async ($, on) => {
701
+ const b = boundaries(on, { domainRules: { ui: DEFAULTS.rules.general } })
702
+ http(on)
703
+ on('model.complete', async () => {
704
+ if (error === 'aborted') await b.clock.advance(6000)
705
+ return { value: error === 'aborted' ? { isAnswered: false, reason: 'aborted', usage: USAGE } : { isAnswered: false, reason: 'api-error', status: 503, error: 'overloaded', usage: USAGE } }
706
+ })
707
+ const sent = spawnStub(on)
708
+ await start($)
709
+ await $.agent.spawn({ ...SPAWN, prompt: '[[ripple: light]]\nRename a variable.' })
710
+ expect(sent[0]?.subagentType).toBe('deepseek-v4-1-flash')
711
+ const rows = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').map(line => JSON.parse(line))
712
+ expect(rows.find(row => row.type === 'pick')).toMatchObject({ by: 'session', domain: 'general', rule: 'general', rung: 'light', tier: 'light', verdict: null })
713
+ expect(rows.filter(row => row.type === 'shadow').length).toBe(1)
714
+ })
715
+
716
+ test('a mode without domain rules skips awaiting Base domains; explicit model markers still bypass the judge', async ($, on) => {
717
+ const b = boundaries(on, { domainRules: { ui: DEFAULTS.rules.general }, modes: [{ id: 'plain', name: 'Plain', rungs: DEFAULTS.rules.general.rungs, delegation: 'normal', until: 'none' }], activeMode: 'plain' })
718
+ http(on)
719
+ const judges = judgeStub(on, '{"domain":"frontend","level":"light","effort":"low","sure":0.9}')
720
+ const sent = spawnStub(on)
721
+ await start($)
722
+ await $.agent.spawn({ ...SPAWN, prompt: '[[ripple: light]]\nWork.' })
723
+ await b.clock.settle()
724
+ const rows = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').map(line => JSON.parse(line))
725
+ expect(rows.find(row => row.type === 'pick')).toMatchObject({ domain: 'general', rule: 'general', mode: 'Plain', verdict: null })
726
+ expect(judges.length).toBe(1)
727
+ // Turn Base on, so its domain override is effective for this explicit marker.
728
+ b.remote({ settings: normalizeSettings({ setupDone: true, domainRules: { ui: DEFAULTS.rules.general } }), revision: 2, stored: true })
729
+ await b.clock.advance(3000)
730
+ await $.agent.spawn({ ...SPAWN, prompt: '[[ripple: gpt-6-astra@max]]\nWork.' })
731
+ expect(judges.length).toBe(1)
732
+ expect(sent[1]).toMatchObject({ subagentType: 'gpt-6-astra', model: 'gpt-6-astra@max' })
733
+ })
734
+
624
735
  test('the limits row shows Claude\'s plan on a chat that runs on another provider', async ($, on) => {
625
736
  const b = boundaries(on, { extras: ['limits'] })
626
737
  b.setModel('muse-spark-1.3-contributor')
@@ -1089,6 +1200,32 @@ test('failed Haiku compaction falls back; precompute skips; subagent stays with
1089
1200
  expect(b.toasts.some(t => t.includes('session compactor'))).toBe(true)
1090
1201
  })
1091
1202
 
1203
+ test('the Compact icon counts the main chat\'s finished compactions; /clear zeroes it', async ($, on) => {
1204
+ boundaries(on, { compactWith: 'session' })
1205
+ http(on)
1206
+ on('session.compact', () => ({ messages: MESSAGES }))
1207
+ await start($)
1208
+ const alt = async (ui: Awaited<ReturnType<typeof band>>) => {
1209
+ const box = await ui.find({ key: 'pic-compact' })
1210
+ return (box?.children[0] as { props?: { alt?: unknown } } | undefined)?.props?.alt
1211
+ }
1212
+ let ui = await band($, 'desktop')
1213
+ expect(await alt(ui)).toBe('Compact')
1214
+ await ui.unmount()
1215
+ await $.session.compact({ trigger: 'manual', messages: MESSAGES })
1216
+ await $.session.compact({ trigger: 'auto', messages: MESSAGES })
1217
+ // Neither a subagent's own compaction nor the ahead-of-time precompute is the chat's.
1218
+ await $.session.compact({ trigger: 'auto', messages: MESSAGES, agentId: 'worker' })
1219
+ await $.session.compact({ trigger: 'precompute', messages: MESSAGES })
1220
+ ui = await band($, 'desktop')
1221
+ expect(await alt(ui)).toBe('Compact 2')
1222
+ await ui.unmount()
1223
+ await $.classic.SessionStart({ source: 'clear' })
1224
+ ui = await band($, 'desktop')
1225
+ expect(await alt(ui)).toBe('Compact')
1226
+ await ui.unmount()
1227
+ })
1228
+
1092
1229
  // ---- Operating modes (docs/specs/2026-10-12-worker-tiers.md "Operating modes") -----------------
1093
1230
 
1094
1231
  const BURN = normalizeModes([{ id: 'burn', name: 'Burn GPT', rungs: { ...DEFAULTS.rules.general.rungs, light: [{ target: { agent: 'gpt-6-1-sol' }, effort: 'low' }] }, delegation: 'eager' }])
@@ -57,6 +57,7 @@ const DASH: DashboardView = {
57
57
  auto: true,
58
58
  handoff: 'off',
59
59
  agents: { live: 0, total: 0 },
60
+ compactions: 0,
60
61
  logOpen: false,
61
62
  agentsOpen: false,
62
63
  effortMenu: false,
@@ -574,7 +575,7 @@ const AGENTS: AgentRec[] = [
574
575
  description: 'Port the request log renderer',
575
576
  type: 'gpt-6-1-sol',
576
577
  model: 'gpt-6.1-sol@high',
577
- pick: { domain: 'frontend', level: 'standard', effort: 'high', sure: 0.8, why: 'designed refactor', target: { agent: 'gpt-6-1-sol' }, by: 'haiku', ms: 900 },
578
+ pick: { domain: 'frontend', rule: 'ui', rung: 'standard', level: 'standard', effort: 'high', sure: 0.8, why: 'designed refactor', target: { agent: 'gpt-6-1-sol' }, by: 'haiku', ms: 900 },
578
579
  state: 'running',
579
580
  now: 'Edit hooks/ui/log.tsx',
580
581
  toolSince: NOW - 14_000,
@@ -618,8 +619,10 @@ test('the Agents pane draws live, finished and empty', async ($, on) => {
618
619
  expect(await ui.find({ type: 'Text', text: 'Summarize the docs' }), surface).toBeDefined()
619
620
  // Who picked it and why, on the terminal; the desktop's cards leave it out (2026-10-20).
620
621
  const why = await ui.find({ type: 'Text', text: /Frontend \/ Standard/ })
621
- if (surface === 'terminal') expect(why, surface).toBeDefined()
622
- else expect(why, surface).toBeUndefined()
622
+ if (surface === 'terminal') {
623
+ expect(why, surface).toBeDefined()
624
+ expect(why?.text).toContain('ui/')
625
+ } else expect(why, surface).toBeUndefined()
623
626
  // The API's own counts: 1200 + 38000 + 800 in, 95% of it from the cache, 5100 out, 12 tools.
624
627
  if (surface === 'terminal') expect(await ui.find({ type: 'Text', text: 'in 40k · cache 95% · out 5.1k · tools 12' }), surface).toBeDefined()
625
628
  else for (const value of ['40k', '95%', '5.1k', '12']) expect(await ui.find({ type: 'Text', text: value }), value).toBeDefined()
@@ -92,6 +92,8 @@ export type Calibration = { effortOffset?: -2 | -1 | 0 | 1 | 2; floor?: Effort;
92
92
  */
93
93
  export type Rungs = Partial<Record<Rung, Step[]>>
94
94
  export type Rule = { mode: 'ladder'; rungs: Rungs }
95
+ /** Optional ladders within one worker set; absent domains follow that set's general ladder. */
96
+ export type DomainRules = { ui?: Rule; backend?: Rule }
95
97
  /** How a provider is paid for: what the Workers card tags each model with, and what presets read. */
96
98
  export type Billing = 'free' | 'payg' | 'subscription'
97
99
  /** A model a rung can run, as GET /api/mod-options describes it. */
@@ -129,6 +131,7 @@ export type Mode = {
129
131
  id: string
130
132
  name: string
131
133
  rungs: Rungs
134
+ domainRules?: DomainRules
132
135
  delegation: 'normal' | 'eager'
133
136
  until: 'none' | number | { reset: string }
134
137
  }
@@ -146,6 +149,8 @@ export type Settings = {
146
149
  jevSource: 'typesafe' | 'zen'
147
150
  /** Only the general ladder: the four workers replaced the per-field rules (2026-10-12). */
148
151
  rules: { general: Rule }
152
+ /** Base's optional domain ladders, separate from retired `rules.<domain>` overrides. */
153
+ domainRules?: DomainRules
149
154
  /**
150
155
  * The models the user ticked (docs/specs/2026-10-12-worker-tiers.md): worker agent names and
151
156
  * `general-purpose:<model>` for Claude's own. Empty means every model. Rungs run, and the fill
@@ -167,6 +172,11 @@ export type Settings = {
167
172
  * the first reset after this.
168
173
  */
169
174
  activeSince?: number
175
+ /**
176
+ * Base's delegation, used while no mode is on: eager has the main chat delegate everything but
177
+ * judgment and final review to ripple:auto. A mode's own delegation replaces it while it is on.
178
+ */
179
+ delegation: 'normal' | 'eager'
170
180
  providers: Record<string, boolean>
171
181
  /**
172
182
  * What ran when a pinned tier's agent was unavailable, before the ladder (2026-10-11). Kept so a
@@ -226,6 +236,8 @@ export type WorkerVerdict = {
226
236
 
227
237
  /** What a spawn of ripple:auto (or a taken-over spawn) was turned into. */
228
238
  export type WorkerPick = WorkerVerdict & {
239
+ /** The worker set's ladder selected for this task (explicit model choices bypass it). */
240
+ rule: 'ui' | 'backend' | 'general'
229
241
  /** The ladder rung that ran it (absent for explicit choices). */
230
242
  rung?: Rung
231
243
  /** One-based position in the selected rung; skipped entries explain ordered fallback. */
@@ -397,6 +409,8 @@ declare module 'claude-code' {
397
409
  compactNote: string
398
410
  effortMenuOpen: boolean
399
411
  agentsOpen: boolean
412
+ /** Finished compactions of the main conversation (the Compact icon's count). */
413
+ compactions: number
400
414
  agentsShowDone: boolean
401
415
  /** The agent whose conversation the Agents pane shows; null shows the list. */
402
416
  agentOpen: string | null