clauderipple 0.9.2 → 0.9.4

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,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.4 — 2026-10-14
4
+
5
+ ### Fixed
6
+
7
+ - **Reasoning models are no longer cut off after five minutes of thinking.** Node's `fetch` dropped a
8
+ response that sent nothing for 300 seconds. Muse Spark 1.3 at xhigh thinks for minutes in silence
9
+ on a large context, so long steps failed with 529 at 303–305 seconds and started over. The router
10
+ now waits up to 15 minutes. OpenCode's own CLI had no such limit, which is why the same model
11
+ worked there.
12
+ - **OpenCode Go and Zen models get their real context window.** They fell back to the global
13
+ 258,400, so Claude Code compacted DeepSeek V4.1 Flash, GLM-5.3 and Muse Spark 1.3 at a quarter of
14
+ their 1M windows. The router now reads the window from the same models.dev catalogue OpenCode's
15
+ CLI uses, and fills models already saved without one.
16
+ - **A ChatGPT account whose limit was reset or topped up is usable again right away.** It stayed
17
+ "cooling" until the old reset time, so the app could not switch to GPT models.
18
+ - **Adding an OpenAI-compatible provider such as B.ai works.** The connection check asked for one
19
+ token, which some vendors refuse ("max_tokens must be greater than 2", #57).
20
+ - **The plan limit shows on the band**, and every control's tip shows on hover even when the band
21
+ is full.
22
+
23
+ ### Changed
24
+
25
+ - **The band:** Auto is a word that lights when on, instead of a switch. Compact and Clear (with a
26
+ confirm) sit beside settings. The limit draws as a small gauge and its percent.
27
+ - **Workers read and edit with the dedicated tools.** Generated worker agents are told to read files
28
+ with Read and change them with Edit, not with `sed`/`cat` through Bash.
29
+ - `/ripple debug` also shows the model, the limit and the host's rate limits.
30
+
31
+ ## 0.9.3 — 2026-10-14
32
+
33
+ ### Fixed
34
+
35
+ - **Claude workers offer X-High.** Claude's effort ladder stopped at high and max, so X-High could
36
+ not be picked for Claude in the mod or the dashboard though GPT had it. Opus, Sonnet and Haiku 5.5
37
+ all take it (#56).
38
+ - **A model ticked after the mod's worker list was saved is usable.** It used to stay off and show
39
+ as unavailable in the mod (GPT-6 Astra in #56). A worker the list has not seen now starts on.
40
+ Settings saved before this keep the old reading until the list is changed once.
41
+ - **The plan limit shows in the band on Claude chats.** The desktop Code tab left the mod's rate
42
+ limits empty, so the limit never appeared even with it switched on. The mod now falls back to the
43
+ router's figure for the same Claude login, and hides it when that figure is over 15 minutes old.
44
+ - `/ripple-bar` no longer says the band is shown above the prompt when the layout is Minimal, where
45
+ only the short line under the prompt is drawn (#56).
46
+ - The close button lights as one disc under the pointer instead of drawing a second box around it.
47
+
3
48
  ## 0.9.2 — 2026-10-14
4
49
 
5
50
  ### Added
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "type": "commonjs"
5
5
  }
@@ -63,6 +63,9 @@ const EN = {
63
63
  'band.warm': 'Warm',
64
64
  'band.handoff': 'Handoff',
65
65
  'band.compact': 'Compact',
66
+ 'band.clear': 'Clear',
67
+ 'band.compactAlt': 'Compact — summarise the conversation to free context',
68
+ 'band.clearAlt': 'New start (/clear) — clear the conversation and start fresh',
66
69
  'band.log': 'Log',
67
70
  'band.agents': 'Agents',
68
71
  'band.settings': 'Settings',
@@ -152,6 +155,9 @@ const EN = {
152
155
  'alerts.demoDown': 'Jev out of credits (demo)',
153
156
  'alerts.copied': 'The resume note was copied; this chat was kept.',
154
157
  'alerts.compactFailed': 'Compaction did not finish.',
158
+ 'alerts.clearTitle': 'Start this conversation over?',
159
+ 'alerts.clearBody': 'The conversation\'s context is emptied; a fresh chat starts with nothing.',
160
+ 'alerts.clearGo': 'Start over',
155
161
  // Settings.
156
162
  'settings.effortBlurb': 'How Auto picks the effort for each prompt you send.',
157
163
  'settings.workers': 'Workers',
@@ -361,6 +367,7 @@ const EN = {
361
367
  'commands.logOpened': 'ClaudeRipple log opened.',
362
368
  'commands.logClosed': 'ClaudeRipple log closed.',
363
369
  'commands.barShown': 'ClaudeRipple line shown above the prompt.',
370
+ '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
371
  'commands.barHidden': 'ClaudeRipple line hidden. /ripple-bar shows it again.',
365
372
  'commands.statsAuto': 'Auto: {prompts} prompts, {requests} requests; {total} weighted tokens',
366
373
  'commands.statsTokens': 'Tokens: input {input}, write {write}, read {read}, output {output}',
@@ -512,6 +519,9 @@ const KO: Record<Key, string> = {
512
519
  'band.warm': '캐시 유지',
513
520
  'band.handoff': '인수인계',
514
521
  'band.compact': '압축',
522
+ 'band.clear': '새로 시작',
523
+ 'band.compactAlt': '대화 압축 — 지금까지 대화를 요약해 컨텍스트를 줄입니다',
524
+ 'band.clearAlt': '새로 시작 (/clear) — 대화 맥락을 비우고 새로 시작합니다',
515
525
  'band.log': '로그',
516
526
  'band.agents': '에이전트',
517
527
  'band.settings': '설정',
@@ -601,6 +611,9 @@ const KO: Record<Key, string> = {
601
611
  'alerts.demoDown': 'Jev 크레딧 소진 (데모)',
602
612
  'alerts.copied': '재개용 요약 메모를 복사했습니다. 현재 대화는 보존됩니다.',
603
613
  'alerts.compactFailed': '대화 압축을 마치지 못했습니다.',
614
+ 'alerts.clearTitle': '대화를 새로 시작할까요?',
615
+ 'alerts.clearBody': '지금 대화 맥락이 비워집니다. 새 대화가 아무것도 없는 상태에서 시작됩니다.',
616
+ 'alerts.clearGo': '새로 시작',
604
617
  // Settings.
605
618
  'settings.effortBlurb': '전송하는 프롬프트마다 추론 강도를 자동으로 선택합니다.',
606
619
  'settings.workers': '워커',
@@ -810,6 +823,7 @@ const KO: Record<Key, string> = {
810
823
  'commands.logOpened': 'ClaudeRipple 로그를 열었습니다.',
811
824
  'commands.logClosed': 'ClaudeRipple 로그를 닫았습니다.',
812
825
  'commands.barShown': '프롬프트 상단에 ClaudeRipple 상태 바를 표시합니다.',
826
+ 'commands.barShownMinimal': '레이아웃이 최소화로 되어 있어 ClaudeRipple은 프롬프트 아래 한 줄로만 표시됩니다. 프롬프트 위에 상태 바를 띄우려면 /ripple settings에서 레이아웃을 대시보드로 바꾸십시오.',
813
827
  'commands.barHidden': 'ClaudeRipple 상태 바를 숨겼습니다. /ripple-bar로 다시 표시할 수 있습니다.',
814
828
  'commands.statsAuto': '자동: 프롬프트 {prompts}개, 요청 {requests}개, 가중 토큰 {total}',
815
829
  'commands.statsTokens': '토큰: 입력 {input}, 쓰기 {write}, 읽기 {read}, 출력 {output}',
@@ -959,6 +973,9 @@ const ZH: Record<Key, string> = {
959
973
  'band.warm': '保温',
960
974
  'band.handoff': '交接',
961
975
  'band.compact': '压缩',
976
+ 'band.clear': '新开',
977
+ 'band.compactAlt': '压缩对话 — 总结对话以释放上下文',
978
+ 'band.clearAlt': '重新开始 (/clear) — 清空对话上下文,重新开始',
962
979
  'band.log': '日志',
963
980
  'band.agents': '代理',
964
981
  'band.settings': '设置',
@@ -1048,6 +1065,9 @@ const ZH: Record<Key, string> = {
1048
1065
  'alerts.demoDown': 'Jev 额度用尽(演示)',
1049
1066
  'alerts.copied': '已复制续接说明,此对话已保留。',
1050
1067
  'alerts.compactFailed': '压缩未完成。',
1068
+ 'alerts.clearTitle': '重新开始此对话?',
1069
+ 'alerts.clearBody': '当前对话的上下文将被清空;新对话从空白开始。',
1070
+ 'alerts.clearGo': '重新开始',
1051
1071
  // Settings.
1052
1072
  'settings.effortBlurb': '自动模式如何为你发送的每条提示词选择推理强度。',
1053
1073
  'settings.workers': 'Worker',
@@ -1257,6 +1277,7 @@ const ZH: Record<Key, string> = {
1257
1277
  'commands.logOpened': '已打开 ClaudeRipple 日志。',
1258
1278
  'commands.logClosed': '已关闭 ClaudeRipple 日志。',
1259
1279
  'commands.barShown': 'ClaudeRipple 状态栏已显示在提示词上方。',
1280
+ 'commands.barShownMinimal': '布局是“极简”,所以 ClaudeRipple 只在提示词下方显示一行。要在提示词上方显示完整状态栏,请在 /ripple settings 里把布局改为“仪表盘”。',
1260
1281
  'commands.barHidden': 'ClaudeRipple 状态栏已隐藏,/ripple-bar 可重新显示。',
1261
1282
  'commands.statsAuto': '自动:{prompts} 条提示词,{requests} 次请求;加权 token {total}',
1262
1283
  'commands.statsTokens': 'Token:输入 {input},写入 {write},读取 {read},输出 {output}',
@@ -1406,6 +1427,9 @@ const JA: Record<Key, string> = {
1406
1427
  'band.warm': '保持',
1407
1428
  'band.handoff': '引き継ぎ',
1408
1429
  'band.compact': '圧縮',
1430
+ 'band.clear': '新しく開始',
1431
+ 'band.compactAlt': '圧縮 — これまでの会話を要約してコンテキストを減らします',
1432
+ 'band.clearAlt': '新しく開始 (/clear) — 会話の文脈を空にして新しく始めます',
1409
1433
  'band.log': 'ログ',
1410
1434
  'band.agents': 'エージェント',
1411
1435
  'band.settings': '設定',
@@ -1495,6 +1519,9 @@ const JA: Record<Key, string> = {
1495
1519
  'alerts.demoDown': 'Jev のクレジット切れ(デモ)',
1496
1520
  'alerts.copied': '再開メモをコピーしました。この会話はそのままです。',
1497
1521
  'alerts.compactFailed': '圧縮が完了しませんでした。',
1522
+ 'alerts.clearTitle': 'この会話を新しく始めますか?',
1523
+ 'alerts.clearBody': '今の会話のコンテキストは空になります。新しい会話は何もない状態から始まります。',
1524
+ 'alerts.clearGo': '新しく開始',
1498
1525
  // Settings.
1499
1526
  'settings.effortBlurb': '送信するプロンプトごとに、自動が推論強度を選ぶ方法。',
1500
1527
  'settings.workers': 'ワーカー',
@@ -1704,6 +1731,7 @@ const JA: Record<Key, string> = {
1704
1731
  'commands.logOpened': 'ClaudeRipple のログを開きました。',
1705
1732
  'commands.logClosed': 'ClaudeRipple のログを閉じました。',
1706
1733
  'commands.barShown': 'ClaudeRipple のバーをプロンプトの上に表示しました。',
1734
+ 'commands.barShownMinimal': 'レイアウトがミニマルなので、ClaudeRipple はプロンプトの下に 1 行で表示されます。プロンプトの上にバーを出すには、/ripple settings でレイアウトをダッシュボードにしてください。',
1707
1735
  'commands.barHidden': 'ClaudeRipple のバーを非表示にしました。/ripple-bar で再表示できます。',
1708
1736
  'commands.statsAuto': '自動: プロンプト {prompts} 件、リクエスト {requests} 件、加重トークン {total}',
1709
1737
  'commands.statsTokens': 'トークン: 入力 {input}、書き込み {write}、読み取り {read}、出力 {output}',
@@ -1853,6 +1881,9 @@ const ES: Record<Key, string> = {
1853
1881
  'band.warm': 'Mantener',
1854
1882
  'band.handoff': 'Traspaso',
1855
1883
  'band.compact': 'Compactar',
1884
+ 'band.clear': 'Reiniciar',
1885
+ 'band.compactAlt': 'Compactar — resume la conversación para liberar contexto',
1886
+ 'band.clearAlt': 'Empezar de nuevo (/clear) — vacía la conversación y empieza desde cero',
1856
1887
  'band.log': 'Registro',
1857
1888
  'band.agents': 'Agentes',
1858
1889
  'band.settings': 'Ajustes',
@@ -1942,6 +1973,9 @@ const ES: Record<Key, string> = {
1942
1973
  'alerts.demoDown': 'Jev sin créditos (demo)',
1943
1974
  'alerts.copied': 'Se copió la nota para continuar; esta conversación se conservó.',
1944
1975
  'alerts.compactFailed': 'La compactación no terminó.',
1976
+ 'alerts.clearTitle': '¿Empezar esta conversación de nuevo?',
1977
+ 'alerts.clearBody': 'El contexto de esta conversación se vacía; una conversación nueva empieza de cero.',
1978
+ 'alerts.clearGo': 'Empezar de nuevo',
1945
1979
  // Settings.
1946
1980
  'settings.effortBlurb': 'Cómo Auto elige el esfuerzo de cada prompt que envías.',
1947
1981
  'settings.workers': 'Workers',
@@ -2151,6 +2185,7 @@ const ES: Record<Key, string> = {
2151
2185
  'commands.logOpened': 'Registro de ClaudeRipple abierto.',
2152
2186
  'commands.logClosed': 'Registro de ClaudeRipple cerrado.',
2153
2187
  'commands.barShown': 'Barra de ClaudeRipple visible sobre el prompt.',
2188
+ '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
2189
  'commands.barHidden': 'Barra de ClaudeRipple oculta. /ripple-bar la vuelve a mostrar.',
2155
2190
  'commands.statsAuto': 'Auto: {prompts} prompts, {requests} solicitudes; {total} tokens ponderados',
2156
2191
  'commands.statsTokens': 'Tokens: entrada {input}, escritura {write}, lectura {read}, salida {output}',
@@ -2300,6 +2335,9 @@ const PT: Record<Key, string> = {
2300
2335
  'band.warm': 'Manter',
2301
2336
  'band.handoff': 'Repasse',
2302
2337
  'band.compact': 'Compactar',
2338
+ 'band.clear': 'Recomeçar',
2339
+ 'band.compactAlt': 'Compactar — resume a conversa para liberar contexto',
2340
+ 'band.clearAlt': 'Começar de novo (/clear) — limpa a conversa e começa do zero',
2303
2341
  'band.log': 'Log',
2304
2342
  'band.agents': 'Agentes',
2305
2343
  'band.settings': 'Ajustes',
@@ -2389,6 +2427,9 @@ const PT: Record<Key, string> = {
2389
2427
  'alerts.demoDown': 'Jev sem créditos (demo)',
2390
2428
  'alerts.copied': 'A nota para continuar foi copiada; esta conversa foi mantida.',
2391
2429
  'alerts.compactFailed': 'A compactação não terminou.',
2430
+ 'alerts.clearTitle': 'Começar esta conversa de novo?',
2431
+ 'alerts.clearBody': 'O contexto desta conversa é esvaziado; uma conversa nova começa do zero.',
2432
+ 'alerts.clearGo': 'Começar de novo',
2392
2433
  // Settings.
2393
2434
  'settings.effortBlurb': 'Como o Auto escolhe o esforço de cada prompt que você envia.',
2394
2435
  'settings.workers': 'Workers',
@@ -2598,6 +2639,7 @@ const PT: Record<Key, string> = {
2598
2639
  'commands.logOpened': 'Log do ClaudeRipple aberto.',
2599
2640
  'commands.logClosed': 'Log do ClaudeRipple fechado.',
2600
2641
  'commands.barShown': 'Barra do ClaudeRipple visível acima do prompt.',
2642
+ '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
2643
  'commands.barHidden': 'Barra do ClaudeRipple oculta. /ripple-bar mostra de novo.',
2602
2644
  'commands.statsAuto': 'Auto: {prompts} prompts, {requests} requisições; {total} tokens ponderados',
2603
2645
  '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,16 @@ async function openCompact($: EngineInterface, surface: Frame['surface']): Promi
931
931
  await update($, compactNote, () => '')
932
932
  await update($, panel, () => 'compact')
933
933
  }
934
+ async function openClear($: EngineInterface): Promise<void> {
935
+ await update($, panel, () => 'clear')
936
+ }
937
+ /** The router's figure for this login's plan windows; nothing when the router cannot say. */
938
+ async function routerClaudeLimits($: EngineInterface): Promise<SessionRateLimit[]> {
939
+ try {
940
+ const status = await $.http.fetch(`${await adminUrl($)}/api/status`)
941
+ return status.ok ? claudeLimitsFrom(JSON.parse(status.text), await $.clock.now()) : []
942
+ } catch (error) { await report($, 'claude limits', error); return [] }
943
+ }
934
944
  async function refreshUsage($: EngineInterface): Promise<void> {
935
945
  const usage = await $.session.usage()
936
946
  const c = usage.context
@@ -940,11 +950,15 @@ async function refreshUsage($: EngineInterface): Promise<void> {
940
950
  // Below the line again (a compact, a clear), the next crossing shows the heavy-context band once.
941
951
  const putOff = await read($, swampHidden)
942
952
  if (putOff !== null && (putOff > 100 || !ctx || ctx.percent < (await currentSettings($)).swampAt)) await update($, swampHidden, () => null)
943
- const h = hottest(usage.rateLimits)
953
+ // The host can leave the plan windows out though the replies carried them; on a Claude chat the
954
+ // router's figure for the same login stands in.
955
+ const own = usage.rateLimits.some(l => ['five_hour', 'seven_day'].includes(l.kind))
956
+ const rateLimits = own || !/^claude-/.test(await $.session.model()) ? usage.rateLimits : [...usage.rateLimits, ...await routerClaudeLimits($)]
957
+ const h = hottest(rateLimits)
944
958
  const previous = await read($, hot)
945
959
  if (previous && (!h || previous.kind !== h.kind || previous.resetsAt !== h.resetsAt)) await update($, hotHidden, () => null)
946
960
  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]
961
+ const top = rateLimits.filter(l => ['five_hour', 'seven_day'].includes(l.kind)).sort((a, b) => b.percentUsed - a.percentUsed)[0]
948
962
  const l = top ? { kind: top.kind, percent: top.percentUsed, ...(top.resetsAt ? { resetsAt: top.resetsAt } : {}) } : null
949
963
  // Check the previous binding deadline before a rolled-forward snapshot replaces it.
950
964
  await endModeIfOver($)
@@ -1079,6 +1093,7 @@ async function alertView($: EngineInterface, now: number): Promise<AlertView | n
1079
1093
  kept: d === 'kept' || keptAlert(await read($, kept), m, now),
1080
1094
  swamp: d === 'swamp' || (s.swampAlert && !await read($, busy) && swampAlert(swamped(c, s.swampAt), await read($, swampHidden))) })
1081
1095
  if (which === 'compact') return { kind: 'compactNote', draft: await read($, compactNote) }
1096
+ if (which === 'clear') return { kind: 'clear' }
1082
1097
  if (which === 'handoff') return { kind: 'handoffChoice', handoffKind: await read($, handoffChoiceKind), after: await read($, handoffChoiceAfter) }
1083
1098
  if (which === 'judgeDown') return { kind: 'judgeDown', judge: s.judge, reason: d === 'down' ? t(language, 'alerts.demoDown') : downReason!, network: d !== 'down' && await read($, judgeDownNetwork) }
1084
1099
  if (which === 'hot') return { kind: 'hot', limitKind: h?.kind ?? 'five_hour', percent: d === 'hot' ? 86 : h!.percent, resetsAt: h?.resetsAt, save: (await read($, saveUntil) ?? 0) > now }
@@ -1116,7 +1131,7 @@ async function dismiss($: EngineInterface, view: AlertView): Promise<void> {
1116
1131
  else await update($, panel, () => null)
1117
1132
  }
1118
1133
  function bandActions($: EngineInterface, surface: Frame['surface']): BandActions {
1119
- return { toggleAuto: () => toggleAuto($), toggleWarm: () => toggleWarm($), compact: () => openCompact($, surface), handoff: () => openHandoff($), toggleLog: () => toggleLog($), toggleAgents: () => toggleAgents($), openSettings: () => startPanel($, 'settings'),
1134
+ return { toggleAuto: () => toggleAuto($), toggleWarm: () => toggleWarm($), compact: () => openCompact($, surface), clear: () => openClear($), handoff: () => openHandoff($), toggleLog: () => toggleLog($), toggleAgents: () => toggleAgents($), openSettings: () => startPanel($, 'settings'),
1120
1135
  toggleEffortMenu: () => update($, effortMenuOpen, value => !value), pickEffort: effort => manualPick($, effort) }
1121
1136
  }
1122
1137
  function settingsActions($: EngineInterface): SettingsActions {
@@ -1136,7 +1151,7 @@ function setupActions($: EngineInterface): SetupActions {
1136
1151
  back: () => update($, setupStep, step => Math.max(1, step - 1) as 1 | 2 | 3 | 4), next: () => update($, setupStep, step => Math.min(4, step + 1) as 1 | 2 | 3 | 4), finish: () => finishSetup($), skip: () => finishSetup($, true) }
1137
1152
  }
1138
1153
  function alertActions($: EngineInterface, view: AlertView): AlertActions {
1139
- return { compact: note => runCompact($, note), handoff: () => openHandoff($), dismiss: () => dismiss($, view), toggleSave: () => toggleSave($), openSettings: () => startPanel($, 'settings'),
1154
+ return { compact: note => runCompact($, note), clear: async () => { await update($, panel, () => null); await $.command.run({ command: 'clear', args: '' }) }, handoff: () => openHandoff($), dismiss: () => dismiss($, view), toggleSave: () => toggleSave($), openSettings: () => startPanel($, 'settings'),
1140
1155
  noteInput: text => update($, compactNote, () => text), setHandoffKind: kind => update($, handoffChoiceKind, () => kind), setHandoffAfter: after => update($, handoffChoiceAfter, () => after), go: () => queueHandoff($), copy: () => copyHandoff($), openNew: () => moveToNewSession($) }
1141
1156
  }
1142
1157
  function toolLine(e: ToolCallInput): string {
@@ -1446,7 +1461,7 @@ export const register: Register = on => {
1446
1461
  const language = await read($, lang)
1447
1462
  const [sub = 'settings', arg] = e.args.trim().toLowerCase().split(/\s+/)
1448
1463
  if (sub === 'stats') return { text: formatStats(await read($, stats), language) }
1449
- if (sub === 'debug') return { text: debugText(language, { judge: (await currentSettings($)).judge, judgeModel: (await currentSettings($)).judgeModel, pick: await read($, pick), paused: await read($, paused), cache: await read($, cache), warm: await read($, warm), lastSpawn: await read($, lastSpawn), lastRequest: await read($, lastRequest) }) }
1464
+ if (sub === 'debug') return { text: debugText(language, { judge: (await currentSettings($)).judge, judgeModel: (await currentSettings($)).judgeModel, pick: await read($, pick), paused: await read($, paused), cache: await read($, cache), warm: await read($, warm), lastSpawn: await read($, lastSpawn), lastRequest: await read($, lastRequest), model: await $.session.model(), limit: await read($, limit), hostRateLimits: (await $.session.usage()).rateLimits }) }
1450
1465
  if (sub === 'settings' || sub === 'setup') await startPanel($, sub)
1451
1466
  else if (sub === 'auto') await toggleAuto($)
1452
1467
  else if (sub === 'warm') await toggleWarm($)
@@ -1469,7 +1484,9 @@ export const register: Register = on => {
1469
1484
  on('command.run', { command: 'ripple-bar' }, async ($, e, next) => {
1470
1485
  try {
1471
1486
  const language = await read($, lang)
1472
- return { text: await toggleBar($) ? t(language, 'commands.barShown') : t(language, 'commands.barHidden') } }
1487
+ if (!await toggleBar($)) return { text: t(language, 'commands.barHidden') }
1488
+ // The minimal layout draws no band above the prompt, only the short line under it (#56).
1489
+ return { text: t(language, (await currentSettings($)).layout === 'minimal' ? 'commands.barShownMinimal' : 'commands.barShown') } }
1473
1490
  catch (error) { await report($, 'ripple-bar', error); return next(e) }
1474
1491
  })
1475
1492
  on('ui.close', { id: LOG_PANE }, async ($, e, next) => {
@@ -1489,7 +1506,7 @@ export const register: Register = on => {
1489
1506
  const frame = await frameOf($, e)
1490
1507
  const el = $.ui.resolve(e)
1491
1508
  if (which === 'settings') return renderSettings(el, frame, await settingsView($), settingsActions($)) as RenderElement
1492
- if (which === 'compact' || which === 'handoff') {
1509
+ if (which === 'compact' || which === 'clear' || which === 'handoff') {
1493
1510
  const choice = await alertView($, frame.now)
1494
1511
  if (choice) return renderAlert(el, frame, choice, alertActions($, choice)) as RenderElement
1495
1512
  }
@@ -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.
@@ -61,9 +87,9 @@ export function swampAlert(percent: number | null, hidden: number | null): boole
61
87
 
62
88
  /** Rendering consumes eligibility, keeping dismissal and idle decisions outside drawing code. */
63
89
  export function bandPriority(state: {
64
- panel?: 'settings' | 'compact' | 'handoff' | 'setup' | null
90
+ panel?: 'settings' | 'compact' | 'clear' | 'handoff' | 'setup' | null
65
91
  judgeDown?: boolean; hot?: boolean; result?: boolean; cold?: boolean; swamp?: boolean; kept?: boolean
66
- }): 'settings' | 'compact' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'kept' | 'dashboard' {
92
+ }): 'settings' | 'compact' | 'clear' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'kept' | 'dashboard' {
67
93
  if (state.panel) return state.panel
68
94
  for (const band of ['judgeDown', 'hot', 'result', 'cold', 'swamp', 'kept'] as const) if (state[band]) return band
69
95
  return 'dashboard'
@@ -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)))]
@@ -114,6 +114,15 @@ function cardOf(frame: Frame, alert: AlertView, a: AlertActions): Card {
114
114
  main: { ...compact, onPress: () => a.compact(alert.draft) },
115
115
  close: cancel,
116
116
  }
117
+ case 'clear':
118
+ return {
119
+ key: 'clear',
120
+ face: 'asking',
121
+ title: t(L, 'alerts.clearTitle'),
122
+ body: t(L, 'alerts.clearBody'),
123
+ main: { key: 'clear-go', label: t(L, 'alerts.clearGo'), hotkey: 'g', onPress: a.clear },
124
+ close: cancel,
125
+ }
117
126
  case 'handoffChoice':
118
127
  return {
119
128
  key: 'handoff',
@@ -222,6 +231,7 @@ function seaOf(card: Card, alert: AlertView): Omit<Sea, 'at'> {
222
231
  case 'judge':
223
232
  return { tone: 'red', weather: 'static' }
224
233
  case 'note':
234
+ case 'clear':
225
235
  case 'handoff':
226
236
  return { tone: 'violet', weather: 'sparkles', ripples: 'calm' }
227
237
  default: {