claude-cache-keepalive 0.1.0 → 0.1.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/README.md +5 -1
- package/package.json +1 -1
- package/src/host.mjs +7 -5
- package/src/keepalive.mjs +53 -7
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# claude-cache-keepalive (`cwarm`)
|
|
2
2
|
|
|
3
|
+
[](https://github.com/fifthadj/claude-cache-keepalive/actions/workflows/test.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/claude-cache-keepalive)
|
|
5
|
+
[](./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
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 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.
|
|
@@ -35,7 +39,7 @@ 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
|
|
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.)
|
|
39
43
|
- **Plan‑aware** — reads your plan from `~/.claude/.credentials.json`:
|
|
40
44
|
- **Max** → cache TTL 1 h → inject after ~58 min idle, cooldown 1 h.
|
|
41
45
|
- **Pro** → cache TTL 5 min → inject after ~4 min idle, cooldown 5 min.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-cache-keepalive",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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/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, planParams, detectPlan, 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
|
-
|
|
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) => {
|
|
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 {}
|
|
@@ -71,10 +72,11 @@ export function startHost(opts = {}) {
|
|
|
71
72
|
const plan = detectPlan(claudeDir);
|
|
72
73
|
const { ttl, idleThreshold } = planParams(plan, overrides);
|
|
73
74
|
const now = Date.now();
|
|
74
|
-
|
|
75
|
+
const idleMs = transcriptIdleMs(claudeDir, process.cwd(), now);
|
|
76
|
+
if (decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE) })) {
|
|
75
77
|
ptyProc.write(msg + '\r');
|
|
76
78
|
lastFire = now;
|
|
77
|
-
const idle = Math.round(
|
|
79
|
+
const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
|
|
78
80
|
try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}" plan=${plan} idle=${idle}s\n`); } catch {}
|
|
79
81
|
}
|
|
80
82
|
}, tickMs);
|
package/src/keepalive.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// keepalive.mjs —
|
|
1
|
+
// keepalive.mjs — 決策邏輯 + plan / 閒置偵測(無 PTY,便於單元測試)。
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
@@ -31,11 +31,57 @@ export function detectPlan(claudeDir = defaultClaudeDir()) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
export function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
// Claude Code 把 transcript 存在 ~/.claude/projects/<編碼後的 cwd>/<uuid>.jsonl,
|
|
35
|
+
// 編碼規則為「非英數字元一律換成 '-'」(例:C:\temp\scripts\cwarm → C--temp-scripts-cwarm)。
|
|
36
|
+
export function encodeProjectDir(cwd) {
|
|
37
|
+
return String(cwd).replace(/[^a-zA-Z0-9]/g, '-');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 某資料夾內所有 *.jsonl 的最新 mtime(毫秒);沒有則 null。
|
|
41
|
+
function newestJsonlMtime(dir) {
|
|
42
|
+
let entries;
|
|
43
|
+
try { entries = fs.readdirSync(dir); } catch { return null; }
|
|
44
|
+
let newest = null;
|
|
45
|
+
for (const name of entries) {
|
|
46
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
47
|
+
try {
|
|
48
|
+
const m = fs.statSync(path.join(dir, name)).mtimeMs;
|
|
49
|
+
if (newest == null || m > newest) newest = m;
|
|
50
|
+
} catch { /* skip unreadable */ }
|
|
51
|
+
}
|
|
52
|
+
return newest;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 本 session transcript 的最新 mtime(毫秒):優先 cwd 對應的 project 資料夾,
|
|
56
|
+
// 找不到再退回 projects 底下全域最新(cwarm 本就假設單一 session)。null = 完全找不到。
|
|
57
|
+
export function transcriptMtimeMs(claudeDir, cwd) {
|
|
58
|
+
const projects = path.join(claudeDir, 'projects');
|
|
59
|
+
const direct = newestJsonlMtime(path.join(projects, encodeProjectDir(cwd)));
|
|
60
|
+
if (direct != null) return direct;
|
|
61
|
+
let subdirs;
|
|
62
|
+
try { subdirs = fs.readdirSync(projects, { withFileTypes: true }); } catch { return null; }
|
|
63
|
+
let newest = null;
|
|
64
|
+
for (const d of subdirs) {
|
|
65
|
+
if (!d.isDirectory()) continue;
|
|
66
|
+
const m = newestJsonlMtime(path.join(projects, d.name));
|
|
67
|
+
if (m != null && (newest == null || m > newest)) newest = m;
|
|
68
|
+
}
|
|
69
|
+
return newest;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 距上次訊息(transcript 寫入)多久(毫秒);找不到 transcript 回 null。
|
|
73
|
+
// 這才是 prompt cache 年齡的正確訊號——終端「輸入」(捲動/讀回覆/打到一半沒送出)
|
|
74
|
+
// 都不會刷新 cache,故不以 stdin 計時,改看 transcript mtime。
|
|
75
|
+
export function transcriptIdleMs(claudeDir, cwd, now = Date.now()) {
|
|
76
|
+
const m = transcriptMtimeMs(claudeDir, cwd);
|
|
77
|
+
return m == null ? null : now - m;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 純決策:現在該不該注入 keepalive?idleMs = 距上次訊息多久(由 transcriptIdleMs 算)。
|
|
81
|
+
export function decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled }) {
|
|
82
|
+
if (disabled) return false; // 暫停開關
|
|
83
|
+
if (idleMs == null) return false; // 找不到 transcript → 保守不發
|
|
84
|
+
if (idleMs < idleThreshold * 1000) return false; // 距上次訊息還不夠久
|
|
85
|
+
if (now - lastFire < ttl * 1000) return false; // 冷卻未滿一個 TTL
|
|
40
86
|
return true;
|
|
41
87
|
}
|