clauderipple 0.9.2 → 0.9.3

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.3 — 2026-10-14
4
+
5
+ ### Fixed
6
+
7
+ - **Claude workers offer X-High.** Claude's effort ladder stopped at high and max, so X-High could
8
+ not be picked for Claude in the mod or the dashboard though GPT had it. Opus, Sonnet and Haiku 5.5
9
+ all take it (#56).
10
+ - **A model ticked after the mod's worker list was saved is usable.** It used to stay off and show
11
+ as unavailable in the mod (GPT-6 Astra in #56). A worker the list has not seen now starts on.
12
+ Settings saved before this keep the old reading until the list is changed once.
13
+ - **The plan limit shows in the band on Claude chats.** The desktop Code tab left the mod's rate
14
+ limits empty, so the limit never appeared even with it switched on. The mod now falls back to the
15
+ router's figure for the same Claude login, and hides it when that figure is over 15 minutes old.
16
+ - `/ripple-bar` no longer says the band is shown above the prompt when the layout is Minimal, where
17
+ only the short line under the prompt is drawn (#56).
18
+ - The close button lights as one disc under the pointer instead of drawing a second box around it.
19
+
3
20
  ## 0.9.2 — 2026-10-14
4
21
 
5
22
  ### Added
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "type": "commonjs"
5
5
  }
@@ -361,6 +361,7 @@ const EN = {
361
361
  'commands.logOpened': 'ClaudeRipple log opened.',
362
362
  'commands.logClosed': 'ClaudeRipple log closed.',
363
363
  'commands.barShown': 'ClaudeRipple line shown above the prompt.',
364
+ 'commands.barShownMinimal': 'The layout is Minimal, so ClaudeRipple shows as a short line under the prompt. For the full band above the prompt, set Layout to Dashboard in /ripple settings.',
364
365
  'commands.barHidden': 'ClaudeRipple line hidden. /ripple-bar shows it again.',
365
366
  'commands.statsAuto': 'Auto: {prompts} prompts, {requests} requests; {total} weighted tokens',
366
367
  'commands.statsTokens': 'Tokens: input {input}, write {write}, read {read}, output {output}',
@@ -810,6 +811,7 @@ const KO: Record<Key, string> = {
810
811
  'commands.logOpened': 'ClaudeRipple 로그를 열었습니다.',
811
812
  'commands.logClosed': 'ClaudeRipple 로그를 닫았습니다.',
812
813
  'commands.barShown': '프롬프트 상단에 ClaudeRipple 상태 바를 표시합니다.',
814
+ 'commands.barShownMinimal': '레이아웃이 최소화로 되어 있어 ClaudeRipple은 프롬프트 아래 한 줄로만 표시됩니다. 프롬프트 위에 상태 바를 띄우려면 /ripple settings에서 레이아웃을 대시보드로 바꾸십시오.',
813
815
  'commands.barHidden': 'ClaudeRipple 상태 바를 숨겼습니다. /ripple-bar로 다시 표시할 수 있습니다.',
814
816
  'commands.statsAuto': '자동: 프롬프트 {prompts}개, 요청 {requests}개, 가중 토큰 {total}',
815
817
  'commands.statsTokens': '토큰: 입력 {input}, 쓰기 {write}, 읽기 {read}, 출력 {output}',
@@ -1257,6 +1259,7 @@ const ZH: Record<Key, string> = {
1257
1259
  'commands.logOpened': '已打开 ClaudeRipple 日志。',
1258
1260
  'commands.logClosed': '已关闭 ClaudeRipple 日志。',
1259
1261
  'commands.barShown': 'ClaudeRipple 状态栏已显示在提示词上方。',
1262
+ 'commands.barShownMinimal': '布局是“极简”,所以 ClaudeRipple 只在提示词下方显示一行。要在提示词上方显示完整状态栏,请在 /ripple settings 里把布局改为“仪表盘”。',
1260
1263
  'commands.barHidden': 'ClaudeRipple 状态栏已隐藏,/ripple-bar 可重新显示。',
1261
1264
  'commands.statsAuto': '自动:{prompts} 条提示词,{requests} 次请求;加权 token {total}',
1262
1265
  'commands.statsTokens': 'Token:输入 {input},写入 {write},读取 {read},输出 {output}',
@@ -1704,6 +1707,7 @@ const JA: Record<Key, string> = {
1704
1707
  'commands.logOpened': 'ClaudeRipple のログを開きました。',
1705
1708
  'commands.logClosed': 'ClaudeRipple のログを閉じました。',
1706
1709
  'commands.barShown': 'ClaudeRipple のバーをプロンプトの上に表示しました。',
1710
+ 'commands.barShownMinimal': 'レイアウトがミニマルなので、ClaudeRipple はプロンプトの下に 1 行で表示されます。プロンプトの上にバーを出すには、/ripple settings でレイアウトをダッシュボードにしてください。',
1707
1711
  'commands.barHidden': 'ClaudeRipple のバーを非表示にしました。/ripple-bar で再表示できます。',
1708
1712
  'commands.statsAuto': '自動: プロンプト {prompts} 件、リクエスト {requests} 件、加重トークン {total}',
1709
1713
  'commands.statsTokens': 'トークン: 入力 {input}、書き込み {write}、読み取り {read}、出力 {output}',
@@ -2151,6 +2155,7 @@ const ES: Record<Key, string> = {
2151
2155
  'commands.logOpened': 'Registro de ClaudeRipple abierto.',
2152
2156
  'commands.logClosed': 'Registro de ClaudeRipple cerrado.',
2153
2157
  'commands.barShown': 'Barra de ClaudeRipple visible sobre el prompt.',
2158
+ 'commands.barShownMinimal': 'La disposición es Mínimo, así que ClaudeRipple aparece como una línea corta bajo el prompt. Para la barra completa sobre el prompt, elige Panel en Disposición en /ripple settings.',
2154
2159
  'commands.barHidden': 'Barra de ClaudeRipple oculta. /ripple-bar la vuelve a mostrar.',
2155
2160
  'commands.statsAuto': 'Auto: {prompts} prompts, {requests} solicitudes; {total} tokens ponderados',
2156
2161
  'commands.statsTokens': 'Tokens: entrada {input}, escritura {write}, lectura {read}, salida {output}',
@@ -2598,6 +2603,7 @@ const PT: Record<Key, string> = {
2598
2603
  'commands.logOpened': 'Log do ClaudeRipple aberto.',
2599
2604
  'commands.logClosed': 'Log do ClaudeRipple fechado.',
2600
2605
  'commands.barShown': 'Barra do ClaudeRipple visível acima do prompt.',
2606
+ 'commands.barShownMinimal': 'O layout está em Mínimo, então o ClaudeRipple aparece como uma linha curta abaixo do prompt. Para a barra completa acima do prompt, escolha Painel em Layout no /ripple settings.',
2601
2607
  'commands.barHidden': 'Barra do ClaudeRipple oculta. /ripple-bar mostra de novo.',
2602
2608
  'commands.statsAuto': 'Auto: {prompts} prompts, {requests} requisições; {total} tokens ponderados',
2603
2609
  'commands.statsTokens': 'Tokens: entrada {input}, gravação {write}, leitura {read}, saída {output}',
@@ -150,9 +150,9 @@ export function preset(id, choices) {
150
150
  }
151
151
  return { rungs, substituted };
152
152
  }
153
- /** The choices a person ticked; an empty list ticks every one. */
154
- export function tickedChoices(choices, allow) {
155
- return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow));
153
+ /** The choices a person ticked; an empty list ticks every one, and a key absent from `known` ticks on too. */
154
+ export function tickedChoices(choices, allow, known = []) {
155
+ return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow, known));
156
156
  }
157
157
  /** Only empty lists need filling; unticked entries stay in order and are skipped at dispatch. */
158
158
  export function ladderNeedsFill(rungs, _allow = []) {
@@ -162,8 +162,8 @@ export function ladderNeedsFill(rungs, _allow = []) {
162
162
  * Fill empty lists from the ticked, connected choices. Existing ordered lists are never rewritten
163
163
  * for temporary availability or shared allow-list changes; dispatch explains each skipped entry.
164
164
  */
165
- export function fillLadder(rungs, allow, choices) {
166
- const pick = preset('claudeGpt', tickedChoices(choices, allow));
165
+ export function fillLadder(rungs, allow, choices, known = []) {
166
+ const pick = preset('claudeGpt', tickedChoices(choices, allow, known));
167
167
  const out = {};
168
168
  const filled = [];
169
169
  for (const rung of RUNGS) {
@@ -146,9 +146,9 @@ export function preset(id: PresetId, choices: readonly LadderChoice[]): Preset {
146
146
  return { rungs, substituted }
147
147
  }
148
148
 
149
- /** The choices a person ticked; an empty list ticks every one. */
150
- export function tickedChoices(choices: readonly LadderChoice[], allow: readonly string[]): LadderChoice[] {
151
- return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow))
149
+ /** The choices a person ticked; an empty list ticks every one, and a key absent from `known` ticks on too. */
150
+ export function tickedChoices(choices: readonly LadderChoice[], allow: readonly string[], known: readonly string[] = []): LadderChoice[] {
151
+ return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow, known))
152
152
  }
153
153
 
154
154
  /** Only empty lists need filling; unticked entries stay in order and are skipped at dispatch. */
@@ -160,8 +160,8 @@ export function ladderNeedsFill(rungs: Rungs, _allow: readonly string[] = []): b
160
160
  * Fill empty lists from the ticked, connected choices. Existing ordered lists are never rewritten
161
161
  * for temporary availability or shared allow-list changes; dispatch explains each skipped entry.
162
162
  */
163
- export function fillLadder(rungs: Rungs, allow: readonly string[], choices: readonly LadderChoice[]): { rungs: Record<Rung, Step[]>; filled: Rung[] } {
164
- const pick = preset('claudeGpt', tickedChoices(choices, allow))
163
+ export function fillLadder(rungs: Rungs, allow: readonly string[], choices: readonly LadderChoice[], known: readonly string[] = []): { rungs: Record<Rung, Step[]>; filled: Rung[] } {
164
+ const pick = preset('claudeGpt', tickedChoices(choices, allow, known))
165
165
  const out = {} as Record<Rung, Step[]>
166
166
  const filled: Rung[] = []
167
167
  for (const rung of RUNGS) {
@@ -1,7 +1,7 @@
1
1
  // Engine wiring lives here: imported logic and drawings never receive the engine interface.
2
2
  // Timers own background work; hooks only admit/rewrite a request and record its result.
3
3
  import { atom, read, update } from 'claude-code'
4
- import type { AgentSpawnInput, EngineInterface, ModelCompleteResult, ModelForkResult, Register, RenderElement, RenderInput, ToolCallInput, TurnStepInput, TurnUsage } from 'claude-code'
4
+ import type { AgentSpawnInput, EngineInterface, ModelCompleteResult, ModelForkResult, Register, RenderElement, RenderInput, SessionRateLimit, ToolCallInput, TurnStepInput, TurnUsage } from 'claude-code'
5
5
  import type { AgentRec, Effort, JudgeInput, JudgeKind, JudgeRun, ModSettingsSnapshot, Pick, Settings, StatusRequest, TierTarget, Verdict, WorkerCandidate, WorkerPick, WorkerVerdict } from '../types'
6
6
  import { MOD_SETTINGS_SCHEMA_VERSION, applySettingsChanges, normalizeSettings, settingsChanges } from './settings'
7
7
  import { debugText, langFrom, t } from './i18n'
@@ -13,7 +13,7 @@ import { activeModeOf, claudeWindow, eagerNote, modeKey, modeOver, quotaWindows,
13
13
  import type { QuotaWindow } from './modes'
14
14
  import type { Spawn, WorkerFix } from './worker'
15
15
  import { afterPing, nextMemo, restoreFromResume, shouldPing } from './cache'
16
- import { bandPriority, coldAlert, hotAlert, hottest, keptAlert, saveUntil as limitSaveUntil, swampAlert, swamped } from './session'
16
+ import { bandPriority, claudeLimitsFrom, coldAlert, hotAlert, hottest, keptAlert, saveUntil as limitSaveUntil, swampAlert, swamped } from './session'
17
17
  import { COMPACT_SYSTEM, compactResult, serializeTranscript } from './compact'
18
18
  import { ADVICE_SYSTEM, FULL_PROMPT, QUICK_PROMPT, adviceDue, adviceInput, parseAdvice, wrapForNewChat } from './handoff'
19
19
  import { addJudge, addPrompt, addRequest, addWarm, addWorker, emptyStats, formatStats, weighted } from './stats'
@@ -442,7 +442,7 @@ async function refreshLadder($: EngineInterface): Promise<void> {
442
442
  try {
443
443
  const response = await $.http.fetch(`${await adminUrl($)}/api/mod-options`)
444
444
  if (!response.ok) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
445
- const fresh = ladderOptionsFrom(JSON.parse(response.text), await $.clock.now() + KEEP_MS, (await currentSettings($)).allow)
445
+ const fresh = ladderOptionsFrom(JSON.parse(response.text), await $.clock.now() + KEEP_MS, (await currentSettings($)).allow, (await currentSettings($)).allowKnown)
446
446
  if (!same(await read($, ladderOptions), fresh)) await update($, ladderOptions, () => fresh)
447
447
  } catch (error) { await report($, 'ladder options', error) }
448
448
  }
@@ -692,7 +692,7 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
692
692
  const routing = routingSettings(s)
693
693
  const resolved = explicitCandidate && candidateUsable(explicitCandidate, s, usable, providers)
694
694
  ? { target: target!, effort: verdict.effort }
695
- : ladderPick(verdict, routing.rules.general, routing, pinnedCandidates, usable, language, routing.allow, providers)
695
+ : ladderPick(verdict, routing.rules.general, routing, pinnedCandidates, usable, language, routing.allow, providers, routing.allowKnown)
696
696
  const selected = resolved.target
697
697
  if (!selected) return { deny: t(language, 'workers.none') }
698
698
  const fallbackReason = resolved.fallbackReason
@@ -931,6 +931,13 @@ async function openCompact($: EngineInterface, surface: Frame['surface']): Promi
931
931
  await update($, compactNote, () => '')
932
932
  await update($, panel, () => 'compact')
933
933
  }
934
+ /** The router's figure for this login's plan windows; nothing when the router cannot say. */
935
+ async function routerClaudeLimits($: EngineInterface): Promise<SessionRateLimit[]> {
936
+ try {
937
+ const status = await $.http.fetch(`${await adminUrl($)}/api/status`)
938
+ return status.ok ? claudeLimitsFrom(JSON.parse(status.text), await $.clock.now()) : []
939
+ } catch (error) { await report($, 'claude limits', error); return [] }
940
+ }
934
941
  async function refreshUsage($: EngineInterface): Promise<void> {
935
942
  const usage = await $.session.usage()
936
943
  const c = usage.context
@@ -940,11 +947,15 @@ async function refreshUsage($: EngineInterface): Promise<void> {
940
947
  // Below the line again (a compact, a clear), the next crossing shows the heavy-context band once.
941
948
  const putOff = await read($, swampHidden)
942
949
  if (putOff !== null && (putOff > 100 || !ctx || ctx.percent < (await currentSettings($)).swampAt)) await update($, swampHidden, () => null)
943
- const h = hottest(usage.rateLimits)
950
+ // The host can leave the plan windows out though the replies carried them; on a Claude chat the
951
+ // router's figure for the same login stands in.
952
+ const own = usage.rateLimits.some(l => ['five_hour', 'seven_day'].includes(l.kind))
953
+ const rateLimits = own || !/^claude-/.test(await $.session.model()) ? usage.rateLimits : [...usage.rateLimits, ...await routerClaudeLimits($)]
954
+ const h = hottest(rateLimits)
944
955
  const previous = await read($, hot)
945
956
  if (previous && (!h || previous.kind !== h.kind || previous.resetsAt !== h.resetsAt)) await update($, hotHidden, () => null)
946
957
  if (JSON.stringify(h) !== JSON.stringify(previous)) await update($, hot, () => h)
947
- const top = usage.rateLimits.filter(l => ['five_hour', 'seven_day'].includes(l.kind)).sort((a, b) => b.percentUsed - a.percentUsed)[0]
958
+ const top = rateLimits.filter(l => ['five_hour', 'seven_day'].includes(l.kind)).sort((a, b) => b.percentUsed - a.percentUsed)[0]
948
959
  const l = top ? { kind: top.kind, percent: top.percentUsed, ...(top.resetsAt ? { resetsAt: top.resetsAt } : {}) } : null
949
960
  // Check the previous binding deadline before a rolled-forward snapshot replaces it.
950
961
  await endModeIfOver($)
@@ -1469,7 +1480,9 @@ export const register: Register = on => {
1469
1480
  on('command.run', { command: 'ripple-bar' }, async ($, e, next) => {
1470
1481
  try {
1471
1482
  const language = await read($, lang)
1472
- return { text: await toggleBar($) ? t(language, 'commands.barShown') : t(language, 'commands.barHidden') } }
1483
+ if (!await toggleBar($)) return { text: t(language, 'commands.barHidden') }
1484
+ // The minimal layout draws no band above the prompt, only the short line under it (#56).
1485
+ return { text: t(language, (await currentSettings($)).layout === 'minimal' ? 'commands.barShownMinimal' : 'commands.barShown') } }
1473
1486
  catch (error) { await report($, 'ripple-bar', error); return next(e) }
1474
1487
  })
1475
1488
  on('ui.close', { id: LOG_PANE }, async ($, e, next) => {
@@ -7,6 +7,32 @@ export function hottest(rateLimits: readonly SessionRateLimit[]): Hot | null {
7
7
  return limit ? { kind: limit.kind, percent: limit.percentUsed, ...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {}) } : null
8
8
  }
9
9
 
10
+ const WINDOW_KIND: Record<number, string> = { 300: 'five_hour', 10080: 'seven_day' }
11
+ /** A router snapshot older than this is not shown as the plan's figure. */
12
+ export const CLAUDE_USAGE_STALE_MS = 15 * 60_000
13
+
14
+ /**
15
+ * The plan windows of the Claude login this session's requests pass through, from the router's
16
+ * GET /api/status (`claude.accounts[id=current].quota`, packages/router/src/admin.ts
17
+ * claudeAccountUsage): for when the host leaves `rateLimits` empty though the requests carry them
18
+ * (measured 2026-10-14: the router passes the `anthropic-ratelimit-unified-*` headers through).
19
+ * Percent as the router reports it; a stale or unknown snapshot gives nothing.
20
+ */
21
+ export function claudeLimitsFrom(status: unknown, now: number): SessionRateLimit[] {
22
+ const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
23
+ const accounts = object(object(status).claude).accounts
24
+ const current = Array.isArray(accounts) ? accounts.map(object).find(a => a.id === 'current') : undefined
25
+ const quota = object(current?.quota)
26
+ if (typeof quota.at !== 'number' || now - quota.at > CLAUDE_USAGE_STALE_MS) return []
27
+ const limits = object(quota.rate_limits)
28
+ return [limits.primary, limits.secondary].map(object).flatMap(w => {
29
+ const kind = typeof w.window_minutes === 'number' ? WINDOW_KIND[w.window_minutes] : undefined
30
+ if (!kind || typeof w.used_percent !== 'number' || !Number.isFinite(w.used_percent)) return []
31
+ const reset = typeof w.reset_at === 'number' ? w.reset_at * (w.reset_at < 1e12 ? 1000 : 1) : NaN
32
+ return [{ kind, percentUsed: Math.round(w.used_percent * 10) / 10, ...(Number.isFinite(reset) ? { resetsAt: new Date(reset).toISOString() } : {}) }]
33
+ })
34
+ }
35
+
10
36
  /**
11
37
  * The reset is read in the person's own clock: `offsetMinutes` is `Date#getTimezoneOffset` (minutes
12
38
  * behind UTC), passed in so tests do not depend on the machine's zone.
@@ -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: [], modes: [],
24
+ allow: [], allowKnown: [], modes: [],
25
25
  providers: {},
26
26
  fallback: { agent: 'general-purpose', model: 'sonnet' },
27
27
  keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
@@ -158,16 +158,16 @@ export function migrateAllow(allow, workers) {
158
158
  const agents = new Set(workers.map(w => w.name));
159
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
160
  }
161
- /** The workers switched on by an allow-list; empty means every worker. */
162
- export function allowedWorkers(allow, workers) {
161
+ /** The workers switched on by an allow-list; empty means every worker. A worker that did not exist when the list was last written (absent from `known`) counts as on. */
162
+ export function allowedWorkers(allow, workers, known = []) {
163
163
  if (!allow.length)
164
164
  return workers.map(w => w.name);
165
165
  const on = new Set(migrateAllow(allow, workers));
166
- return workers.filter(w => on.has(w.name)).map(w => w.name);
166
+ return workers.filter(w => on.has(w.name) || known.length > 0 && !known.includes(w.name)).map(w => w.name);
167
167
  }
168
- /** Switches one worker; every worker on again is stored as [], and the last one cannot go off. */
169
- export function toggleAllow(allow, name, workers) {
170
- const on = allowedWorkers(allow, workers);
168
+ /** Switches one worker; every worker on again is stored as [], and the last one cannot go off. `known` is the allow-list keys the writer knew. */
169
+ export function toggleAllow(allow, name, workers, known = []) {
170
+ const on = allowedWorkers(allow, workers, known);
171
171
  const next = on.includes(name) ? on.filter(n => n !== name) : workers.filter(w => w.name === name || on.includes(w.name)).map(w => w.name);
172
172
  if (!next.length)
173
173
  return [...allow];
@@ -175,10 +175,11 @@ export function toggleAllow(allow, name, workers) {
175
175
  }
176
176
  /**
177
177
  * Whether a stored model is ticked: its key (an agent name, or `general-purpose:<model>`) or, for
178
- * settings from before 2026-10-10, its provider is listed; an empty list ticks every model.
178
+ * settings from before 2026-10-10, its provider is listed; an empty list ticks every model. A key
179
+ * absent from a non-empty `known` list (a worker added after the list was last written) ticks on.
179
180
  */
180
- export function isAllowed(key, provider, allow) {
181
- return !allow.length || allow.includes(key) || allow.includes(provider);
181
+ export function isAllowed(key, provider, allow, known = []) {
182
+ return !allow.length || allow.includes(key) || allow.includes(provider) || known.length > 0 && !known.includes(key);
182
183
  }
183
184
  /** The design ladder every save stored until 2026-10-12: Claude, Sonnet then Opus. */
184
185
  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]));
@@ -266,7 +267,8 @@ export function normalizeSettings(raw, workers) {
266
267
  rules.general = ladderFromPin(targets, {});
267
268
  }
268
269
  const ticked = names(Array.isArray(value.allow) ? value.allow : object(stored.general).mode === 'auto' ? object(stored.general).allow : []);
269
- const result = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } };
270
+ const savedKnown = names(value.allowKnown);
271
+ const result = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, allowKnown: savedKnown, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } };
270
272
  for (const [name, enabled] of Object.entries(object(value.providers)))
271
273
  if (typeof enabled === 'boolean')
272
274
  result.providers[name] = enabled;
@@ -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: [], modes: [],
29
+ allow: [], allowKnown: [], modes: [],
30
30
  providers: {},
31
31
  fallback: { agent: 'general-purpose', model: 'sonnet' },
32
32
  keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
@@ -159,16 +159,16 @@ export function migrateAllow(allow: readonly string[], workers: readonly Worker[
159
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
160
  }
161
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[] {
162
+ /** The workers switched on by an allow-list; empty means every worker. A worker that did not exist when the list was last written (absent from `known`) counts as on. */
163
+ export function allowedWorkers(allow: readonly string[], workers: readonly Worker[], known: readonly string[] = []): string[] {
164
164
  if (!allow.length) return workers.map(w => w.name)
165
165
  const on = new Set(migrateAllow(allow, workers))
166
- return workers.filter(w => on.has(w.name)).map(w => w.name)
166
+ return workers.filter(w => on.has(w.name) || known.length > 0 && !known.includes(w.name)).map(w => w.name)
167
167
  }
168
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)
169
+ /** Switches one worker; every worker on again is stored as [], and the last one cannot go off. `known` is the allow-list keys the writer knew. */
170
+ export function toggleAllow(allow: readonly string[], name: string, workers: readonly Worker[], known: readonly string[] = []): string[] {
171
+ const on = allowedWorkers(allow, workers, known)
172
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
173
  if (!next.length) return [...allow]
174
174
  return next.length === workers.length ? [] : next
@@ -176,10 +176,11 @@ export function toggleAllow(allow: readonly string[], name: string, workers: rea
176
176
 
177
177
  /**
178
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.
179
+ * settings from before 2026-10-10, its provider is listed; an empty list ticks every model. A key
180
+ * absent from a non-empty `known` list (a worker added after the list was last written) ticks on.
180
181
  */
181
- export function isAllowed(key: string, provider: string, allow: readonly string[]): boolean {
182
- return !allow.length || allow.includes(key) || allow.includes(provider)
182
+ export function isAllowed(key: string, provider: string, allow: readonly string[], known: readonly string[] = []): boolean {
183
+ return !allow.length || allow.includes(key) || allow.includes(provider) || known.length > 0 && !known.includes(key)
183
184
  }
184
185
 
185
186
  /** The design ladder every save stored until 2026-10-12: Claude, Sonnet then Opus. */
@@ -262,7 +263,8 @@ export function normalizeSettings(raw: unknown, workers?: readonly Worker[]): Se
262
263
  rules.general = ladderFromPin(targets, {})
263
264
  }
264
265
  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
+ const savedKnown = names(value.allowKnown)
267
+ const result: Settings = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, allowKnown: savedKnown, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } }
266
268
  for (const [name, enabled] of Object.entries(object(value.providers))) if (typeof enabled === 'boolean') result.providers[name] = enabled
267
269
  for (const key of ['effortAuto', 'keepWarm', 'setupDone', 'swampAlert'] as const) if (typeof value[key] === 'boolean') result[key] = value[key]
268
270
  if (Array.isArray(value.extras)) result.extras = [...new Set(value.extras.filter((part): part is string => typeof part === 'string' && EXTRAS.includes(part)))]
@@ -431,8 +431,8 @@ export function wellArt(width: number, height: number): string {
431
431
  export const CLOSE_PX = 22
432
432
 
433
433
  /** The way out of a capsule, 22 px: a soft disc with a ×. */
434
- export function closeArt(): string {
435
- return svg(CLOSE_PX, CLOSE_PX, '<circle cx="11" cy="11" r="10" fill="#fff" fill-opacity=".07" stroke="' + HAIR + '" stroke-opacity=".2"/><path d="M7.6 7.6l6.8 6.8M14.4 7.6l-6.8 6.8" stroke="' + LABEL + '" stroke-width="1.6" stroke-linecap="round"/>')
434
+ export function closeArt(lit = false): string {
435
+ return svg(CLOSE_PX, CLOSE_PX, '<circle cx="11" cy="11" r="10" fill="#fff" fill-opacity="' + (lit ? '.16' : '.07') + '" stroke="' + HAIR + '" stroke-opacity="' + (lit ? '.45' : '.2') + '"/><path d="M7.6 7.6l6.8 6.8M14.4 7.6l-6.8 6.8" stroke="' + LABEL + '" stroke-width="1.6" stroke-linecap="round"/>')
436
436
  }
437
437
 
438
438
  // ---- The band's instruments ---------------------------------------------------------------------
@@ -161,9 +161,22 @@ export function pill(el: Els, key: string, label: string, look: PillLook, onPres
161
161
  /** Cells the way out takes. */
162
162
  export const CLOSE_CELLS = cellsOf(CLOSE_PX + 4)
163
163
 
164
- /** The way out: a soft disc with a ×, `label` its name for a reader that cannot see it. */
164
+ /**
165
+ * The way out: a soft disc with a ×, `label` its name for a reader that cannot see it. The disc is
166
+ * its own light under the pointer: `lit`'s rounded box around it read as a second button
167
+ * (2026-10-14).
168
+ */
165
169
  export function closeButton(el: Els, key: string, label: string, onPress: () => void, scope?: string): RenderNode {
166
- return lit(el, key, CLOSE_CELLS, [pic(el, key, closeArt(), label, CLOSE_PX, CLOSE_PX), press(el, key, CLOSE_CELLS * CELL_PX, onPress)], 0.25, scope)
170
+ const { Box } = el
171
+ return (
172
+ <Box key={'hv-' + key} position="relative" flexDirection="row" alignItems="center" justifyContent="center" width={CLOSE_CELLS} flexShrink={0} {...(scope ? { hover: { scope } } : {})}>
173
+ {pic(el, key, closeArt(), label, CLOSE_PX, CLOSE_PX)}
174
+ <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center" display="none" hover={{ display: 'flex' }}>
175
+ {pic(el, key + '-lit', closeArt(true), label, CLOSE_PX, CLOSE_PX)}
176
+ </Box>
177
+ {press(el, key, CLOSE_CELLS * CELL_PX, onPress)}
178
+ </Box>
179
+ )
167
180
  }
168
181
 
169
182
  /** Cells a switch and its word take, their hover's air included. */
@@ -93,8 +93,8 @@ export function scoreModels(raw: unknown): ModelScore[] {
93
93
  * Allow-lists name worker agents, and Claude's own models as `general-purpose:<model>`; provider
94
94
  * names from settings saved before 2026-10-10 still match.
95
95
  */
96
- export function allowed(candidate: WorkerCandidate, allow: readonly string[]): boolean {
97
- return isAllowed(candidateKey(candidate.target), candidate.provider, allow)
96
+ export function allowed(candidate: WorkerCandidate, allow: readonly string[], known: readonly string[] = []): boolean {
97
+ return isAllowed(candidateKey(candidate.target), candidate.provider, allow, known)
98
98
  }
99
99
  /** Claude's own models as candidates: general-purpose on each. */
100
100
  export function claudeCandidates(): WorkerCandidate[] {
@@ -106,16 +106,16 @@ const BILLINGS: readonly Billing[] = ['free', 'payg', 'subscription']
106
106
  * provider can run now, with ladders, billing and cheapest measured cost, then Claude's own
107
107
  * models; only the ticked ones (`allow`, empty for every model).
108
108
  */
109
- export function ladderOptionsFrom(raw: unknown, until: number, allow: readonly string[] = []): LadderOptions {
109
+ export function ladderOptionsFrom(raw: unknown, until: number, allow: readonly string[] = [], allowKnown: readonly string[] = []): LadderOptions {
110
110
  const root = record(raw)
111
111
  const efforts = (value: unknown): Effort[] => list(value).filter((e): e is Effort => EFFORTS.includes(e as Effort))
112
112
  const usable = new Set(list(root.providers).flatMap(p => { const v = record(p); return typeof v.name === 'string' && v.usable !== false ? [v.name] : [] }))
113
- const known = list(root.providers).length > 0
113
+ const catalogue = list(root.providers).length > 0
114
114
  const scores = scoreModels(root.scores)
115
115
  const choices: LadderChoice[] = list(root.agents).flatMap(value => {
116
116
  const a = record(value)
117
117
  if (typeof a.name !== 'string' || !a.name || typeof a.model !== 'string' || typeof a.provider !== 'string') return []
118
- if (known && !usable.has(a.provider)) return []
118
+ if (catalogue && !usable.has(a.provider)) return []
119
119
  const costs = (scores.find(s => s.model === a.model && s.provider === a.provider)?.variants ?? []).flatMap(v => v.costPerTask === null ? [] : [v.costPerTask])
120
120
  const billing = BILLINGS.includes(a.billing as Billing) ? a.billing as Billing : 'payg'
121
121
  return [{ target: { agent: a.name }, model: a.model, provider: a.provider, billing, efforts: efforts(a.efforts), ...(costs.length ? { cost: Math.min(...costs) } : {}) }]
@@ -125,7 +125,7 @@ export function ladderOptionsFrom(raw: unknown, until: number, allow: readonly s
125
125
  if (typeof p.name !== 'string' || typeof p.usable !== 'boolean') return []
126
126
  return [[p.name, { usable: p.usable, ...(typeof p.reason === 'string' ? { reason: p.reason } : {}), ...(typeof p.exhaustedUntil === 'number' ? { exhaustedUntil: p.exhaustedUntil } : {}) }]]
127
127
  }))
128
- return { until, choices: tickedChoices([...choices, ...claudeChoices(efforts(root.claudeEfforts))], allow), providers }
128
+ return { until, choices: tickedChoices([...choices, ...claudeChoices(efforts(root.claudeEfforts))], allow, allowKnown), providers }
129
129
  }
130
130
  export function candidateUsable(candidate: WorkerCandidate, settings: Settings, chatgpt: boolean, providers: NonNullable<LadderOptions['providers']> = {}): boolean {
131
131
  const live = providers[candidate.provider]
@@ -152,7 +152,7 @@ export const SESSION_MODEL: TierTarget = { agent: 'general-purpose' }
152
152
  * The ladder: the verdict's rung, else one rung up, else the session model, never down. Whatever
153
153
  * ran instead says why (the fallback toast).
154
154
  */
155
- export function ladderPick(verdict: WorkerVerdict, rule: Rule, settings: Settings, candidates: readonly WorkerCandidate[], chatgpt: boolean, lang: Lang, allow: readonly string[] = [], providers: NonNullable<LadderOptions['providers']> = {}): Resolution {
155
+ export function ladderPick(verdict: WorkerVerdict, rule: Rule, settings: Settings, candidates: readonly WorkerCandidate[], chatgpt: boolean, lang: Lang, allow: readonly string[] = [], providers: NonNullable<LadderOptions['providers']> = {}, known: readonly string[] = []): Resolution {
156
156
  const wanted = rungOf(verdict)
157
157
  const skipped: NonNullable<Resolution['skipped']> = []
158
158
  const reasons: string[] = []
@@ -163,7 +163,7 @@ export function ladderPick(verdict: WorkerVerdict, rule: Rule, settings: Setting
163
163
  for (const [index, step] of steps.entries()) {
164
164
  const target = step.target
165
165
  const candidate = candidateOf(target, candidates)
166
- const ticked = target.agent === 'general-purpose' && !target.model || candidate && allowed(candidate, allow)
166
+ const ticked = target.agent === 'general-purpose' && !target.model || candidate && allowed(candidate, allow, known)
167
167
  if (candidate && candidateUsable(candidate, settings, chatgpt, providers) && ticked) {
168
168
  return { target: { ...target }, effort: step.effort, rung, position: index + 1, skipped, ...(reasons.length ? { fallbackReason: reasons.join('; ') } : {}) }
169
169
  }
@@ -1184,6 +1184,16 @@ for (const surface of ['terminal', 'desktop'] as const) test('quick controls sav
1184
1184
  await ui.unmount()
1185
1185
  })
1186
1186
 
1187
+ test('ripple-bar says where the line is when the layout is minimal', async ($, on) => {
1188
+ const b = boundaries(on, { layout: 'minimal' })
1189
+ http(on)
1190
+ await start($)
1191
+ const run = () => $.command.run({ command: 'ripple-bar', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: '' })
1192
+ expect((await run()).text).toContain('hidden')
1193
+ expect(b.saved.get('bandHidden')).toBe(true)
1194
+ expect((await run()).text).toContain('Minimal')
1195
+ })
1196
+
1187
1197
  for (const surface of ['terminal', 'desktop'] as const) test('quick settings select a saved mode, never edit rungs, on ' + surface, async ($, on) => {
1188
1198
  const b = boundaries(on, { modes: BURN })
1189
1199
  http(on)
@@ -149,6 +149,12 @@ export type Settings = {
149
149
  * picks, only among these.
150
150
  */
151
151
  allow: string[]
152
+ /**
153
+ * The allow-list keys of every worker that existed when `allow` was last written (what
154
+ * `candidateKey` returns). A key absent from this non-empty list counts as ticked on; empty means
155
+ * either every worker is ticked (`allow` empty) or all writers predate the field (#56).
156
+ */
157
+ allowKnown: string[]
152
158
  /** The user's operating modes; none is built in. */
153
159
  modes: Mode[]
154
160
  /** The mode that is on, by id; absent when none is. */
@@ -100,7 +100,8 @@ async function claudeAccountUsage(deps) {
100
100
  }
101
101
  return rows;
102
102
  }
103
- const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "max"];
103
+ // The CLI's `--effort` takes the same five; Opus, Sonnet and Haiku 5.5 answered `xhigh` (requests.jsonl, 2026-10-14, #56).
104
+ const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
104
105
  const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
105
106
  let chatgptLogin = { running: false };
106
107
  /** The Claude subscription sign-in in progress (or the last one), for GET /api/claude-oauth. */
@@ -411,8 +412,8 @@ async function withFilledLadder(deps, scores, settings) {
411
412
  return settings;
412
413
  try {
413
414
  const choices = ladderChoices(await modOptions(deps, scores));
414
- const { rungs } = fillLadder(general.rungs, settings.allow, choices);
415
- const modes = settings.modes.map(mode => ({ ...mode, rungs: fillLadder(mode.rungs, settings.allow, choices).rungs }));
415
+ const { rungs } = fillLadder(general.rungs, settings.allow, choices, settings.allowKnown);
416
+ const modes = settings.modes.map(mode => ({ ...mode, rungs: fillLadder(mode.rungs, settings.allow, choices, settings.allowKnown).rungs }));
416
417
  return { ...settings, rules: { general: { mode: "ladder", rungs } }, modes };
417
418
  }
418
419
  catch (error) {
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.9.2";
8
+ export const VERSION = "0.9.3";
package/dist/ui/app.js CHANGED
@@ -2486,7 +2486,7 @@ const MOD_DEFAULTS = {
2486
2486
  hard: [{ target: { agent: "gpt-6-astra" }, effort: "high" }], plan: [modClaude("opus", "high")],
2487
2487
  } },
2488
2488
  },
2489
- allow: [], modes: [], providers: {},
2489
+ allow: [], allowKnown: [], modes: [], providers: {},
2490
2490
  fallback: { agent: "general-purpose", model: "sonnet" },
2491
2491
  keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
2492
2492
  compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
@@ -2510,6 +2510,7 @@ function modNormalize(raw) {
2510
2510
  ...value,
2511
2511
  rules: { ...clone(MOD_DEFAULTS.rules), ...(value.rules || {}) },
2512
2512
  allow: Array.isArray(value.allow) ? value.allow : [],
2513
+ allowKnown: Array.isArray(value.allowKnown) ? value.allowKnown : [],
2513
2514
  modes: Array.isArray(value.modes) ? value.modes : [],
2514
2515
  providers: { ...(value.providers || {}) },
2515
2516
  hide: Array.isArray(value.hide) ? value.hide : clone(MOD_DEFAULTS.hide),
@@ -2695,7 +2696,7 @@ function modEffortCard() {
2695
2696
  */
2696
2697
  function modChoices() {
2697
2698
  const options = modState.options || {};
2698
- const claudeEfforts = Array.isArray(options.claudeEfforts) && options.claudeEfforts.length ? options.claudeEfforts : ["low", "medium", "high", "max"];
2699
+ const claudeEfforts = Array.isArray(options.claudeEfforts) && options.claudeEfforts.length ? options.claudeEfforts : ["low", "medium", "high", "xhigh", "max"];
2699
2700
  const workers = modAgents().map((agent) => {
2700
2701
  const info = modProviderInfo(agent.provider);
2701
2702
  return {
@@ -2749,17 +2750,20 @@ function modGroupLabel(group) { return group.billing ? `${group.name} · ${t(`mo
2749
2750
  function modTickKey(choice) { return choice.claude ? choice.value.replace("+", ":") : choice.value; }
2750
2751
  /** The models that can be ticked: every worker and Claude's own; the session's model is always there. */
2751
2752
  function modTickable() { return modChoices().filter((choice) => choice.value !== "general-purpose"); }
2752
- /** Whether a model is ticked: an empty list means every model, and an old provider name ticks that provider's. */
2753
+ /** Whether a model is ticked: an empty list means every model, an old provider name ticks that provider's, and a model added after the list was last written (absent from `allowKnown`) ticks on. */
2753
2754
  function modTicked(choice) {
2754
2755
  const allow = modState.settings.allow || [];
2755
- return !allow.length || allow.includes(modTickKey(choice)) || allow.includes(choice.provider);
2756
+ const known = modState.settings.allowKnown || [];
2757
+ return !allow.length || allow.includes(modTickKey(choice)) || allow.includes(choice.provider) || known.length > 0 && !known.includes(modTickKey(choice));
2756
2758
  }
2757
2759
  /** Ticks one model; every model on again is stored as [], and the last one on cannot go off. */
2758
2760
  function modTick(choice, on) {
2759
2761
  const all = modTickable();
2760
- const kept = new Set(all.filter(modTicked).map(modTickKey));
2762
+ const known = all.map(modTickKey);
2763
+ const kept = new Set(all.filter((model) => modTicked(model)).map(modTickKey));
2761
2764
  if (on) kept.add(modTickKey(choice)); else kept.delete(modTickKey(choice));
2762
- modPatch({ allow: kept.size === all.length ? [] : all.map(modTickKey).filter((key) => kept.has(key)) });
2765
+ const allow = kept.size === known.length ? [] : known.filter((key) => kept.has(key));
2766
+ modPatch({ allow, allowKnown: allow.length ? known : [] });
2763
2767
  }
2764
2768
  function modTickRows() {
2765
2769
  const choices = modTickable();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Use GPT (any ChatGPT plan with Codex), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
5
5
  "license": "GPL-3.0-only",
6
6
  "workspaces": [