claude-cache-keepalive 0.1.16 → 0.1.17

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
@@ -142,7 +142,7 @@ Environment variables (mostly for testing / advanced use):
142
142
  | Var | Meaning |
143
143
  |-----|---------|
144
144
  | `CWARM_MSG` | keepalive message (default `hi`) |
145
- | `CWARM_TICK_MS` | check interval (default `20000`) |
145
+ | `CWARM_TICK_MS` | check interval (default `10000`) |
146
146
  | `CWARM_QUIET_MS` | screen must be silent this long before injecting (default `2500`) |
147
147
  | `CWARM_ESC_DELAY_MS` | gap between the `Esc` and the keepalive message (default `250`) |
148
148
  | `CWARM_ENTER_DELAY_MS` | gap between the injected text and the `Enter` that submits it (default `80`) — written as two separate `pty.write()` calls so a long AI‑mode message isn't fast enough to look like a paste, which would swallow the trailing `Enter` as pasted text instead of submitting |
@@ -265,7 +265,7 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
265
265
  | 變數 | 意義 |
266
266
  |-----|------|
267
267
  | `CWARM_MSG` | keepalive 訊息(預設 `hi`) |
268
- | `CWARM_TICK_MS` | 檢查間隔(預設 `20000`) |
268
+ | `CWARM_TICK_MS` | 檢查間隔(預設 `10000`) |
269
269
  | `CWARM_QUIET_MS` | 畫面需靜止多久才注入(預設 `2500`) |
270
270
  | `CWARM_ESC_DELAY_MS` | `Esc` 與訊息之間的間隔(預設 `250`) |
271
271
  | `CWARM_ENTER_DELAY_MS` | 注入內容與送出用的 `Enter` 之間的間隔(預設 `80`)——內容和 `Enter` 分成兩次 `pty.write()` 寫入,避免 AI 模式的長訊息因為一次寫入太快被判成「貼上」,導致夾在同一包裡的 `Enter` 只變成貼上內容的一部分而沒有真正送出 |
@@ -283,6 +283,18 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
283
283
 
284
284
  ## Changelog
285
285
 
286
+ ### 0.1.17
287
+ - **Fix:** the keepalive could stay silent for hours while the cache went cold. Idle was measured from the transcript file's **mtime**, and something (still unidentified — it only ever touches the file of a *live* session, never an old one) updates that mtime without appending a single byte. Measured on a real session: mtime claimed 12 minutes idle while the last actual turn was 68 minutes old, so the ping never fired. Idle now comes from the timestamp of the last conversation turn recorded *inside* the transcript, which no outside toucher can move; the old mtime path stays as a fallback for when no timestamp can be read. The statusline countdown had the identical bug — it now calls the exact same function as the host, so the number you see is the number the keepalive decides on.
288
+ - **修正:** 保溫可能整整幾小時不出手,眼睜睜看著 cache 冷掉。閒置時間原本是看 transcript 檔案的 **mtime**,而有東西(還沒查出是誰——它只會碰「正在使用中」的 session 檔,舊檔一個都不碰)會把 mtime 往前更新卻連一個位元組都沒寫進去。實測一個真實 session:mtime 說才閒置 12 分鐘,實際上最後一輪對話已經是 68 分鐘前,保溫因此完全沒發。現在改用 transcript **檔案內**最後一輪對話的時間戳,外部碰觸動不了它;讀不到時間戳時才退回原本的 mtime 路徑。statusline 的倒數本來有一模一樣的 bug,現在改呼叫跟 host 同一支函式——你看到的數字就是保溫拿來做決策的那個。
289
+ - **Fix:** the startup sweep for orphaned session-bridge files deleted the pin file of a **live** session if it happened to read it mid-write (the statusline rewrites it every few seconds). The victim silently reverted to the pre-0.1.16 "newest `.jsonl` in the folder" guess — the very bug 0.1.16 fixed. Bridge files are now written atomically (temp + rename), and the sweep never deletes a file whose mtime is recent, whatever it contains.
290
+ - **修正:** 啟動時清理孤兒 session bridge 檔的那段,只要剛好讀到別人寫到一半的內容(statusline 每幾秒重寫一次),就會把**還活著**的 session 的針定檔刪掉。受害的 session 於是悄悄退回 0.1.16 之前「猜資料夾裡最新的 `.jsonl`」的舊行為——正好就是 0.1.16 修掉的那個 bug。現在橋接檔一律原子寫入(暫存檔 + rename),而且清理時只要 mtime 還新就一律留著,不管內容長什麼樣。
291
+ - **Fix:** `CWARM_QUIET_MS=0` was silently ignored — the same `Number(env) || default` pattern that 0.1.16 fixed for the other delay variables, missed in this one spot.
292
+ - **修正:** `CWARM_QUIET_MS=0` 會被悄悄忽略——就是 0.1.16 已經為其他延遲變數修掉的 `Number(env) || 預設值` 寫法,這一處漏改。
293
+ - **Change:** the default tick interval drops from 20s to 10s. The idle threshold deliberately fires ~2 minutes before the TTL expires, and that margin is the whole safety net; waiting for the screen to go quiet used to eat up to 40s of it. Measured before and after on the same machine: the margin went from 1m17s back to 1m43s.
294
+ - **變更:** 預設檢查間隔從 20 秒改成 10 秒。閒置門檻是刻意設在 TTL 到期前約 2 分鐘,那 2 分鐘就是全部的保命餘裕,而「等畫面靜止」過去最多會吃掉其中 40 秒。同一台機器前後實測:餘裕從 1 分 17 秒拿回到 1 分 43 秒。
295
+ - **Feature:** diagnostic logging, because a keepalive that never fires used to leave no trace whatsoever. Every line now carries the host's `[pid]` (one log file is shared by every host under the same `~/.claude`, and the lines interleave); a `start:` line records the settings actually in effect; when an injection is held back the log names the gate that stopped it along with every relevant number (`skip: gate=screenBusy idle=3487s/3480s screenIdle=1710ms/2500ms humanIdle=…s/300s regime=long ttl=3600s pin=bridge src=turn tpath=…`), throttled so it stays readable; and a `gap:` line appears if the tick loop ever misses beats.
296
+ - **功能:** 診斷日誌——因為「保溫從來沒發過」這件事,過去完全不留任何痕跡。現在每一行都帶 host 的 `[pid]`(同一個 `~/.claude` 底下所有 host 共用一份 log,行是交錯寫入的);`start:` 行記下實際生效的設定;注入被擋下時,log 會指名是哪一道門檻擋的、連同所有相關數值一起記(`skip: gate=screenBusy idle=3487s/3480s screenIdle=1710ms/2500ms humanIdle=…s/300s regime=long ttl=3600s pin=bridge src=turn tpath=…`),並加上節流讓它保持可讀;tick 迴圈若曾漏拍,則會出現 `gap:` 行。
297
+
286
298
  ### 0.1.16
287
299
  - **Fix:** several bugs in 0.1.15's EN/ZH cycle-language switching, found and fixed across four rounds of post-release review. A custom `CWARM_AI_MSG_FILE` cycle could get the wrong-language (or a nonsensical) one-time briefing glued onto it, or make the statusline show a language that had nothing to do with what was actually being injected — both are now fully bypassed when a custom cycle is active. `CHINESE_LANG_RE` didn't recognize a bare `language: "zh"` value. `Ctrl+L` silently changed nothing (while logging a false "success" line) whenever `CWARM_TOGGLE_KEY` and `CWARM_LANG_TOGGLE_KEY` collided, or whenever a custom cycle was active — both now log an honest "ignored" message instead, and the statusline drops the dead hotkey hint during a collision instead of continuing to advertise it. `CWARM_ESC_DELAY_MS=0` / `CWARM_ENTER_DELAY_MS=0` (legitimate values) were silently overridden back to their defaults by a `Number(env) || default` pattern that doesn't treat `0` as set — switched to the same `isFinite`-based helper already used elsewhere for this exact reason. A handful of doc/comment inaccuracies (stale pre-0.1.15 pace-ball thresholds in source comments, an imprecise `--help` line about hotkey-collision winners, an undocumented statusline display case) were also corrected.
288
300
  - **修正:** 0.1.15 的 EN/ZH 循環語言切換功能,經過發版後四輪 review 找到並修好了好幾個 bug。自訂的 `CWARM_AI_MSG_FILE` 循環可能被貼上語言不對(或內容根本文不對題)的一次性簡報,或讓 statusline 顯示的語言跟實際注入內容完全無關——這兩者現在只要偵測到自訂循環生效就完全繞過,不受語言判斷影響。`CHINESE_LANG_RE` 原本認不出裸字串 `language: "zh"`。`Ctrl+L` 在 `CWARM_TOGGLE_KEY` 跟 `CWARM_LANG_TOGGLE_KEY` 撞鍵、或自訂循環生效時會悄悄什麼都不做(卻在 log 裡寫著騙人的「切換成功」),現在兩種情況都改記誠實的「已忽略」訊息,撞鍵時 statusline 也會拿掉那個按了沒用的熱鍵提示、不再繼續宣傳它。`CWARM_ESC_DELAY_MS=0`/`CWARM_ENTER_DELAY_MS=0`(合法值)過去會被 `Number(env) || 預設值` 這種把 `0` 誤判成「沒設定」的寫法悄悄蓋掉,現在改用專案裡本來就有、專門防這個坑的 `isFinite` 判斷式。另外也修正了幾處文件/註解不準確的地方(原始碼裡還留著 0.1.15 修 bug 前的舊配速球門檻、`--help` 裡一句不夠精確的熱鍵撞鍵說明、一個沒寫進文件的 statusline 顯示情況)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-cache-keepalive",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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": {
@@ -21,10 +21,10 @@
21
21
  "url": "https://github.com/fifthadj/claude-cache-keepalive/issues"
22
22
  },
23
23
  "scripts": {
24
- "test": "node --test test/keepalive.test.mjs test/host.test.mjs"
24
+ "test": "node --test test/*.test.mjs"
25
25
  },
26
26
  "engines": {
27
- "node": ">=18"
27
+ "node": ">=22"
28
28
  },
29
29
  "keywords": [
30
30
  "claude",
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, injectWriteSequence, aiPacing, weeklyGate, readAiMsgFile, aiStatePath, extractAiToggle, clampHumanQuiet, toggleKeySpec, readAiState, initialAiEnabled, sessionBridgePath, readSessionTranscript, readTtlRegime, transcriptIdleMsAt, detectConfiguredLanguage, toggleBuiltinLang, resolvePersistedLang, pickAiMsgAndBriefing } from './keepalive.mjs';
8
+ import { defaultClaudeDir, regimeParams, detectTtlRegime, decideInject, transcriptIdleMs, looksLikeTrustPrompt, detectBillingMode, readUsageBridge, usageState, looksLikeHumanInput, pickInjectMsg, injectWriteSequence, aiPacing, weeklyGate, readAiMsgFile, aiStatePath, extractAiToggle, clampHumanQuiet, toggleKeySpec, readAiState, initialAiEnabled, sessionBridgePath, readSessionTranscript, readTtlRegime, transcriptIdleMsAt, detectConfiguredLanguage, toggleBuiltinLang, resolvePersistedLang, pickAiMsgAndBriefing, injectSkipReason, SKIP_REASONS, transcriptPath, orphanBridgeVerdict, writeJsonAtomic, tickStalled, idleMsFromTranscript, ownProjectTranscript, lastEntryTimeMs } from './keepalive.mjs';
9
9
 
10
10
  const require = createRequire(import.meta.url);
11
11
  const isWin = process.platform === 'win32';
@@ -56,8 +56,12 @@ export function startHost(opts = {}) {
56
56
  // …\n\`); } catch {}` 這個樣板原本在這支檔案裡重複了 14 次,逐字一致、只有訊息內容不同——
57
57
  // 抽成這支小函式。純機械式抽取,訊息內容逐字保留(用 diff 核對過每一處),行為完全不變:
58
58
  // 一樣是「best-effort、寫失敗就吞掉」的 log,時間戳格式一樣是 toISOString()。
59
+ // 2026-09-09 加上 [pid]:這個 log 是整個 claudeDir 共用一份,多個 cwarm host(多分頁、
60
+ // 多專案)會把行交錯寫進來。原本每行只有時間戳,事後根本分不出哪一行是哪個 session 寫的
61
+ // ——而「是不是被別的 session 影響」正是最常要查的問題。pid 立刻可用(不必等 hostId 算出來),
62
+ // 也對得上工作管理員與 hostId 前半段(hostId = pid.toString(36) + '-' + 啟動時刻)。
59
63
  function logLine(msg) {
60
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`); } catch {}
64
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} [${process.pid}] ${msg}\n`); } catch {}
61
65
  }
62
66
 
63
67
  // 完全透傳:cwarm [args] === claude [args]。不帶參數就是乾淨的 `claude`(全新 session);
@@ -75,14 +79,23 @@ export function startHost(opts = {}) {
75
79
  const hostId = `${process.pid.toString(36)}-${Date.now().toString(36)}`;
76
80
  // 孤兒橋接檔清理:host 正常退出會刪掉自己那份,但 crash / 斷電會留下殘檔,啟動時
77
81
  // 順手清掉太舊的(>7 天,肯定不會再有 host 認領)。
82
+ // 2026-09-09 修:這裡原本是「JSON.parse 失敗就 unlinkSync」,而 statusline 用非原子寫入
83
+ // 每幾秒重寫這些檔——開新 session 時剛好讀到寫一半的內容就會把**還活著**的 session 的
84
+ // 針定檔刪掉,那個 session 於是退回 0.1.16 之前「猜資料夾內最新 .jsonl」的舊行為。
85
+ // 判定移到 orphanBridgeVerdict(純函式、有測試),鐵則是 mtime 還新的一律留著。
78
86
  try {
79
87
  for (const name of fs.readdirSync(claudeDir)) {
80
- if (!/^cwarm-session-.*\.json$/.test(name)) continue;
88
+ // .tmp 是 writeJsonAtomic 的暫存檔:正常情況 rename 完就不存在,但寫到一半被砍
89
+ //(斷電/kill)會留下。跟著一起用同一套「夠舊才刪」的判定收掉,否則永遠沒人清。
90
+ const isTmp = /^cwarm-session-.*\.json\.\d+\.tmp$/.test(name);
91
+ if (!isTmp && !/^cwarm-session-.*\.json$/.test(name)) continue;
81
92
  const p = path.join(claudeDir, name);
82
93
  try {
83
- const o = JSON.parse(fs.readFileSync(p, 'utf8'));
84
- if (o == null || o.ts == null || Date.now() - o.ts > 7 * 86_400_000) fs.unlinkSync(p);
85
- } catch { try { fs.unlinkSync(p); } catch {} }
94
+ let raw = null, mtimeMs = null;
95
+ try { mtimeMs = fs.statSync(p).mtimeMs; } catch { continue; } // 檔在掃的當下沒了:別人剛退出,不關我們的事
96
+ try { raw = fs.readFileSync(p, 'utf8'); } catch { /* 讀不到 → raw 維持 null,交給判定 */ }
97
+ if (orphanBridgeVerdict(raw, { mtimeMs }) === 'delete') fs.unlinkSync(p);
98
+ } catch { /* 單一檔案處理失敗不影響其他檔,也絕不因此刪檔 */ }
86
99
  }
87
100
  } catch { /* claudeDir 讀不到就算了 */ }
88
101
  const ptyProc = pty.spawn(file, spawnArgs, {
@@ -142,7 +155,9 @@ export function startHost(opts = {}) {
142
155
  // 本來就會在 lang 是 falsy 時跳過整段,等於誠實承認「這裡不知道/不適用哪個語言」。
143
156
  const lang = resolvePersistedLang(fixedAiMsg, langOverride, configuredLang);
144
157
  try {
145
- fs.writeFileSync(aiStatePath(claudeDir, hostCwd), JSON.stringify({
158
+ // 原子寫入(同 session bridge/usage bridge):statusline 每次刷新都會讀這個檔,
159
+ // 讀到半截會讓 AI 開關那一段整個消失。
160
+ writeJsonAtomic(aiStatePath(claudeDir, hostCwd), {
146
161
  enabled: aiEnabled, ts: Date.now(), key: toggleKey.label, lang, langKey: langToggleKey.label,
147
162
  // langTouched:這個 session 裡有沒有真的按過語言熱鍵——AI 模式關著時語言段預設不顯示
148
163
  // (見 segment.mjs),但使用者若想在開 AI 之前先試按 Ctrl+L 確認有沒有生效,需要一個
@@ -152,7 +167,7 @@ export function startHost(opts = {}) {
152
167
  // (而非藉由拿掉 langKey)才能跟「舊版狀態檔沒這個欄位」區分開——舊檔案沒寫代表
153
168
  // 「當時沒有撞鍵這回事」,理當預設為 true,不能讓兩種「沒資訊」的情況長得一樣。
154
169
  langHotkeyLive,
155
- }));
170
+ });
156
171
  } catch {}
157
172
  }
158
173
  writeAiState();
@@ -216,9 +231,18 @@ export function startHost(opts = {}) {
216
231
  });
217
232
 
218
233
  // ---- 內建保溫 ----
219
- const tickMs = Number(process.env.CWARM_TICK_MS) || 20_000;
234
+ // 2026-09-09 20s 改成 10s:門檻是刻意比 TTL 早 2 分鐘(3480s vs 3600s),那 2 分鐘就是
235
+ // 保命餘裕。實測一次注入落在跨過門檻後的第 2 個 tick——畫面當下在動(statusline 重畫)被
236
+ // quietMs 擋下,等下一輪才發,光這一項就吃掉 43 秒,餘裕只剩 1 分 17 秒。tick 縮短後,
237
+ // 跨過門檻到抓到「畫面靜止」空檔之間的等待減半,餘裕拿回來。
238
+ // 代價只是每輪多幾次小檔 stat/讀取(實測每 tick 約 128KB,可忽略)。
239
+ const tickMs = Number(process.env.CWARM_TICK_MS) || 10_000;
220
240
  const msg = process.env.CWARM_MSG || opts.msg || 'hi';
221
- const quietMs = Number(process.env.CWARM_QUIET_MS) || 2500; // 畫面需靜止這麼久才注入
241
+ // 2026-09-09:這行原本也是 Number(env) || 2500,跟上面 CWARM_ESC_DELAY_MS 那段註解裡
242
+ // 2026-08-30 修掉的坑一模一樣——當時漏改這一個。CWARM_QUIET_MS=0(合法值,代表關掉
243
+ // 「畫面靜止」門檻,做對照實驗時要用)會被 falsy 吃掉、悄悄退回 2500,害人以為
244
+ // 關掉了還是沒注入而排除錯的假設。改用 envNumber,0 就是 0。
245
+ const quietMs = envNumber('CWARM_QUIET_MS', 2500, LOG); // 畫面需靜止這麼久才注入
222
246
  // 2026-08-30 code review(第四輪)抓到:原本 Number(env) || 250 這個寫法,跟同檔案的
223
247
  // envNumber() 特地防的坑一模一樣——CWARM_ESC_DELAY_MS=0(合法值,代表使用者要 Esc 跟內容
224
248
  // 幾乎同時送出)會被 falsy 判斷悄悄吃掉、退回預設 250,且完全沒有警告。改用 envNumber,
@@ -271,7 +295,30 @@ export function startHost(opts = {}) {
271
295
  let limitLogged = false;
272
296
  let noUsageWarned = false; // --ai 但讀不到額度橋接檔時警告一次(額度閘門全數失效)
273
297
  let noTranscriptWarned = false; // 啟動夠久了還是找不到本專案 transcript,警告一次(診斷路徑不符等問題)
298
+ // 診斷:被門檻擋下時記一行。2026-09-09 有一次整整 91 分鐘沒注入,log 一片空白,
299
+ // 事後無從判斷是哪一關擋的——因為 decideInject 回 false 時什麼都不留。
300
+ // 異常門檻(screenBusy/humanRecent/noTranscript/disabled)5 分鐘一行;idleShort/
301
+ // cooldown 是每天大部分時間的正常狀態,改成 30 分鐘一行——不能完全不記:若 host 針定到
302
+ // 「別的 session 的 transcript」(使用者長期懷疑的跨 session 干擾),idle 會被別人的活動
303
+ // 一直洗掉、理由永遠停在 idleShort,完全不記就等於這個情境查不到。每行都帶 tpath,
304
+ // 借到誰的 transcript 一眼可見。
305
+ let lastSkipReason = null;
306
+ let lastSkipLogMs = 0;
307
+ const skipLogMs = envNumber('CWARM_SKIP_LOG_MS', 300_000, LOG);
308
+ const quietSkipLogMs = envNumber('CWARM_SKIP_LOG_QUIET_MS', 1_800_000, LOG);
309
+ const QUIET_SKIPS = new Set([SKIP_REASONS.idleShort, SKIP_REASONS.cooldown]);
310
+ // 啟動就記一行:否則「log 一片空白」有兩種解讀(新 code 還沒到門檻/根本還在跑舊 code),
311
+ // 分不出來就沒辦法判斷蒐證有沒有真的開始。順便把當下生效的參數留底。
312
+ logLine(`start: hostId=${hostId} cwd=${hostCwd} tick=${tickMs}ms quiet=${quietMs}ms humanQuiet=${Math.round(humanQuietMs / 1000)}s threshold=${overrides.idleThreshold ?? 'regime'} ttl=${overrides.ttl ?? 'regime'} ai=${aiEnabled ? 'on' : 'off'}`);
313
+ let prevTickMs = null;
274
314
  const timer = setInterval(() => {
315
+ // 這行必須是 callback 的第一件事:量的是「這個 tick 距上一個 tick 多久」,
316
+ // 放在任何 early return 之後就量不到被跳過的那些 tick。
317
+ const tickNow = Date.now();
318
+ if (tickStalled(prevTickMs, tickNow, tickMs)) {
319
+ logLine(`gap: tick loop stalled ${Math.round((tickNow - prevTickMs) / 1000)}s (tick=${tickMs}ms) — no keepalive decision was made during that time`);
320
+ }
321
+ prevTickMs = tickNow;
275
322
  writeAiState(); // 心跳:statusline 靠 ts 新鮮度判斷 host 還活著才顯示開關段
276
323
  // credits / API 計費(/login 切到 Console 帳號、或只用 ANTHROPIC_API_KEY)時,每次注入
277
324
  // 都是實際花錢,保溫沒有意義 → 自動暫停。每個 tick 重新偵測,session 中 /login 切回
@@ -350,9 +397,12 @@ export function startHost(opts = {}) {
350
397
  fastMaxPct: envNumber('CWARM_AI_FAST_PCT', 70, LOG),
351
398
  });
352
399
  const now = Date.now();
353
- const idleMs = ownTranscript
354
- ? transcriptIdleMsAt(ownTranscript, now, startedMs)
355
- : transcriptIdleMs(claudeDir, hostCwd, now, startedMs);
400
+ // 2026-09-09:idle 改用「最後一輪對話的時間」(idleMsFromTranscript 讀 transcript 檔尾),
401
+ // 不再直接用檔案 mtime——實測有東西會把閒置中 session 的 transcript mtime 往前碰、內容
402
+ // 卻一個位元組都沒變,mtime 一被碰 idle 就歸零,保溫永遠等不到門檻。讀不到時間戳才退回 mtime。
403
+ // 兩條路徑(有無 session bridge)統一走同一支,沒裝 statusline 的人也一起修到。
404
+ const idleTpath = ownTranscript || ownProjectTranscript(claudeDir, hostCwd);
405
+ const idleMs = idleMsFromTranscript(idleTpath, now, startedMs);
356
406
  // idleMs 找不到 transcript 時保溫整個靜默失效,且原本沒有任何提示——啟動超過 2 分鐘
357
407
  // (早期本來就會是 null,Context 0% 正常現象,不必吵)仍找不到才警告一次,提示可能是
358
408
  // cwd 與 Claude Code 建立的 transcript 資料夾對不上(磁碟代號大小寫、junction 等)。
@@ -375,7 +425,9 @@ export function startHost(opts = {}) {
375
425
  // humanQuiet 鉗在 idleThreshold-60s 內:short 檔(TTL 300s/門檻 240s)若照吃 300s
376
426
  // 靜默,注入會落在 cache 冷掉之後——保命的提早注入餘裕不能被這個門檻吃掉。
377
427
  const hqMs = clampHumanQuiet(humanQuietMs, idleThreshold);
378
- if (decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE), screenIdleMs, quietMs, humanIdleMs, humanQuietMs: hqMs })) {
428
+ // 同一份參數餵給 decideInject 與診斷用的 injectSkipReason,兩邊看到的數值必然一致。
429
+ const gateArgs = { now, idleMs, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE), screenIdleMs, quietMs, humanIdleMs, humanQuietMs: hqMs };
430
+ if (decideInject(gateArgs)) {
379
431
  // 先送 Esc:把任何「必答」modal(權限/選單/計畫批准)收掉、退回輸入框,後面那個 Enter 才不會誤選預設項;
380
432
  // 空輸入框時 Esc 等同 no-op。隔一小段再送訊息——讓 claude 先把 modal 收乾淨,也避免 ESC 與字元被併成 Meta 鍵。
381
433
  const aiAllowed = aiEnabled && wgate !== 'off';
@@ -396,6 +448,27 @@ export function startHost(opts = {}) {
396
448
  const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
397
449
  const msgLabel = injectMsg.length > 24 ? `${injectMsg.slice(0, 24)}…` : injectMsg;
398
450
  logLine(`inject "${msgLabel}" regime=${regime ?? 'unknown'} idle=${idle}s screenIdle=${Math.round(screenIdleMs / 1000)}s consec=${consecInjects} aiStep=${aiStep} weekly=${wgate}`);
451
+ lastSkipReason = null; // 發出去了:下次再被擋要重新記一行
452
+ } else {
453
+ const reason = injectSkipReason(gateArgs);
454
+ // 理由變了就立刻記(狀態轉換才是關鍵訊息),同一理由持續則節流(正常門檻節流得更久)。
455
+ const throttleMs = QUIET_SKIPS.has(reason) ? quietSkipLogMs : skipLogMs;
456
+ if (reason !== lastSkipReason || now - lastSkipLogMs >= throttleMs) {
457
+ lastSkipReason = reason;
458
+ lastSkipLogMs = now;
459
+ // 數值全記:只印被擋的那一道,看不出後面幾道是否也不合格(門檻是短路的)。
460
+ // tpath 記檔名即可(路徑含使用者名稱與專案名,log 不必留全長)+針定來源:
461
+ // pin=bridge 表示用 statusline 落地的本 session transcript,pin=guess 是回退的
462
+ // 「資料夾內最新 .jsonl」——後者才有可能猜到隔壁 session。
463
+ const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
464
+ const hIdle = humanIdleMs == null ? -1 : Math.round(humanIdleMs / 1000);
465
+ // 回退模式沒有現成路徑,重算一次(只在真的要寫 log 時才做,不進每個 tick)。
466
+ const tName = idleTpath ? path.basename(idleTpath) : '(none)';
467
+ // src=turn 表示 idle 來自最後一輪對話的時間戳(正確訊號);src=mtime 是讀不到時間戳
468
+ // 的退路,那條路徑會被「碰 mtime」影響,看到它就知道要查為什麼讀不到。
469
+ const idleSrc = lastEntryTimeMs(idleTpath) != null ? 'turn' : 'mtime';
470
+ logLine(`skip: gate=${reason} idle=${idle}s/${idleThreshold}s screenIdle=${Math.round(screenIdleMs)}ms/${quietMs}ms humanIdle=${hIdle}s/${Math.round(hqMs / 1000)}s regime=${regime ?? 'unknown'} ttl=${ttl}s sinceFire=${Math.round((now - lastFire) / 1000)}s pin=${ownTranscript ? 'bridge' : 'guess'} src=${idleSrc} tpath=${tName}`);
471
+ }
399
472
  }
400
473
  }, tickMs);
401
474
 
package/src/keepalive.mjs CHANGED
@@ -120,6 +120,35 @@ export function sessionBridgePath(claudeDir, hostId) {
120
120
  return path.join(claudeDir, `cwarm-session-${String(hostId).replace(/[^a-zA-Z0-9_-]/g, '-')}.json`);
121
121
  }
122
122
 
123
+ // 橋接檔用原子寫入:先寫同目錄暫存檔再 rename(同磁碟的 rename 是原子操作),讀的一方
124
+ // 永遠只會看到「完整的舊版」或「完整的新版」,不會讀到寫到一半的半截 JSON。
125
+ // 2026-09-09 加:statusline 原本用 fs.writeFileSync 直接覆寫,另一個 session 啟動時
126
+ // 掃孤兒剛好讀到半截內容 → JSON.parse 失敗 → 把還活著的 session 的針定檔刪掉
127
+ //(見 orphanBridgeVerdict)。那邊改成不刪新檔是防守,這裡才是根因。
128
+ export function writeJsonAtomic(p, obj) {
129
+ const tmp = `${p}.${process.pid}.tmp`;
130
+ try {
131
+ fs.writeFileSync(tmp, JSON.stringify(obj));
132
+ fs.renameSync(tmp, p);
133
+ } catch (e) {
134
+ try { fs.unlinkSync(tmp); } catch { /* 暫存檔清不掉就算了,不要蓋掉原本的錯 */ }
135
+ throw e;
136
+ }
137
+ }
138
+
139
+ // 啟動時掃孤兒橋接檔的判定:'delete'(確定是殘檔)或 'keep'。
140
+ // raw = 檔案內容(讀不到給 null)。鐵則:**mtime 還新的一律留著**——解析失敗最常見的
141
+ // 原因不是壞檔,而是另一個 session 的 statusline 正在寫。刪錯的代價是那個 session
142
+ // 失去 transcript 針定、悄悄退回「猜資料夾內最新 .jsonl」的舊行為(0.1.16 修掉的那個 bug);
143
+ // 少刪一個殘檔的代價只是多留一個幾十位元組的檔到下次啟動。兩者不對等,故保守。
144
+ export function orphanBridgeVerdict(raw, { mtimeMs, now = Date.now(), maxAgeMs = 7 * 86_400_000 } = {}) {
145
+ if (mtimeMs != null && now - mtimeMs <= maxAgeMs) return 'keep'; // 近期被寫過 → 可能有 host 正在用
146
+ let o = null;
147
+ try { o = raw == null ? null : JSON.parse(raw); } catch { return 'delete'; } // 壞檔且夠舊
148
+ if (o == null || o.ts == null) return 'delete';
149
+ return now - o.ts > maxAgeMs ? 'delete' : 'keep';
150
+ }
151
+
123
152
  // 讀橋接檔取回本 session 的 transcript 路徑;hostId 空、檔案不存在、或路徑已失效 → null。
124
153
  // 不做 ts 時效檢查:檔案是本 host 專屬(hostId 每次啟動唯一),內容只會被自己 session 的
125
154
  // statusline 更新,路徑本身不會「過期」(/clear 換 session 檔時 statusline 下次刷新就改寫)。
@@ -142,6 +171,64 @@ export function transcriptIdleMsAt(tpath, now = Date.now(), sinceMs = null) {
142
171
  return now - m;
143
172
  }
144
173
 
174
+ // ---- idle 的正確訊號:最後一輪對話的時間(不是檔案 mtime)----
175
+ // 2026-09-09 實測:一個閒置中的 session,transcript 的 mtime 從 19:05:31 跳到 20:02:00,
176
+ // 但檔案大小與最後一筆內容時間戳完全沒變——有東西「碰」了檔案卻沒寫內容(誰做的還沒查到;
177
+ // 只有正在被開啟的那個檔會被碰,舊檔都不會)。mtime 一被碰,cwarm 的 idle 就歸零,
178
+ // 保溫永遠等不到門檻,而且因為理由停在 idleShort、被節流吃掉,log 還一片空白。
179
+ // mtime 語意是「檔案何時被寫」;我們要的是「最後一輪對話何時發生」——後者就寫在檔案裡,
180
+ // 對任何碰觸者都免疫,也才是 prompt cache 年齡的正確定義。
181
+ // 只讀檔尾(同 readTtlRegime 的手法),大檔也不必整份讀。
182
+ export function lastEntryTimeMs(tpath, { maxBytes = 65536 } = {}) {
183
+ if (!tpath) return null;
184
+ let buf, size;
185
+ let fd = null;
186
+ try {
187
+ size = fs.statSync(tpath).size;
188
+ const start = Math.max(0, size - maxBytes);
189
+ const len = size - start;
190
+ buf = Buffer.alloc(len);
191
+ fd = fs.openSync(tpath, 'r');
192
+ fs.readSync(fd, buf, 0, len, start);
193
+ } catch {
194
+ return null;
195
+ } finally {
196
+ if (fd != null) { try { fs.closeSync(fd); } catch {} }
197
+ }
198
+ let lines = buf.toString('utf8').split(/\r?\n/);
199
+ if (size > maxBytes && lines.length) lines = lines.slice(1); // 丟掉被切半的第一行
200
+ // 由後往前找第一筆「有 timestamp」的:檔尾常常是 mode/atis-latch/cost-state 這類
201
+ // 沒有時間戳的中介行(實測一份檔裡有 222 行),只看最後一行會抓不到。
202
+ for (let i = lines.length - 1; i >= 0; i--) {
203
+ const line = lines[i].trim();
204
+ if (!line) continue;
205
+ let obj;
206
+ try { obj = JSON.parse(line); } catch { continue; }
207
+ if (!obj || typeof obj !== 'object' || !obj.timestamp) continue;
208
+ // 帶 Z 的 ISO 字串交給 Date.parse 就是正確的 UTC→epoch;不可自己拿本地時間換算。
209
+ const t = Date.parse(obj.timestamp);
210
+ if (Number.isFinite(t)) return t;
211
+ }
212
+ return null;
213
+ }
214
+
215
+ // 本專案資料夾內最新的 transcript(**不做跨專案 fallback**)。沒裝 statusline、拿不到
216
+ // session bridge 時用它——寧可找不到(idle=null、不注入)也不要猜到別的專案的 session。
217
+ export function ownProjectTranscript(claudeDir, cwd) {
218
+ return newestJsonlPath(path.join(claudeDir, 'projects', encodeProjectDir(cwd)));
219
+ }
220
+
221
+ // idle(毫秒):優先用最後一輪對話的時間,讀不到才退回 mtime(空檔、檔尾全是超長單行、
222
+ // 舊格式…)。退回時語意與 transcriptIdleMsAt 完全一致,包含 sinceMs 的「Context 0% 不保溫」。
223
+ export function idleMsFromTranscript(tpath, now = Date.now(), sinceMs = null) {
224
+ const t = lastEntryTimeMs(tpath);
225
+ if (t != null) {
226
+ if (sinceMs != null && t < sinceMs) return null; // 本 session 啟動後還沒講過話
227
+ return now - t;
228
+ }
229
+ return transcriptIdleMsAt(tpath, now, sinceMs);
230
+ }
231
+
145
232
  // 讀 transcript 尾端,判斷這個 session 實際拿到的 cache TTL 檔位。
146
233
  // 回傳 'long'(1h) / 'short'(5m) / null(找不到可判讀的 cache_creation)。
147
234
  // 為什麼讀 transcript 而非帳號方案:message.usage.cache_creation 的
@@ -721,14 +808,42 @@ export function toggleBuiltinLang(currentOverride, autoDetectedLang) {
721
808
  // humanIdleMs/humanQuietMs:距使用者最後一次敲鍵多久/需靜多久才放行。打字中途停下來
722
809
  // 想事情(畫面靜止 >quietMs)不代表人不在——半句草稿 + "hi" + Enter 一起送出就是事故。
723
810
  // 故人剛敲過鍵(預設 5 分鐘內)一律不注入;null 不套用(相容既有呼叫者)。
724
- export function decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled, screenIdleMs, quietMs, humanIdleMs, humanQuietMs }) {
725
- if (disabled) return false; // 暫停開關
726
- if (idleMs == null) return false; // 找不到 transcript → 保守不發
727
- if (idleMs < idleThreshold * 1000) return false; // 距上次訊息還不夠久
728
- if (now - lastFire < ttl * 1000) return false; // 冷卻未滿一個 TTL
729
- if (quietMs != null && screenIdleMs != null && screenIdleMs < quietMs) return false; // 畫面還在動(提示/生成/打字中)
730
- if (humanQuietMs != null && humanIdleMs != null && humanIdleMs < humanQuietMs) return false; // 人剛敲過鍵(可能在打字)
731
- return true;
811
+ // 擋下注入的門檻代號。host 的診斷日誌直接印這些字串,出事時 log 才說得出是哪一關。
812
+ export const SKIP_REASONS = {
813
+ disabled: 'disabled', // cwarm.disabled 旗標
814
+ noTranscript: 'noTranscript', // 找不到本 session transcript
815
+ idleShort: 'idleShort', // 距上次訊息還不夠久
816
+ cooldown: 'cooldown', // 距上次注入未滿一個 TTL
817
+ screenBusy: 'screenBusy', // 畫面還在動
818
+ humanRecent: 'humanRecent', // 人剛敲過鍵
819
+ };
820
+
821
+ // decideInject 的「說得出理由」版:可注入回 null,否則回 SKIP_REASONS 之一。
822
+ // 2026-09-09 加:原本 decideInject 回 false 時不留任何痕跡,事後(91 分鐘沒注入)
823
+ // 完全無從判斷是哪一道門檻擋的。門檻邏輯只留這一份,decideInject 改為呼叫它,
824
+ // 兩邊不可能走鐘(測試 'decideInject and injectSkipReason never disagree' 釘住)。
825
+ export function injectSkipReason({ now, idleMs, lastFire, idleThreshold, ttl, disabled, screenIdleMs, quietMs, humanIdleMs, humanQuietMs }) {
826
+ if (disabled) return SKIP_REASONS.disabled;
827
+ if (idleMs == null) return SKIP_REASONS.noTranscript; // 保守不發
828
+ if (idleMs < idleThreshold * 1000) return SKIP_REASONS.idleShort;
829
+ if (now - lastFire < ttl * 1000) return SKIP_REASONS.cooldown;
830
+ if (quietMs != null && screenIdleMs != null && screenIdleMs < quietMs) return SKIP_REASONS.screenBusy; // 提示/生成/打字中
831
+ if (humanQuietMs != null && humanIdleMs != null && humanIdleMs < humanQuietMs) return SKIP_REASONS.humanRecent;
832
+ return null;
833
+ }
834
+
835
+ export function decideInject(args) {
836
+ return injectSkipReason(args) === null;
837
+ }
838
+
839
+ // tick 迴圈實際有沒有照 tickMs 跑起來。2026-09-09:有一段 13 分鐘的閒置窗,idle 明明
840
+ // 早該跨過門檻、理由早該從 idleShort 變掉(一變就會寫一行),結果 log 完全空白,而且
841
+ // 節流時間戳是完美的 30 分鐘間隔——推論是那些 tick 根本沒執行(Windows 上 process.stdout.write
842
+ // 對背景 console 是同步阻塞的,可能把 event loop 卡住)。這支就是用來證實/推翻那個推論。
843
+ // 容忍 2 倍 tick:正常抖動(GC、磁碟)不該報,真的漏拍才報。
844
+ export function tickStalled(prevMs, nowMs, tickMs) {
845
+ if (prevMs == null || tickMs == null) return false;
846
+ return nowMs - prevMs > 2 * tickMs;
732
847
  }
733
848
 
734
849
  // 畫面上是否正顯示 Claude Code 的「資料夾信任」對話框("Do you trust the files in this folder?")。
@@ -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, sessionBridgePath, weeklyPaceInfo, aiSegmentText } from '../keepalive.mjs';
7
+ import { defaultClaudeDir, readTtlRegime, regimeParams, billingModeFromSources, accountInfoFromSources, readAuthSources, usageState, usageBridgePath, readAiState, sessionBridgePath, weeklyPaceInfo, aiSegmentText, writeJsonAtomic, idleMsFromTranscript } from '../keepalive.mjs';
8
8
 
9
9
  const claudeDir = defaultClaudeDir();
10
10
  const ORIG = path.join(claudeDir, 'cwarm-statusline-orig.json');
@@ -16,17 +16,22 @@ function readStdin() {
16
16
  // 憑證/設定來源整份只讀一次(billing 與帳號段共用),render 路徑不重複 IO。
17
17
  const auth = readAuthSources(claudeDir);
18
18
 
19
- // cache 倒數段:用 transcript mtime 當 idle、用 transcript 實測的 cache_creation 判 TTL(1h / 5m)。
19
+ // cache 倒數段:idle 用「最後一輪對話的時間」、TTL transcript 實測的 cache_creation(1h / 5m)。
20
+ // 2026-09-09 改:原本這裡是 fs.statSync(tp).mtimeMs,跟 host 當時一樣踩到「有東西碰 mtime
21
+ // 但沒寫內容」的坑——畫面上的倒數會莫名其妙被拉回滿格,看起來像剛保溫過,其實 cache 一直在冷。
22
+ // 更關鍵的是:**顯示的數字必須跟 host 拿來做決策的是同一個**。兩邊各算各的,畫面就會跟
23
+ // 實際行為對不起來,出事時完全查不下去(2026-09-09 就是這樣卡了一整天)。故改呼叫同一支
24
+ // idleMsFromTranscript。TTL 檔位本來就已經是讀 API 實際回報的 cache_creation,不動。
20
25
  function cacheSegment(payload) {
21
26
  // credits/API 計費時 keepalive 已暫停,倒數沒有意義且會誤導 → 顯示暫停標記
22
27
  if (billingModeFromSources({ env: process.env, ...auth }) === 'credits') return '⏸️ cwarm off (API)';
23
28
  const tp = payload?.transcript_path;
24
29
  if (!tp) return '';
25
- let mtimeMs;
26
- try { mtimeMs = fs.statSync(tp).mtimeMs; } catch { return ''; }
30
+ const idleMs = idleMsFromTranscript(tp);
31
+ if (idleMs == null) return '';
27
32
  const { ttl } = regimeParams(readTtlRegime(tp)); // 'long'→3600 / 'short'|null→300
28
33
 
29
- const idle = Math.max(0, Math.floor((Date.now() - mtimeMs) / 1000));
34
+ const idle = Math.max(0, Math.floor(idleMs / 1000));
30
35
  const rem = ttl - idle;
31
36
  if (rem <= 0) return `\u{1F534} cache ${Math.floor(idle / 60)}m`; // 🔴 已冷
32
37
  if (rem <= 60) return `\u{1F7E1} cache ${rem}s`; // 🟡 快過期
@@ -67,12 +72,14 @@ function usageBridgeAndWarn(payload, cwd) {
67
72
  const resetsAt = fh?.resets_at ?? null;
68
73
  if (cwd) {
69
74
  try {
70
- fs.writeFileSync(usageBridgePath(claudeDir, cwd), JSON.stringify({
75
+ // 同樣原子寫入:host 每個 tick 讀這個檔,讀到半截會被 readJsonSafe 吞成 null,
76
+ // 額度/週配速閘門就整組變 unknown 而且完全不出聲。
77
+ writeJsonAtomic(usageBridgePath(claudeDir, cwd), {
71
78
  used_percentage: usedPct,
72
79
  resets_at: resetsAt,
73
80
  seven_day: { used_percentage: sd?.used_percentage ?? null, resets_at: sd?.resets_at ?? null },
74
81
  ts: Date.now(),
75
- }));
82
+ });
76
83
  } catch { /* 落地失敗不影響顯示 */ }
77
84
  }
78
85
  const nowSec = Date.now() / 1000;
@@ -134,8 +141,10 @@ try { payload = JSON.parse(raw); } catch { /* 空/壞就用空物件 */ }
134
141
  // 沒有這個變數代表這個 session 不是 cwarm 帶起來的(純 claude),不落地。
135
142
  if (process.env.CWARM_HOST_ID && payload?.transcript_path) {
136
143
  try {
137
- fs.writeFileSync(sessionBridgePath(claudeDir, process.env.CWARM_HOST_ID),
138
- JSON.stringify({ transcript_path: payload.transcript_path, ts: Date.now() }));
144
+ // 原子寫入:這個檔每幾秒重寫一次,而別的 session 啟動時會掃描它——直接覆寫會讓
145
+ // 掃描方讀到半截 JSON(見 host.mjs 的孤兒清理與 orphanBridgeVerdict)。
146
+ writeJsonAtomic(sessionBridgePath(claudeDir, process.env.CWARM_HOST_ID),
147
+ { transcript_path: payload.transcript_path, ts: Date.now() });
139
148
  } catch { /* 落地失敗不影響顯示 */ }
140
149
  }
141
150