claude-cache-keepalive 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # claude-cache-keepalive (`cwarm`)
2
2
 
3
+ [![CI](https://github.com/fifthadj/claude-cache-keepalive/actions/workflows/test.yml/badge.svg)](https://github.com/fifthadj/claude-cache-keepalive/actions/workflows/test.yml)
4
+ [![npm version](https://img.shields.io/npm/v/claude-cache-keepalive.svg)](https://www.npmjs.com/package/claude-cache-keepalive)
5
+ [![license: MIT](https://img.shields.io/npm/l/claude-cache-keepalive.svg)](./LICENSE)
6
+
3
7
  Keep [Claude Code](https://claude.com/claude-code)'s **prompt cache warm while you're idle**, so coming back to a session you stepped away from doesn't pay a full cache‑miss.
4
8
 
5
- It runs `claude` inside a PTY it controls (via [node-pty](https://github.com/microsoft/node-pty)) and, when you've been idle past your plan's cache TTL, injects a tiny keepalive so the cache stays warm. Because injection is an in‑process PTY write, **it keeps working when the window is unfocused, minimized, or in the background** — only closing the window stops it.
9
+ It runs `claude` inside a PTY it controls (via [node-pty](https://github.com/microsoft/node-pty)) and, when you've been idle past your cache's TTL, injects a tiny keepalive so the cache stays warm. Because injection is an in‑process PTY write, **it keeps working when the window is unfocused, minimized, or in the background** — only closing the window stops it.
6
10
 
7
11
  Cross‑platform, **no tmux required**. This is the missing piece for setups (Windows / Git Bash, plain terminals) where the usual tmux‑based keepalive isn't available.
8
12
 
9
- > ⚠️ **Honest note — this uses your usage/quota.** Keeping the cache warm means sending a small message (`hi`) when you go idle, which counts against your plan usage and leaves `hi` turns in the conversation. It only fires after a long idle (≈58 min on Max, ≈4 min on Pro) with a one‑TTL cooldown, so it's conservative — but it is opt‑in by design. If that trade‑off isn't for you, don't use it.
13
+ > ⚠️ **Honest note — this uses your usage/quota.** Keeping the cache warm means sending a small message (`hi`) when you go idle, which counts against your plan usage and leaves `hi` turns in the conversation. It only fires after a long idle (≈58 min on a 1‑hour cache, ≈4 min on a 5‑minute cache) with a one‑TTL cooldown, so it's conservative — but it is opt‑in by design. If that trade‑off isn't for you, don't use it.
10
14
 
11
15
  ## Install
12
16
 
@@ -35,10 +39,11 @@ It's transparent — type and use claude exactly as normal (no `Ctrl-b` prefix,
35
39
  ## How it works
36
40
 
37
41
  - **PTY host** — `cwarm` spawns `claude` inside a pseudo‑terminal it owns and transparently pipes your keyboard ↔ claude ↔ screen (and window resizes). This is the same approach tmux / expect / VS Code's terminal use, and the only robust way to inject input into a terminal program.
38
- - **Idle detection** — idle = time since your last keystroke. Selfcontained, no external files. When you step away, idle grows; when you type, it resets.
39
- - **Plan‑aware** — reads your plan from `~/.claude/.credentials.json`:
40
- - **Max** cache TTL 1 h → inject after ~58 min idle, cooldown 1 h.
41
- - **Pro** cache TTL 5 min → inject after ~4 min idle, cooldown 5 min.
42
+ - **Idle detection** — idle = time since your last **message**, measured from the newest transcript file under `~/.claude/projects/`. This is what actually governs cache age: scrolling, arrowkey reading, or a half‑typed prompt are terminal input but don't refresh the cache, so they must *not* count as activity. (Earlier versions timed keystrokes, which let the cache go cold while you were reading.)
43
+ - **TTL‑aware (measured, not guessed)** — the cache TTL is read straight from the transcript's `message.usage.cache_creation`, not inferred from your subscription:
44
+ - any recent turn wrote `ephemeral_1h_input_tokens` → **1 h cache** → inject after ~58 min idle, cooldown 1 h.
45
+ - only `ephemeral_5m_input_tokens` (or no evidence yet) → **5 min cache** (conservative) → inject after ~4 min idle, cooldown 5 min.
46
+ - This survives client‑version, env‑var and server‑flag changes that the plan string can't see (e.g. a Pro account can still get a 1 h cache).
42
47
  - **Focus/minimize independent** — injection is an in‑process `pty.write`, unrelated to window state. Only closing the window (ending the host process) stops it.
43
48
 
44
49
  ## Optional: cache‑countdown statusline
@@ -87,9 +92,9 @@ Environment variables (mostly for testing / advanced use):
87
92
 
88
93
  讓 [Claude Code](https://claude.com/claude-code) 的 **prompt cache 在你離開時保持溫熱**,回來時就不必再付一次完整的 cache‑miss。
89
94
 
90
- `cwarm` 把 `claude` 跑在自己控制的 PTY 裡;當你閒置超過方案的 cache TTL 時,注入一個極小的 keepalive 訊息讓 cache 不過期。因為注入是行程內部的 PTY 寫入,**視窗非焦點、縮小、在背景都照常運作**——只有關閉視窗才會停。跨平台、**不需要 tmux**。
95
+ `cwarm` 把 `claude` 跑在自己控制的 PTY 裡;當你閒置超過 cache TTL 時,注入一個極小的 keepalive 訊息讓 cache 不過期。因為注入是行程內部的 PTY 寫入,**視窗非焦點、縮小、在背景都照常運作**——只有關閉視窗才會停。跨平台、**不需要 tmux**。
91
96
 
92
- > ⚠️ **誠實說明**:保溫=閒置時送一則小訊息(`hi`),會消耗你的方案用量、並在對話留下 `hi` 紀錄。只在長時間閒置後才觸發(Max 約 58 分、Pro 約 4 分)且有冷卻,屬保守設計、明確 opt‑in。不接受這個取捨就別用。
97
+ > ⚠️ **誠實說明**:保溫=閒置時送一則小訊息(`hi`),會消耗你的方案用量、並在對話留下 `hi` 紀錄。只在長時間閒置後才觸發(1h cache 約 58 分、5m cache 約 4 分)且有冷卻,屬保守設計、明確 opt‑in。不接受這個取捨就別用。
93
98
 
94
99
  - **安裝**:`npm install -g claude-cache-keepalive`
95
100
  - **使用**:`cwarm`(=`claude --continue` 跑在保溫 host 裡;其餘參數原樣轉給 claude)
@@ -97,6 +102,17 @@ Environment variables (mostly for testing / advanced use):
97
102
  - **選配 statusline**(顯示 `♻️ cache 58m12s` 倒數;會先備份、包裝既有 statusline、可一鍵還原):`cwarm setup` / `cwarm setup --remove`
98
103
  - **限制**:不能 detach(關視窗=結束,但縮小/背景照常保溫)。
99
104
 
105
+ ## Changelog
106
+
107
+ ### 0.1.3
108
+ - **Change:** the cache TTL is now **measured from the transcript** (`message.usage.cache_creation`'s `ephemeral_1h` / `ephemeral_5m` tokens) instead of being guessed from your subscription plan. A recent 1h write → 1h regime; only 5m writes (or no evidence) → 5m regime (conservative). This drops the `~/.claude/.credentials.json` read entirely and is correct even when a Pro account gets a 1h cache. Adds `transcriptPath` / `readTtlRegime` / `detectTtlRegime` / `regimeParams`; removes `detectPlan` / `planParams`.
109
+
110
+ ### 0.1.2
111
+ - **Fix:** idle is now measured from the newest transcript file's mtime — i.e. time since your last *message* — instead of keystrokes. Scrolling, arrow‑key reading, or a half‑typed prompt no longer reset the idle timer, so the keepalive actually fires while you're reading and the cache stops going cold. Adds `encodeProjectDir` / `transcriptMtimeMs` / `transcriptIdleMs`.
112
+
113
+ ### 0.1.0
114
+ - Initial release. (0.1.1 was a version‑only bump and was never published to npm.)
115
+
100
116
  ## License
101
117
 
102
118
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-cache-keepalive",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Keep Claude Code's prompt cache warm while idle, by running claude inside a PTY host and injecting a tiny keepalive when you step away. Cross-platform, no tmux required.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -27,8 +27,10 @@ Usage:
27
27
  Everything except the 'setup' subcommand is passed straight to claude
28
28
  (e.g. \`cwarm --version\`, \`cwarm resume\`, \`cwarm -p "..."\`).
29
29
 
30
- Keepalive only fires after you've been idle past the plan threshold
31
- (max ~58min / pro ~4min). Pause anytime: touch ~/.claude/cwarm.disabled
30
+ Keepalive only fires after you've been idle past the cache-TTL threshold.
31
+ The TTL is auto-detected from the transcript's cache_creation (1h cache ->
32
+ fire after ~58min idle; 5m cache -> ~4min), not guessed from your plan.
33
+ Pause anytime: touch ~/.claude/cwarm.disabled
32
34
  Log: ~/.claude/cwarm-keepalive.log
33
35
  `);
34
36
  }
package/src/host.mjs CHANGED
@@ -5,7 +5,7 @@ import { createRequire } from 'node:module';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { spawnSync } from 'node:child_process';
8
- import { defaultClaudeDir, planParams, detectPlan, decideInject } from './keepalive.mjs';
8
+ import { defaultClaudeDir, regimeParams, detectTtlRegime, decideInject, transcriptIdleMs } from './keepalive.mjs';
9
9
 
10
10
  const require = createRequire(import.meta.url);
11
11
  const isWin = process.platform === 'win32';
@@ -48,10 +48,11 @@ export function startHost(opts = {}) {
48
48
 
49
49
  // ---- 透明 I/O 多工 ----
50
50
  // 直接寫 Buffer(純位元組轉送):UTF-8 多位元組(中文等)才不會被重編碼弄壞。
51
- let lastInput = Date.now();
51
+ // 注意:閒置判斷改看 transcript mtime(距上次「訊息」多久),不再用 stdin 計時——
52
+ // 終端輸入(捲動/讀回覆/打到一半沒送出)不會刷新 cache,拿來計時會誤判成「使用者還在忙」。
52
53
  if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
53
54
  process.stdin.resume();
54
- process.stdin.on('data', (d) => { lastInput = Date.now(); ptyProc.write(d); });
55
+ process.stdin.on('data', (d) => { ptyProc.write(d); });
55
56
  ptyProc.onData((d) => process.stdout.write(d));
56
57
  process.stdout.on('resize', () => {
57
58
  try { ptyProc.resize(process.stdout.columns || 80, process.stdout.rows || 24); } catch {}
@@ -68,14 +69,16 @@ export function startHost(opts = {}) {
68
69
 
69
70
  let lastFire = 0;
70
71
  const timer = setInterval(() => {
71
- const plan = detectPlan(claudeDir);
72
- const { ttl, idleThreshold } = planParams(plan, overrides);
72
+ const cwd = process.cwd();
73
+ const regime = detectTtlRegime(claudeDir, cwd); // 從 transcript 實測 1h/5m,不再猜方案
74
+ const { ttl, idleThreshold } = regimeParams(regime, overrides);
73
75
  const now = Date.now();
74
- if (decideInject({ now, lastInput, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE) })) {
76
+ const idleMs = transcriptIdleMs(claudeDir, cwd, now);
77
+ if (decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE) })) {
75
78
  ptyProc.write(msg + '\r');
76
79
  lastFire = now;
77
- const idle = Math.round((now - lastInput) / 1000);
78
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}" plan=${plan} idle=${idle}s\n`); } catch {}
80
+ const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
81
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}" regime=${regime ?? 'unknown'} idle=${idle}s\n`); } catch {}
79
82
  }
80
83
  }, tickMs);
81
84
 
package/src/keepalive.mjs CHANGED
@@ -1,16 +1,17 @@
1
- // keepalive.mjs — 純決策邏輯 + plan 偵測(無 PTY、無副作用,便於單元測試)。
1
+ // keepalive.mjs — 決策邏輯 + TTL 檔位 / 閒置偵測(無 PTY,便於單元測試)。
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
- // plan → { ttl(秒), idleThreshold(秒) }。對齊各家慣例:max 提早 ~2 分注入、pro 提早 1 分。
7
- export const PLAN_PARAMS = {
8
- max: { ttl: 3600, idleThreshold: 3480 },
9
- pro: { ttl: 300, idleThreshold: 240 },
6
+ // TTL 檔位 → { ttl(秒), idleThreshold(秒) }。long=1h cache、short=5m cache。
7
+ // 提早注入幅度對齊各家慣例:long 提早 ~2 分、short 提早 1 分。
8
+ export const REGIME_PARAMS = {
9
+ long: { ttl: 3600, idleThreshold: 3480 },
10
+ short: { ttl: 300, idleThreshold: 240 },
10
11
  };
11
12
 
12
- export function planParams(plan, overrides = {}) {
13
- const base = PLAN_PARAMS[plan] || PLAN_PARAMS.pro;
13
+ export function regimeParams(regime, overrides = {}) {
14
+ const base = REGIME_PARAMS[regime] || REGIME_PARAMS.short; // 未知一律 short(保守,門檻短不讓 cache 冷掉)
14
15
  return {
15
16
  ttl: overrides.ttl ?? base.ttl,
16
17
  idleThreshold: overrides.idleThreshold ?? base.idleThreshold,
@@ -21,21 +22,115 @@ export function defaultClaudeDir() {
21
22
  return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
22
23
  }
23
24
 
24
- // Claude Code 憑證判方案;讀不到一律當 'pro'(保守)。
25
- export function detectPlan(claudeDir = defaultClaudeDir()) {
25
+ // Claude Code transcript 存在 ~/.claude/projects/<編碼後的 cwd>/<uuid>.jsonl,
26
+ // 編碼規則為「非英數字元一律換成 '-'」(例:C:\temp\scripts\cwarm C--temp-scripts-cwarm)。
27
+ export function encodeProjectDir(cwd) {
28
+ return String(cwd).replace(/[^a-zA-Z0-9]/g, '-');
29
+ }
30
+
31
+ // 某資料夾內 mtime 最新的 *.jsonl 完整路徑;沒有則 null。
32
+ function newestJsonlPath(dir) {
33
+ let entries;
34
+ try { entries = fs.readdirSync(dir); } catch { return null; }
35
+ let best = null, bestM = null;
36
+ for (const name of entries) {
37
+ if (!name.endsWith('.jsonl')) continue;
38
+ try {
39
+ const m = fs.statSync(path.join(dir, name)).mtimeMs;
40
+ if (bestM == null || m > bestM) { bestM = m; best = path.join(dir, name); }
41
+ } catch { /* skip unreadable */ }
42
+ }
43
+ return best;
44
+ }
45
+
46
+ // 本 session transcript 的完整路徑:優先 cwd 對應的 project 資料夾,
47
+ // 找不到再退回 projects 底下全域最新(cwarm 本就假設單一 session)。null = 完全找不到。
48
+ export function transcriptPath(claudeDir, cwd) {
49
+ const projects = path.join(claudeDir, 'projects');
50
+ const direct = newestJsonlPath(path.join(projects, encodeProjectDir(cwd)));
51
+ if (direct != null) return direct;
52
+ let subdirs;
53
+ try { subdirs = fs.readdirSync(projects, { withFileTypes: true }); } catch { return null; }
54
+ let best = null, bestM = null;
55
+ for (const d of subdirs) {
56
+ if (!d.isDirectory()) continue;
57
+ const p = newestJsonlPath(path.join(projects, d.name));
58
+ if (p == null) continue;
59
+ try {
60
+ const m = fs.statSync(p).mtimeMs;
61
+ if (bestM == null || m > bestM) { bestM = m; best = p; }
62
+ } catch { /* skip */ }
63
+ }
64
+ return best;
65
+ }
66
+
67
+ // 本 session transcript 的最新 mtime(毫秒);null = 完全找不到。
68
+ export function transcriptMtimeMs(claudeDir, cwd) {
69
+ const p = transcriptPath(claudeDir, cwd);
70
+ if (!p) return null;
71
+ try { return fs.statSync(p).mtimeMs; } catch { return null; }
72
+ }
73
+
74
+ // 距上次訊息(transcript 寫入)多久(毫秒);找不到 transcript 回 null。
75
+ // 這才是 prompt cache 年齡的正確訊號——終端「輸入」(捲動/讀回覆/打到一半沒送出)
76
+ // 都不會刷新 cache,故不以 stdin 計時,改看 transcript mtime。
77
+ export function transcriptIdleMs(claudeDir, cwd, now = Date.now()) {
78
+ const m = transcriptMtimeMs(claudeDir, cwd);
79
+ return m == null ? null : now - m;
80
+ }
81
+
82
+ // 讀 transcript 尾端,判斷這個 session 實際拿到的 cache TTL 檔位。
83
+ // 回傳 'long'(1h) / 'short'(5m) / null(找不到可判讀的 cache_creation)。
84
+ // 為什麼讀 transcript 而非帳號方案:message.usage.cache_creation 的
85
+ // ephemeral_1h_input_tokens / ephemeral_5m_input_tokens 是 API 親口回報「這批 token
86
+ // 寫進哪個 TTL 桶」的實測值;subscriptionType 只是間接猜測,且會被 client 版本、
87
+ // 環境變數、伺服器端旗標影響而失準(實測 pro 帳號也可能拿到 1h)。
88
+ export function readTtlRegime(tpath, { maxBytes = 65536, scanTurns = 8 } = {}) {
89
+ if (!tpath) return null;
90
+ let size, buf;
91
+ let fd = null;
26
92
  try {
27
- const cred = JSON.parse(fs.readFileSync(path.join(claudeDir, '.credentials.json'), 'utf8'));
28
- return cred?.claudeAiOauth?.subscriptionType === 'max' ? 'max' : 'pro';
93
+ size = fs.statSync(tpath).size;
94
+ const start = Math.max(0, size - maxBytes);
95
+ const len = size - start;
96
+ buf = Buffer.alloc(len);
97
+ fd = fs.openSync(tpath, 'r');
98
+ fs.readSync(fd, buf, 0, len, start);
29
99
  } catch {
30
- return 'pro';
100
+ return null;
101
+ } finally {
102
+ if (fd != null) { try { fs.closeSync(fd); } catch {} }
31
103
  }
104
+ let lines = buf.toString('utf8').split(/\r?\n/);
105
+ if (size > maxBytes && lines.length) lines = lines.slice(1); // 丟掉被切半的第一行
106
+ let sawShort = false;
107
+ let examined = 0;
108
+ for (let i = lines.length - 1; i >= 0; i--) {
109
+ const line = lines[i].trim();
110
+ if (!line) continue;
111
+ let obj;
112
+ try { obj = JSON.parse(line); } catch { continue; }
113
+ if (!obj || obj.type !== 'assistant') continue;
114
+ const cc = obj.message && obj.message.usage && obj.message.usage.cache_creation;
115
+ if (!cc || typeof cc !== 'object') continue;
116
+ const h = cc.ephemeral_1h_input_tokens || 0;
117
+ const m = cc.ephemeral_5m_input_tokens || 0;
118
+ if (h > 0) return 'long'; // 最近任一回合寫過 1h → 1h 檔位(決定性)
119
+ if (m > 0) { sawShort = true; if (++examined >= scanTurns) break; } // 只看最近數筆有寫 cache 的回合
120
+ }
121
+ return sawShort ? 'short' : null;
122
+ }
123
+
124
+ // 找出本 session transcript 並判斷 TTL 檔位。'long' / 'short' / null。
125
+ export function detectTtlRegime(claudeDir, cwd, opts) {
126
+ return readTtlRegime(transcriptPath(claudeDir, cwd), opts);
32
127
  }
33
128
 
34
- // 純決策:現在該不該注入 keepalive?
35
- // idle = now - lastInput(使用者最後一次按鍵到現在)。自包含、跨平台、不依賴外部檔。
36
- export function decideInject({ now, lastInput, lastFire, idleThreshold, ttl, disabled }) {
37
- if (disabled) return false; // 暫停開關
38
- if (now - lastInput < idleThreshold * 1000) return false; // 使用者最近還在操作
39
- if (now - lastFire < ttl * 1000) return false; // 冷卻未滿一個 TTL
129
+ // 純決策:現在該不該注入 keepalive?idleMs = 距上次訊息多久(由 transcriptIdleMs 算)。
130
+ export function decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled }) {
131
+ if (disabled) return false; // 暫停開關
132
+ if (idleMs == null) return false; // 找不到 transcript → 保守不發
133
+ if (idleMs < idleThreshold * 1000) return false; // 距上次訊息還不夠久
134
+ if (now - lastFire < ttl * 1000) return false; // 冷卻未滿一個 TTL
40
135
  return true;
41
136
  }
@@ -4,7 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
- import { defaultClaudeDir, detectPlan } from '../keepalive.mjs';
7
+ import { defaultClaudeDir, readTtlRegime, regimeParams } from '../keepalive.mjs';
8
8
 
9
9
  const claudeDir = defaultClaudeDir();
10
10
  const ORIG = path.join(claudeDir, 'cwarm-statusline-orig.json');
@@ -13,14 +13,14 @@ function readStdin() {
13
13
  try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
14
14
  }
15
15
 
16
- // cache 倒數段:用 transcript mtime 當 idle、plan 決定 TTL(max 1h / pro 5min)。
16
+ // cache 倒數段:用 transcript mtime 當 idle、用 transcript 實測的 cache_creation 判 TTL(1h / 5m)。
17
17
  function cacheSegment(payload) {
18
18
  const tp = payload?.transcript_path;
19
19
  if (!tp) return '';
20
20
  let mtimeMs;
21
21
  try { mtimeMs = fs.statSync(tp).mtimeMs; } catch { return ''; }
22
- const plan = detectPlan(claudeDir);
23
- const ttl = plan === 'max' ? 3600 : 300;
22
+ const { ttl } = regimeParams(readTtlRegime(tp)); // 'long'→3600 / 'short'|null→300
23
+
24
24
  const idle = Math.max(0, Math.floor((Date.now() - mtimeMs) / 1000));
25
25
  const rem = ttl - idle;
26
26
  if (rem <= 0) return `\u{1F534} cache ${Math.floor(idle / 60)}m`; // 🔴 已冷