clauderipple 0.9.0 → 0.9.2

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 (40) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.es.md +22 -11
  3. package/README.ja.md +14 -5
  4. package/README.ko.md +19 -8
  5. package/README.md +22 -10
  6. package/README.pt-BR.md +22 -11
  7. package/README.zh-CN.md +14 -5
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/index.js +2 -1
  10. package/dist/mod/hooks/i18n.ts +36 -12
  11. package/dist/mod/hooks/judge.ts +17 -1
  12. package/dist/mod/hooks/register.tsx +39 -9
  13. package/dist/mod/hooks/session.ts +11 -3
  14. package/dist/mod/hooks/ui/alerts.tsx +21 -5
  15. package/dist/mod/hooks/ui/band.tsx +1 -1
  16. package/dist/mod/hooks/ui/view.ts +4 -1
  17. package/dist/mod/tests/register.test.tsx +53 -0
  18. package/dist/mod/tests/ui.test.tsx +10 -6
  19. package/dist/mod/types/index.d.ts +4 -1
  20. package/dist/router/src/admin.js +72 -2
  21. package/dist/router/src/agents.js +4 -0
  22. package/dist/router/src/config.js +24 -0
  23. package/dist/router/src/ingress/server.js +2 -2
  24. package/dist/router/src/mod-settings.js +2 -1
  25. package/dist/router/src/providers/chatgpt/index.js +19 -6
  26. package/dist/router/src/providers/chatgpt/translate.js +3 -0
  27. package/dist/router/src/providers/google/index.js +4 -3
  28. package/dist/router/src/providers/grok/auth.js +289 -0
  29. package/dist/router/src/providers/grok/catalog.js +52 -0
  30. package/dist/router/src/providers/grok/index.js +94 -0
  31. package/dist/router/src/providers/openai/index.js +71 -17
  32. package/dist/router/src/providers/openai/translate.js +11 -1
  33. package/dist/router/src/proxy.js +91 -21
  34. package/dist/router/src/redact.js +7 -2
  35. package/dist/router/src/requestlog.js +78 -20
  36. package/dist/router/src/version.js +1 -1
  37. package/dist/ui/app.js +105 -0
  38. package/dist/ui/i18n.js +6 -6
  39. package/docs/ARCHITECTURE.md +91 -0
  40. package/package.json +1 -1
@@ -70,6 +70,7 @@ const EN = {
70
70
  'band.hit': 'hit {percent}%',
71
71
  'band.effortAutoAlt': 'Effort Auto {state}',
72
72
  'band.warmAlt': 'Keep cache warm {state}',
73
+ 'band.warmHere': 'this chat only; new chats follow Settings',
73
74
  'band.handoffAdvised': 'Handoff (advised: a fresh chat pays now)',
74
75
  'band.closeLog': 'Close the request log',
75
76
  'band.openLog': 'Open the request log',
@@ -90,6 +91,9 @@ const EN = {
90
91
  // Alerts.
91
92
  'alerts.coldTitle': 'The cache went cold',
92
93
  'alerts.coldBody': 'It sat {minutes} min, so the next prompt re-reads all {tokens} tokens at full price.',
94
+ 'alerts.keptTitle': 'Cache kept warm while you were away',
95
+ 'alerts.keptBody': '{pings} ping(s) held {tokens} tokens in cache, so your next prompt reads them at the cached rate. {minutes} min left.',
96
+ 'alerts.gotIt': 'Got it',
93
97
  'alerts.swampTitle': 'Context {percent}% full',
94
98
  'alerts.swampBody': '{tokens} tokens ride along with every prompt: slower, dearer, blurrier.',
95
99
  'alerts.hotTitle': '{window} limit at {percent}%',
@@ -347,12 +351,12 @@ const EN = {
347
351
  'toasts.workerMissing': '{name} is not installed',
348
352
  // Commands (stats and debug labels included).
349
353
  'commands.description': 'ClaudeRipple effort, workers, cache and session tools',
350
- 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down',
354
+ 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down|kept',
351
355
  'commands.logDescription': 'Open or close the ClaudeRipple request log',
352
356
  'commands.barDescription': 'Show or hide the ClaudeRipple band',
353
357
  'commands.logTitle': 'ClaudeRipple',
354
358
  'commands.agentsTitle': 'ClaudeRipple Agents',
355
- 'commands.help': 'ClaudeRipple: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down.',
359
+ 'commands.help': 'ClaudeRipple: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down, kept.',
356
360
  'commands.done': 'ClaudeRipple: {command}.',
357
361
  'commands.logOpened': 'ClaudeRipple log opened.',
358
362
  'commands.logClosed': 'ClaudeRipple log closed.',
@@ -515,6 +519,7 @@ const KO: Record<Key, string> = {
515
519
  'band.hit': '적중 {percent}%',
516
520
  'band.effortAutoAlt': '추론 강도 자동 {state}',
517
521
  'band.warmAlt': '캐시 유지 {state}',
522
+ 'band.warmHere': '이 세션만 적용, 새 세션은 설정을 따릅니다',
518
523
  'band.handoffAdvised': '인수인계 (권장: 지금은 새 대화가 더 효율적입니다)',
519
524
  'band.closeLog': '요청 로그 닫기',
520
525
  'band.openLog': '요청 로그 열기',
@@ -535,6 +540,9 @@ const KO: Record<Key, string> = {
535
540
  // Alerts.
536
541
  'alerts.coldTitle': '캐시가 만료되었습니다',
537
542
  'alerts.coldBody': '{minutes}분 동안 대기하여 캐시가 만료되었습니다. 다음 프롬프트는 {tokens} 토큰 전체를 새로 읽어 비용이 발생합니다.',
543
+ 'alerts.keptTitle': '자리를 비운 동안 캐시를 유지했습니다',
544
+ 'alerts.keptBody': '핑 {pings}회로 {tokens} 토큰을 캐시에 붙잡아 두었습니다. 다음 프롬프트도 캐시로 읽습니다. 만료까지 {minutes}분 남았습니다.',
545
+ 'alerts.gotIt': '확인',
538
546
  'alerts.swampTitle': '컨텍스트 {percent}% 사용',
539
547
  'alerts.swampBody': '프롬프트마다 {tokens} 토큰이 함께 전송되어 응답 속도와 정확도, 비용에 영향을 줍니다.',
540
548
  'alerts.hotTitle': '{window} 한도 {percent}% 도달',
@@ -792,12 +800,12 @@ const KO: Record<Key, string> = {
792
800
  'toasts.workerMissing': '{name} 에이전트 미설치',
793
801
  // Commands (stats and debug labels included).
794
802
  'commands.description': 'ClaudeRipple 추론 강도, 워커, 캐시 및 세션 도구',
795
- 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down',
803
+ 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down|kept',
796
804
  'commands.logDescription': 'ClaudeRipple 요청 로그 열기/닫기',
797
805
  'commands.barDescription': 'ClaudeRipple 상태 바 표시/숨기기',
798
806
  'commands.logTitle': 'ClaudeRipple',
799
807
  'commands.agentsTitle': 'ClaudeRipple 에이전트',
800
- 'commands.help': 'ClaudeRipple 하위 명령: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down.',
808
+ 'commands.help': 'ClaudeRipple 하위 명령: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down, kept.',
801
809
  'commands.done': 'ClaudeRipple: {command} 완료.',
802
810
  'commands.logOpened': 'ClaudeRipple 로그를 열었습니다.',
803
811
  'commands.logClosed': 'ClaudeRipple 로그를 닫았습니다.',
@@ -958,6 +966,7 @@ const ZH: Record<Key, string> = {
958
966
  'band.hit': '命中 {percent}%',
959
967
  'band.effortAutoAlt': '推理强度自动{state}',
960
968
  'band.warmAlt': '缓存保温{state}',
969
+ 'band.warmHere': '仅限本会话,新会话按设置',
961
970
  'band.handoffAdvised': '交接(建议:现在换新对话更划算)',
962
971
  'band.closeLog': '关闭请求日志',
963
972
  'band.openLog': '打开请求日志',
@@ -978,6 +987,9 @@ const ZH: Record<Key, string> = {
978
987
  // Alerts.
979
988
  'alerts.coldTitle': '缓存已过期',
980
989
  'alerts.coldBody': '闲置了 {minutes} 分钟,下一条提示词会按全价重读全部 {tokens} token。',
990
+ 'alerts.keptTitle': '您离开期间缓存已保持温热',
991
+ 'alerts.keptBody': '保温 ping {pings} 次,把 {tokens} token 留在了缓存里,下一条提示词仍按缓存价读取。还有 {minutes} 分钟过期。',
992
+ 'alerts.gotIt': '知道了',
981
993
  'alerts.swampTitle': '上下文已用 {percent}%',
982
994
  'alerts.swampBody': '每条提示词都带着 {tokens} token:更慢、更贵、更模糊。',
983
995
  'alerts.hotTitle': '{window}限额已达 {percent}%',
@@ -1235,12 +1247,12 @@ const ZH: Record<Key, string> = {
1235
1247
  'toasts.workerMissing': '未安装 {name}',
1236
1248
  // Commands (stats and debug labels included).
1237
1249
  'commands.description': 'ClaudeRipple 推理强度、Worker、缓存与会话工具',
1238
- 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down',
1250
+ 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down|kept',
1239
1251
  'commands.logDescription': '打开或关闭 ClaudeRipple 请求日志',
1240
1252
  'commands.barDescription': '显示或隐藏 ClaudeRipple 状态栏',
1241
1253
  'commands.logTitle': 'ClaudeRipple',
1242
1254
  'commands.agentsTitle': 'ClaudeRipple 代理',
1243
- 'commands.help': 'ClaudeRipple 子命令:settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down.',
1255
+ 'commands.help': 'ClaudeRipple 子命令:settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down, kept.',
1244
1256
  'commands.done': 'ClaudeRipple: {command}.',
1245
1257
  'commands.logOpened': '已打开 ClaudeRipple 日志。',
1246
1258
  'commands.logClosed': '已关闭 ClaudeRipple 日志。',
@@ -1401,6 +1413,7 @@ const JA: Record<Key, string> = {
1401
1413
  'band.hit': 'ヒット {percent}%',
1402
1414
  'band.effortAutoAlt': '推論強度の自動 {state}',
1403
1415
  'band.warmAlt': 'キャッシュ保持 {state}',
1416
+ 'band.warmHere': 'このチャットだけ。新しいチャットは設定に従います',
1404
1417
  'band.handoffAdvised': '引き継ぎ(推奨: 今は新しい会話のほうが得です)',
1405
1418
  'band.closeLog': 'リクエストログを閉じる',
1406
1419
  'band.openLog': 'リクエストログを開く',
@@ -1421,6 +1434,9 @@ const JA: Record<Key, string> = {
1421
1434
  // Alerts.
1422
1435
  'alerts.coldTitle': 'キャッシュの期限が切れました',
1423
1436
  'alerts.coldBody': '{minutes} 分止まっていたので、次のプロンプトで {tokens} トークンを定価で読み直します。',
1437
+ 'alerts.keptTitle': '不在中もキャッシュを温かいまま保ちました',
1438
+ 'alerts.keptBody': 'キープウォームが {pings} 回、{tokens} トークンをキャッシュに保持しました。次のプロンプトもキャッシュで読めます。期限まで {minutes} 分です。',
1439
+ 'alerts.gotIt': '了解',
1424
1440
  'alerts.swampTitle': 'コンテキスト {percent}% 使用中',
1425
1441
  'alerts.swampBody': 'プロンプトごとに {tokens} トークンが付いていき、遅く・高く・曖昧になります。',
1426
1442
  'alerts.hotTitle': '{window}上限の {percent}% に到達',
@@ -1678,12 +1694,12 @@ const JA: Record<Key, string> = {
1678
1694
  'toasts.workerMissing': '{name} がインストールされていません',
1679
1695
  // Commands (stats and debug labels included).
1680
1696
  'commands.description': 'ClaudeRipple の推論強度、ワーカー、キャッシュ、セッションツール',
1681
- 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down',
1697
+ 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down|kept',
1682
1698
  'commands.logDescription': 'ClaudeRipple のリクエストログを開閉',
1683
1699
  'commands.barDescription': 'ClaudeRipple のバーを表示・非表示',
1684
1700
  'commands.logTitle': 'ClaudeRipple',
1685
1701
  'commands.agentsTitle': 'ClaudeRipple エージェント',
1686
- 'commands.help': 'ClaudeRipple のサブコマンド: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down.',
1702
+ 'commands.help': 'ClaudeRipple のサブコマンド: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down, kept.',
1687
1703
  'commands.done': 'ClaudeRipple: {command}.',
1688
1704
  'commands.logOpened': 'ClaudeRipple のログを開きました。',
1689
1705
  'commands.logClosed': 'ClaudeRipple のログを閉じました。',
@@ -1844,6 +1860,7 @@ const ES: Record<Key, string> = {
1844
1860
  'band.hit': 'acierto {percent}%',
1845
1861
  'band.effortAutoAlt': 'Esfuerzo auto {state}',
1846
1862
  'band.warmAlt': 'Mantener la caché caliente {state}',
1863
+ 'band.warmHere': 'solo este chat; los nuevos siguen los Ajustes',
1847
1864
  'band.handoffAdvised': 'Traspaso (recomendado: ahora conviene una conversación nueva)',
1848
1865
  'band.closeLog': 'Cerrar el registro de solicitudes',
1849
1866
  'band.openLog': 'Abrir el registro de solicitudes',
@@ -1864,6 +1881,9 @@ const ES: Record<Key, string> = {
1864
1881
  // Alerts.
1865
1882
  'alerts.coldTitle': 'La caché se enfrió',
1866
1883
  'alerts.coldBody': 'Estuvo {minutes} min parada, así que el próximo prompt relee sus {tokens} tokens a precio completo.',
1884
+ 'alerts.keptTitle': 'La caché se mantuvo caliente mientras no estabas',
1885
+ 'alerts.keptBody': '{pings} ping(s) mantuvieron {tokens} tokens en caché, así que tu próximo prompt los lee al precio en caché. Quedan {minutes} min.',
1886
+ 'alerts.gotIt': 'Entendido',
1867
1887
  'alerts.swampTitle': 'Contexto al {percent}%',
1868
1888
  'alerts.swampBody': '{tokens} tokens viajan con cada prompt: más lento, más caro, más difuso.',
1869
1889
  'alerts.hotTitle': 'Límite {window} al {percent}%',
@@ -2121,12 +2141,12 @@ const ES: Record<Key, string> = {
2121
2141
  'toasts.workerMissing': '{name} no está instalado',
2122
2142
  // Commands (stats and debug labels included).
2123
2143
  'commands.description': 'Esfuerzo, workers, caché y herramientas de sesión de ClaudeRipple',
2124
- 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down',
2144
+ 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down|kept',
2125
2145
  'commands.logDescription': 'Abre o cierra el registro de solicitudes de ClaudeRipple',
2126
2146
  'commands.barDescription': 'Muestra u oculta la barra de ClaudeRipple',
2127
2147
  'commands.logTitle': 'ClaudeRipple',
2128
2148
  'commands.agentsTitle': 'Agentes de ClaudeRipple',
2129
- 'commands.help': 'Subcomandos de ClaudeRipple: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down.',
2149
+ 'commands.help': 'Subcomandos de ClaudeRipple: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down, kept.',
2130
2150
  'commands.done': 'ClaudeRipple: {command}.',
2131
2151
  'commands.logOpened': 'Registro de ClaudeRipple abierto.',
2132
2152
  'commands.logClosed': 'Registro de ClaudeRipple cerrado.',
@@ -2287,6 +2307,7 @@ const PT: Record<Key, string> = {
2287
2307
  'band.hit': 'acerto {percent}%',
2288
2308
  'band.effortAutoAlt': 'Esforço auto {state}',
2289
2309
  'band.warmAlt': 'Manter o cache quente {state}',
2310
+ 'band.warmHere': 'só esta conversa; as novas seguem as Configurações',
2290
2311
  'band.handoffAdvised': 'Repasse (recomendado: agora uma conversa nova compensa)',
2291
2312
  'band.closeLog': 'Fechar o log de requisições',
2292
2313
  'band.openLog': 'Abrir o log de requisições',
@@ -2307,6 +2328,9 @@ const PT: Record<Key, string> = {
2307
2328
  // Alerts.
2308
2329
  'alerts.coldTitle': 'O cache esfriou',
2309
2330
  'alerts.coldBody': 'Ficou parada {minutes} min, então o próximo prompt relê os {tokens} tokens pelo preço cheio.',
2331
+ 'alerts.keptTitle': 'O cache ficou quente enquanto você esteve fora',
2332
+ 'alerts.keptBody': '{pings} ping(s) seguraram {tokens} tokens no cache, então o próximo prompt os lê pelo preço em cache. Faltam {minutes} min.',
2333
+ 'alerts.gotIt': 'Entendi',
2310
2334
  'alerts.swampTitle': 'Contexto {percent}% cheio',
2311
2335
  'alerts.swampBody': '{tokens} tokens vão junto com cada prompt: mais lento, mais caro, mais confuso.',
2312
2336
  'alerts.hotTitle': 'Limite {window} em {percent}%',
@@ -2564,12 +2588,12 @@ const PT: Record<Key, string> = {
2564
2588
  'toasts.workerMissing': '{name} não está instalado',
2565
2589
  // Commands (stats and debug labels included).
2566
2590
  'commands.description': 'Esforço, workers, cache e ferramentas de sessão do ClaudeRipple',
2567
- 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down',
2591
+ 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down|kept',
2568
2592
  'commands.logDescription': 'Abre ou fecha o log de requisições do ClaudeRipple',
2569
2593
  'commands.barDescription': 'Mostra ou oculta a barra do ClaudeRipple',
2570
2594
  'commands.logTitle': 'ClaudeRipple',
2571
2595
  'commands.agentsTitle': 'Agentes do ClaudeRipple',
2572
- 'commands.help': 'Subcomandos do ClaudeRipple: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down.',
2596
+ 'commands.help': 'Subcomandos do ClaudeRipple: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down, kept.',
2573
2597
  'commands.done': 'ClaudeRipple: {command}.',
2574
2598
  'commands.logOpened': 'Log do ClaudeRipple aberto.',
2575
2599
  'commands.logClosed': 'Log do ClaudeRipple fechado.',
@@ -90,7 +90,23 @@ export function verdictObject(text: string): Record<string, unknown> | undefined
90
90
  }
91
91
  }
92
92
  }
93
- return undefined
93
+ return looseVerdictObject(text)
94
+ }
95
+
96
+ /**
97
+ * A small judge sometimes breaks its JSON (Haiku answered `…"why":"…","}`, #53). Its complete
98
+ * `"key": value` pairs still carry the verdict, so they are read one by one; the parsers above
99
+ * check every field they use, so nothing here is trusted beyond a JSON scalar.
100
+ */
101
+ function looseVerdictObject(text: string): Record<string, unknown> | undefined {
102
+ const start = text.indexOf('{')
103
+ if (start === -1) return undefined
104
+ const value: Record<string, unknown> = {}
105
+ const pair = /"(\w+)"\s*:\s*("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false|null)/g
106
+ for (const match of text.slice(start).matchAll(pair)) {
107
+ try { value[match[1]!] ??= JSON.parse(match[2]!) } catch { /* a malformed scalar adds nothing */ }
108
+ }
109
+ return Object.keys(value).length ? value : undefined
94
110
  }
95
111
 
96
112
  const effortCriteria = {
@@ -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, saveUntil as limitSaveUntil, swampAlert, swamped } from './session'
16
+ import { bandPriority, 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'
@@ -48,6 +48,7 @@ const lastSpawn = atom({ plugin: 'ripple', key: 'lastSpawn' } as const, null)
48
48
  const agents = atom({ plugin: 'ripple', key: 'agents' } as const, [])
49
49
  const cache = atom({ plugin: 'ripple', key: 'cache' } as const, null)
50
50
  const warm = atom({ plugin: 'ripple', key: 'warm' } as const, { pings: 0, cost: 0 })
51
+ const warmHere = atom({ plugin: 'ripple', key: 'warmHere' } as const, null)
51
52
  const lastRealReplyAt = atom({ plugin: 'ripple', key: 'lastRealReplyAt' } as const, null)
52
53
  const context = atom({ plugin: 'ripple', key: 'context' } as const, null)
53
54
  const hot = atom({ plugin: 'ripple', key: 'hot' } as const, null)
@@ -56,6 +57,7 @@ const hotHidden = atom({ plugin: 'ripple', key: 'hotHidden' } as const, null)
56
57
  const saveUntil = atom({ plugin: 'ripple', key: 'saveUntil' } as const, null)
57
58
  const swampHidden = atom({ plugin: 'ripple', key: 'swampHidden' } as const, null)
58
59
  const coldHidden = atom({ plugin: 'ripple', key: 'coldHidden' } as const, false)
60
+ const kept = atom({ plugin: 'ripple', key: 'kept' } as const, null)
59
61
  const judgeDown = atom({ plugin: 'ripple', key: 'judgeDown' } as const, null)
60
62
  const judgeDownHidden = atom({ plugin: 'ripple', key: 'judgeDownHidden' } as const, null)
61
63
  const judgeSeen = atom({ plugin: 'ripple', key: 'judgeSeen' } as const, [])
@@ -233,6 +235,8 @@ async function installSettings($: EngineInterface, s: Settings): Promise<void> {
233
235
  try { await registerAuto($, s) } catch (error) { await report($, 'agent register', error) }
234
236
  if (!s.effortAuto) await update($, heldPick, () => null)
235
237
  if (s.keepWarm && !previous.keepWarm) await update($, warm, value => ({ ...value, stopped: undefined }))
238
+ // Settings caught up with this chat's own choice: it follows Settings again.
239
+ if (await read($, warmHere) === s.keepWarm) await update($, warmHere, () => null)
236
240
  }
237
241
  function settingsSnapshot(text: string): ModSettingsSnapshot {
238
242
  const value = object(JSON.parse(text))
@@ -712,9 +716,15 @@ async function toggleAuto($: EngineInterface): Promise<void> {
712
716
  const s = await currentSettings($)
713
717
  await saveSettings($, { ...s, effortAuto: !s.effortAuto })
714
718
  }
719
+ /** The band's flame turns keep-warm on or off for this chat only; Settings is what a new chat starts with. */
720
+ async function keepWarmOn($: EngineInterface, s: Settings): Promise<boolean> {
721
+ return await read($, warmHere) ?? s.keepWarm
722
+ }
715
723
  async function toggleWarm($: EngineInterface): Promise<void> {
716
724
  const s = await currentSettings($)
717
- await saveSettings($, { ...s, keepWarm: !s.keepWarm })
725
+ const on = !await keepWarmOn($, s)
726
+ await update($, warmHere, () => on === s.keepWarm ? null : on)
727
+ if (on) await update($, warm, value => ({ ...value, stopped: undefined }))
718
728
  }
719
729
  async function toggleSave($: EngineInterface): Promise<void> {
720
730
  const now = await $.clock.now()
@@ -986,7 +996,8 @@ async function tick($: EngineInterface): Promise<void> {
986
996
  const answer = await $.model.complete({ model: HAIKU, system: ADVICE_SYSTEM, prompt: queued, effort: 'low', maxTokens: 60, timeoutMs: 8000 })
987
997
  if (answer.isAnswered) await update($, advice, () => parseAdvice(answer.text))
988
998
  }
989
- const s = await currentSettings($)
999
+ const s = { ...await currentSettings($) }
1000
+ s.keepWarm = await keepWarmOn($, s)
990
1001
  const decision = shouldPing({ settings: s, memo: await read($, cache), warm: await read($, warm), now: await $.clock.now(), idle: !await read($, busy) && !await read($, deciding) && !forking,
991
1002
  contextTokens: (await read($, context))?.tokens ?? 0, lastRealReplyAt: await read($, lastRealReplyAt) })
992
1003
  if (decision.ping) {
@@ -997,6 +1008,15 @@ async function tick($: EngineInterface): Promise<void> {
997
1008
  const after = afterPing(before, answer, await $.clock.now(), language)
998
1009
  await update($, warm, () => after)
999
1010
  if (after.stopped && !before.stopped) await $.ui.toast(t(language, 'toasts.warmStopped', { reason: after.stopped }))
1011
+ // A ping read cache tokens: the cache held while the person was away. One card counts them all
1012
+ // (a real reply or the expiry clears it; register.tsx kept), until then each ping adds up.
1013
+ const usage = answer && 'usage' in answer ? answer.usage : null
1014
+ if ((usage?.cache_read_input_tokens ?? 0) > 0) {
1015
+ await update($, kept, prev => ({
1016
+ pings: (prev?.pings ?? 0) + 1,
1017
+ tokens: usage!.cache_read_input_tokens,
1018
+ }))
1019
+ }
1000
1020
  } else if (s.keepWarm && !await read($, busy) && decision.reason === 'keep warm time bound reached' && !(await read($, warm)).stopped) {
1001
1021
  await update($, warm, value => ({ ...value, stopped: t(language, 'toasts.warmBoundReason') }))
1002
1022
  await $.ui.toast(t(language, 'toasts.warmBound'))
@@ -1025,7 +1045,7 @@ async function dashboard($: EngineInterface, working: boolean): Promise<Dashboar
1025
1045
  mode: await read($, deciding) ? 'deciding' : await read($, paused) ? 'paused' : p?.by === 'manual' ? 'manual' : s.effortAuto ? 'auto' : 'off',
1026
1046
  ...(p?.why ? { why: p.why, by: p.by } : {}), ...(await read($, decidingSince) !== null ? { since: (await read($, decidingSince))! } : {}) },
1027
1047
  model: await read($, modelIs) ? modelName((await read($, modelIs))!) : null,
1028
- context: await read($, context), cache: await read($, cache), warm: { ...w, on: s.keepWarm },
1048
+ context: await read($, context), cache: await read($, cache), warm: { ...w, on: await keepWarmOn($, s), ...(await read($, warmHere) !== null ? { here: true } : {}) },
1029
1049
  limit: l ? { ...l, save: saving } : null, route: record ? routeOf(record) : null, notice: await read($, line),
1030
1050
  auto: s.effortAuto, handoff: await read($, advice) ? 'advised' : s.handoffButton === 'always' ? 'always' : 'off',
1031
1051
  agents: { live: list.filter(a => ['running', 'waiting'].includes(a.state)).length, total: list.length },
@@ -1056,6 +1076,7 @@ async function alertView($: EngineInterface, now: number): Promise<AlertView | n
1056
1076
  const which = bandPriority({ panel: await read($, panel), judgeDown: d === 'down' || (!!downReason && downReason !== await read($, judgeDownHidden)),
1057
1077
  hot: d === 'hot' || hotAlert(h, await read($, hotHidden)), result: !!writing || !!result,
1058
1078
  cold: d === 'cold' || coldAlert(m, c?.tokens ?? 0, now, await read($, coldHidden)),
1079
+ kept: d === 'kept' || keptAlert(await read($, kept), m, now),
1059
1080
  swamp: d === 'swamp' || (s.swampAlert && !await read($, busy) && swampAlert(swamped(c, s.swampAt), await read($, swampHidden))) })
1060
1081
  if (which === 'compact') return { kind: 'compactNote', draft: await read($, compactNote) }
1061
1082
  if (which === 'handoff') return { kind: 'handoffChoice', handoffKind: await read($, handoffChoiceKind), after: await read($, handoffChoiceAfter) }
@@ -1068,22 +1089,27 @@ async function alertView($: EngineInterface, now: number): Promise<AlertView | n
1068
1089
  return { kind: 'result', stage, handoffKind: writing.kind, ...(detail ? { detail } : {}) }
1069
1090
  }
1070
1091
  if (which === 'cold') return { kind: 'cold', tokens: d === 'cold' ? 180000 : c!.tokens, idleMinutes: m ? Math.round((now - m.lastReplyAt) / 60000) : 61 }
1092
+ if (which === 'kept') {
1093
+ const k = d === 'kept' ? { pings: 2, tokens: 312000 } : (await read($, kept)) ?? { pings: 0, tokens: 0 }
1094
+ return { kind: 'kept', pings: k.pings, tokens: k.tokens, minutesLeft: Math.ceil(((d === 'kept' ? now + 47 * 60000 : m!.expiresAt) - now) / 60000) }
1095
+ }
1071
1096
  if (which === 'swamp') return { kind: 'swamp', percent: d === 'swamp' ? 65 : c!.percent, tokens: d === 'swamp' ? 160000 : c!.tokens }
1072
1097
  return null
1073
1098
  }
1074
1099
  /**
1075
- * A prompt sent while the cold, heavy or hot band shows counts as having seen it: it comes back
1076
- * only on its own next occasion (the next expiry, 15 more points of context, 10 more of the plan
1077
- * window), never after every message.
1100
+ * A prompt sent while the cold, kept, heavy or hot band shows counts as having seen it: it comes
1101
+ * back only on its own next occasion (the next expiry, one more ping, 15 more points of context,
1102
+ * 10 more of the plan window), never after every message.
1078
1103
  */
1079
1104
  async function markSeen($: EngineInterface): Promise<void> {
1080
1105
  const view = await alertView($, await $.clock.now())
1081
- if (view && (view.kind === 'cold' || view.kind === 'swamp' || view.kind === 'hot')) await dismiss($, view)
1106
+ if (view && (view.kind === 'cold' || view.kind === 'kept' || view.kind === 'swamp' || view.kind === 'hot')) await dismiss($, view)
1082
1107
  }
1083
1108
  async function dismiss($: EngineInterface, view: AlertView): Promise<void> {
1084
1109
  await update($, demo, () => null)
1085
1110
  if (view.kind === 'hot') await update($, hotHidden, () => view.percent)
1086
1111
  else if (view.kind === 'cold') await update($, coldHidden, () => true)
1112
+ else if (view.kind === 'kept') await update($, kept, () => null)
1087
1113
  else if (view.kind === 'swamp') await update($, swampHidden, () => view.percent)
1088
1114
  else if (view.kind === 'judgeDown') await update($, judgeDownHidden, () => view.reason)
1089
1115
  else if (view.kind === 'result') { await update($, resultCard, () => null); await update($, handoff, () => null) }
@@ -1133,6 +1159,7 @@ async function prepareStep($: EngineInterface, e: TurnStepInput): Promise<TurnSt
1133
1159
  if (previousModel !== null) {
1134
1160
  await update($, cache, () => null)
1135
1161
  await update($, lastRealReplyAt, () => null)
1162
+ if (await read($, kept)) await update($, kept, () => null)
1136
1163
  }
1137
1164
  }
1138
1165
  const safe = isCacheSafeModel(e.model)
@@ -1200,6 +1227,7 @@ export const register: Register = on => {
1200
1227
  await update($, cache, () => null)
1201
1228
  await update($, context, () => null)
1202
1229
  await update($, lastRealReplyAt, () => null)
1230
+ if (await read($, kept)) await update($, kept, () => null)
1203
1231
  }
1204
1232
  if (e.model) await update($, modelIs, () => e.model!)
1205
1233
  await update($, paused, () => e.model ? !isCacheSafeModel(e.model) : false)
@@ -1291,6 +1319,7 @@ export const register: Register = on => {
1291
1319
  const memo = nextMemo(await read($, cache), answer.usage, now)
1292
1320
  await update($, cache, () => memo)
1293
1321
  if (await read($, coldHidden)) await update($, coldHidden, () => false)
1322
+ if (await read($, kept)) await update($, kept, () => null)
1294
1323
  await update($, lastRealReplyAt, () => now)
1295
1324
  if ((await read($, warm)).stopped !== undefined) await update($, warm, value => ({ ...value, stopped: undefined }))
1296
1325
  }
@@ -1403,6 +1432,7 @@ export const register: Register = on => {
1403
1432
  if (!answer.isAnswered || answer.text.trim().length <= 200) throw new Error(t(language, 'toasts.summaryEmpty'))
1404
1433
  await update($, cache, () => null)
1405
1434
  await update($, context, () => null)
1435
+ if (await read($, kept)) await update($, kept, () => null)
1406
1436
  await update($, resultCard, () => ({ stage: 'compacted' }))
1407
1437
  return compactResult(answer.text)
1408
1438
  } catch (error) {
@@ -1425,7 +1455,7 @@ export const register: Register = on => {
1425
1455
  else if (sub === 'log') await toggleLog($)
1426
1456
  else if (sub === 'agents') await toggleAgents($)
1427
1457
  else if (sub === 'bar') await toggleBar($)
1428
- else if (['cold', 'swamp', 'hot', 'down'].includes(sub)) { await update($, panel, () => null); await update($, demo, () => sub as 'cold' | 'swamp' | 'hot' | 'down') }
1458
+ else if (['cold', 'swamp', 'hot', 'down', 'kept'].includes(sub)) { await update($, panel, () => null); await update($, demo, () => sub as 'cold' | 'swamp' | 'hot' | 'down' | 'kept') }
1429
1459
  else return { text: t(language, 'commands.help') }
1430
1460
  return { text: t(language, 'commands.done', { command: sub }) }
1431
1461
  } catch (error) { await report($, 'ripple command', error); return next(e) }
@@ -37,6 +37,14 @@ export function coldAlert(memo: CacheMemo | null, contextTokens: number, now: nu
37
37
  return !hidden && memo !== null && memo.expiresAt <= now && contextTokens >= 150000
38
38
  }
39
39
 
40
+ /**
41
+ * Keep-warm held the cache while the person was away: at least one ping read cache tokens and the
42
+ * memo says the cache is still warm. Once it has expired the cold card is the truth, never this.
43
+ */
44
+ export function keptAlert(kept: { pings: number; tokens: number } | null, memo: CacheMemo | null, now: number): boolean {
45
+ return kept !== null && kept.pings > 0 && memo !== null && memo.expiresAt > now
46
+ }
47
+
40
48
  export function hotAlert(hot: Hot | null, hidden: number | null): boolean {
41
49
  return hot !== null && hot.percent >= 80 && (hidden === null || hot.percent >= hidden + 10)
42
50
  }
@@ -54,9 +62,9 @@ export function swampAlert(percent: number | null, hidden: number | null): boole
54
62
  /** Rendering consumes eligibility, keeping dismissal and idle decisions outside drawing code. */
55
63
  export function bandPriority(state: {
56
64
  panel?: 'settings' | 'compact' | 'handoff' | 'setup' | null
57
- judgeDown?: boolean; hot?: boolean; result?: boolean; cold?: boolean; swamp?: boolean
58
- }): 'settings' | 'compact' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'dashboard' {
65
+ judgeDown?: boolean; hot?: boolean; result?: boolean; cold?: boolean; swamp?: boolean; kept?: boolean
66
+ }): 'settings' | 'compact' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'kept' | 'dashboard' {
59
67
  if (state.panel) return state.panel
60
- for (const band of ['judgeDown', 'hot', 'result', 'cold', 'swamp'] as const) if (state[band]) return band
68
+ for (const band of ['judgeDown', 'hot', 'result', 'cold', 'swamp', 'kept'] as const) if (state[band]) return band
61
69
  return 'dashboard'
62
70
  }
@@ -12,7 +12,7 @@ import { t } from '../i18n'
12
12
  import type { RenderNode } from 'claude-code'
13
13
 
14
14
  import type { HandoffKind, Settings } from '../../types'
15
- import { CELL_PX, MUTE, SUB, TONE, pillArt } from './art'
15
+ import { CELL_PX, MUTE, ROW_PX, SUB, TONE, pillArt } from './art'
16
16
  import type { Sea, Tone } from './art'
17
17
  import { capsule, closeButton, pill, ripple, segmented as drawnSegmented, well } from './kit'
18
18
  import type { SegmentOption } from './kit'
@@ -61,6 +61,14 @@ function cardOf(frame: Frame, alert: AlertView, a: AlertActions): Card {
61
61
  quiet: [handoff],
62
62
  close: notNow,
63
63
  }
64
+ case 'kept':
65
+ return {
66
+ key: 'kept',
67
+ face: 'pleased',
68
+ title: t(L, 'alerts.keptTitle'),
69
+ body: t(L, 'alerts.keptBody', { pings: alert.pings, tokens: compactNumber(alert.tokens), minutes: alert.minutesLeft }),
70
+ close: { key: 'dismiss', label: t(L, 'alerts.gotIt'), hotkey: 'x', onPress: a.dismiss },
71
+ }
64
72
  case 'swamp':
65
73
  return {
66
74
  key: 'swamp',
@@ -205,6 +213,8 @@ function seaOf(card: Card, alert: AlertView): Omit<Sea, 'at'> {
205
213
  switch (card.key) {
206
214
  case 'cold':
207
215
  return { tone: 'ice', weather: 'snow', motes: true }
216
+ case 'kept':
217
+ return { tone: 'amber', weather: 'sparkles', ripples: 'calm' }
208
218
  case 'swamp':
209
219
  return { tone: 'amber', weather: 'bubbles', ripples: 'calm' }
210
220
  case 'hot':
@@ -305,8 +315,11 @@ function drawnAlert(el: Els, frame: Frame, alert: AlertView, a: AlertActions): R
305
315
  const oneRow = !more.length && faceCells + chOf(card.title) * 1.12 + 2 + chOf(card.body) + 2 + actionCells <= room
306
316
  // Too narrow to keep the actions beside the words: they go under them.
307
317
  const actionsBelow = !oneRow && room - faceCells - actionCells < 30
318
+ // Beside a title and a why of two lines, the actions sit at the card's middle, not on the title's
319
+ // line (2026-10-13: they rode high). Beside the handoff's options they stay by the title, the ×
320
+ // where a way out is looked for.
308
321
  const buttons = (
309
- <Box key="actions" flexDirection="row" alignItems="center" columnGap={1} flexShrink={0} flexWrap="wrap" rowGap={1}>
322
+ <Box key="actions" flexDirection="row" alignItems="center" alignSelf={more.length ? 'flex-start' : 'center'} columnGap={1} flexShrink={0} flexWrap="wrap" rowGap={1}>
310
323
  {quiet.map(c => pill(el, c.key, c.label, 'ghost', c.onPress, tone))}
311
324
  {main ? pill(el, main.key, main.label, 'primary', main.onPress, tone) : null}
312
325
  {card.close ? closeButton(el, card.close.key, card.close.label, card.close.onPress) : null}
@@ -326,7 +339,8 @@ function drawnAlert(el: Els, frame: Frame, alert: AlertView, a: AlertActions): R
326
339
  </Box>
327
340
  ) : (
328
341
  <Box key="top" flexDirection="row" alignItems="flex-start" columnGap={2} width="100%" minWidth={0}>
329
- {face}
342
+ {/* Beside two lines 리플이 sits at their middle, not on the title's line (2026-10-14: it rode high). */}
343
+ <Box key="face" alignSelf="center" flexShrink={0}>{face}</Box>
330
344
  <Box key="say" flexDirection="column" rowGap={1} flexGrow={1} flexShrink={1} minWidth={0}>
331
345
  {title}
332
346
  {body}
@@ -336,10 +350,12 @@ function drawnAlert(el: Els, frame: Frame, alert: AlertView, a: AlertActions): R
336
350
  {actionsBelow ? null : buttons}
337
351
  </Box>
338
352
  )
353
+ const rows = oneRow ? 2 : 4 + (more.length ? 3.2 : 0) + (actionsBelow ? 2 : 0) + (frame.columns < 60 ? 3 : 0)
339
354
  return capsule(el, frame, {
340
355
  key: 'alert-' + card.key,
341
- sea: { ...sea, reach: 130, at: { x: 1.5 * CELL_PX + CARD_FACE / 2, y: 0.5 * 19 * 0.5 + CARD_FACE / 2 } },
342
- rows: oneRow ? 2 : 4 + (more.length ? 3.2 : 0) + (actionsBelow ? 2 : 0) + (frame.columns < 60 ? 3 : 0),
356
+ // The light comes from 리플이: on the title's line in one row, at the card's middle beside more.
357
+ sea: { ...sea, reach: 130, at: { x: 1.5 * CELL_PX + CARD_FACE / 2, y: oneRow ? 0.5 * ROW_PX * 0.5 + CARD_FACE / 2 : rows * ROW_PX / 2 } },
358
+ rows,
343
359
  children: [top],
344
360
  })
345
361
  }
@@ -596,7 +596,7 @@ function controlsDrawn(el: Els, frame: Frame, view: DashboardView, actions: Band
596
596
  ? { key: 'auto', cells: toggleCells(autoLabel), node: toggle(el, 'auto', view.auto, autoLabel, actions.toggleAuto, view.effort.mode === 'deciding', tipScope('auto')), tip: autoTip }
597
597
  : { key: 'auto', cells: SWITCH_ONLY, node: lit(el, 'auto', SWITCH_ONLY, [pic(el, 'auto', switchArt(view.auto, view.effort.mode === 'deciding'), autoLabel, 36, 22), press(el, 'auto', SWITCH_ONLY * CELL_PX, actions.toggleAuto)], 0.25, tipScope('auto')), tip: autoTip })
598
598
  const warmLabel = t(L, 'band.warm')
599
- out.push(iconControl(el, 'warm', iconArt('flame', view.warm.on, 'amber'), ICON_PX, warmLabel, t(L, 'band.warmAlt', { state: state(view.warm.on) }) + ' · ' + t(L, 'settings.warmLine'), actions.toggleWarm, words.includes('warm') ? warmLabel : undefined, view.warm.on ? TONE.amber.text : SUB))
599
+ out.push(iconControl(el, 'warm', iconArt('flame', view.warm.on, 'amber'), ICON_PX, warmLabel, t(L, 'band.warmAlt', { state: state(view.warm.on) }) + ' · ' + t(L, view.warm.here ? 'band.warmHere' : 'settings.warmLine'), actions.toggleWarm, words.includes('warm') ? warmLabel : undefined, view.warm.on ? TONE.amber.text : SUB))
600
600
  const agentsLabel = t(L, 'band.agents')
601
601
  const live = view.agents.live
602
602
  // The live count sits on the icon's shoulder, in one picture of one width: no width change.
@@ -65,7 +65,8 @@ export type DashboardView = {
65
65
  context: { percent: number; tokens: number; window: number } | null
66
66
  /** The last main reply's cache; null when unknown. */
67
67
  cache: { expiresAt: number; ttlMs: number } | null
68
- warm: { on: boolean; pings: number; cost: number; stopped?: string }
68
+ /** `here`: this chat differs from Settings, set by the band's flame. */
69
+ warm: { on: boolean; pings: number; cost: number; stopped?: string; here?: boolean }
69
70
  /** The hottest plan window, shown when present. */
70
71
  limit: { kind: string; percent: number; resetsAt?: string; save: boolean } | null
71
72
  route: RouteView | null
@@ -113,6 +114,8 @@ export type ResultStage = 'handingOff' | 'handoffReady' | 'handoffDone' | 'compa
113
114
  export type AlertView =
114
115
  /** The cache expired on a big chat: the next prompt re-reads it all. */
115
116
  | { kind: 'cold'; tokens: number; idleMinutes: number }
117
+ /** Keep-warm held the cache while the person was away. */
118
+ | { kind: 'kept'; pings: number; tokens: number; minutesLeft: number }
116
119
  /** Context is heavy. */
117
120
  | { kind: 'swamp'; percent: number; tokens: number }
118
121
  /** A plan window at or above 80%. */
@@ -918,6 +918,42 @@ test('keep-warm forks near expiry, refreshes the memo and stops once on a null f
918
918
  expect(stats.text).toContain('Warm: 1 pings')
919
919
  })
920
920
 
921
+ test('a cache-reading ping keeps the card, a real reply and /clear drop it, and the demo draws it', async ($, on) => {
922
+ const b = boundaries(on, { keepWarm: true })
923
+ http(on)
924
+ judgeStub(on)
925
+ stepStub(on)
926
+ on('model.fork', () => ({ value: { isAnswered: true, text: 'ok', usage: USAGE } }))
927
+ await start($)
928
+ await step($)
929
+ await complete($)
930
+ await b.clock.advance(3375000)
931
+ // The ping read 40000 cache tokens: the card tells it, with the memo's minutes left.
932
+ const ui = await band($)
933
+ expect(await ui.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeDefined()
934
+ await ui.unmount()
935
+ // A prompt counts as having seen the card; the next is a real reply, which clears the count.
936
+ await submit($)
937
+ await step($)
938
+ await complete($)
939
+ const after = await band($)
940
+ expect(await after.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeUndefined()
941
+ await after.unmount()
942
+ await b.clock.advance(3375000)
943
+ await b.clock.settle()
944
+ const again = await band($)
945
+ expect(await again.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeDefined()
946
+ await again.unmount()
947
+ await $.classic.SessionStart({ source: 'clear' })
948
+ const cleared = await band($)
949
+ expect(await cleared.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeUndefined()
950
+ await cleared.unmount()
951
+ // The demo shows the card with sample values, whatever the state.
952
+ await command($, 'kept')
953
+ const demo = await band($)
954
+ expect(await demo.find({ type: 'Text', text: /held 312k tokens in cache/ })).toBeDefined()
955
+ })
956
+
921
957
  test('fork turn.step is not effort-steered, busy or main-request cost', async ($, on) => {
922
958
  const b = boundaries(on, { keepWarm: true })
923
959
  http(on)
@@ -941,6 +977,23 @@ test('fork turn.step is not effort-steered, busy or main-request cost', async ($
941
977
  expect(stats.text).toContain('Warm: 1 pings')
942
978
  })
943
979
 
980
+ test('the flame turns keep-warm off for this chat only; Settings stay as they are', async ($, on) => {
981
+ const b = boundaries(on, { keepWarm: true })
982
+ http(on)
983
+ judgeStub(on, '{"effort":"high","why":"scope"}')
984
+ stepStub(on)
985
+ let forks = 0
986
+ on('model.fork', async () => { forks++; return { value: { isAnswered: true, text: 'ok', usage: USAGE } } })
987
+ await start($)
988
+ await submit($)
989
+ await step($)
990
+ await complete($)
991
+ await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'warm' })
992
+ await b.clock.advance(3375000)
993
+ expect(forks).toBe(0)
994
+ expect(b.router().settings.keepWarm).toBe(true)
995
+ })
996
+
944
997
  test('Haiku compaction returns replacement messages and forwards the keep note', async ($, on) => {
945
998
  boundaries(on)
946
999
  http(on)