clauderipple 0.8.10 → 0.9.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 (69) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.es.md +21 -15
  3. package/README.ja.md +13 -9
  4. package/README.ko.md +23 -9
  5. package/README.md +26 -11
  6. package/README.pt-BR.md +21 -15
  7. package/README.zh-CN.md +13 -9
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/codex.js +3 -1
  10. package/dist/cli/src/settings.js +5 -2
  11. package/dist/mod/.claude-plugin/plugin.json +9 -1
  12. package/dist/mod/hooks/cache.ts +60 -0
  13. package/dist/mod/hooks/compact.ts +40 -0
  14. package/dist/mod/hooks/handoff.ts +43 -0
  15. package/dist/mod/hooks/i18n.ts +2716 -0
  16. package/dist/mod/hooks/judge.ts +242 -0
  17. package/dist/mod/hooks/ladder.js +179 -0
  18. package/dist/mod/hooks/ladder.ts +174 -0
  19. package/dist/mod/hooks/modes.ts +93 -0
  20. package/dist/mod/hooks/register.tsx +1458 -256
  21. package/dist/mod/hooks/session.ts +62 -0
  22. package/dist/mod/hooks/settings.js +342 -0
  23. package/dist/mod/hooks/settings.ts +321 -0
  24. package/dist/mod/hooks/stats.ts +59 -0
  25. package/dist/mod/hooks/ui/agents.tsx +138 -0
  26. package/dist/mod/hooks/ui/alerts.tsx +373 -0
  27. package/dist/mod/hooks/ui/art.ts +628 -0
  28. package/dist/mod/hooks/ui/band.tsx +836 -0
  29. package/dist/mod/hooks/ui/kit.tsx +258 -0
  30. package/dist/mod/hooks/ui/log.tsx +109 -0
  31. package/dist/mod/hooks/ui/parts.tsx +217 -0
  32. package/dist/mod/hooks/ui/ripple.ts +614 -0
  33. package/dist/mod/hooks/ui/settings.tsx +376 -0
  34. package/dist/mod/hooks/ui/setup.tsx +148 -0
  35. package/dist/mod/hooks/ui/theme.ts +217 -0
  36. package/dist/mod/hooks/ui/view.ts +371 -0
  37. package/dist/mod/hooks/worker.ts +1 -1
  38. package/dist/mod/hooks/workers.ts +217 -0
  39. package/dist/mod/tests/register.test.tsx +1730 -0
  40. package/dist/mod/tests/ui.test.tsx +853 -0
  41. package/dist/mod/tsconfig.json +2 -1
  42. package/dist/mod/types/index.d.ts +320 -1
  43. package/dist/router/src/admin.js +170 -52
  44. package/dist/router/src/agents.js +24 -5
  45. package/dist/router/src/bootstrap.js +4 -2
  46. package/dist/router/src/config.js +3 -0
  47. package/dist/router/src/health.js +33 -0
  48. package/dist/router/src/index.js +1 -0
  49. package/dist/router/src/ingress/models.js +4 -1
  50. package/dist/router/src/mod-settings.js +120 -0
  51. package/dist/router/src/model-policy.js +6 -0
  52. package/dist/router/src/model-scores.js +159 -0
  53. package/dist/router/src/picker.js +7 -0
  54. package/dist/router/src/providers/chatgpt/index.js +23 -5
  55. package/dist/router/src/providers/chatgpt/translate.js +4 -1
  56. package/dist/router/src/providers/google/index.js +8 -3
  57. package/dist/router/src/providers/openai/index.js +6 -1
  58. package/dist/router/src/providers/openai/translate.js +6 -1
  59. package/dist/router/src/proxy.js +62 -24
  60. package/dist/router/src/requestlog.js +78 -20
  61. package/dist/router/src/version.js +1 -1
  62. package/dist/ui/app.js +908 -209
  63. package/dist/ui/favicon-32.png +0 -0
  64. package/dist/ui/favicon.svg +12 -0
  65. package/dist/ui/i18n.js +919 -233
  66. package/dist/ui/index.html +159 -87
  67. package/dist/ui/style.css +789 -410
  68. package/docs/ARCHITECTURE.md +141 -0
  69. package/package.json +7 -3
@@ -0,0 +1,321 @@
1
+ import type { Calibration, Domain, Effort, Level, Mode, Rule, Rung, Settings, SettingsChange, Step, TierTarget } from '../types/index.d.ts'
2
+
3
+ /** Bump whenever a writer's normalization can lose another format's fields. */
4
+ export const MOD_SETTINGS_SCHEMA_VERSION = 1
5
+
6
+ export const DOMAINS: readonly Domain[] = ['general', 'design', 'frontend', 'backend', 'infra', 'data', 'test', 'docs', 'research']
7
+ export const LEVELS: readonly Level[] = ['light', 'standard', 'deep']
8
+ export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
9
+ /** The worker ladder, bottom to top (docs/specs/2026-10-11-worker-ladder.md). */
10
+ export const RUNGS: readonly Rung[] = ['light', 'standard', 'hard', 'plan']
11
+ /** The rung each judged level lands on before kind and confidence move it. */
12
+ export const LEVEL_RUNG: Readonly<Record<Level, Rung>> = { light: 'light', standard: 'standard', deep: 'hard' }
13
+ /** A rung's effort when nothing else says one: a stored rung without one, a migrated pin. */
14
+ export const RUNG_EFFORT: Readonly<Record<Rung, Effort>> = { light: 'low', standard: 'medium', hard: 'high', plan: 'high' }
15
+
16
+ const claude = (model: string, effort: Effort): Step => ({ target: { agent: 'general-purpose', model }, effort })
17
+
18
+ export const DEFAULTS: Settings = {
19
+ effortAuto: true, judge: 'haiku', judgeModel: 'deepseek-v4.1-flash', jevSource: 'typesafe',
20
+ bias: 0, floor: 'low', ceiling: 'max', agentAuto: 'auto',
21
+ rules: {
22
+ // The spec's "Claude + GPT" example; a rung whose worker is missing moves up and says so.
23
+ // Only the general rule: the four workers replaced the per-field rules (2026-10-12).
24
+ general: { mode: 'ladder', rungs: {
25
+ light: [{ target: { agent: 'deepseek-v4-1-flash' }, effort: 'max' }], standard: [{ target: { agent: 'gpt-6-1-sol' }, effort: 'medium' }],
26
+ hard: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }], plan: [claude('opus', 'high')],
27
+ } },
28
+ },
29
+ allow: [], modes: [],
30
+ providers: {},
31
+ fallback: { agent: 'general-purpose', model: 'sonnet' },
32
+ keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
33
+ compactWith: 'haiku', handoffSkill: '', handoffAfter: 'continue', handoffButton: 'advised',
34
+ swampAt: 50, swampAlert: false, layout: 'dashboard', appearance: 'auto', language: 'auto', hide: ['reason'], extras: [], setupDone: false,
35
+ }
36
+
37
+ /** Band chips that show only when switched on in Look. */
38
+ export const EXTRAS: readonly string[] = ['context', 'limits']
39
+
40
+ /**
41
+ * Claude's own models as workers: the built-in general-purpose agent run on the model, `model` its
42
+ * current id. The key is what the allow-list stores.
43
+ */
44
+ export const CLAUDE_WORKERS: readonly { name: string; model: string; provider: string; target: TierTarget }[] = [
45
+ { name: 'general-purpose:haiku', model: 'claude-haiku-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'haiku' } },
46
+ { name: 'general-purpose:sonnet', model: 'claude-sonnet-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'sonnet' } },
47
+ { name: 'general-purpose:opus', model: 'claude-opus-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'opus' } },
48
+ { name: 'general-purpose:fable', model: 'claude-fable-5-1', provider: 'anthropic', target: { agent: 'general-purpose', model: 'fable' } },
49
+ ]
50
+
51
+ /** The allow-list key of a target: the agent's name, or `general-purpose:<model>` for Claude's own. */
52
+ export function candidateKey(target: TierTarget): string {
53
+ return target.model ? target.agent + ':' + target.model : target.agent
54
+ }
55
+
56
+ function object(raw: unknown): Record<string, unknown> {
57
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {}
58
+ }
59
+ function names(raw: unknown): string[] {
60
+ return Array.isArray(raw) ? [...new Set(raw.filter((v): v is string => typeof v === 'string' && !!v.trim()).map(v => v.trim()))] : []
61
+ }
62
+ function target(raw: unknown, fallback?: TierTarget): TierTarget | undefined {
63
+ const value = object(raw)
64
+ const agent = typeof value.agent === 'string' && value.agent.trim() ? value.agent.trim() : fallback?.agent
65
+ const model = typeof value.model === 'string' && value.model.trim() ? value.model.trim() : fallback?.model
66
+ return agent ? { agent, ...(model ? { model } : {}) } : undefined
67
+ }
68
+ function calibration(value: Record<string, unknown>): Calibration {
69
+ const out: Calibration = {}
70
+ if ([-2, -1, 0, 1, 2].includes(Number(value.effortOffset)) && typeof value.effortOffset === 'number') out.effortOffset = value.effortOffset as NonNullable<Calibration['effortOffset']>
71
+ for (const field of ['floor', 'ceiling'] as const) if (EFFORTS.includes(value[field] as Effort)) out[field] = value[field] as Effort
72
+ return out
73
+ }
74
+ function copy<T>(value: T): T {
75
+ return JSON.parse(JSON.stringify(value)) as T
76
+ }
77
+ function sameTarget(a: TierTarget | undefined, b: TierTarget | undefined): boolean {
78
+ return !!a && !!b && a.agent === b.agent && a.model === b.model
79
+ }
80
+ /** An effort moved by calibration steps, then held between floor and ceiling (the floor wins). */
81
+ function calibrated(effort: Effort, cal: Calibration): Effort {
82
+ const min = EFFORTS.indexOf(cal.floor ?? 'low')
83
+ const max = Math.max(min, EFFORTS.indexOf(cal.ceiling ?? 'max'))
84
+ const shifted = Math.max(0, Math.min(4, EFFORTS.indexOf(effort) + (cal.effortOffset ?? 0)))
85
+ return EFFORTS[Math.max(min, Math.min(max, shifted))]!
86
+ }
87
+ /** One effort step up, max staying max. */
88
+ export function effortUp(effort: Effort): Effort {
89
+ return EFFORTS[Math.min(4, EFFORTS.indexOf(effort) + 1)]!
90
+ }
91
+ function step(raw: unknown, rung: Rung): Step | undefined {
92
+ const v = object(raw)
93
+ const chosen = target(v.target)
94
+ if (!chosen) return undefined
95
+ // A rung's backup model is dropped on load: the four workers have none (the next one up stands in).
96
+ return { target: chosen, effort: EFFORTS.includes(v.effort as Effort) ? v.effort as Effort : RUNG_EFFORT[rung] }
97
+ }
98
+ /** The effort a migrated rung starts at: the default ladder's when it runs the same model there. */
99
+ function migratedEffort(rung: Rung, chosen: TierTarget): Effort {
100
+ const own = DEFAULTS.rules.general.rungs[rung]?.[0]
101
+ return own && sameTarget(own.target, chosen) ? own.effort : RUNG_EFFORT[rung]
102
+ }
103
+ /**
104
+ * A pin stored before 2026-10-11 as a ladder: light/standard/deep become Light/Standard/Hard, a
105
+ * missing level takes the nearest one above (as the pin climbed) or else below, each rung's effort
106
+ * is its default calibrated as the pin was, and Plan is Hard's model one effort higher.
107
+ */
108
+ export function ladderFromPin(targets: Partial<Record<Level, TierTarget>>, cal: Calibration): Rule {
109
+ const known = LEVELS.filter(level => targets[level])
110
+ if (!known.length) return copy(DEFAULTS.rules.general)
111
+ const rungs: Partial<Record<Rung, Step>> = {}
112
+ LEVELS.forEach((level, at) => {
113
+ const above = LEVELS.slice(at).find(l => targets[l])
114
+ const chosen = targets[above ?? [...LEVELS.slice(0, at)].reverse().find(l => targets[l])!]!
115
+ const rung = LEVEL_RUNG[level]
116
+ rungs[rung] = { target: { ...chosen }, effort: calibrated(migratedEffort(rung, chosen), cal) }
117
+ })
118
+ rungs.plan = { target: { ...rungs.hard!.target }, effort: effortUp(rungs.hard!.effort) }
119
+ return { mode: 'ladder', rungs: ladderRungs(rungs) }
120
+ }
121
+ /** Single steps from older saves become one-item lists; malformed items are dropped in place. */
122
+ function ladderRungs(raw: unknown): Rule['rungs'] {
123
+ const source = object(raw)
124
+ const rungs: Rule['rungs'] = {}
125
+ for (const rung of RUNGS) {
126
+ const items = Array.isArray(source[rung]) ? source[rung] : [source[rung]]
127
+ const parsed = items.flatMap(item => { const value = step(item, rung); return value ? [value] : [] })
128
+ if (parsed.length) rungs[rung] = parsed
129
+ }
130
+ return rungs
131
+ }
132
+ /**
133
+ * The one rule left, the general ladder (the four workers). A ladder keeps its rungs; a pin from
134
+ * before the ladder becomes one; Auto (experimental, removed 2026-10-12) becomes a ladder with no
135
+ * rungs set, which the router fills from the ticked models (`fillLadder`). Field rules are gone.
136
+ */
137
+ function generalRule(raw: unknown): Rule {
138
+ const v = object(raw)
139
+ if (v.mode === 'ladder') return { mode: 'ladder', rungs: ladderRungs(v.rungs) }
140
+ if (v.mode === 'auto') return { mode: 'ladder', rungs: {} }
141
+ if (v.mode === 'pin') {
142
+ const source = object(v.targets)
143
+ const targets: Partial<Record<Level, TierTarget>> = {}
144
+ for (const level of LEVELS) { const parsed = target(source[level]); if (parsed) targets[level] = parsed }
145
+ return ladderFromPin(targets, calibration(v))
146
+ }
147
+ return copy(DEFAULTS.rules.general)
148
+ }
149
+
150
+ type Worker = { name: string; provider: string }
151
+
152
+ /**
153
+ * Auto's allow-list names worker agents. Settings saved before 2026-10-10 named providers there;
154
+ * with the worker list known, a provider name becomes that provider's workers (names it does not
155
+ * know are kept: a worker may be missing only for now).
156
+ */
157
+ export function migrateAllow(allow: readonly string[], workers: readonly Worker[]): string[] {
158
+ const agents = new Set(workers.map(w => w.name))
159
+ return [...new Set(allow.flatMap(entry => agents.has(entry) || !workers.some(w => w.provider === entry) ? [entry] : workers.filter(w => w.provider === entry).map(w => w.name)))]
160
+ }
161
+
162
+ /** The workers switched on by an allow-list; empty means every worker. */
163
+ export function allowedWorkers(allow: readonly string[], workers: readonly Worker[]): string[] {
164
+ if (!allow.length) return workers.map(w => w.name)
165
+ const on = new Set(migrateAllow(allow, workers))
166
+ return workers.filter(w => on.has(w.name)).map(w => w.name)
167
+ }
168
+
169
+ /** Switches one worker; every worker on again is stored as [], and the last one cannot go off. */
170
+ export function toggleAllow(allow: readonly string[], name: string, workers: readonly Worker[]): string[] {
171
+ const on = allowedWorkers(allow, workers)
172
+ const next = on.includes(name) ? on.filter(n => n !== name) : workers.filter(w => w.name === name || on.includes(w.name)).map(w => w.name)
173
+ if (!next.length) return [...allow]
174
+ return next.length === workers.length ? [] : next
175
+ }
176
+
177
+ /**
178
+ * Whether a stored model is ticked: its key (an agent name, or `general-purpose:<model>`) or, for
179
+ * settings from before 2026-10-10, its provider is listed; an empty list ticks every model.
180
+ */
181
+ export function isAllowed(key: string, provider: string, allow: readonly string[]): boolean {
182
+ return !allow.length || allow.includes(key) || allow.includes(provider)
183
+ }
184
+
185
+ /** The design ladder every save stored until 2026-10-12: Claude, Sonnet then Opus. */
186
+ const RETIRED_DESIGN = JSON.stringify([claude('sonnet', 'medium'), claude('sonnet', 'high'), claude('opus', 'high'), claude('opus', 'high')].map(s => [s.target.agent, s.target.model, s.effort]))
187
+
188
+ /**
189
+ * Whether stored settings still carry what the four workers replaced: Auto (experimental), or a
190
+ * field rule that is not the design ladder every save used to store. The dashboard says so once.
191
+ */
192
+ export function hadRetiredRouting(raw: unknown): boolean {
193
+ const rules = object(object(raw).rules)
194
+ if (object(rules.general).mode === 'auto') return true
195
+ return DOMAINS.some(domain => {
196
+ if (domain === 'general') return false
197
+ const stored = object(rules[domain])
198
+ if (stored.mode === undefined || stored.mode === 'inherit') return false
199
+ if (domain !== 'design' || stored.mode !== 'ladder') return true
200
+ const rungs = ladderRungs(stored.rungs)
201
+ return JSON.stringify(RUNGS.map(rung => { const s = rungs[rung]?.[0]; return s ? [s.target.agent, s.target.model, s.effort] : null })) !== RETIRED_DESIGN
202
+ })
203
+ }
204
+
205
+ /** How long a mode's name may be: it shows in the band. */
206
+ export const MODE_NAME_MAX = 40
207
+ /** How many modes are kept. */
208
+ export const MODES_MAX = 20
209
+
210
+ /** How long a mode's reset provider name may be. */
211
+ const PROVIDER_NAME_MAX = 64
212
+
213
+ /**
214
+ * A mode's end: a time, one provider's next quota reset, or none. Until 2026-10-12 `'reset'` meant
215
+ * any used provider's; it becomes the first that could have offered it, ChatGPT then Claude. Settings
216
+ * do not carry a worker's provider, and ChatGPT is the only worker provider with a known reset, so
217
+ * any worker that is not Claude's own stands for ChatGPT.
218
+ */
219
+ function modeUntil(raw: unknown, rungs: Rule['rungs']): Mode['until'] {
220
+ if (typeof raw === 'number') return Number.isFinite(raw) && raw > 0 ? raw : 'none'
221
+ if (raw === 'reset') return { reset: Object.values(rungs).some(steps => steps?.some(step => step.target.agent !== 'general-purpose')) ? 'chatgpt' : 'anthropic' }
222
+ const reset = object(raw).reset
223
+ return typeof reset === 'string' && reset.trim() ? { reset: reset.trim().slice(0, PROVIDER_NAME_MAX) } : 'none'
224
+ }
225
+
226
+ /**
227
+ * Saved worker sets. Old prefer/avoid modes copy the base ladder on load, dropping their policy.
228
+ * Every entry owns its lists: later base edits cannot mutate a saved mode.
229
+ */
230
+ export function normalizeModes(raw: unknown, base: Rule['rungs'] = DEFAULTS.rules.general.rungs): Mode[] {
231
+ if (!Array.isArray(raw)) return []
232
+ const out: Mode[] = []
233
+ for (const entry of raw) {
234
+ const v = object(entry)
235
+ const id = typeof v.id === 'string' ? v.id.trim().slice(0, 64) : ''
236
+ const name = typeof v.name === 'string' ? v.name.trim().slice(0, MODE_NAME_MAX) : ''
237
+ if (!id || !name || out.some(m => m.id === id)) continue
238
+ const rungs = ladderRungs(v.rungs === undefined ? base : v.rungs)
239
+ out.push({ id, name, rungs, delegation: v.delegation === 'eager' ? 'eager' : 'normal', until: modeUntil(v.until, rungs) })
240
+ if (out.length === MODES_MAX) break
241
+ }
242
+ return out
243
+ }
244
+
245
+ /** The general pin every install had before the ladder: what stored `tiers` filled in. */
246
+ const LEGACY_GENERAL: Readonly<Record<Level, TierTarget>> = { light: { agent: 'deepseek-v4-1-flash' }, standard: { agent: 'gpt-6-1-sol' }, deep: { agent: 'gpt-6-astra' } }
247
+
248
+ /**
249
+ * Persisted settings may be old; migrate tiers and pins once and never alias nested default data.
250
+ * Only the general ladder survives: Auto becomes a ladder with no rungs set (its candidates become
251
+ * the ticked models); field rules and Auto's own fields (preference, autoFloors, autoTieBand) are
252
+ * dropped. With `workers`, provider names in an allow-list become those providers' workers.
253
+ */
254
+ export function normalizeSettings(raw: unknown, workers?: readonly Worker[]): Settings {
255
+ const value = object(raw)
256
+ const stored = object(value.rules)
257
+ const rules: Settings['rules'] = { general: generalRule(stored.general) }
258
+ const old = object(value.tiers)
259
+ if (!Object.hasOwn(stored, 'general') && Object.keys(old).length) {
260
+ const targets: Partial<Record<Level, TierTarget>> = copy(LEGACY_GENERAL)
261
+ for (const level of LEVELS) { const parsed = target(old[level]); if (parsed) targets[level] = parsed }
262
+ rules.general = ladderFromPin(targets, {})
263
+ }
264
+ const ticked = names(Array.isArray(value.allow) ? value.allow : object(stored.general).mode === 'auto' ? object(stored.general).allow : [])
265
+ const result: Settings = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } }
266
+ for (const [name, enabled] of Object.entries(object(value.providers))) if (typeof enabled === 'boolean') result.providers[name] = enabled
267
+ for (const key of ['effortAuto', 'keepWarm', 'setupDone', 'swampAlert'] as const) if (typeof value[key] === 'boolean') result[key] = value[key]
268
+ if (Array.isArray(value.extras)) result.extras = [...new Set(value.extras.filter((part): part is string => typeof part === 'string' && EXTRAS.includes(part)))]
269
+ const choices = {
270
+ judge: ['haiku', 'jev', 'model'], jevSource: ['typesafe', 'zen'], bias: [-2, -1, 0, 1, 2], floor: EFFORTS,
271
+ ceiling: EFFORTS, agentAuto: ['off', 'auto', 'all'], keepWarmHours: [1, 2, 4, 8], compactWith: ['haiku', 'session'],
272
+ handoffAfter: ['continue', 'confirm', 'copy'], handoffButton: ['advised', 'always'], layout: ['dashboard', 'minimal'],
273
+ appearance: ['auto', 'dark', 'light'], language: ['auto', 'ko', 'en', 'zh', 'ja', 'es', 'pt'],
274
+ } as const
275
+ for (const key of Object.keys(choices) as (keyof typeof choices)[]) if ((choices[key] as readonly unknown[]).includes(value[key])) Object.assign(result, { [key]: value[key] })
276
+ if (typeof value.judgeModel === 'string' && value.judgeModel.trim()) result.judgeModel = value.judgeModel.trim()
277
+ if (typeof value.handoffSkill === 'string') result.handoffSkill = value.handoffSkill.trim().replace(/^\//, '')
278
+ if (typeof value.keepWarmMinTokens === 'number' && Number.isFinite(value.keepWarmMinTokens) && value.keepWarmMinTokens >= 0) result.keepWarmMinTokens = value.keepWarmMinTokens
279
+ if (typeof value.swampAt === 'number' && Number.isFinite(value.swampAt) && value.swampAt >= 0 && value.swampAt <= 100) result.swampAt = value.swampAt
280
+ 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)))]
281
+ result.fallback = target(value.fallback, DEFAULTS.fallback)!
282
+ result.modes = normalizeModes(value.modes, rules.general.rungs)
283
+ // An active mode must be one of the modes; its start time stays only with it.
284
+ if (typeof value.activeMode === 'string' && result.modes.some(m => m.id === value.activeMode)) {
285
+ result.activeMode = value.activeMode
286
+ if (typeof value.activeSince === 'number' && Number.isFinite(value.activeSince) && value.activeSince > 0) result.activeSince = value.activeSince
287
+ }
288
+ return result
289
+ }
290
+
291
+ /** Preserve unrelated remote edits, including sibling domains and provider switches. */
292
+ export function settingsChanges(before: Settings, after: Settings): SettingsChange[] {
293
+ function diff(a: unknown, b: unknown, path: string[]): SettingsChange[] {
294
+ if (JSON.stringify(a) === JSON.stringify(b)) return []
295
+ if (b === undefined) return [{ path, remove: true }]
296
+ if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
297
+ const left = object(a), right = object(b)
298
+ // Changing a discriminated rule replaces it, rather than retaining the old mode's fields.
299
+ if (left.mode === right.mode) return [...new Set([...Object.keys(left), ...Object.keys(right)])].flatMap(key => diff(left[key], right[key], [...path, key]))
300
+ }
301
+ return [{ path, value: b }]
302
+ }
303
+ return diff(before, after, [])
304
+ }
305
+
306
+ export function applySettingsChanges(base: Settings, changes: readonly SettingsChange[]): Settings {
307
+ const result = object(normalizeSettings(base))
308
+ for (const change of changes) {
309
+ let parent = result
310
+ for (const key of change.path.slice(0, -1)) {
311
+ const child = object(parent[key])
312
+ parent[key] = child
313
+ parent = child
314
+ }
315
+ const key = change.path.at(-1)
316
+ if (key === undefined) continue
317
+ if (change.remove) delete parent[key]
318
+ else parent[key] = change.value
319
+ }
320
+ return normalizeSettings(result)
321
+ }
@@ -0,0 +1,59 @@
1
+ import type { Effort, JudgeKind, Stats, Tier, Usage } from '../types'
2
+ import { t } from './i18n.ts'
3
+ import type { Lang } from './i18n.ts'
4
+
5
+ export function emptyStats(): Stats {
6
+ return { prompts: 0, requests: 0, input: 0, write: 0, read: 0, out: 0, byEffort: {},
7
+ judge: { haiku: 0, jev: 0, model: 0, ms: 0, tokens: 0 }, workers: {}, warm: { pings: 0, cost: 0 } }
8
+ }
9
+
10
+ /** Input-equivalent tokens compare real usage, not hypothetical dollars saved. */
11
+ export function weighted(usage: Usage): number {
12
+ return (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) * 1.25 +
13
+ (usage.cache_read_input_tokens ?? 0) * 0.1 + (usage.output_tokens ?? 0) * 5
14
+ }
15
+
16
+ export function addRequest(stats: Stats, usage: Usage, effort: Effort): Stats {
17
+ const bucket = stats.byEffort[effort] ?? { prompts: 0, cost: 0 }
18
+ return { ...stats, requests: stats.requests + 1, input: stats.input + (usage.input_tokens ?? 0),
19
+ write: stats.write + (usage.cache_creation_input_tokens ?? 0), read: stats.read + (usage.cache_read_input_tokens ?? 0),
20
+ out: stats.out + (usage.output_tokens ?? 0),
21
+ byEffort: { ...stats.byEffort, [effort]: { ...bucket, cost: bucket.cost + weighted(usage) } } }
22
+ }
23
+
24
+ export function addPrompt(stats: Stats, effort: Effort): Stats {
25
+ const bucket = stats.byEffort[effort] ?? { prompts: 0, cost: 0 }
26
+ return { ...stats, prompts: stats.prompts + 1,
27
+ byEffort: { ...stats.byEffort, [effort]: { ...bucket, prompts: bucket.prompts + 1 } } }
28
+ }
29
+
30
+ export function addJudge(stats: Stats, judge: JudgeKind, ms: number, tokens: number): Stats {
31
+ return { ...stats, judge: { ...stats.judge, [judge]: stats.judge[judge] + 1,
32
+ ms: stats.judge.ms + ms, tokens: stats.judge.tokens + tokens } }
33
+ }
34
+
35
+ export function addWorker(stats: Stats, tier: Tier): Stats {
36
+ return { ...stats, workers: { ...stats.workers, [tier]: (stats.workers[tier] ?? 0) + 1 } }
37
+ }
38
+
39
+ export function addWarm(stats: Stats, usage: Usage): Stats {
40
+ return { ...stats, warm: { pings: stats.warm.pings + 1, cost: stats.warm.cost + weighted(usage) } }
41
+ }
42
+
43
+ export function formatStats(stats: Stats, lang: Lang): string {
44
+ const total = weighted({ input_tokens: stats.input, cache_creation_input_tokens: stats.write,
45
+ cache_read_input_tokens: stats.read, output_tokens: stats.out })
46
+ const judges = stats.judge.haiku + stats.judge.jev + stats.judge.model
47
+ const efforts = (['low', 'medium', 'high', 'xhigh', 'max'] as Effort[]).filter(level => stats.byEffort[level]).map(level => {
48
+ const bucket = stats.byEffort[level]!
49
+ return t(lang, 'commands.statsEffort', { effort: t(lang, 'band.' + level).toLowerCase(), prompts: bucket.prompts, cost: Math.round(bucket.cost) })
50
+ })
51
+ return [
52
+ t(lang, 'commands.statsAuto', { prompts: stats.prompts, requests: stats.requests, total: Math.round(total) }),
53
+ t(lang, 'commands.statsTokens', { input: stats.input, write: stats.write, read: stats.read, output: stats.out }),
54
+ ...efforts,
55
+ t(lang, 'commands.statsJudges', { ...stats.judge, avg: judges ? Math.round(stats.judge.ms / judges) : 0 }),
56
+ t(lang, 'commands.statsWorkers', { workers: (['light', 'standard', 'deep', 'design'] as Tier[]).map(tier => `${t(lang, 'settings.' + tier).toLowerCase()} ${stats.workers[tier] ?? 0}`).join(', ') }),
57
+ t(lang, 'commands.statsWarm', { pings: stats.warm.pings, cost: Math.round(stats.warm.cost) }),
58
+ ].join('\n')
59
+ }
@@ -0,0 +1,138 @@
1
+ // The Agents pane: what the host's own task cards do not say about each subagent of this session:
2
+ // the model it ran on, at what effort, who picked it and why. Live ones first, each a small card;
3
+ // finished ones fold into one line until opened.
4
+ //
5
+ // The desktop draws the host's task list right above this pane with the elapsed time, tokens,
6
+ // tool count and current activity of every agent. This pane leaves those to it there: its own
7
+ // count started at the spawn and summed every step, so the same agent showed two different times
8
+ // and totals a few lines apart. The terminal has no such list, so it keeps them.
9
+ //
10
+ // No pictures while agents run: every step of an agent redraws this pane, and each redraw starts
11
+ // 리플이's motion over, so among running agents it would never get past its first second. Only the
12
+ // empty pane, which nothing redraws, shows 리플이, waiting at its greeting size.
13
+
14
+ import { t } from '../i18n'
15
+ import type { RenderNode } from 'claude-code'
16
+
17
+ import type { AgentRec } from '../../types'
18
+ import { GREET_SIZE, speak, textButton } from './parts'
19
+ import type { Els } from './parts'
20
+ import { textTone } from './theme'
21
+ import type { AgentsActions, AgentsView, Frame } from './view'
22
+ import { compactNumber, effortLabel, elapsed, isRich, modelName, targetLabel } from './view'
23
+
24
+ const BY = { haiku: 'settings.haiku', jev: 'settings.jev', model: 'agents.routerModel', marker: 'agents.marker', default: 'agents.default', tier: 'toasts.yourPick' } as const
25
+
26
+ function who(agent: AgentRec, lang: Frame['lang']): string {
27
+ const pick = agent.pick
28
+ // The agent a person picked by name; Claude's own agents by their model.
29
+ return pick ? targetLabel(pick.target, lang) : agent.model ? modelName(agent.model.split('@')[0]!) : agent.type
30
+ }
31
+
32
+ /** The model it ran on, unless that is what the description's own prefix ("Sol·high · …") already says. */
33
+ function modelLine(frame: Frame, agent: AgentRec): string[] {
34
+ const out = [who(agent, frame.lang)]
35
+ if (agent.pick) out.push(effortLabel(agent.pick.effort, frame.lang))
36
+ // The type only when it says something the model does not (Explore, Plan, a named agent).
37
+ if (agent.type && !out[0]!.toLowerCase().replace(/[^a-z0-9]/g, '').includes(agent.type.toLowerCase().replace(/[^a-z0-9]/g, ''))) out.push(agent.type)
38
+ return out
39
+ }
40
+
41
+ /** Why this model: who chose it, their reason, and the measured score behind an Auto pick. */
42
+ function whyLine(frame: Frame, agent: AgentRec): string {
43
+ const pick = agent.pick
44
+ if (!pick) return ''
45
+ 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) : '')]
46
+ if (pick.why) parts.push('“' + pick.why + '”')
47
+ return parts.join(' · ')
48
+ }
49
+
50
+ function liveCard(el: Els, frame: Frame, agent: AgentRec, now: number): RenderNode {
51
+ const { Box, Text } = el
52
+ const tone = textTone(frame.appearance)
53
+ const rich = isRich(frame)
54
+ const pick = agent.pick
55
+ const waiting = agent.state === 'waiting'
56
+ const why = whyLine(frame, agent)
57
+ const doing = waiting ? t(frame.lang, 'agents.waiting') : agent.now ? agent.now + (agent.toolSince ? ' · ' + elapsed(now - agent.toolSince, frame.lang) : '') : t(frame.lang, 'agents.thinking')
58
+ const body: RenderNode[] = [
59
+ <Box key="title" flexDirection="row" alignItems="center" columnGap={1}>
60
+ <Text color={waiting ? undefined : tone.accent} dimColor={waiting}>{waiting ? '○' : '●'}</Text>
61
+ <Text bold wrap="wrap">{agent.description}</Text>
62
+ </Box>,
63
+ <Box key="model" flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={2}>
64
+ <Text>{modelLine(frame, agent).join(' · ')}</Text>
65
+ </Box>,
66
+ why ? (
67
+ <Box key="why" paddingLeft={2}>
68
+ <Text dimColor wrap="wrap">{why}</Text>
69
+ </Box>
70
+ ) : '',
71
+ pick?.fallbackReason ? (
72
+ <Box key="fallback" paddingLeft={2}>
73
+ <Text color={tone.warn} wrap="wrap">{t(frame.lang, 'agents.fallback', { reason: pick.fallbackReason })}</Text>
74
+ </Box>
75
+ ) : '',
76
+ rich ? '' : (
77
+ // The terminal has no task list of its own: what it is doing, for how long, at what cost.
78
+ <Box key="doing" flexDirection="row" flexWrap="wrap" columnGap={2} paddingLeft={2}>
79
+ <Text color={waiting ? undefined : tone.accent} dimColor={waiting} wrap="wrap">{'▸ ' + doing}</Text>
80
+ <Text dimColor>{elapsed(now - agent.startedAt, frame.lang)}</Text>
81
+ {agent.cost > 0 ? <Text dimColor>{t(frame.lang, 'agents.tokens', { tokens: compactNumber(agent.cost) })}</Text> : null}
82
+ </Box>
83
+ ),
84
+ ]
85
+ // No box around it: the dot, the bold name and the rows under it are the card, a row of air
86
+ // the gap to the next.
87
+ return (
88
+ <Box key={'agent-' + agent.id} flexDirection="column">
89
+ {body}
90
+ </Box>
91
+ )
92
+ }
93
+
94
+ function doneLine(el: Els, frame: Frame, agent: AgentRec): RenderNode {
95
+ const { Box, Text } = el
96
+ const tone = textTone(frame.appearance)
97
+ const ok = agent.state === 'done'
98
+ return (
99
+ <Box key={'done-' + agent.id} flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={1}>
100
+ <Text color={ok ? tone.ok : tone.bad}>{ok ? '✓' : '✕'}</Text>
101
+ <Text wrap="wrap">{agent.description}</Text>
102
+ <Text dimColor>{modelLine(frame, agent).join(' · ')}</Text>
103
+ {isRich(frame) ? null : <Text dimColor>{elapsed((agent.endedAt ?? agent.startedAt) - agent.startedAt, frame.lang)}</Text>}
104
+ {!isRich(frame) && agent.cost > 0 ? <Text dimColor>{'≈' + compactNumber(agent.cost)}</Text> : null}
105
+ </Box>
106
+ )
107
+ }
108
+
109
+ /** The Agents pane body. */
110
+ export function renderAgents(el: Els, frame: Frame, view: AgentsView, actions: AgentsActions): RenderNode {
111
+ const { Box, Text } = el
112
+ const live = view.agents.filter(a => a.state === 'running' || a.state === 'waiting').sort((a, b) => a.startedAt - b.startedAt)
113
+ const done = view.agents.filter(a => a.state === 'done' || a.state === 'failed').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
114
+ // Empty, the pane has nothing to redraw it, so 리플이 can live out its idle loops here.
115
+ if (view.agents.length === 0) return speak(el, frame, { key: 'agents', face: 'calm', size: GREET_SIZE, title: t(frame.lang, 'agents.emptyTitle'), line: t(frame.lang, 'agents.emptyBody') })
116
+ const { Button } = el
117
+ return (
118
+ <Box flexDirection="column" rowGap={1} width="100%">
119
+ <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
120
+ <Text bold>{live.length ? t(frame.lang, 'agents.liveCount', { count: live.length }) : t(frame.lang, 'agents.noneRunning')}</Text>
121
+ {done.length ? <Text dimColor>{t(frame.lang, 'agents.doneCount', { count: done.length })}</Text> : null}
122
+ </Box>
123
+ {live.map(agent => liveCard(el, frame, agent, view.now))}
124
+ {done.length ? (
125
+ <Box flexDirection="column">
126
+ <Box flexDirection="row">
127
+ {isRich(frame) ? (
128
+ <Button key="done" plain dimColor label={(view.showDone ? '▾ ' : '▸ ') + t(frame.lang, 'agents.doneCount', { count: done.length })} onPress={() => actions.toggleDone()} />
129
+ ) : (
130
+ textButton(el, 'done', (view.showDone ? '▾ ' : '▸ ') + t(frame.lang, 'agents.doneCount', { count: done.length }), actions.toggleDone, 'f')
131
+ )}
132
+ </Box>
133
+ {view.showDone ? done.map(agent => doneLine(el, frame, agent)) : null}
134
+ </Box>
135
+ ) : null}
136
+ </Box>
137
+ )
138
+ }