claude-cache-keepalive 0.1.2 → 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 +19 -7
- package/package.json +1 -1
- package/src/cli.mjs +4 -2
- package/src/host.mjs +6 -5
- package/src/keepalive.mjs +78 -29
- package/src/statusline/segment.mjs +4 -4
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
|
|
7
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.
|
|
8
8
|
|
|
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
|
|
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.
|
|
10
10
|
|
|
11
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.
|
|
12
12
|
|
|
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
|
|
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.
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
@@ -40,9 +40,10 @@ It's transparent — type and use claude exactly as normal (no `Ctrl-b` prefix,
|
|
|
40
40
|
|
|
41
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.
|
|
42
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, arrow‑key 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
|
-
- **
|
|
44
|
-
-
|
|
45
|
-
-
|
|
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).
|
|
46
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.
|
|
47
48
|
|
|
48
49
|
## Optional: cache‑countdown statusline
|
|
@@ -91,9 +92,9 @@ Environment variables (mostly for testing / advanced use):
|
|
|
91
92
|
|
|
92
93
|
讓 [Claude Code](https://claude.com/claude-code) 的 **prompt cache 在你離開時保持溫熱**,回來時就不必再付一次完整的 cache‑miss。
|
|
93
94
|
|
|
94
|
-
`cwarm` 把 `claude` 跑在自己控制的 PTY
|
|
95
|
+
`cwarm` 把 `claude` 跑在自己控制的 PTY 裡;當你閒置超過 cache 的 TTL 時,注入一個極小的 keepalive 訊息讓 cache 不過期。因為注入是行程內部的 PTY 寫入,**視窗非焦點、縮小、在背景都照常運作**——只有關閉視窗才會停。跨平台、**不需要 tmux**。
|
|
95
96
|
|
|
96
|
-
> ⚠️ **誠實說明**:保溫=閒置時送一則小訊息(`hi`),會消耗你的方案用量、並在對話留下 `hi` 紀錄。只在長時間閒置後才觸發(
|
|
97
|
+
> ⚠️ **誠實說明**:保溫=閒置時送一則小訊息(`hi`),會消耗你的方案用量、並在對話留下 `hi` 紀錄。只在長時間閒置後才觸發(1h cache 約 58 分、5m cache 約 4 分)且有冷卻,屬保守設計、明確 opt‑in。不接受這個取捨就別用。
|
|
97
98
|
|
|
98
99
|
- **安裝**:`npm install -g claude-cache-keepalive`
|
|
99
100
|
- **使用**:`cwarm`(=`claude --continue` 跑在保溫 host 裡;其餘參數原樣轉給 claude)
|
|
@@ -101,6 +102,17 @@ Environment variables (mostly for testing / advanced use):
|
|
|
101
102
|
- **選配 statusline**(顯示 `♻️ cache 58m12s` 倒數;會先備份、包裝既有 statusline、可一鍵還原):`cwarm setup` / `cwarm setup --remove`
|
|
102
103
|
- **限制**:不能 detach(關視窗=結束,但縮小/背景照常保溫)。
|
|
103
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
|
+
|
|
104
116
|
## License
|
|
105
117
|
|
|
106
118
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-cache-keepalive",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
31
|
-
|
|
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,
|
|
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';
|
|
@@ -69,15 +69,16 @@ export function startHost(opts = {}) {
|
|
|
69
69
|
|
|
70
70
|
let lastFire = 0;
|
|
71
71
|
const timer = setInterval(() => {
|
|
72
|
-
const
|
|
73
|
-
const
|
|
72
|
+
const cwd = process.cwd();
|
|
73
|
+
const regime = detectTtlRegime(claudeDir, cwd); // 從 transcript 實測 1h/5m,不再猜方案
|
|
74
|
+
const { ttl, idleThreshold } = regimeParams(regime, overrides);
|
|
74
75
|
const now = Date.now();
|
|
75
|
-
const idleMs = transcriptIdleMs(claudeDir,
|
|
76
|
+
const idleMs = transcriptIdleMs(claudeDir, cwd, now);
|
|
76
77
|
if (decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE) })) {
|
|
77
78
|
ptyProc.write(msg + '\r');
|
|
78
79
|
lastFire = now;
|
|
79
80
|
const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
|
|
80
|
-
try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}"
|
|
81
|
+
try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}" regime=${regime ?? 'unknown'} idle=${idle}s\n`); } catch {}
|
|
81
82
|
}
|
|
82
83
|
}, tickMs);
|
|
83
84
|
|
package/src/keepalive.mjs
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
// keepalive.mjs — 決策邏輯 +
|
|
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
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
13
|
-
const base =
|
|
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,52 +22,53 @@ 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()) {
|
|
26
|
-
try {
|
|
27
|
-
const cred = JSON.parse(fs.readFileSync(path.join(claudeDir, '.credentials.json'), 'utf8'));
|
|
28
|
-
return cred?.claudeAiOauth?.subscriptionType === 'max' ? 'max' : 'pro';
|
|
29
|
-
} catch {
|
|
30
|
-
return 'pro';
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
25
|
// Claude Code 把 transcript 存在 ~/.claude/projects/<編碼後的 cwd>/<uuid>.jsonl,
|
|
35
26
|
// 編碼規則為「非英數字元一律換成 '-'」(例:C:\temp\scripts\cwarm → C--temp-scripts-cwarm)。
|
|
36
27
|
export function encodeProjectDir(cwd) {
|
|
37
28
|
return String(cwd).replace(/[^a-zA-Z0-9]/g, '-');
|
|
38
29
|
}
|
|
39
30
|
|
|
40
|
-
//
|
|
41
|
-
function
|
|
31
|
+
// 某資料夾內 mtime 最新的 *.jsonl 完整路徑;沒有則 null。
|
|
32
|
+
function newestJsonlPath(dir) {
|
|
42
33
|
let entries;
|
|
43
34
|
try { entries = fs.readdirSync(dir); } catch { return null; }
|
|
44
|
-
let
|
|
35
|
+
let best = null, bestM = null;
|
|
45
36
|
for (const name of entries) {
|
|
46
37
|
if (!name.endsWith('.jsonl')) continue;
|
|
47
38
|
try {
|
|
48
39
|
const m = fs.statSync(path.join(dir, name)).mtimeMs;
|
|
49
|
-
if (
|
|
40
|
+
if (bestM == null || m > bestM) { bestM = m; best = path.join(dir, name); }
|
|
50
41
|
} catch { /* skip unreadable */ }
|
|
51
42
|
}
|
|
52
|
-
return
|
|
43
|
+
return best;
|
|
53
44
|
}
|
|
54
45
|
|
|
55
|
-
// 本 session transcript
|
|
46
|
+
// 本 session transcript 的完整路徑:優先 cwd 對應的 project 資料夾,
|
|
56
47
|
// 找不到再退回 projects 底下全域最新(cwarm 本就假設單一 session)。null = 完全找不到。
|
|
57
|
-
export function
|
|
48
|
+
export function transcriptPath(claudeDir, cwd) {
|
|
58
49
|
const projects = path.join(claudeDir, 'projects');
|
|
59
|
-
const direct =
|
|
50
|
+
const direct = newestJsonlPath(path.join(projects, encodeProjectDir(cwd)));
|
|
60
51
|
if (direct != null) return direct;
|
|
61
52
|
let subdirs;
|
|
62
53
|
try { subdirs = fs.readdirSync(projects, { withFileTypes: true }); } catch { return null; }
|
|
63
|
-
let
|
|
54
|
+
let best = null, bestM = null;
|
|
64
55
|
for (const d of subdirs) {
|
|
65
56
|
if (!d.isDirectory()) continue;
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
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 */ }
|
|
68
63
|
}
|
|
69
|
-
return
|
|
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; }
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
// 距上次訊息(transcript 寫入)多久(毫秒);找不到 transcript 回 null。
|
|
@@ -77,6 +79,53 @@ export function transcriptIdleMs(claudeDir, cwd, now = Date.now()) {
|
|
|
77
79
|
return m == null ? null : now - m;
|
|
78
80
|
}
|
|
79
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;
|
|
92
|
+
try {
|
|
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);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
} finally {
|
|
102
|
+
if (fd != null) { try { fs.closeSync(fd); } catch {} }
|
|
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);
|
|
127
|
+
}
|
|
128
|
+
|
|
80
129
|
// 純決策:現在該不該注入 keepalive?idleMs = 距上次訊息多久(由 transcriptIdleMs 算)。
|
|
81
130
|
export function decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled }) {
|
|
82
131
|
if (disabled) return false; // 暫停開關
|
|
@@ -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,
|
|
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
|
|
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
|
|
23
|
-
|
|
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`; // 🔴 已冷
|