claude-cache-keepalive 0.1.12 → 0.1.13
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 +7 -3
- package/package.json +1 -1
- package/src/host.mjs +30 -4
- package/src/keepalive.mjs +36 -0
- package/src/statusline/segment.mjs +12 -1
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ It's transparent — type and use claude exactly as normal (no `Ctrl-b` prefix,
|
|
|
39
39
|
## How it works
|
|
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
|
-
- **Idle detection** — idle = time since your last **message**, measured from
|
|
42
|
+
- **Idle detection** — idle = time since your last **message**, measured from this session's 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.) With the statusline add‑on installed (`cwarm setup`), the host **pins its own session's transcript** via a tiny per‑session bridge file (`~/.claude/cwarm-session-<id>.json`, written from the statusline's `transcript_path`, removed on exit) — so several tabs running sessions in the *same folder* no longer confuse each other. Without the statusline it falls back to "newest transcript in this project's folder".
|
|
43
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
44
|
- any recent turn wrote `ephemeral_1h_input_tokens` → **1 h cache** → inject after ~58 min idle, cooldown 1 h.
|
|
45
45
|
- only `ephemeral_5m_input_tokens` (or no evidence yet) → **5 min cache** (conservative) → inject after ~4 min idle, cooldown 5 min.
|
|
@@ -134,7 +134,7 @@ Environment variables (mostly for testing / advanced use):
|
|
|
134
134
|
## Limitations
|
|
135
135
|
|
|
136
136
|
- **No detach.** Closing the window ends the session — there's no tmux‑style detach/reattach (that would mean reimplementing a terminal multiplexer; out of scope). But minimize / background / unfocused all keep working.
|
|
137
|
-
- **
|
|
137
|
+
- **Concurrent sessions in the same folder need the statusline.** With the statusline add‑on installed (`cwarm setup`), each `cwarm` tab pins its own session's transcript and multiple tabs coexist cleanly — even in the same folder. Without it, two sessions in the *same* folder will read each other's transcript activity and the keepalive misfires (sessions in different folders are always fine).
|
|
138
138
|
- If you walk away with a half‑typed draft and stay idle past the threshold, the keepalive's `Esc` clears the draft before sending `hi`. Rare.
|
|
139
139
|
- **A genuinely blocking prompt can't be kept warm.** While Claude Code waits on a mandatory answer, no API turn can happen, so the cache may cool during that window; warming resumes automatically once you answer.
|
|
140
140
|
|
|
@@ -161,7 +161,7 @@ Environment variables (mostly for testing / advanced use):
|
|
|
161
161
|
### 運作原理
|
|
162
162
|
|
|
163
163
|
- **PTY host**:`cwarm` 把 `claude` spawn 在一個它自己擁有的 pseudo-terminal 裡,透明地把你的鍵盤 ↔ claude ↔ 畫面(含視窗 resize)接起來。這跟 tmux/expect/VS Code 終端的做法相同,也是唯一穩健、能把輸入注入終端程式的方式。
|
|
164
|
-
- **閒置偵測**:閒置=距你上次**訊息**多久,量自 `~/.claude/projects/`
|
|
164
|
+
- **閒置偵測**:閒置=距你上次**訊息**多久,量自 `~/.claude/projects/` 底下本 session 的 transcript 檔。這才是決定 cache 年齡的訊號——捲動、用方向鍵讀、打到一半沒送出,都是終端輸入但不會刷新 cache,所以不該算成活動。(早期版本計時鍵盤輸入,會讓你在閱讀時 cache 冷掉。)裝了 statusline 附加元件(`cwarm setup`)後,host 會透過一個極小的 per-session 橋接檔(`~/.claude/cwarm-session-<id>.json`,由 statusline 的 `transcript_path` 落地、退出時自動刪除)**針定自己 session 的 transcript**——多個分頁在**同一個資料夾**各開 session 也不會互相干擾。沒裝 statusline 則回退「本專案資料夾裡最新的 transcript」猜法。
|
|
165
165
|
- **TTL 感知(實測,非猜測)**:cache TTL 直接讀自 transcript 的 `message.usage.cache_creation`,不從訂閱方案推斷——最近有寫 `ephemeral_1h_input_tokens` → 1h cache(閒置約 58 分才注入、冷卻 1h);只有 `ephemeral_5m_input_tokens`(或還沒證據)→ 5m cache(保守,約 4 分注入、冷卻 5m)。這能撐過 client 版本、環境變數、伺服器旗標的變動(例如 Pro 帳號也可能拿到 1h cache)。
|
|
166
166
|
- **提示安全注入**:keepalive 只在 PTY **靜止一小段時間後**才觸發(`CWARM_QUIET_MS`,預設 2.5 秒)。必答提示(工具權限、`AskUserQuestion`、計畫批准)的 spinner 會一直動,忙著跑工具時也持續輸出,兩者都「不安靜」,所以 keepalive 不會送進去(不會誤選選單預設項、也不會打斷長工具執行)。而且注入時會**先送 `Esc`** 退回輸入框,那個 Enter 永遠落不到提示上。(提示真的卡住時 cache 本來就無法保溫,等你回答後會自動恢復。)
|
|
167
167
|
- **與焦點/縮小無關**:注入是行程內部的 `pty.write`,跟視窗狀態無關。只有關閉視窗(結束 host 行程)才會停。
|
|
@@ -241,6 +241,10 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
|
|
|
241
241
|
|
|
242
242
|
## Changelog
|
|
243
243
|
|
|
244
|
+
### 0.1.13
|
|
245
|
+
- **Fix:** running multiple Claude Code sessions in tabs **in the same folder** no longer corrupts each other's keepalive timing. The host used to measure idle from the *newest* transcript in the project folder — with a busier sibling session next door, its own idle never crossed the threshold, the keepalive never fired, and the cache countdown ran cold (🔴). Now the statusline lands each session's exact `transcript_path` into a per‑session bridge file (`~/.claude/cwarm-session-<id>.json`, keyed by a `CWARM_HOST_ID` the host passes through the PTY environment), and the host pins idle *and* TTL‑regime detection to its own transcript. Bridge files are deleted on exit; stale orphans (crashes) are swept on startup. Without the statusline installed, behavior falls back to the previous folder‑newest heuristic.
|
|
246
|
+
- **修正:** 用分頁在**同一個資料夾**開多個 Claude Code session 時,保溫計時不再互相干擾。過去 host 是拿專案資料夾裡 *mtime 最新* 的 transcript 判閒置——隔壁分頁還在活動時,自己的閒置永遠算不滿門檻、keepalive 不發、cache 倒數一路走到冷掉(🔴)。現在 statusline 會把每個 session 精確的 `transcript_path` 落地成 per-session 橋接檔(`~/.claude/cwarm-session-<id>.json`,以 host 經 PTY 環境變數傳入的 `CWARM_HOST_ID` 為鍵),host 的閒置判定**與 TTL 檔位偵測**都針定自己的 transcript。橋接檔退出時自動刪除;crash 留下的孤兒檔啟動時順手清掉。沒裝 statusline 時回退原本「資料夾最新」的猜法。
|
|
247
|
+
|
|
244
248
|
### 0.1.12
|
|
245
249
|
- **Feature:** each fresh unattended stretch now opens with a one-time **briefing message** — the first checklist step of a new round (after human activity resets the cycle) is prefixed with a short explanation telling Claude that cwarm's AI mode is driving, why it's receiving instructions with no request from the user, how the quota pacing works, that a real human message always overrides it, and the built-in safety bounds. Mid-cycle wraparounds within the same unattended stretch don't repeat it. Implemented as a pure, tested `pickInjectMsg` option — no duplicated logic in the host loop.
|
|
246
250
|
- **Docs:** added a full **"Unattended AI mode"** section (English + 繁體中文) covering how it detects you've stepped away, how it paces itself against your 5h/weekly quota, when to turn it on vs. leave it off, how to toggle it, the built-in 18-step cycle, and every `CWARM_AI_*` config knob — previously this was only in `cwarm help` and the changelog, with no standalone explanation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-cache-keepalive",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
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, regimeParams, detectTtlRegime, decideInject, transcriptIdleMs, looksLikeTrustPrompt, detectBillingMode, readUsageBridge, usageState, looksLikeHumanInput, pickInjectMsg, aiPacing, weeklyGate, readAiMsgFile, aiStatePath, extractAiToggle, clampHumanQuiet, toggleKeySpec, readAiState, initialAiEnabled } from './keepalive.mjs';
|
|
8
|
+
import { defaultClaudeDir, regimeParams, detectTtlRegime, decideInject, transcriptIdleMs, looksLikeTrustPrompt, detectBillingMode, readUsageBridge, usageState, looksLikeHumanInput, pickInjectMsg, aiPacing, weeklyGate, readAiMsgFile, aiStatePath, extractAiToggle, clampHumanQuiet, toggleKeySpec, readAiState, initialAiEnabled, sessionBridgePath, readSessionTranscript, readTtlRegime, transcriptIdleMsAt } from './keepalive.mjs';
|
|
9
9
|
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
11
11
|
const isWin = process.platform === 'win32';
|
|
@@ -57,12 +57,29 @@ export function startHost(opts = {}) {
|
|
|
57
57
|
// 固定取一次 cwd,全程共用(host 不會 chdir):writeAiState/usage bridge/transcript 查找
|
|
58
58
|
// 都用同一個值,避免不同時間點呼叫 process.cwd() 理論上不一致、間接影響 cwdKey 對應。
|
|
59
59
|
const hostCwd = process.cwd();
|
|
60
|
+
// session bridge 用的 host 識別碼:pid+啟動時刻,每次啟動唯一。經環境變數傳進 pty 裡的
|
|
61
|
+
// claude,statusline 子行程繼承後把本 session 的 transcript_path 落地成
|
|
62
|
+
// cwarm-session-<hostId>.json,host 讀回即可針定自己的 transcript——多分頁在同一個
|
|
63
|
+
// 資料夾各開 session 時,不再誤拿「資料夾裡最新的 .jsonl」(可能是隔壁 session 的)。
|
|
64
|
+
const hostId = `${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
|
65
|
+
// 孤兒橋接檔清理:host 正常退出會刪掉自己那份,但 crash / 斷電會留下殘檔,啟動時
|
|
66
|
+
// 順手清掉太舊的(>7 天,肯定不會再有 host 認領)。
|
|
67
|
+
try {
|
|
68
|
+
for (const name of fs.readdirSync(claudeDir)) {
|
|
69
|
+
if (!/^cwarm-session-.*\.json$/.test(name)) continue;
|
|
70
|
+
const p = path.join(claudeDir, name);
|
|
71
|
+
try {
|
|
72
|
+
const o = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
73
|
+
if (o == null || o.ts == null || Date.now() - o.ts > 7 * 86_400_000) fs.unlinkSync(p);
|
|
74
|
+
} catch { try { fs.unlinkSync(p); } catch {} }
|
|
75
|
+
}
|
|
76
|
+
} catch { /* claudeDir 讀不到就算了 */ }
|
|
60
77
|
const ptyProc = pty.spawn(file, spawnArgs, {
|
|
61
78
|
name: process.env.TERM || 'xterm-256color',
|
|
62
79
|
cols: process.stdout.columns || 80,
|
|
63
80
|
rows: process.stdout.rows || 24,
|
|
64
81
|
cwd: hostCwd,
|
|
65
|
-
env: process.env,
|
|
82
|
+
env: { ...process.env, CWARM_HOST_ID: hostId },
|
|
66
83
|
});
|
|
67
84
|
|
|
68
85
|
// ---- 透明 I/O 多工 ----
|
|
@@ -259,7 +276,12 @@ export function startHost(opts = {}) {
|
|
|
259
276
|
pendingResume = true; // 視窗已重置:下一發改敲 resumeMsg,回溫 cache 兼續跑被打斷的任務
|
|
260
277
|
try { fs.appendFileSync(LOG, `${new Date().toISOString()} window reset — next inject will send "${resumeMsg()}"\n`); } catch {}
|
|
261
278
|
}
|
|
262
|
-
|
|
279
|
+
// session bridge 有落地時針定本 session 的 transcript(多分頁同資料夾不互相干擾);
|
|
280
|
+
// 沒有(未跑 cwarm setup 裝 statusline、或 payload 還沒刷新過)回退舊的「資料夾最新」猜法。
|
|
281
|
+
const ownTranscript = readSessionTranscript(claudeDir, hostId);
|
|
282
|
+
const regime = ownTranscript
|
|
283
|
+
? readTtlRegime(ownTranscript) // 從自己 session 的 transcript 實測 1h/5m
|
|
284
|
+
: detectTtlRegime(claudeDir, hostCwd); // 回退:資料夾內最新 transcript
|
|
263
285
|
// 週配速:7 天視窗以日均(100/7 ≈ 14.3%/天)攤出進度線,ok=可快跑、slow=退回
|
|
264
286
|
// 一個 TTL 一步、off=超線一天日均以上 → 暫停 AI 工作只剩純保溫 "hi"。
|
|
265
287
|
const wgate = weeklyGate({
|
|
@@ -280,7 +302,9 @@ export function startHost(opts = {}) {
|
|
|
280
302
|
fastMaxPct: envNumber('CWARM_AI_FAST_PCT', 70, LOG),
|
|
281
303
|
});
|
|
282
304
|
const now = Date.now();
|
|
283
|
-
const idleMs =
|
|
305
|
+
const idleMs = ownTranscript
|
|
306
|
+
? transcriptIdleMsAt(ownTranscript, now, startedMs)
|
|
307
|
+
: transcriptIdleMs(claudeDir, hostCwd, now, startedMs);
|
|
284
308
|
// idleMs 找不到 transcript 時保溫整個靜默失效,且原本沒有任何提示——啟動超過 2 分鐘
|
|
285
309
|
// (早期本來就會是 null,Context 0% 正常現象,不必吵)仍找不到才警告一次,提示可能是
|
|
286
310
|
// cwd 與 Claude Code 建立的 transcript 資料夾對不上(磁碟代號大小寫、junction 等)。
|
|
@@ -337,6 +361,7 @@ export function startHost(opts = {}) {
|
|
|
337
361
|
if (exiting) return;
|
|
338
362
|
exiting = true;
|
|
339
363
|
clearInterval(timer);
|
|
364
|
+
try { fs.unlinkSync(sessionBridgePath(claudeDir, hostId)); } catch {} // 自己的 session bridge 檔用完即刪
|
|
340
365
|
try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
|
|
341
366
|
try { process.stdin.pause(); } catch {}
|
|
342
367
|
try { process.stdout.write(RESET, () => process.exit(code)); }
|
|
@@ -352,6 +377,7 @@ export function startHost(opts = {}) {
|
|
|
352
377
|
process.on('SIGHUP', () => shutdown(0));
|
|
353
378
|
// 任何路徑退出都殺掉 pty,並盡力(同步)還原終端,作為最後保險。
|
|
354
379
|
process.on('exit', () => {
|
|
380
|
+
try { fs.unlinkSync(sessionBridgePath(claudeDir, hostId)); } catch {} // shutdown 沒走到時的最後保險
|
|
355
381
|
try { ptyProc.kill(); } catch {}
|
|
356
382
|
try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
|
|
357
383
|
try { process.stdout.write(RESET); } catch {}
|
package/src/keepalive.mjs
CHANGED
|
@@ -106,6 +106,42 @@ export function transcriptIdleMs(claudeDir, cwd, now = Date.now(), sinceMs = nul
|
|
|
106
106
|
return now - m;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// ---- 本 session transcript 針定(session bridge)----
|
|
110
|
+
// 多分頁在「同一個資料夾」各開一個 session 時,上面「取 mtime 最新的 .jsonl」會抓到
|
|
111
|
+
// 隔壁分頁的 transcript:只要隔壁還在活動,自己這邊的 idle 永遠算不滿門檻,保溫不發、
|
|
112
|
+
// cache 冷掉(倒數走到🔴)。TTL 檔位偵測也可能讀到別人的檔。
|
|
113
|
+
// 解法:只有 statusline 拿得到 Claude Code payload 的 transcript_path(session 專屬、
|
|
114
|
+
// 絕對正確),由 segment.mjs 落地成 per-host 橋接檔(cwarm-session-<hostId>.json),
|
|
115
|
+
// host 每 tick 讀回,從此針定自己的 transcript。hostId 由 host 產生、經 CWARM_HOST_ID
|
|
116
|
+
// 環境變數傳進 pty 裡的 claude,statusline 子行程自然繼承。
|
|
117
|
+
// 沒裝 statusline(未跑 cwarm setup)就沒有橋接檔 → 回退舊行為(單 session 不受影響)。
|
|
118
|
+
export function sessionBridgePath(claudeDir, hostId) {
|
|
119
|
+
// hostId 進檔名前先消毒:值理論上只來自自家 host,但環境變數可被外部塞怪字元,不給路徑穿越機會。
|
|
120
|
+
return path.join(claudeDir, `cwarm-session-${String(hostId).replace(/[^a-zA-Z0-9_-]/g, '-')}.json`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 讀橋接檔取回本 session 的 transcript 路徑;hostId 空、檔案不存在、或路徑已失效 → null。
|
|
124
|
+
// 不做 ts 時效檢查:檔案是本 host 專屬(hostId 每次啟動唯一),內容只會被自己 session 的
|
|
125
|
+
// statusline 更新,路徑本身不會「過期」(/clear 換 session 檔時 statusline 下次刷新就改寫)。
|
|
126
|
+
export function readSessionTranscript(claudeDir, hostId) {
|
|
127
|
+
if (!hostId) return null;
|
|
128
|
+
const o = readJsonSafe(sessionBridgePath(claudeDir, hostId));
|
|
129
|
+
const p = o && typeof o.transcript_path === 'string' && o.transcript_path ? o.transcript_path : null;
|
|
130
|
+
if (!p) return null;
|
|
131
|
+
try { fs.statSync(p); } catch { return null; }
|
|
132
|
+
return p;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 針定版 idle:直接看指定 transcript 的 mtime,語意同 transcriptIdleMs(含 sinceMs 的
|
|
136
|
+
// 「Context 0% 不保溫」檢查——resume 舊 session 未寫入前 mtime 早於啟動時刻 → null 不注入)。
|
|
137
|
+
export function transcriptIdleMsAt(tpath, now = Date.now(), sinceMs = null) {
|
|
138
|
+
if (!tpath) return null;
|
|
139
|
+
let m;
|
|
140
|
+
try { m = fs.statSync(tpath).mtimeMs; } catch { return null; }
|
|
141
|
+
if (sinceMs != null && m < sinceMs) return null;
|
|
142
|
+
return now - m;
|
|
143
|
+
}
|
|
144
|
+
|
|
109
145
|
// 讀 transcript 尾端,判斷這個 session 實際拿到的 cache TTL 檔位。
|
|
110
146
|
// 回傳 'long'(1h) / 'short'(5m) / null(找不到可判讀的 cache_creation)。
|
|
111
147
|
// 為什麼讀 transcript 而非帳號方案:message.usage.cache_creation 的
|
|
@@ -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, readTtlRegime, regimeParams, billingModeFromSources, accountInfoFromSources, readAuthSources, usageState, usageBridgePath, readAiState } from '../keepalive.mjs';
|
|
7
|
+
import { defaultClaudeDir, readTtlRegime, regimeParams, billingModeFromSources, accountInfoFromSources, readAuthSources, usageState, usageBridgePath, readAiState, sessionBridgePath } from '../keepalive.mjs';
|
|
8
8
|
|
|
9
9
|
const claudeDir = defaultClaudeDir();
|
|
10
10
|
const ORIG = path.join(claudeDir, 'cwarm-statusline-orig.json');
|
|
@@ -106,6 +106,17 @@ const raw = readStdin();
|
|
|
106
106
|
let payload = {};
|
|
107
107
|
try { payload = JSON.parse(raw); } catch { /* 空/壞就用空物件 */ }
|
|
108
108
|
|
|
109
|
+
// 本 session transcript 落地給 host(session bridge):payload 的 transcript_path 是
|
|
110
|
+
// session 專屬的正確值,host 端只能猜「資料夾裡最新的 .jsonl」——多分頁同資料夾時會
|
|
111
|
+
// 猜到隔壁 session、保溫失準。host 產生的 CWARM_HOST_ID 經 pty 環境變數傳到這裡;
|
|
112
|
+
// 沒有這個變數代表這個 session 不是 cwarm 帶起來的(純 claude),不落地。
|
|
113
|
+
if (process.env.CWARM_HOST_ID && payload?.transcript_path) {
|
|
114
|
+
try {
|
|
115
|
+
fs.writeFileSync(sessionBridgePath(claudeDir, process.env.CWARM_HOST_ID),
|
|
116
|
+
JSON.stringify({ transcript_path: payload.transcript_path, ts: Date.now() }));
|
|
117
|
+
} catch { /* 落地失敗不影響顯示 */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
const cwd = payloadCwd(payload);
|
|
110
121
|
const seg = cacheSegment(payload);
|
|
111
122
|
const warn = usageBridgeAndWarn(payload, cwd);
|