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.
- package/CHANGELOG.md +34 -0
- package/README.es.md +48 -0
- package/README.ja.md +42 -0
- package/README.ko.md +39 -0
- package/README.md +44 -0
- package/README.pt-BR.md +46 -0
- package/README.zh-CN.md +40 -0
- package/dist/app/dist/claude-desktop.js +31 -0
- package/dist/app/dist/main.js +127 -8
- package/dist/app/dist/status-model.js +12 -0
- package/dist/app/dist/update-model.js +32 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +5 -1
- package/dist/mod/hooks/i18n.ts +6 -0
- package/dist/mod/hooks/modes.ts +15 -8
- package/dist/mod/hooks/register.tsx +67 -19
- package/dist/mod/hooks/settings.js +37 -2
- package/dist/mod/hooks/settings.ts +31 -3
- package/dist/mod/hooks/ui/agents.tsx +1 -1
- package/dist/mod/hooks/ui/art.ts +8 -3
- package/dist/mod/hooks/ui/band.tsx +6 -2
- package/dist/mod/hooks/ui/flat.ts +11 -8
- package/dist/mod/hooks/ui/look.ts +3 -1
- package/dist/mod/hooks/ui/view.ts +2 -0
- package/dist/mod/hooks/workers.ts +8 -1
- package/dist/mod/tests/delegation.test.tsx +50 -0
- package/dist/mod/tests/register.test.tsx +138 -1
- package/dist/mod/tests/ui.test.tsx +6 -3
- package/dist/mod/types/index.d.ts +14 -0
- package/dist/router/src/admin.js +41 -76
- package/dist/router/src/autoupdate.js +299 -0
- package/dist/router/src/bootstrap.js +3 -2
- package/dist/router/src/cli-control.js +65 -0
- package/dist/router/src/config.js +70 -11
- package/dist/router/src/index.js +11 -1
- package/dist/router/src/picker.js +25 -1
- package/dist/router/src/presets.js +21 -13
- package/dist/router/src/providers/chatgpt/index.js +11 -0
- package/dist/router/src/providers/google/index.js +7 -2
- package/dist/router/src/providers/google/transport.js +16 -1
- package/dist/router/src/proxy.js +46 -6
- package/dist/router/src/requestlog.js +3 -2
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +2 -2
- package/dist/ui/app.js +247 -42
- package/dist/ui/i18n.js +154 -18
- package/dist/ui/style.css +21 -0
- package/docs/ARCHITECTURE.md +61 -11
- 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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/mod/hooks/ui/art.ts
CHANGED
|
@@ -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
|
-
/**
|
|
613
|
-
export function
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
622
|
-
|
|
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
|