clauderipple 0.9.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2 — 2026-10-14
4
+
5
+ ### Added
6
+
7
+ - **The mod shows when keep-warm held your cache.** A successful ping used to be silent, so it was
8
+ easy to think keep-warm never ran. When you come back, a card now says how many pings kept how
9
+ many tokens in cache and how long is left. It goes when you send a prompt or close it, and the
10
+ cold-cache card takes over if the cache expires after all.
11
+ - **Keep-warm per chat.** The band's flame now turns keep-warm on or off for that chat only: a full
12
+ flame where it is on, an empty one where it is off. Settings is what a new chat starts with.
13
+ After a restart or resume, a chat follows Settings again.
14
+
15
+ - **Grok through a Grok subscription.** A new "Grok subscription" provider uses Grok 4.7, 4.7
16
+ Fast, 4.6 and 4.5 with the Grok CLI's own sign-in (`grok login`), with no API key. It reads the
17
+ CLI's session and, when the session is about to expire, has the CLI refresh it. Tools,
18
+ thinking and effort (low…xhigh, per model) work as on the other translated providers. This
19
+ is the channel the Grok CLI itself talks to, not a published xAI API, so a CLI update can
20
+ change it. Pay-per-token access with an xAI API key stays under the xAI preset. Thanks @nakjiya (#52).
21
+
22
+ ### Fixed
23
+
24
+ - An OpenAI-compatible provider's error that echoed the bare token, without its `Bearer` scheme,
25
+ could show it in the error message. It is now redacted like the full header value (#52).
26
+ - On a two-line mod card, 리플이 sat on the title's line; it now sits at the card's middle.
27
+
3
28
  ## 0.9.1 — 2026-10-14
4
29
 
5
30
  ### Added
package/README.es.md CHANGED
@@ -552,6 +552,7 @@ compatible con Anthropic que hayas configurado está disponible de la misma form
552
552
  |---|---|---|---|---|
553
553
  | Suscripción de ChatGPT | Backend de Codex | varios inicios de sesión, con cambio cuando uno se agota (también reutiliza el de Codex) | leída de tu suscripción (GPT-6 Sol, Luna, Astra, GPT-5.6 …) | esfuerzo low…max (Luna: ultra), caché de prompts 94–99 % |
554
554
  | Google Gemini | API de Gemini | clave de API de AI Studio o inicio de sesión con cuenta de Google (Antigravity, consulta las preguntas frecuentes) | descubierta | traducido (Messages ⇄ generateContent), las firmas de pensamiento se conservan entre turnos |
555
+ | Suscripción de Grok | proxy de chat de la CLI de Grok | el inicio de sesión de la CLI de Grok (`grok login`), renovado por la CLI | leída de tu suscripción (Grok 4.7, 4.7 Fast, 4.6, 4.5) | traducido (Chat Completions), esfuerzo low…xhigh; canal propio de la CLI, no una API publicada ([§4e](docs/ARCHITECTURE.md#4e-grok-providers-implemented-2026-10-08)) |
555
556
  | OpenRouter | Compatible con Anthropic | clave de API | más de 400, descubierta | compatibilidad de esfuerzo por modelo leída de la API |
556
557
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen (intl / cn) | Compatible con Anthropic | clave de API | predefinida | verificado con la documentación del proveedor |
557
558
  | xAI Grok, Mistral, Groq, Together, Fireworks | Compatible con OpenAI | clave de API | descubierta | traducido (Chat Completions / Responses) |
package/README.ja.md CHANGED
@@ -321,6 +321,7 @@ ClaudeのモデルはCodexのモデル一覧にそれぞれの名前で表示さ
321
321
  |---|---|---|---|---|
322
322
  | ChatGPTサブスクリプション | Codexバックエンド | 複数ログイン、上限で自動切り替え(Codexのログインも再利用) | サブスクリプションから取得(GPT-6 Sol、Luna、Astra、GPT-5.6 …) | 推論強度 low…max(Lunaはultra)、プロンプトキャッシュ 94–99 % |
323
323
  | Google Gemini | Gemini API | AI StudioのAPIキー、またはGoogleアカウントでのログイン(Antigravity、FAQ参照) | 自動検出 | 変換(Messages ⇄ generateContent)、thought signatureをターン間で保持 |
324
+ | Grokサブスクリプション | Grok CLIのチャットプロキシ | Grok CLIのログイン(`grok login`)、更新はCLIが行う | サブスクリプションから取得(Grok 4.7、4.7 Fast、4.6、4.5) | 変換(Chat Completions)、推論強度 low…xhigh。公開APIではなくCLI専用の経路([§4e](docs/ARCHITECTURE.md#4e-grok-providers-implemented-2026-10-08)) |
324
325
  | OpenRouter | Anthropic互換 | APIキー | 400以上、自動検出 | モデルごとの推論強度への対応をAPIから取得 |
325
326
  | DeepSeek、Kimi、Z.ai GLM、MiniMax、Qwen(国際版 / 中国版) | Anthropic互換 | APIキー | プリセット | ベンダーのドキュメントで確認済み |
326
327
  | xAI Grok、Mistral、Groq、Together、Fireworks | OpenAI互換 | APIキー | 自動検出 | 変換(Chat Completions / Responses) |
package/README.ko.md CHANGED
@@ -449,6 +449,7 @@ Anthropic API 키로 갑니다. 구독 로그인 재사용은 Anthropic 약관
449
449
  |---|---|---|---|---|
450
450
  | ChatGPT 구독 | Codex 백엔드 | 로그인 여러 개, 한도 차면 자동 전환(Codex 로그인도 재사용) | 구독에서 자동으로 읽음(GPT-6 Sol·Luna·Astra, GPT-5.6 …) | 추론 강도 low…max(Luna는 ultra), 프롬프트 캐시 94~99 % |
451
451
  | Google Gemini | Gemini API | AI Studio API 키, 또는 Google 계정 로그인(Antigravity, 위 FAQ 참고) | 자동 검색 | 번역(Messages ⇄ generateContent), 턴 사이 thought signature 유지 |
452
+ | Grok 구독 | Grok CLI 채팅 프록시 | Grok CLI 로그인(`grok login`), 갱신은 CLI가 함 | 구독에서 자동으로 읽음(Grok 4.7, 4.7 Fast, 4.6, 4.5) | 번역(Chat Completions), 추론 강도 low…xhigh. 공개 API가 아닌 CLI 전용 채널([§4e](docs/ARCHITECTURE.md#4e-grok-providers-implemented-2026-10-08)) |
452
453
  | OpenRouter | Anthropic 호환 | API 키 | 400+, 자동 검색 | 모델별 추론 강도 지원을 API에서 읽음 |
453
454
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen(국제/중국) | Anthropic 호환 | API 키 | 프리셋 | 벤더 공식 문서로 확인 |
454
455
  | xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI 호환 | API 키 | 자동 검색 | 번역(Chat Completions / Responses) |
package/README.md CHANGED
@@ -527,6 +527,7 @@ provider you configured is available the same way.
527
527
  |---|---|---|---|---|
528
528
  | ChatGPT subscription | Codex backend | several sign-ins, switching when one runs out (Codex login reused too) | read from your subscription (GPT-6 Sol, Luna, Astra, GPT-5.6 …) | effort low…max (Luna: ultra), prompt cache 94–99 % |
529
529
  | Google Gemini | Gemini API | AI Studio API key, or Google account sign-in (Antigravity, see the FAQ) | discovered | translated (Messages ⇄ generateContent), thought signatures kept across turns |
530
+ | Grok subscription | Grok CLI chat proxy | the Grok CLI's sign-in (`grok login`), refreshed through the CLI | read from your subscription (Grok 4.7, 4.7 Fast, 4.6, 4.5) | translated (Chat Completions), effort low…xhigh; the CLI's own channel, not a published API ([§4e](docs/ARCHITECTURE.md#4e-grok-providers-implemented-2026-10-08)) |
530
531
  | OpenRouter | Anthropic-compatible | API key | 400+, discovered | per-model effort support read from the API |
531
532
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen (intl / cn) | Anthropic-compatible | API key | preset | verified against vendor docs |
532
533
  | xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI-compatible | API key | discovered | translated (Chat Completions / Responses) |
package/README.pt-BR.md CHANGED
@@ -539,6 +539,7 @@ que você tenha configurado fica disponível da mesma forma.
539
539
  |---|---|---|---|---|
540
540
  | Assinatura do ChatGPT | backend do Codex | vários logins, trocando quando um se esgota (o login do Codex também é reaproveitado) | lida da sua assinatura (GPT-6 Sol, Luna, Astra, GPT-5.6 …) | esforço low…max (Luna: ultra), cache de prompt 94–99% |
541
541
  | Google Gemini | API Gemini | chave de API do AI Studio, ou login com conta do Google (Antigravity, veja as Perguntas frequentes) | descoberta automática | traduzido (Messages ⇄ generateContent), thought signatures mantidas entre turnos |
542
+ | Assinatura do Grok | proxy de chat da CLI do Grok | o login da CLI do Grok (`grok login`), renovado pela CLI | lida da sua assinatura (Grok 4.7, 4.7 Fast, 4.6, 4.5) | traduzido (Chat Completions), esforço low…xhigh; canal próprio da CLI, não uma API publicada ([§4e](docs/ARCHITECTURE.md#4e-grok-providers-implemented-2026-10-08)) |
542
543
  | OpenRouter | compatível com Anthropic | chave de API | mais de 400, descoberta automática | suporte a esforço por modelo lido da API |
543
544
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen (intl / cn) | compatível com Anthropic | chave de API | preset | conferido com a documentação do fornecedor |
544
545
  | xAI Grok, Mistral, Groq, Together, Fireworks | compatível com OpenAI | chave de API | descoberta automática | traduzido (Chat Completions / Responses) |
package/README.zh-CN.md CHANGED
@@ -322,6 +322,7 @@ Claude 模型会以各自的名称出现在 Codex 的模型列表中(ClaudeRip
322
322
  |---|---|---|---|---|
323
323
  | ChatGPT 订阅 | Codex 后端 | 可登录多个账号,一个用尽自动切换(也会复用 Codex 登录) | 从你的订阅读取(GPT-6 Sol、Luna、Astra、GPT-5.6 …) | 推理强度 low…max(Luna:ultra),提示缓存 94–99 % |
324
324
  | Google Gemini | Gemini API | AI Studio API 密钥,或 Google 账号登录(Antigravity,见[常见问题](#通过-google-账号使用-gemini-呢)) | 自动发现 | 格式转换(Messages ⇄ generateContent),跨轮次保留思维签名(thought signatures) |
325
+ | Grok 订阅 | Grok CLI 聊天代理 | Grok CLI 的登录(`grok login`),由 CLI 刷新 | 从你的订阅读取(Grok 4.7、4.7 Fast、4.6、4.5) | 格式转换(Chat Completions),推理强度 low…xhigh;CLI 自己的通道,不是公开 API([§4e](docs/ARCHITECTURE.md#4e-grok-providers-implemented-2026-10-08)) |
325
326
  | OpenRouter | Anthropic 兼容 | API 密钥 | 400+,自动发现 | 从 API 读取各模型对推理强度的支持情况 |
326
327
  | DeepSeek、Kimi、Z.ai GLM、MiniMax、Qwen(国际版 / 国内版) | Anthropic 兼容 | API 密钥 | 预设 | 已对照厂商文档验证 |
327
328
  | xAI Grok、Mistral、Groq、Together、Fireworks | OpenAI 兼容 | API 密钥 | 自动发现 | 格式转换(Chat Completions / Responses) |
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "type": "commonjs"
5
5
  }
@@ -20,6 +20,7 @@ import { applyAppProxy, caTrusted, currentAppProxy, nssDb, removeAppProxy, trust
20
20
  import { runtime } from "./runtime.js";
21
21
  import { codexOff, codexOn } from "./codex.js";
22
22
  import { ingressModels } from "../../router/src/ingress/models.js";
23
+ import { GROK_DEFAULT_URL } from "../../router/src/providers/grok/catalog.js";
23
24
  import { claudeLogin, claudeLogout, desktopClaudeCodeDirs } from "./claude-auth.js";
24
25
  import { openBrowser } from "./browser.js";
25
26
  import { electronPath, installTrayRuntime, startTray } from "./tray.js";
@@ -315,7 +316,7 @@ async function status() {
315
316
  const p = await probe({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
316
317
  rows.push(["probe", `${p.ok ? "ok" : "FAIL"}: ${p.detail} (${p.ms}ms)`]);
317
318
  for (const [name, p2] of Object.entries(cfg.providers)) {
318
- const providerUrl = p2.type === "chatgpt" ? (p2.url ?? "https://chatgpt.com") : p2.type === "anthropic" ? "https://api.anthropic.com" : p2.type === "google" ? (p2.url ?? "https://generativelanguage.googleapis.com") : p2.url;
319
+ const providerUrl = p2.type === "chatgpt" ? (p2.url ?? "https://chatgpt.com") : p2.type === "anthropic" ? "https://api.anthropic.com" : p2.type === "google" ? (p2.url ?? "https://generativelanguage.googleapis.com") : p2.type === "grok" ? (p2.url ?? GROK_DEFAULT_URL) : p2.url;
319
320
  const u = new URL(providerUrl);
320
321
  rows.push([`provider ${name}`, await tcpCheck(u.hostname, Number(u.port) || (u.protocol === "https:" ? 443 : 80))]);
321
322
  }
@@ -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.',
@@ -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':
@@ -329,7 +339,8 @@ function drawnAlert(el: Els, frame: Frame, alert: AlertView, a: AlertActions): R
329
339
  </Box>
330
340
  ) : (
331
341
  <Box key="top" flexDirection="row" alignItems="flex-start" columnGap={2} width="100%" minWidth={0}>
332
- {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>
333
344
  <Box key="say" flexDirection="column" rowGap={1} flexGrow={1} flexShrink={1} minWidth={0}>
334
345
  {title}
335
346
  {body}
@@ -339,10 +350,12 @@ function drawnAlert(el: Els, frame: Frame, alert: AlertView, a: AlertActions): R
339
350
  {actionsBelow ? null : buttons}
340
351
  </Box>
341
352
  )
353
+ const rows = oneRow ? 2 : 4 + (more.length ? 3.2 : 0) + (actionsBelow ? 2 : 0) + (frame.columns < 60 ? 3 : 0)
342
354
  return capsule(el, frame, {
343
355
  key: 'alert-' + card.key,
344
- sea: { ...sea, reach: 130, at: { x: 1.5 * CELL_PX + CARD_FACE / 2, y: 0.5 * 19 * 0.5 + CARD_FACE / 2 } },
345
- 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,
346
359
  children: [top],
347
360
  })
348
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%. */