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
@@ -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,27 @@ 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
+
174
+ /**
175
+ * Visual design depends on the model's taste, not on how hard the task is (2026-10-21: a store
176
+ * screenshot redesign judged "design, standard" ran on the light pair). Under the ui ladder a design
177
+ * verdict on the light or standard rung is raised to the hard rung; frontend, other ladders and
178
+ * verdicts already on hard or plan are untouched.
179
+ */
180
+ export function domainVerdict(verdict: WorkerVerdict, rule: WorkerPick['rule']): { verdict: WorkerVerdict; raised?: 'design' } {
181
+ if (rule !== 'ui' || verdict.domain !== 'design') return { verdict }
182
+ const rung = rungOf(verdict)
183
+ if (rung !== 'light' && rung !== 'standard') return { verdict }
184
+ const { sure: _sure, ...rest } = verdict
185
+ return { verdict: { ...rest, ...TIER_MARKERS.hard! }, raised: 'design' }
186
+ }
187
+
167
188
  export type Resolution = { target?: TierTarget; effort: Effort; rung?: Rung; position?: number; skipped?: { rung: Rung; position: number; model: string; reason: string }[]; fallbackReason?: string }
168
189
 
169
190
  /** 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,102 @@ 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' }], hard: [{ target: { agent: 'gpt-6-1-sol' }, 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
+ // A light design verdict is raised to the heavy pair (visual taste, not difficulty); frontend keeps its level.
667
+ const raised = domain === 'design'
668
+ expect(sent[0]?.subagentType).toBe(rule === 'ui' ? (raised ? 'gpt-6-1-sol' : 'gpt-6-astra') : rule === 'backend' ? 'gpt-6-1-sol' : 'deepseek-v4-1-flash')
669
+ const row = JSON.parse(b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!)
670
+ expect(row).toMatchObject({ rule, domain, level: 'light', rung: raised ? 'hard' : 'light' })
671
+ expect(row.raised).toBe(raised ? 'design' : undefined)
672
+ })
673
+
674
+ test('a design task under the ui ladder takes the heavy level past a tier marker', async ($, on) => {
675
+ const b = boundaries(on, { domainRules: { ui: { mode: 'ladder', rungs: { light: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }], hard: [{ target: { agent: 'gpt-6-1-sol' }, effort: 'high' }] } } } })
676
+ http(on)
677
+ judgeStub(on, JSON.stringify({ domain: 'design', level: 'deep', kind: 'do', effort: 'high', sure: 0.9, why: 'domain' }))
678
+ const sent = spawnStub(on)
679
+ await start($)
680
+ await $.agent.spawn({ ...SPAWN, prompt: '[[ripple: light]]\nTweak a color.' })
681
+ expect(sent[0]?.subagentType).toBe('gpt-6-1-sol')
682
+ const row = JSON.parse(b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').find(line => line.includes('"type":"pick"'))!)
683
+ expect(row).toMatchObject({ by: 'session', rule: 'ui', domain: 'design', rung: 'hard', raised: 'design' })
684
+ })
685
+
686
+ 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) => {
687
+ const b = boundaries(on, domains ? { domainRules: { ui: { mode: 'ladder', rungs: { light: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }] } } } } : {})
688
+ http(on)
689
+ let release!: () => void
690
+ const pending = new Promise<void>(resolve => { release = resolve })
691
+ let calls = 0
692
+ on('model.complete', async () => {
693
+ calls++
694
+ await pending
695
+ return { value: { isAnswered: true, text: JSON.stringify({ domain: 'frontend', level: 'deep', kind: 'plan', effort: 'max', sure: 0.1, why: 'conflicts with tier' }), usage: USAGE } }
696
+ })
697
+ const sent = spawnStub(on)
698
+ await start($)
699
+ const spawn = $.agent.spawn({ ...SPAWN, prompt: '[[ripple: light]]\nBuild the UI.' })
700
+ await b.clock.settle()
701
+ expect(calls).toBe(1)
702
+ expect(sent.length).toBe(domains ? 0 : 1)
703
+ release()
704
+ await spawn
705
+ await b.clock.settle()
706
+ expect(calls).toBe(1)
707
+ expect(sent[0]?.subagentType).toBe(domains ? 'gpt-6-astra' : 'deepseek-v4-1-flash')
708
+ const rows = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').map(line => JSON.parse(line))
709
+ const pick = rows.find(row => row.type === 'pick')
710
+ expect(pick).toMatchObject({ by: 'session', tier: 'light', rung: 'light', level: 'light', kind: 'do', rule: domains ? 'ui' : 'general', domain: domains ? 'frontend' : 'general' })
711
+ expect(pick.sure).toBeUndefined()
712
+ expect(rows.filter(row => row.type === 'shadow').length).toBe(1)
713
+ expect(rows.find(row => row.type === 'shadow')).toMatchObject({ pick: pick.id, rung: 'plan', verdict: { domain: 'frontend', sure: 0.1 } })
714
+ })
715
+
716
+ 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) => {
717
+ const b = boundaries(on, { domainRules: { ui: DEFAULTS.rules.general } })
718
+ http(on)
719
+ on('model.complete', async () => {
720
+ if (error === 'aborted') await b.clock.advance(6000)
721
+ return { value: error === 'aborted' ? { isAnswered: false, reason: 'aborted', usage: USAGE } : { isAnswered: false, reason: 'api-error', status: 503, error: 'overloaded', usage: USAGE } }
722
+ })
723
+ const sent = spawnStub(on)
724
+ await start($)
725
+ await $.agent.spawn({ ...SPAWN, prompt: '[[ripple: light]]\nRename a variable.' })
726
+ expect(sent[0]?.subagentType).toBe('deepseek-v4-1-flash')
727
+ const rows = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').map(line => JSON.parse(line))
728
+ expect(rows.find(row => row.type === 'pick')).toMatchObject({ by: 'session', domain: 'general', rule: 'general', rung: 'light', tier: 'light', verdict: null })
729
+ expect(rows.filter(row => row.type === 'shadow').length).toBe(1)
730
+ })
731
+
732
+ test('a mode without domain rules skips awaiting Base domains; explicit model markers still bypass the judge', async ($, on) => {
733
+ 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' })
734
+ http(on)
735
+ const judges = judgeStub(on, '{"domain":"frontend","level":"light","effort":"low","sure":0.9}')
736
+ const sent = spawnStub(on)
737
+ await start($)
738
+ await $.agent.spawn({ ...SPAWN, prompt: '[[ripple: light]]\nWork.' })
739
+ await b.clock.settle()
740
+ const rows = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').map(line => JSON.parse(line))
741
+ expect(rows.find(row => row.type === 'pick')).toMatchObject({ domain: 'general', rule: 'general', mode: 'Plain', verdict: null })
742
+ expect(judges.length).toBe(1)
743
+ // Turn Base on, so its domain override is effective for this explicit marker.
744
+ b.remote({ settings: normalizeSettings({ setupDone: true, domainRules: { ui: DEFAULTS.rules.general } }), revision: 2, stored: true })
745
+ await b.clock.advance(3000)
746
+ await $.agent.spawn({ ...SPAWN, prompt: '[[ripple: gpt-6-astra@max]]\nWork.' })
747
+ expect(judges.length).toBe(1)
748
+ expect(sent[1]).toMatchObject({ subagentType: 'gpt-6-astra', model: 'gpt-6-astra@max' })
749
+ })
750
+
624
751
  test('the limits row shows Claude\'s plan on a chat that runs on another provider', async ($, on) => {
625
752
  const b = boundaries(on, { extras: ['limits'] })
626
753
  b.setModel('muse-spark-1.3-contributor')
@@ -1089,6 +1216,32 @@ test('failed Haiku compaction falls back; precompute skips; subagent stays with
1089
1216
  expect(b.toasts.some(t => t.includes('session compactor'))).toBe(true)
1090
1217
  })
1091
1218
 
1219
+ test('the Compact icon counts the main chat\'s finished compactions; /clear zeroes it', async ($, on) => {
1220
+ boundaries(on, { compactWith: 'session' })
1221
+ http(on)
1222
+ on('session.compact', () => ({ messages: MESSAGES }))
1223
+ await start($)
1224
+ const alt = async (ui: Awaited<ReturnType<typeof band>>) => {
1225
+ const box = await ui.find({ key: 'pic-compact' })
1226
+ return (box?.children[0] as { props?: { alt?: unknown } } | undefined)?.props?.alt
1227
+ }
1228
+ let ui = await band($, 'desktop')
1229
+ expect(await alt(ui)).toBe('Compact')
1230
+ await ui.unmount()
1231
+ await $.session.compact({ trigger: 'manual', messages: MESSAGES })
1232
+ await $.session.compact({ trigger: 'auto', messages: MESSAGES })
1233
+ // Neither a subagent's own compaction nor the ahead-of-time precompute is the chat's.
1234
+ await $.session.compact({ trigger: 'auto', messages: MESSAGES, agentId: 'worker' })
1235
+ await $.session.compact({ trigger: 'precompute', messages: MESSAGES })
1236
+ ui = await band($, 'desktop')
1237
+ expect(await alt(ui)).toBe('Compact 2')
1238
+ await ui.unmount()
1239
+ await $.classic.SessionStart({ source: 'clear' })
1240
+ ui = await band($, 'desktop')
1241
+ expect(await alt(ui)).toBe('Compact')
1242
+ await ui.unmount()
1243
+ })
1244
+
1092
1245
  // ---- Operating modes (docs/specs/2026-10-12-worker-tiers.md "Operating modes") -----------------
1093
1246
 
1094
1247
  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()