clauderipple 0.8.9 → 0.9.0

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 (75) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.es.md +0 -4
  3. package/README.ja.md +0 -4
  4. package/README.ko.md +5 -1
  5. package/README.md +5 -1
  6. package/README.pt-BR.md +0 -4
  7. package/README.zh-CN.md +0 -4
  8. package/dist/app/dist/main.js +23 -3
  9. package/dist/app/package.json +1 -1
  10. package/dist/cli/src/codex.js +3 -1
  11. package/dist/cli/src/index.js +1 -1
  12. package/dist/cli/src/settings.js +5 -2
  13. package/dist/cli/src/tray.js +41 -5
  14. package/dist/mod/.claude-plugin/plugin.json +9 -1
  15. package/dist/mod/hooks/cache.ts +60 -0
  16. package/dist/mod/hooks/compact.ts +40 -0
  17. package/dist/mod/hooks/handoff.ts +43 -0
  18. package/dist/mod/hooks/i18n.ts +2716 -0
  19. package/dist/mod/hooks/judge.ts +226 -0
  20. package/dist/mod/hooks/ladder.js +179 -0
  21. package/dist/mod/hooks/ladder.ts +174 -0
  22. package/dist/mod/hooks/modes.ts +93 -0
  23. package/dist/mod/hooks/register.tsx +1458 -256
  24. package/dist/mod/hooks/session.ts +62 -0
  25. package/dist/mod/hooks/settings.js +342 -0
  26. package/dist/mod/hooks/settings.ts +321 -0
  27. package/dist/mod/hooks/stats.ts +59 -0
  28. package/dist/mod/hooks/ui/agents.tsx +138 -0
  29. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  30. package/dist/mod/hooks/ui/art.ts +628 -0
  31. package/dist/mod/hooks/ui/band.tsx +836 -0
  32. package/dist/mod/hooks/ui/kit.tsx +258 -0
  33. package/dist/mod/hooks/ui/log.tsx +109 -0
  34. package/dist/mod/hooks/ui/parts.tsx +217 -0
  35. package/dist/mod/hooks/ui/ripple.ts +614 -0
  36. package/dist/mod/hooks/ui/settings.tsx +376 -0
  37. package/dist/mod/hooks/ui/setup.tsx +148 -0
  38. package/dist/mod/hooks/ui/theme.ts +217 -0
  39. package/dist/mod/hooks/ui/view.ts +371 -0
  40. package/dist/mod/hooks/worker.ts +1 -1
  41. package/dist/mod/hooks/workers.ts +217 -0
  42. package/dist/mod/tests/register.test.tsx +1730 -0
  43. package/dist/mod/tests/ui.test.tsx +853 -0
  44. package/dist/mod/tsconfig.json +2 -1
  45. package/dist/mod/types/index.d.ts +320 -1
  46. package/dist/router/src/admin.js +195 -53
  47. package/dist/router/src/agents.js +24 -5
  48. package/dist/router/src/bootstrap.js +4 -2
  49. package/dist/router/src/compat.js +10 -2
  50. package/dist/router/src/config.js +3 -0
  51. package/dist/router/src/health.js +33 -0
  52. package/dist/router/src/index.js +1 -0
  53. package/dist/router/src/ingress/models.js +4 -1
  54. package/dist/router/src/mod-settings.js +119 -0
  55. package/dist/router/src/model-policy.js +6 -0
  56. package/dist/router/src/model-scores.js +159 -0
  57. package/dist/router/src/picker.js +7 -0
  58. package/dist/router/src/providers/anthropic-account-pool.js +10 -4
  59. package/dist/router/src/providers/anthropic-accounts.js +26 -1
  60. package/dist/router/src/providers/chatgpt/catalog.js +45 -14
  61. package/dist/router/src/providers/chatgpt/index.js +9 -3
  62. package/dist/router/src/providers/chatgpt/translate.js +9 -5
  63. package/dist/router/src/providers/google/index.js +6 -2
  64. package/dist/router/src/providers/openai/index.js +4 -1
  65. package/dist/router/src/providers/openai/translate.js +1 -1
  66. package/dist/router/src/proxy.js +14 -3
  67. package/dist/router/src/version.js +1 -1
  68. package/dist/ui/app.js +932 -209
  69. package/dist/ui/favicon-32.png +0 -0
  70. package/dist/ui/favicon.svg +12 -0
  71. package/dist/ui/i18n.js +923 -237
  72. package/dist/ui/index.html +159 -87
  73. package/dist/ui/style.css +789 -408
  74. package/docs/ARCHITECTURE.md +134 -2
  75. package/package.json +7 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — 2026-10-13
4
+
5
+ ### Added
6
+
7
+ - **The ripple mod.** A band above the Code tab's prompt shows reasoning effort, context, the
8
+ cache timer, and which model answered each request with its cache hit. Log opens the request
9
+ log, ⚙ opens quick settings, and the dashboard has a mod page with every setting.
10
+ - **Effort picked per prompt.** A small judge model (Haiku 5.5 or Jev) reads the prompt and the
11
+ recent turns, then sets low through max. Pick an effort yourself whenever you want; Auto comes
12
+ back when you switch it on.
13
+ - **Workers picked per task.** The main chat hands work to `ripple:auto`, and the judge decides
14
+ how hard each task is and sends it to the matching rung: light work to DeepSeek or GLM, hard work
15
+ to GPT or Opus. Rungs, candidate models and named modes (prefer or avoid a model, eager
16
+ delegation, switch off at a quota reset) are set on the dashboard. Every pick is kept in
17
+ `logs/worker-picks.jsonl`.
18
+ - **Warnings that save tokens.** Cards appear when the prompt cache has gone cold on a big chat,
19
+ when the context gets heavy, and when a plan window passes 80%. Each card offers compact or a
20
+ handoff.
21
+ - **Handoff to a fresh session.** A handoff writes a summary and opens a new session in the same
22
+ folder with it in the prompt box. The current chat is left as it is.
23
+ - **리플이**, the mascot, on the band and every card, acting out what the band is doing. Mod text
24
+ in six languages, following the Mac's system language.
25
+ - **Artificial Analysis scores** for every routable model, refreshed daily, used to rank worker
26
+ candidates by measured cost.
27
+ - A provider that answers 402 or says its quota is exhausted is skipped for a while.
28
+
29
+ ### Changed
30
+
31
+ - Dashboard settings are rebuilt in the style of macOS System Settings.
32
+ - The mod's settings live in the router, so every session follows a change at once.
33
+
34
+ ### Fixed
35
+
36
+ - Judge-only models such as Jev no longer appear in pickers, worker lists or Codex.
37
+ - The install script works under macOS `/bin/sh` again.
38
+
39
+ ## 0.8.10 — 2026-10-10
40
+
41
+ ### Added
42
+
43
+ - **Choose which Claude account is used first.** With several Claude accounts, the current Claude
44
+ login was always tried first. Each account card now has a "Use first" button (Providers →
45
+ Claude → Accounts). New conversations start on that account, move to the next one when it hits
46
+ its limit, and return to it once the limit resets. A conversation already running stays on its
47
+ account to keep its cache.
48
+
49
+ ### Fixed
50
+
51
+ - **DeepSeek subagents no longer fail with 400 on the Artifact tool.** DeepSeek's Anthropic
52
+ endpoint rejected Claude Desktop's Artifact tool over a regular expression it cannot compile
53
+ (`"^[^\0]*$" is not a "regex"`). That pattern is now removed on the way out, as it already was for
54
+ OpenAI-format providers (#51).
55
+ - **The tray starts again after a Windows restart.** Installed with the install script, the tray
56
+ was set to open at logon without its own script, so Windows opened a bare Electron instead
57
+ (#50). Restarting the tray once after updating fixes the entry. `clauderipple tray` now also
58
+ reports a tray that quits right after starting, with its output in `logs/tray.log`. Running it
59
+ while a tray is already up no longer adds a second icon.
60
+ - **New ChatGPT models show up without the Codex CLI installed.** OpenAI's model list hides
61
+ models from older Codex versions, and without a Codex CLI the router asked as 0.155.0, so GPT-6.1
62
+ Sol never appeared (#50). It now asks as the newest published Codex. New models appear unticked in
63
+ the ChatGPT provider form.
64
+
3
65
  ## 0.8.9 — 2026-10-08
4
66
 
5
67
  ### Fixed
package/README.es.md CHANGED
@@ -1,7 +1,3 @@
1
- <p align="center">
2
- <img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
3
- </p>
4
-
5
1
  <h1 align="center">ClaudeRipple</h1>
6
2
 
7
3
  <p align="center"><a href="README.md">English</a> · <a href="README.ko.md">한국어</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.ja.md">日本語</a> · <b>Español</b> · <a href="README.pt-BR.md">Português (BR)</a></p>
package/README.ja.md CHANGED
@@ -1,7 +1,3 @@
1
- <p align="center">
2
- <img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
3
- </p>
4
-
5
1
  <h1 align="center">ClaudeRipple</h1>
6
2
 
7
3
  <p align="center"><a href="README.md">English</a> · <a href="README.ko.md">한국어</a> · <a href="README.zh-CN.md">简体中文</a> · <b>日本語</b> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (BR)</a></p>
package/README.ko.md CHANGED
@@ -1,5 +1,9 @@
1
1
  <p align="center">
2
- <img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="brand/mark-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="brand/mark.svg">
5
+ <img src="brand/mark.svg" width="120" height="120" alt="ClaudeRipple 마스코트 리플이: 속이 빈 파문 고리에 물마루가 말려 오른 모습">
6
+ </picture>
3
7
  </p>
4
8
 
5
9
  <h1 align="center">ClaudeRipple</h1>
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  <p align="center">
2
- <img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="brand/mark-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="brand/mark.svg">
5
+ <img src="brand/mark.svg" width="120" height="120" alt="The ClaudeRipple mascot: a hollow ripple ring with a curling wave crest">
6
+ </picture>
3
7
  </p>
4
8
 
5
9
  <h1 align="center">ClaudeRipple</h1>
package/README.pt-BR.md CHANGED
@@ -1,7 +1,3 @@
1
- <p align="center">
2
- <img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
3
- </p>
4
-
5
1
  <h1 align="center">ClaudeRipple</h1>
6
2
 
7
3
  <p align="center"><a href="README.md">English</a> · <a href="README.ko.md">한국어</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.es.md">Español</a> · <b>Português (BR)</b></p>
package/README.zh-CN.md CHANGED
@@ -1,7 +1,3 @@
1
- <p align="center">
2
- <img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
3
- </p>
4
-
5
1
  <h1 align="center">ClaudeRipple</h1>
6
2
 
7
3
  <p align="center"><a href="README.md">English</a> · <a href="README.ko.md">한국어</a> · <b>简体中文</b> · <a href="README.ja.md">日本語</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (BR)</a></p>
@@ -512,9 +512,19 @@ function writeAppState(patch) {
512
512
  /* a read-only home only costs us the remembered default */
513
513
  }
514
514
  }
515
+ /**
516
+ * How Windows starts this tray at login. A packaged app is its own binary. Installed from npm, the
517
+ * binary is a bare Electron that needs our script after it: registered without one, every logon
518
+ * opened Electron's default app and no tray (issue #50). macOS ignores `path`/`args`.
519
+ */
520
+ function loginItemLaunch() {
521
+ if (electron_1.app.isPackaged || process.platform !== "win32" || !process.argv[1])
522
+ return undefined;
523
+ return { path: process.execPath, args: [node_path_1.default.resolve(process.argv[1])] };
524
+ }
515
525
  function loginItemOn() {
516
526
  try {
517
- return electron_1.app.getLoginItemSettings().openAtLogin;
527
+ return electron_1.app.getLoginItemSettings(loginItemLaunch()).openAtLogin;
518
528
  }
519
529
  catch {
520
530
  return false;
@@ -522,7 +532,7 @@ function loginItemOn() {
522
532
  }
523
533
  function setLoginItem(on) {
524
534
  try {
525
- electron_1.app.setLoginItemSettings({ openAtLogin: on });
535
+ electron_1.app.setLoginItemSettings({ openAtLogin: on, ...loginItemLaunch() });
526
536
  }
527
537
  catch {
528
538
  /* macOS may refuse in an unsigned development build */
@@ -798,7 +808,9 @@ async function offerUpdate(check) {
798
808
  availableUpdate = null;
799
809
  if (electron_1.Notification.isSupported())
800
810
  new electron_1.Notification({ title: "ClaudeRipple", body: L.updateDone(version) }).show();
801
- // A new tray from the new files, then this one goes.
811
+ // A new tray from the new files, then this one goes. The lock is let go first, or the new tray
812
+ // would see this one and leave.
813
+ electron_1.app.releaseSingleInstanceLock();
802
814
  await runCliResult(["tray"]);
803
815
  electron_1.app.quit();
804
816
  }
@@ -914,6 +926,10 @@ function render() {
914
926
  tray.setContextMenu(electron_1.Menu.buildFromTemplate(template));
915
927
  tray.setToolTip(s ? L.tooltip(state) : L.tooltipDown);
916
928
  }
929
+ // One tray. `clauderipple tray` run while one is up started a second icon (Windows VM,
930
+ // 2026-10-10); the newcomer now leaves at once with code 0, which the CLI reads as "already running".
931
+ if (!electron_1.app.requestSingleInstanceLock())
932
+ electron_1.app.exit(0);
917
933
  electron_1.app.whenReady().then(() => {
918
934
  L = STRINGS[uiLang()];
919
935
  // Without this, Windows attributes our notifications to "Electron" instead of ClaudeRipple.
@@ -927,6 +943,10 @@ electron_1.app.whenReady().then(() => {
927
943
  // Default to starting at login on first run; after that the user's choice stands.
928
944
  if (!appState().loginItemInitialized)
929
945
  setLoginItem(true);
946
+ // An entry an older version wrote names bare Electron (the query without args finds it); the
947
+ // user had start-at-login on, so it is rewritten with the script rather than left broken.
948
+ else if (loginItemLaunch() && electron_1.app.getLoginItemSettings().openAtLogin && !loginItemOn())
949
+ setLoginItem(true);
930
950
  tray = new electron_1.Tray(icon("down"));
931
951
  tray.on("click", () => tray?.popUpContextMenu());
932
952
  render();
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.8.9",
3
+ "version": "0.9.0",
4
4
  "type": "commonjs"
5
5
  }
@@ -4,6 +4,7 @@
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { isJudgeOnlyModel } from "../../router/src/model-policy.js";
7
8
  const START = "# >>> ClaudeRipple Codex provider >>>";
8
9
  const END = "# <<< ClaudeRipple Codex provider <<<";
9
10
  const PROFILE_START = "# >>> ClaudeRipple Codex profile >>>";
@@ -76,7 +77,8 @@ export function writeCodexCatalog(models, home = codexHome()) {
76
77
  catch {
77
78
  return undefined;
78
79
  }
79
- const entries = (cached.models ?? []).filter((m) => typeof m.slug === "string");
80
+ models = models.filter(m => !isJudgeOnlyModel(m.id));
81
+ const entries = (cached.models ?? []).filter((m) => typeof m.slug === "string" && !isJudgeOnlyModel(m.slug));
80
82
  const template = entries.find((m) => m.slug === "gpt-5.5" && m.model_messages) ?? entries.find((m) => m.model_messages);
81
83
  if (!template)
82
84
  return undefined;
@@ -647,7 +647,7 @@ try {
647
647
  await update();
648
648
  break;
649
649
  case "tray": {
650
- const result = args.includes("--install") ? installTrayRuntime() : startTray();
650
+ const result = args.includes("--install") ? installTrayRuntime() : await startTray();
651
651
  console.log(result.message);
652
652
  if (!result.ok)
653
653
  process.exit(1);
@@ -216,11 +216,14 @@ export function agentTitleHookEnabled() {
216
216
  }
217
217
  // ---- Status mod (packages/mod) ------------------------------------------------------------
218
218
  /** The `name` in packages/mod/.claude-plugin/plugin.json: how our entry is told from anyone else's. */
219
- export const STATUS_MOD_NAME = "clauderipple-status";
219
+ export const STATUS_MOD_NAME = "ripple";
220
+ /** Its name until 0.8.10: an entry an older install added is still ours to replace or remove. */
221
+ const OLD_STATUS_MOD_NAMES = ["clauderipple-status"];
220
222
  const PLUGIN_DIRS = "CLAUDE_CODE_PLUGIN_DIRS";
221
223
  function isStatusMod(dir) {
222
224
  try {
223
- return JSON.parse(fs.readFileSync(path.join(dir, ".claude-plugin", "plugin.json"), "utf8")).name === STATUS_MOD_NAME;
225
+ const name = JSON.parse(fs.readFileSync(path.join(dir, ".claude-plugin", "plugin.json"), "utf8")).name;
226
+ return name === STATUS_MOD_NAME || OLD_STATUS_MOD_NAMES.includes(name);
224
227
  }
225
228
  catch {
226
229
  return false;
@@ -52,10 +52,11 @@ export function trayEnv(env = process.env) {
52
52
  return out;
53
53
  }
54
54
  /**
55
- * Starts the tray and returns immediately. `detached` outlives this process, which is what a
56
- * command typed in a terminal wants; the supervisor uses the foreground form instead.
55
+ * Starts the tray and returns once it has stayed up for a moment. `detached` outlives this
56
+ * process, which is what a command typed in a terminal wants; the supervisor uses the foreground
57
+ * form instead.
57
58
  */
58
- export function startTray(options = {}) {
59
+ export async function startTray(options = {}) {
59
60
  const current = runtime();
60
61
  if (current.packaged) {
61
62
  // The packaged app IS the tray: relaunching its own binary with no script is what opens it.
@@ -75,15 +76,50 @@ export function startTray(options = {}) {
75
76
  if (!fs.existsSync(main)) {
76
77
  return { ok: false, message: `the tray is not built: ${main} is missing (run: npm run build)` };
77
78
  }
79
+ // Electron's own output goes to a file. With it discarded, a tray that died at launch still
80
+ // printed "✓ tray started" and left nothing to go on (issue #50).
81
+ const log = trayLogPath();
82
+ fs.mkdirSync(path.dirname(log), { recursive: true });
83
+ const out = fs.openSync(log, "a");
78
84
  const child = spawn(electron, [main], {
79
85
  detached: options.detached ?? true,
80
- stdio: "ignore",
86
+ stdio: ["ignore", out, out],
81
87
  // Electron would otherwise re-enter as a plain Node process, since that is how the CLI runs.
82
88
  env: trayEnv(),
83
89
  });
90
+ fs.closeSync(out);
84
91
  if (options.detached ?? true)
85
92
  child.unref();
86
- return { ok: true, message: "✓ tray started" };
93
+ return earlyExit(child, log);
94
+ }
95
+ export function trayLogPath() {
96
+ return path.join(homeDir(), "logs", "tray.log");
97
+ }
98
+ /** A tray that is still running after a moment has started; one that exits before then has not. */
99
+ export function earlyExit(child, log, waitMs = 2_000) {
100
+ return new Promise((resolve) => {
101
+ // Not unref'd: the command has to stay up long enough to see the exit it is waiting for.
102
+ const timer = setTimeout(() => resolve({ ok: true, message: "✓ tray started" }), waitMs);
103
+ const failed = (detail) => {
104
+ clearTimeout(timer);
105
+ let tail = "";
106
+ try {
107
+ tail = fs.readFileSync(log, "utf8").trim().split(/\r?\n/).slice(-8).join("\n");
108
+ }
109
+ catch { /* nothing logged */ }
110
+ resolve({ ok: false, message: `the tray exited at once (${detail}). Its output is in ${log}${tail ? `:\n${tail}` : ""}` });
111
+ };
112
+ child.once("error", (error) => failed(error.message));
113
+ child.once("exit", (code, signal) => {
114
+ // A clean exit at once is a tray that found another one running (main.ts's single-instance lock).
115
+ if (code === 0 && !signal) {
116
+ clearTimeout(timer);
117
+ resolve({ ok: true, message: "✓ tray is already running" });
118
+ }
119
+ else
120
+ failed(signal ? `signal ${signal}` : `exit code ${code}`);
121
+ });
122
+ });
87
123
  }
88
124
  /** The npm that came with the Node running us, as a script we can hand to that same Node. */
89
125
  function npmCli() {
@@ -1 +1,9 @@
1
- { "name": "clauderipple-status", "version": "0.1.0", "description": "Shows where ClaudeRipple sent each request and its request log, and keeps ClaudeRipple workers on their own model and effort.", "types": "./types/index.d.ts" }
1
+ {
2
+ "name": "ripple",
3
+ "displayName": "ClaudeRipple",
4
+ "version": "0.7.0",
5
+ "description": "Picks the reasoning effort for every prompt and the worker for every delegated task, can keep the prompt cache warm, and shows where ClaudeRipple sent each request.",
6
+ "author": { "name": "ClaudeRipple" },
7
+ "license": "GPL-3.0-only",
8
+ "types": "./types/index.d.ts"
9
+ }
@@ -0,0 +1,60 @@
1
+ import type { ModelForkResult } from 'claude-code'
2
+ import type { CacheMemo, Settings, Usage, WarmState } from '../types'
3
+ import { weighted } from './stats'
4
+ import { t } from './i18n.ts'
5
+ import type { Lang } from './i18n.ts'
6
+
7
+ const HOUR = 3600000
8
+ const FIVE_MINUTES = 300000
9
+
10
+ export function ttlFromUsage(usage: Usage): number {
11
+ if ((usage.cache_creation?.ephemeral_1h_input_tokens ?? 0) > 0) return HOUR
12
+ if ((usage.cache_creation?.ephemeral_5m_input_tokens ?? 0) > 0) return FIVE_MINUTES
13
+ return HOUR
14
+ }
15
+
16
+ /** A five-minute tail must not shorten a mostly-read hour-long prefix. */
17
+ export function nextMemo(prev: CacheMemo | null, usage: Usage, now: number): CacheMemo {
18
+ const total = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0)
19
+ const mostlyRead = (usage.cache_read_input_tokens ?? 0) > total / 2
20
+ const explicit = (usage.cache_creation?.ephemeral_1h_input_tokens ?? 0) > 0 || (usage.cache_creation?.ephemeral_5m_input_tokens ?? 0) > 0
21
+ let ttlMs = explicit ? ttlFromUsage(usage) : prev?.ttlMs ?? HOUR
22
+ if (prev?.ttlMs === HOUR && mostlyRead) ttlMs = HOUR
23
+ if (!explicit && prev && now - prev.lastReplyAt > FIVE_MINUTES && mostlyRead) ttlMs = HOUR
24
+ return { ttlMs, lastReplyAt: now, expiresAt: now + ttlMs }
25
+ }
26
+
27
+ export function secondsLeft(memo: CacheMemo | null, now: number): number | null {
28
+ return memo ? Math.max(0, Math.ceil((memo.expiresAt - now) / 1000)) : null
29
+ }
30
+
31
+ /** Never pay to revive an expired cache or extend the last-real-reply bound with our own pings. */
32
+ export function shouldPing(input: {
33
+ settings: Settings; memo: CacheMemo | null; warm: WarmState; now: number; idle: boolean
34
+ contextTokens: number; lastRealReplyAt: number | null
35
+ }): { ping: true } | { ping: false; reason?: string } {
36
+ const { settings, memo, warm, now } = input
37
+ if (!settings.keepWarm) return { ping: false, reason: 'keep warm is off' }
38
+ if (warm.stopped) return { ping: false, reason: warm.stopped }
39
+ if (!input.idle) return { ping: false, reason: 'chat is busy' }
40
+ if (!memo || memo.expiresAt <= now) return { ping: false, reason: 'cache is not warm' }
41
+ if (input.lastRealReplyAt === null || now >= input.lastRealReplyAt + settings.keepWarmHours * HOUR) return { ping: false, reason: 'keep warm time bound reached' }
42
+ if (input.contextTokens < settings.keepWarmMinTokens) return { ping: false, reason: 'chat is too small' }
43
+ const lead = memo.ttlMs === FIVE_MINUTES ? 60000 : 240000
44
+ return memo.expiresAt - now <= lead ? { ping: true } : { ping: false }
45
+ }
46
+
47
+ /** Failed forks stop once; answered cache misses still count the real tokens they spent. */
48
+ export function afterPing(warm: WarmState, forkResult: ModelForkResult | null, now: number, lang: Lang = 'en'): WarmState {
49
+ if (!forkResult?.isAnswered) return { ...warm, stopped: t(lang, 'toasts.warmNoReply') }
50
+ const next: WarmState = { pings: warm.pings + 1, cost: warm.cost + weighted(forkResult.usage), lastPingAt: now }
51
+ if (forkResult.usage.cache_read_input_tokens <= 0) next.stopped = t(lang, 'toasts.warmNoCache')
52
+ return next
53
+ }
54
+
55
+ export function restoreFromResume(secondsSinceLastResponse: number | undefined, likelyExpired: boolean | undefined, ttlMs: number, now: number): CacheMemo | null {
56
+ if (secondsSinceLastResponse === undefined || !Number.isFinite(secondsSinceLastResponse) || secondsSinceLastResponse < 0) return null
57
+ const lastReplyAt = now - secondsSinceLastResponse * 1000
58
+ const expiresAt = lastReplyAt + ttlMs
59
+ return { ttlMs, lastReplyAt, expiresAt: likelyExpired || expiresAt <= now ? now - 1 : expiresAt }
60
+ }
@@ -0,0 +1,40 @@
1
+ import type { SessionCompacted, SessionMessage } from 'claude-code'
2
+
3
+ export const COMPACT_SYSTEM = `Create a faithful continuation record of this coding conversation. Keep the user's intent and changes of mind, decisions and constraints, relevant concepts, files and code touched, errors and their fixes, and user feedback. List user requests in order, distinguish solved from pending work, and state exactly what is happening now. Include a next step only when the conversation justifies it. Preserve useful exact paths and commands, but omit secrets, redundant logs, and obsolete detail. Do not invent verification or claim unfinished work is complete. Write in the user's language.`
4
+
5
+ function clipped(text: string, limit: number): string {
6
+ return text.length > limit ? `${text.slice(0, limit)}\n[truncated]` : text
7
+ }
8
+
9
+ function render(messages: readonly SessionMessage[], tight: boolean): string {
10
+ return messages.map((message, index) => {
11
+ const tail = index >= messages.length - 6
12
+ const inputLimit = tail ? (tight ? 4000 : 20000) : 400
13
+ const resultLimit = tail ? (tight ? 4000 : 20000) : (tight ? 300 : 2000)
14
+ const parts = [`${message.role}:\n${message.text}`]
15
+ for (const use of message.toolUses) {
16
+ parts.push(`tool ${use.tool} (${use.tool_use_id}):\n${clipped(JSON.stringify(use.input), inputLimit)}`)
17
+ const result = use.text ?? (use.result !== undefined ? JSON.stringify(use.result) : undefined)
18
+ if (result !== undefined) parts.push(`result${use.isError ? ' ERROR' : ''}:\n${clipped(result, resultLimit)}`)
19
+ }
20
+ if (!message.toolUses.length) {
21
+ for (const result of message.toolResults ?? []) parts.push(`result ${result.tool_use_id}${result.isError ? ' ERROR' : ''}:\n${clipped(result.text, resultLimit)}`)
22
+ }
23
+ return parts.join('\n\n')
24
+ }).join('\n\n---\n\n')
25
+ }
26
+
27
+ /** Shrink only tool bulk; losing user intent to hit a budget is worse than engine fallback. */
28
+ export function serializeTranscript(messages: readonly SessionMessage[], budget = 2400000): string | null {
29
+ if (!messages.length) return null
30
+ const full = render(messages, false)
31
+ if (full.length <= budget) return full
32
+ const tight = render(messages, true)
33
+ return tight.length <= budget ? tight : null
34
+ }
35
+
36
+ /** No handle: the engine builds this replacement from role, text and tool blocks. */
37
+ export function compactResult(summary: string): SessionCompacted {
38
+ return { messages: [{ role: 'user', toolUses: [], text:
39
+ `Continuation record from the previous conversation:\n\n${summary.trim()}\n\nResume the pending work from this record. Do not repeat completed work or ask for information already recorded.` }] }
40
+ }
@@ -0,0 +1,43 @@
1
+ import type { SessionMessage } from 'claude-code'
2
+ import type { HandoffKind, Settings } from '../types'
3
+
4
+ export const QUICK_PROMPT = `Prepare a concise resume note for a fresh coding chat, in the user's language, about forty lines of bullets. Use only this conversation; do not run tools. Record project, folder and branch if known, the goal, verified completed work, unfinished work and blockers, git state as last checked, decisions and failed approaches, permissions or resume details, and the highest-priority runnable next action. Mark actions requiring the user. Keep only current relevant state and point to files or specs instead of copying them. Exclude secrets, environment values and raw logs. Output only the note, without an introduction or farewell.`
5
+
6
+ export const FULL_PROMPT = `Write a durable handoff for the next coding chat. First verify the working directory, git status, recent commits and whether changes are pushed; check open pull requests if gh is available. Rebuild project-root HANDOFF.md from the current facts, fewer than eighty lines, in the user's language. Include the goal, verified done and undone work, blockers, changed files, decisions and failed approaches, permissions, exact current state and a prioritized next action. Distinguish facts from unverified claims. Never expose secrets, environment values or raw logs. Finish with the saved file path and a concise status for the next chat.`
7
+
8
+ export function wrapForNewChat(text: string, after: Settings['handoffAfter'], kind: HandoffKind, hasSkill: boolean): string {
9
+ const source = kind === 'full' && hasSkill ?
10
+ 'First read the saved handoff files named below; if no file is named, read HANDOFF.md. The last answer alone may not contain the handoff.\n\n' : ''
11
+ const action = after === 'confirm' ?
12
+ 'State the current status and proposed next action in two lines, then wait for my confirmation without starting work.' :
13
+ 'Continue with the justified next action. If it needs my permission or input, explain what is needed and wait.'
14
+ return `${source}Resume context from the previous chat:\n\n${text.trim()}\n\n${action}`
15
+ }
16
+
17
+ export const ADVICE_SYSTEM = `Decide whether starting a fresh chat clearly helps this coding work. Recommend it only when completed work is followed by a different task, unrelated history is dead weight, the topic changed, or repeated failed approaches are going in circles. A long chat, high context usage, or an ordinary next step is not enough. When uncertain, do not recommend it. Output only JSON {"handoff":null} or {"handoff":"reason"}, using the user's language and at most eight words.`
18
+
19
+ /** Cadence counts real eligible prompts, not tool steps, pings or judge requests. */
20
+ export function adviceDue(count: number, percent: number): boolean {
21
+ return percent >= 30 && Number.isInteger(count) && count > 0 && count % 2 === 0
22
+ }
23
+
24
+ export function adviceInput(input: { messages: readonly SessionMessage[]; next: string; contextPercent: number; warm: boolean }): string {
25
+ const users = input.messages.filter(message => message.role === 'user' && message.text.trim())
26
+ const assistant = input.messages.findLast(message => message.role === 'assistant' && message.text.trim())
27
+ const trail = users.slice(-8, -1).map(message => message.text.replace(/\s+/g, ' ').slice(0, 160)).join('\n')
28
+ return [`Context: ${Math.round(input.contextPercent)}%; user messages: ${users.length}; cache: ${input.warm ? 'warm' : 'cold'}`,
29
+ `Initial request: ${users[0]?.text.slice(0, 500) ?? ''}`, `Earlier requests:\n${trail}`,
30
+ `Assistant tail: ${assistant?.text.slice(-700) ?? ''}`, `Next request: ${input.next.slice(0, 600)}`].join('\n\n')
31
+ }
32
+
33
+ export function parseAdvice(text: string): string | null {
34
+ const span = /\{[\s\S]*\}/.exec(text)?.[0]
35
+ if (!span) return null
36
+ try {
37
+ const value: unknown = JSON.parse(span)
38
+ if (!value || typeof value !== 'object' || !('handoff' in value)) return null
39
+ return typeof value.handoff === 'string' && value.handoff.trim() ? value.handoff.trim().slice(0, 70) : null
40
+ } catch {
41
+ return null
42
+ }
43
+ }