claude-cache-keepalive 0.1.15 → 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
@@ -115,7 +115,9 @@ Or toggle **live**, anytime, with `Ctrl+\` (rebindable — see `CWARM_TOGGLE_KEY
115
115
 
116
116
  The cycle defaults to English (fewer tokens per injection), but auto-switches to a hand-translated Traditional Chinese version when Claude Code's own `language` setting (the one `/config` writes to `settings.json`) is a Chinese locale (`繁體中文`, `Chinese`, `zh-TW`, `zh-Hant`, …, case-insensitive; simplified variants get the Traditional text too — there's only one Chinese version). This only changes the *injected trigger text* — what language Claude actually works and replies in was always governed by your `CLAUDE.md`, independently of the injection language, so this is a readability nicety, not something that changes behavior. It's not meant to generalize to every possible `language` value — for anything else, `CWARM_AI_MSG_FILE` (below) is the general escape hatch: write your own cycle in whatever language or domain you want.
117
117
 
118
- Auto-detection wrong, or just want to flip it for this session? Press `Ctrl+L` (like the AI-mode toggle, rebindable, see `CWARM_LANG_TOGGLE_KEY` below) to switch the built-in cycle between English and Traditional Chinese on the spot — no restart needed. It flips whichever language is *currently in effect* (your override if you've already pressed it once this session, otherwise the auto-detected one), and stays pinned to your choice until you press it again; a fresh `cwarm` restart goes back to auto-detecting from `language`. Only affects the built-in EN/ZH cycle — if `CWARM_AI_MSG`/`CWARM_AI_MSG_FILE` is set, the hotkey has nothing to switch.
118
+ Auto-detection wrong, or just want to flip it for this session? Press `Ctrl+L` (like the AI-mode toggle, rebindable, see `CWARM_LANG_TOGGLE_KEY` below — worth rebinding if your terminal or shell relies on `Ctrl+L` for clear/redraw, since this hotkey consumes the keystroke entirely and never forwards it through) to switch the built-in cycle between English and Traditional Chinese on the spot — no restart needed. It flips whichever language is *currently in effect* (your override if you've already pressed it once this session, otherwise the auto-detected one), and stays pinned to your choice until you press it again; a fresh `cwarm` restart goes back to auto-detecting from `language`. Only affects the built-in EN/ZH cycle — if `CWARM_AI_MSG`/`CWARM_AI_MSG_FILE` is set, the hotkey has nothing to switch.
119
+
120
+ If `CWARM_TOGGLE_KEY` and `CWARM_LANG_TOGGLE_KEY` end up resolving to the same key, `Ctrl+L` silently does nothing for the rest of the session (the on/off toggle checks first and always wins the collision) — a warning is logged once, and the statusline drops the `(Ctrl+L)` hint from the language suffix (`🤖AI on (Ctrl+\) 中` instead of `🤖AI on (Ctrl+\) 中(Ctrl+L)`) so it doesn't keep advertising a hotkey that can't fire. Rebind one of the two if you hit this.
119
121
 
120
122
  Replace it entirely with your own, or tune the pacing:
121
123
 
@@ -140,7 +142,7 @@ Environment variables (mostly for testing / advanced use):
140
142
  | Var | Meaning |
141
143
  |-----|---------|
142
144
  | `CWARM_MSG` | keepalive message (default `hi`) |
143
- | `CWARM_TICK_MS` | check interval (default `20000`) |
145
+ | `CWARM_TICK_MS` | check interval (default `10000`) |
144
146
  | `CWARM_QUIET_MS` | screen must be silent this long before injecting (default `2500`) |
145
147
  | `CWARM_ESC_DELAY_MS` | gap between the `Esc` and the keepalive message (default `250`) |
146
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 |
@@ -236,7 +238,9 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
236
238
 
237
239
  循環預設英文(每次注入省 token),但當 Claude Code 自己的 `language` 設定(`/config` 寫進 `settings.json` 的那個欄位)是中文語系時(`繁體中文`、`Chinese`、`zh-TW`、`zh-Hant`… 不分大小寫;簡體變體也一律給繁體版,沒有另外的簡體翻譯),會自動切成手工翻譯的繁體中文版。這只改變**注入的觸發文字**本身——Claude 實際動手做事、回覆用什麼語言,本來就由你的 `CLAUDE.md` 控制、不受注入語言影響,所以這只是看起來順眼,不影響行為。也不打算為每一種可能的 `language` 值都做泛化支援——其他語言請用下面的 `CWARM_AI_MSG_FILE`:那才是通用解法,想要哪種語言、哪個領域的循環都自己寫一份。
238
240
 
239
- 自動偵測猜錯了,或只是這次 session 想手動換一下?按 `Ctrl+L`(跟 AI 模式開關熱鍵一樣可換鍵,見下方 `CWARM_LANG_TOGGLE_KEY`)就能當場在英文/繁體中文之間切換內建循環,不用重開 cwarm。切的是「目前實際生效的語言」(這個 session 裡如果已經按過一次,就以那次按的結果為準;沒按過就以自動偵測為準),切完就固定住,直到再按一次;重開 cwarm 又會回到照 `language` 自動偵測。只影響內建的 EN/ZH 循環——如果你已經設了 `CWARM_AI_MSG`/`CWARM_AI_MSG_FILE`,這顆熱鍵沒東西可切。
241
+ 自動偵測猜錯了,或只是這次 session 想手動換一下?按 `Ctrl+L`(跟 AI 模式開關熱鍵一樣可換鍵,見下方 `CWARM_LANG_TOGGLE_KEY`——如果你的終端機或 shell 平常靠 `Ctrl+L` 清畫面/重繪,建議換掉,這顆熱鍵會把按鍵整個吃掉、不會透傳下去)就能當場在英文/繁體中文之間切換內建循環,不用重開 cwarm。切的是「目前實際生效的語言」(這個 session 裡如果已經按過一次,就以那次按的結果為準;沒按過就以自動偵測為準),切完就固定住,直到再按一次;重開 cwarm 又會回到照 `language` 自動偵測。只影響內建的 EN/ZH 循環——如果你已經設了 `CWARM_AI_MSG`/`CWARM_AI_MSG_FILE`,這顆熱鍵沒東西可切。
242
+
243
+ 如果 `CWARM_TOGGLE_KEY` 跟 `CWARM_LANG_TOGGLE_KEY` 最後撞成同一個鍵,`Ctrl+L` 這個 session 就會整個沒反應(AI 開關熱鍵先被檢查、永遠贏過撞鍵)——log 會記一次警告,statusline 的語言後綴也會少附熱鍵提示(顯示 `🤖AI on (Ctrl+\) 中`,不是 `🤖AI on (Ctrl+\) 中(Ctrl+L)`),不會一直宣傳一顆按了沒用的鍵。遇到就把其中一顆換掉。
240
244
 
241
245
  想整套換掉、或調節奏,可用:
242
246
 
@@ -261,7 +265,7 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
261
265
  | 變數 | 意義 |
262
266
  |-----|------|
263
267
  | `CWARM_MSG` | keepalive 訊息(預設 `hi`) |
264
- | `CWARM_TICK_MS` | 檢查間隔(預設 `20000`) |
268
+ | `CWARM_TICK_MS` | 檢查間隔(預設 `10000`) |
265
269
  | `CWARM_QUIET_MS` | 畫面需靜止多久才注入(預設 `2500`) |
266
270
  | `CWARM_ESC_DELAY_MS` | `Esc` 與訊息之間的間隔(預設 `250`) |
267
271
  | `CWARM_ENTER_DELAY_MS` | 注入內容與送出用的 `Enter` 之間的間隔(預設 `80`)——內容和 `Enter` 分成兩次 `pty.write()` 寫入,避免 AI 模式的長訊息因為一次寫入太快被判成「貼上」,導致夾在同一包裡的 `Enter` 只變成貼上內容的一部分而沒有真正送出 |
@@ -279,6 +283,22 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
279
283
 
280
284
  ## Changelog
281
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
+
298
+ ### 0.1.16
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.
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 顯示情況)。
301
+
282
302
  ### 0.1.15
283
303
  - **Fix:** the weekly pace ball (0.1.14) could vanish entirely instead of just going quiet. Two separate causes: (1) the statusline bailed out of the *whole* quota section — not just the weekly part — whenever the 5-hour window's usage data was momentarily missing; the two windows are now read independently, so a missing one only costs its own segment. (2) `weeklyPaceInfo()` left the ball unset while 2-3 points ahead of pace and then returned nothing at all, dropping the segment instead of showing it green; under 3 points ahead is green now, and the "rest to get back to green" estimate targets that same 3-point line so a green ball is never told to rest.
284
304
  - **修正:** 週配速球(0.1.14)原本可能整段消失,不只是變安靜。兩個各自獨立的原因:(1) statusline 只要 5 小時視窗的用量資料一時缺席,就會把**整段**額度區塊(不只週段)一起吞掉;現在兩個時間窗各自獨立讀取,缺一個只影響它自己那段。(2) `weeklyPaceInfo()` 在超前配速 2~3 個百分點時把球留白、然後直接回傳空值,整段消失而不是顯示綠球;現在 3% 以下一律顯示綠球,「休息多久回綠」的估算也同步對齊這條 3% 門檻,綠球不會被叫去休息。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-cache-keepalive",
3
- "version": "0.1.15",
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"
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/cli.mjs CHANGED
@@ -43,10 +43,18 @@ Unattended AI mode (opt-in, off by default):
43
43
  etc. rebinds it if your IME steals the default).
44
44
  The toggle persists across restarts, per project;
45
45
  --ai / CWARM_AI=0 override the remembered state.
46
- Customize the cycle
46
+ The built-in cycle auto-picks English or Traditional
47
+ Chinese from Claude Code's own \`language\` setting;
48
+ press Ctrl+L anytime to override it for the rest of
49
+ the session (CWARM_LANG_TOGGLE_KEY=g etc. rebinds it —
50
+ worth rebinding if it collides with CWARM_TOGGLE_KEY,
51
+ since the on/off toggle is checked first and always
52
+ wins a collision, silently disabling Ctrl+L).
53
+ Customize the cycle entirely
47
54
  with CWARM_AI_MSG (single message) or
48
- CWARM_AI_MSG_FILE (one instruction per line, # = comment)
49
- — the built-in cycle is software-engineering oriented.
55
+ CWARM_AI_MSG_FILE (one instruction per line, # = comment,
56
+ any language) overrides the built-in EN/ZH cycle and
57
+ its language switch.
50
58
 
51
59
  Keepalive only fires after you've been idle past the cache-TTL threshold.
52
60
  The TTL is auto-detected from the transcript's cache_creation (1h cache ->
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, effectiveIsChinese } 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';
@@ -24,7 +24,11 @@ export function resolveClaude() {
24
24
  // 讀數字型環境變數:空/未設回 def;非數字(如打錯成 "5m")回 def 並記警告(不可靜默失效
25
25
  // 掉安全機制,如 CWARM_HUMAN_QUIET_S 打錯字就悄悄關掉打字防護);用 isFinite 而非 `|| def`,
26
26
  // 讓合法的 "0"(如使用者故意要 CWARM_AI_FAST_PCT=0 關閉快節奏)不會被 falsy 誤當未設定。
27
- function envNumber(name, def, log) {
27
+ // 2026-08-30(無人值守:測試覆蓋率檢查)export 出來直接測——這支函式今晚才修過一個真實
28
+ // bug(CWARM_ESC_DELAY_MS=0 被 `||` 吃掉),卻從沒被任何測試直接覆蓋過:test/keepalive.test.mjs
29
+ // 從頭到尾只 import keepalive.mjs,host.mjs 完全沒有專屬測試。只加 export,不改函式本身或任何
30
+ // 呼叫端行為——維持最小改動。
31
+ export function envNumber(name, def, log) {
28
32
  const raw = process.env[name];
29
33
  if (raw == null || raw === '') return def;
30
34
  const n = Number(raw);
@@ -48,6 +52,17 @@ export function startHost(opts = {}) {
48
52
  const claudeDir = defaultClaudeDir();
49
53
  const LOG = path.join(claudeDir, 'cwarm-keepalive.log');
50
54
  const DISABLE = path.join(claudeDir, 'cwarm.disabled');
55
+ // 2026-08-30(無人值守:小步重構)`try { fs.appendFileSync(LOG, \`${new Date().toISOString()}
56
+ // …\n\`); } catch {}` 這個樣板原本在這支檔案裡重複了 14 次,逐字一致、只有訊息內容不同——
57
+ // 抽成這支小函式。純機械式抽取,訊息內容逐字保留(用 diff 核對過每一處),行為完全不變:
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) + '-' + 啟動時刻)。
63
+ function logLine(msg) {
64
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} [${process.pid}] ${msg}\n`); } catch {}
65
+ }
51
66
 
52
67
  // 完全透傳:cwarm [args] === claude [args]。不帶參數就是乾淨的 `claude`(全新 session);
53
68
  // 要接續上次請自己打 `cwarm --continue`。不再隱含補 --continue。
@@ -64,14 +79,23 @@ export function startHost(opts = {}) {
64
79
  const hostId = `${process.pid.toString(36)}-${Date.now().toString(36)}`;
65
80
  // 孤兒橋接檔清理:host 正常退出會刪掉自己那份,但 crash / 斷電會留下殘檔,啟動時
66
81
  // 順手清掉太舊的(>7 天,肯定不會再有 host 認領)。
82
+ // 2026-09-09 修:這裡原本是「JSON.parse 失敗就 unlinkSync」,而 statusline 用非原子寫入
83
+ // 每幾秒重寫這些檔——開新 session 時剛好讀到寫一半的內容就會把**還活著**的 session 的
84
+ // 針定檔刪掉,那個 session 於是退回 0.1.16 之前「猜資料夾內最新 .jsonl」的舊行為。
85
+ // 判定移到 orphanBridgeVerdict(純函式、有測試),鐵則是 mtime 還新的一律留著。
67
86
  try {
68
87
  for (const name of fs.readdirSync(claudeDir)) {
69
- 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;
70
92
  const p = path.join(claudeDir, name);
71
93
  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 {} }
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 { /* 單一檔案處理失敗不影響其他檔,也絕不因此刪檔 */ }
75
99
  }
76
100
  } catch { /* claudeDir 讀不到就算了 */ }
77
101
  const ptyProc = pty.spawn(file, spawnArgs, {
@@ -104,8 +128,14 @@ export function startHost(opts = {}) {
104
128
  // 語言熱鍵:預設 Ctrl+L('l' → 0x0C)——跟 AI 開關熱鍵一樣是 toggleKeySpec 算出來的單一
105
129
  // control code,只是預設字元不同,才不會撞在一起。CWARM_LANG_TOGGLE_KEY 可換單一字元。
106
130
  const langToggleKey = toggleKeySpec(process.env.CWARM_LANG_TOGGLE_KEY || 'l');
107
- if (langToggleKey.code === toggleKey.code) {
108
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} warn: CWARM_TOGGLE_KEY and CWARM_LANG_TOGGLE_KEY both resolve to ${toggleKey.label} — the language hotkey will never fire\n`); } catch {}
131
+ // 2026-08-30 code review(第四輪)補:兩把熱鍵撞同一個 control code 時,langToggle 那段
132
+ // 檢查(見下方 stdin handler)永遠拿不到 rest 可比對,語言熱鍵形同死掉——原本只寫進 log,
133
+ // 但 statusline 完全不知道,還是照樣印出「EN(Ctrl+L)」,讓使用者以為那顆鍵真的有用。
134
+ // langHotkeyLive 記下這個 session 的熱鍵是否真的可能觸發,餵進 writeAiState/
135
+ // aiSegmentText,撞鍵時只顯示語言本身、不再附上一個按了沒用的熱鍵提示。
136
+ const langHotkeyLive = langToggleKey.code !== toggleKey.code;
137
+ if (!langHotkeyLive) {
138
+ logLine(`warn: CWARM_TOGGLE_KEY and CWARM_LANG_TOGGLE_KEY both resolve to ${toggleKey.label} — the language hotkey will never fire`);
109
139
  }
110
140
  // 目前生效的內建循環語言覆蓋:null=沒按過熱鍵,跟著 /config 自動偵測;'zh'/'en'=按過熱鍵
111
141
  // 手動固定住。只在這個 session 內生效,不持久化(見 keepalive.mjs 的 toggleBuiltinLang 註解)。
@@ -113,15 +143,31 @@ export function startHost(opts = {}) {
113
143
  // /config 的 language 設定,啟動時讀一次即可(跟 aiEnabled 一樣不會在 session 中途被外部改掉;
114
144
  // 使用者要中途換語言請用 langToggleKey,不是去改 settings.json 期待熱重載)。
115
145
  const configuredLang = detectConfiguredLanguage(claudeDir, hostCwd);
146
+ // CWARM_AI_MSG(固定一句)> CWARM_AI_MSG_FILE(自訂循環,一行一條,語言/領域不限)> 內建
147
+ // 循環(依語言選 EN/ZH)——這個優先序全程固定,啟動時讀一次即可,跟語言熱鍵無關,故提到
148
+ // writeAiState 之前宣告:語言判斷(下面)跟 statusline 顯示都要知道「現在是不是自訂循環在
149
+ // 跑」,自訂循環時語言判斷完全不適用(見 keepalive.mjs 的 pickAiMsgAndBriefing/
150
+ // resolvePersistedLang,兩處都吃 fixedAiMsg 當第一個參數短路掉語言判斷)。
151
+ const fixedAiMsg = process.env.CWARM_AI_MSG || readAiMsgFile(process.env.CWARM_AI_MSG_FILE);
116
152
  function writeAiState() {
117
- // lang 永遠寫「目前實際生效」的值(覆蓋優先,沒覆蓋就用自動偵測),不是 langOverride 本身
118
- // (那個未按過熱鍵時是 null)——statusline 才不用自己重算一次。務必用 effectiveIsChinese,
119
- // 不能直接把 langOverride 丟給 isChineseLanguage(見該函式註解的事故紀錄)。
120
- const lang = effectiveIsChinese(langOverride, configuredLang) ? 'zh' : 'en';
153
+ // lang 算法交給 resolvePersistedLang(keepalive.mjs,純函式、有測試)——自訂循環
154
+ //(CWARM_AI_MSG/CWARM_AI_MSG_FILE)語言未知時回傳 nullstatusline aiSegmentText
155
+ // 本來就會在 lang falsy 時跳過整段,等於誠實承認「這裡不知道/不適用哪個語言」。
156
+ const lang = resolvePersistedLang(fixedAiMsg, langOverride, configuredLang);
121
157
  try {
122
- fs.writeFileSync(aiStatePath(claudeDir, hostCwd), JSON.stringify({
158
+ // 原子寫入(同 session bridge/usage bridge):statusline 每次刷新都會讀這個檔,
159
+ // 讀到半截會讓 AI 開關那一段整個消失。
160
+ writeJsonAtomic(aiStatePath(claudeDir, hostCwd), {
123
161
  enabled: aiEnabled, ts: Date.now(), key: toggleKey.label, lang, langKey: langToggleKey.label,
124
- }));
162
+ // langTouched:這個 session 裡有沒有真的按過語言熱鍵——AI 模式關著時語言段預設不顯示
163
+ // (見 segment.mjs),但使用者若想在開 AI 之前先試按 Ctrl+L 確認有沒有生效,需要一個
164
+ // 例外:按過就算「已表達意圖」,就算 AI 還關著也讓 statusline 顯示,不然熱鍵看起來像壞的。
165
+ langTouched: langOverride != null,
166
+ // langHotkeyLive:這把熱鍵是否真的可能觸發(見上方同名變數的說明)。明確寫 false
167
+ // (而非藉由拿掉 langKey)才能跟「舊版狀態檔沒這個欄位」區分開——舊檔案沒寫代表
168
+ // 「當時沒有撞鍵這回事」,理當預設為 true,不能讓兩種「沒資訊」的情況長得一樣。
169
+ langHotkeyLive,
170
+ });
125
171
  } catch {}
126
172
  }
127
173
  writeAiState();
@@ -144,16 +190,25 @@ export function startHost(opts = {}) {
144
190
  consecInjects = 0;
145
191
  aiStep = 0;
146
192
  lastHumanMs = Date.now(); // 按熱鍵也是人為活動
147
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} ai mode ${aiEnabled ? 'ON' : 'OFF'} (${toggleKey.label} toggle)\n`); } catch {}
193
+ logLine(`ai mode ${aiEnabled ? 'ON' : 'OFF'} (${toggleKey.label} toggle)`);
148
194
  }
149
195
  // 語言熱鍵吃掉 AI 開關熱鍵剩下的 rest——同一個 chunk 理論上只會含其中一個熱鍵,
150
196
  // 兩個都檢查一遍即可,extractAiToggle 本來就是通用的單一 control code 偵測。
151
197
  const langToggle = extractAiToggle(aiToggle.rest, langToggleKey.code);
152
198
  if (langToggle.toggled) {
153
- langOverride = toggleBuiltinLang(langOverride, configuredLang);
154
- writeAiState(); // 立刻更新,statusline 不用等下一次心跳(tick 間隔可能到 20 秒)才看到新語言
155
- lastHumanMs = Date.now();
156
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} ai-mode builtin cycle language -> ${langOverride} (${langToggleKey.label} toggle)\n`); } catch {}
199
+ lastHumanMs = Date.now(); // 按熱鍵是人為活動,不管下面這次按鍵有沒有實際生效都算
200
+ // 2026-08-30(第三輪 code review):fixedAiMsg 生效時,內建循環的語言完全不適用
201
+ //(pickAiMsgAndBriefing/resolvePersistedLang 都會忽略 langOverride)——與其讓
202
+ // langOverride 悄悄變了、卻對實際注入內容和 statusline 都沒有任何可見效果(使用者
203
+ // 按了熱鍵、log 卻寫「切換成功」,正是這次要修的「熱鍵看起來像壞的」同一類問題),
204
+ // 乾脆完全不處理這次按鍵,log 留一行清楚的「被忽略」訊息,誠實反映沒有東西可切。
205
+ if (fixedAiMsg) {
206
+ logLine(`ai-mode language hotkey (${langToggleKey.label}) ignored — CWARM_AI_MSG/CWARM_AI_MSG_FILE is set, no built-in cycle to switch`);
207
+ } else {
208
+ langOverride = toggleBuiltinLang(langOverride, configuredLang);
209
+ writeAiState(); // 立刻更新,statusline 不用等下一次心跳(tick 間隔可能到 20 秒)才看到新語言
210
+ logLine(`ai-mode builtin cycle language -> ${langOverride} (${langToggleKey.label} toggle)`);
211
+ }
157
212
  }
158
213
  const rest = langToggle.rest;
159
214
  if (rest.length === 0) return;
@@ -176,10 +231,23 @@ export function startHost(opts = {}) {
176
231
  });
177
232
 
178
233
  // ---- 內建保溫 ----
179
- 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;
180
240
  const msg = process.env.CWARM_MSG || opts.msg || 'hi';
181
- const quietMs = Number(process.env.CWARM_QUIET_MS) || 2500; // 畫面需靜止這麼久才注入
182
- const escDelayMs = Number(process.env.CWARM_ESC_DELAY_MS) || 250; // Esc 與訊息之間的間隔
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); // 畫面需靜止這麼久才注入
246
+ // 2026-08-30 code review(第四輪)抓到:原本 Number(env) || 250 這個寫法,跟同檔案的
247
+ // envNumber() 特地防的坑一模一樣——CWARM_ESC_DELAY_MS=0(合法值,代表使用者要 Esc 跟內容
248
+ // 幾乎同時送出)會被 falsy 判斷悄悄吃掉、退回預設 250,且完全沒有警告。改用 envNumber,
249
+ // 跟下面的 enterDelayMs 一致,也跟 CWARM_AI_FAST_PCT=0 那類合法 0 值的既有處理方式對齊。
250
+ const escDelayMs = envNumber('CWARM_ESC_DELAY_MS', 250, LOG); // Esc 與訊息之間的間隔
183
251
  // 2026-08-30 訊息本體與結尾 Enter 之間的間隔:AI 循環訊息(尤其附 briefing 的第一步,
184
252
  // 常常 700+ 字元)一次 pty.write() 連內容帶 \r 整包送出時,Claude Code 的輸入框會把
185
253
  // 這種遠超人類打字速度的長串位元組判成「貼上」,貼上內容裡夾帶的 \r 只變成貼上文字
@@ -187,7 +255,7 @@ export function startHost(opts = {}) {
187
255
  // 短訊息("hi"、"go on")因為位元組數少、大多數判斷式不會誤觸,才會正常送出。
188
256
  // 修法:仿照使用者手感——先寫內容,隔一小段再單獨寫 \r,讓終端把 Enter 當成獨立按鍵
189
257
  // 事件而不是貼上內容的尾巴。
190
- const enterDelayMs = Number(process.env.CWARM_ENTER_DELAY_MS) || 80;
258
+ const enterDelayMs = envNumber('CWARM_ENTER_DELAY_MS', 80, LOG); // 同上,0 是合法值不能被吃掉
191
259
  const overrides = {};
192
260
  const thr = process.env.CWARM_THRESHOLD_S ?? opts.thresholdS;
193
261
  const ttlO = process.env.CWARM_TTL_S ?? opts.ttlS;
@@ -197,93 +265,19 @@ export function startHost(opts = {}) {
197
265
  // reset 後的回溫訊息:"go on"(續跑任務)也算 AI 自主行為,未開 AI 模式時只補一般訊息回溫。
198
266
  // 做成函式:Ctrl+] 隨時切換 aiEnabled,用時才取值。
199
267
  const resumeMsg = () => process.env.CWARM_RESUME_MSG || (aiEnabled ? 'go on' : msg);
200
- // 無人值守 AI 模式:連兩發 "hi" 都沒人碰鍵盤後,第三發起改敲 18 階段循環工作流,
201
- // 讓閒置視窗產生實際價值。設計原則:每步「安全、有界、可驗證」——檢視類在前、
202
- // 執行類帶安全閥(不可部署、不可破壞性操作、不可展開大型新工作,對齊全域 SOP)、
203
- // 末段自我迭代與收尾報告,使用者回來直接驗收。CWARM_AI_MSG 可覆蓋成固定一句
204
- //(設成 "hi" 即等於關掉本模式)。1h 檔位一步一小時,一輪約半天。
205
- // 指令預設英文(省 token);回覆語言本來就由使用者的 CLAUDE.md 控制,不受注入語言影響——
206
- // 2026-08-30 起額外對齊了一份繁體中文版(AI_MSG_ZH,逐句對照 AI_MSG_EN),並且會自動讀
207
- // Claude Code 本身的 language 設定(/config 那個欄位)決定用哪一版:使用者已經選中文時,
208
- // 不必再手動帶 CWARM_AI_MSG_FILE 才看得到中文注入。明確設定的 CWARM_AI_MSG/
209
- // CWARM_AI_MSG_FILE 優先序不變,一律蓋過語言自動判斷。18 階段中文對照:
210
- // 檢視段:review 批判 review → TODO/FIXME 掃描 → 保守建議
211
- // 執行段:依建議執行 測試覆蓋 → mutation check(測試有效性)→ 錯誤處理稽核 →
212
- // 資安自查 相依套件體檢(只報告不升級)→ 效能低垂果實 → 小步重構 → 跨平台審視
213
- // 收斂段:文件同步 → README 快速上手驗證 → 對照方案探索(防路徑依賴)→
214
- // 經驗蒸餾(寫進 memory/CLAUDE.md,複利最高)→ 收尾報告+決策佇列(睡醒五分鐘拍板解鎖下一天)
215
- const AI_MSG_EN = [
216
- 'Unattended: review the work done in this session; briefly list problems and possible improvements.',
217
- 'Unattended: critical review — challenge earlier assumptions and approaches; point out risks, blind spots, and missing test scenarios.',
218
- 'Unattended: sweep the code for TODO / FIXME / HACK markers; triage into quick-fix / should-fix / ignore, and fix the quick ones.',
219
- 'Unattended: based on the findings above, propose a conservative, low-risk improvement list with priorities.',
220
- 'Unattended: execute the safest suggested items one by one, verifying each. No deploys, no destructive operations, no large new work.',
221
- 'Unattended: check test coverage — find uncovered branches and edge cases, add the needed tests, run the full suite.',
222
- 'Unattended: mutation check — mentally (or on a scratch copy) break one small piece of logic and verify existing tests would catch it; where they would not, that coverage is fake — add a real test.',
223
- 'Unattended: audit error handling — walk the failure paths (bad input, missing files, timeouts, permissions); patch gaps minimally, with tests.',
224
- 'Unattended: light security self-check — leaked secrets in code/logs, injection risks, over-broad permissions; fix only clear issues.',
225
- 'Unattended: dependency health check — outdated or vulnerable dependencies (e.g. npm outdated / npm audit); report findings only, do not upgrade.',
226
- 'Unattended: performance pass — find low-hanging fruit (repeated IO, needless polling, cacheable recomputation); small certain wins only, no big refactors.',
227
- 'Unattended: small refactors — naming, duplication, readability; behavior must not change, tests guard every step.',
228
- 'Unattended: cross-platform review — check assumptions about paths, line endings, shells and permissions across Windows/macOS/Linux; list suspicious spots and add guards where clearly needed.',
229
- 'Unattended: sync docs — check README, comments and usage notes against reality; update stale parts, fill gaps.',
230
- 'Unattended: verify the README quickstart — follow the install/usage steps literally from scratch and note where documentation and reality diverge; fix the docs.',
231
- "Unattended: devil's advocate — pick one design decision made in this session, seriously sketch a different approach, compare trade-offs, and conclude; analysis only, no code changes.",
232
- 'Unattended: distill lessons — write the non-obvious, durable lessons from this session into project memory / CLAUDE.md so every future session benefits; skip anything already recorded.',
233
- 'Unattended: wrap up — write a concise report of all changes and open items, plus a decision queue: questions only the user can answer, each with context, options, and your recommendation.',
234
- ];
235
- // 繁體中文版,逐句對照 AI_MSG_EN(順序與含意一致)。目前只有這一版中文(不分簡繁),
236
- // isChineseLanguage 判斷是中文語系設定就一律用這份。
237
- const AI_MSG_ZH = [
238
- '無人值守:檢視這次 session 已完成的工作,簡短列出問題與可能的改進方向。',
239
- '無人值守:批判式檢視——挑戰先前的假設與做法,指出風險、盲點,以及遺漏的測試情境。',
240
- '無人值守:掃描程式碼裡的 TODO / FIXME / HACK 標記,分成「順手修」/「該修」/「可忽略」三類,順手修的直接修掉。',
241
- '無人值守:根據上面的發現,提出一份保守、低風險的改進清單並排出優先序。',
242
- '無人值守:依序執行最安全的建議項目,每一步都驗證過再往下走。不部署、不做破壞性操作、不展開大型新工作。',
243
- '無人值守:檢查測試覆蓋率——找出沒覆蓋到的分支與邊界情況,補上需要的測試,跑過整套測試。',
244
- '無人值守:mutation check(測試有效性檢查)——找一小段邏輯,在腦中(或草稿副本上)故意弄壞,確認既有測試抓得到;抓不到的話,那份覆蓋率是假的,補一個真正有效的測試。',
245
- '無人值守:稽核錯誤處理——走過各種失敗路徑(錯誤輸入、檔案缺失、逾時、權限不足),用測試補上明顯的漏洞,改動盡量小。',
246
- '無人值守:輕量資安自查——檢查程式碼/log 裡有沒有外洩的機密、注入風險、過寬的權限;只修明確有問題的部分。',
247
- '無人值守:相依套件體檢——檢查有沒有過時或有漏洞的相依套件(如 npm outdated/npm audit),只回報發現,不主動升級。',
248
- '無人值守:效能低垂果實——找容易改善的地方(重複 IO、不必要的輪詢、可快取的重複計算),只做有把握的小改善,不做大重構。',
249
- '無人值守:小步重構——命名、重複程式碼、可讀性;行為不能改變,每一步都要有測試守著。',
250
- '無人值守:跨平台審視——檢查路徑、換行符、shell、權限在 Windows/macOS/Linux 上的假設是否成立,列出可疑之處,明顯需要防護的地方補上。',
251
- '無人值守:文件同步——對照 README、註解與實際狀況,更新過時的部分、補上缺口。',
252
- '無人值守:驗證 README 的快速上手——從頭照著安裝/使用步驟原樣操作一次,記下文件跟實際不符的地方,修正文件。',
253
- '無人值守:唱反調——挑這次 session 裡的一個設計決策,認真想一個不同的做法,比較利弊並下結論;只做分析,不改程式碼。',
254
- '無人值守:蒸餾經驗——把這次 session 裡不明顯、但長期有用的心得寫進專案的 memory/CLAUDE.md,讓以後每個 session 都受惠;已經記過的就跳過。',
255
- '無人值守:收尾——寫一份簡短的變更與待辦報告,附上只有使用者能回答的決策清單(每項附脈絡、選項、你的建議)。',
256
- ];
257
- // 指令來源優先序:CWARM_AI_MSG(固定一句)> CWARM_AI_MSG_FILE(自訂循環,一行一條)
258
- // > 內建循環(依語言選 EN/ZH——語言熱鍵可在 session 中途切換,故這段必須是每次注入時
259
- // 現算,不能是啟動時就固定住的 const;CWARM_AI_MSG/FILE 沒這個問題,仍是啟動時讀一次)。
260
- const fixedAiMsg = process.env.CWARM_AI_MSG || readAiMsgFile(process.env.CWARM_AI_MSG_FILE);
261
- // 無人值守循環第一步附帶一次性簡報:被驅動的 Claude 本身看不到 cwarm 的原始碼與這段機制,
262
- // 光靠 "Unattended: review …" 猜不出「為什麼會收到這句」「還會不會繼續來」「額度用完會
263
- // 怎樣」「使用者真的回來打字算不算數」。實際判斷(只在陣列循環剛回到第 0 步時附加一次,
264
- // 不是每步都附)交給 pickInjectMsg(keepalive.mjs)——那裡才有 aiStep/aiMsg 的完整脈絡,
265
- // 這裡只需把說明文字傳進去;CWARM_AI_MSG(固定一句)情境自動不適用(非陣列)。跟著
266
- // 目前生效的內建循環語言選——用了中文循環卻附英文簡報會混雜,讀起來奇怪。
267
- const AI_BRIEFING_EN = "Unattended: heads up — cwarm's AI mode is now driving this session on its own. "
268
- + 'Whenever this terminal sits idle past a threshold with no human keystrokes, it injects one instruction '
269
- + "like this automatically, cycling through a fixed checklist (review, tests, docs, refactors, …); it's not "
270
- + "you deciding to keep going, it's the tool. Pace is quota-aware — faster when there's headroom, paused near "
271
- + "your usage limit and resumed with a plain 'go on' once it resets. If real human input ever shows up in this "
272
- + 'session, that always takes priority over anything below. Stay conservative: no deploys, no destructive '
273
- + 'operations, no large new scope — verify each step before moving to the next.';
274
- const AI_BRIEFING_ZH = '無人值守提醒:cwarm 的 AI 模式現在正在自主驅動這個 session。只要這個終端閒置超過門檻、'
275
- + '又沒有人為按鍵,就會像這樣自動注入一則指令,依序輪過固定的檢查清單(review、測試、文件、重構……);'
276
- + '接下去做的決定不是你自己要繼續,而是這個工具在推動。節奏會看額度調整——額度充裕時較快,接近用量上限時'
277
- + '暫停,額度重置後用一句普通的『go on』恢復。如果這個 session 裡真的出現人為輸入,那一律優先於下面任何'
278
- + '內容。保持保守:不部署、不做破壞性操作、不展開大型新範圍——每一步都驗證過再往下走。';
279
- // 每次注入前現算:langOverride(語言熱鍵按過就固定)優先於 configuredLang(/config 自動
280
- // 偵測),再套 CWARM_AI_MSG/FILE > 內建循環的既有優先序。回傳的 aiMsg 給 pickInjectMsg,
281
- // briefing 只在真的用到內建陣列循環時才有意義(固定字串 aiMsg 情境下 pickInjectMsg 本來就不理它)。
282
- function currentAiMsgAndBriefing() {
283
- const zh = effectiveIsChinese(langOverride, configuredLang);
284
- const builtinCycle = zh ? AI_MSG_ZH : AI_MSG_EN;
285
- return { aiMsg: fixedAiMsg || builtinCycle, briefing: zh ? AI_BRIEFING_ZH : AI_BRIEFING_EN };
286
- }
268
+ // 無人值守 AI 模式:連兩發 "hi" 都沒人碰鍵盤後,第三發起改敲 18 階段循環工作流(內容見
269
+ // keepalive.mjs 的 AI_MSG_EN/AI_MSG_ZH/AI_BRIEFING_EN/AI_BRIEFING_ZH),讓閒置視窗產生
270
+ // 實際價值。設計原則:每步「安全、有界、可驗證」——檢視類在前、執行類帶安全閥(不可部署、
271
+ // 不可破壞性操作、不可展開大型新工作,對齊全域 SOP)、末段自我迭代與收尾報告,使用者回來
272
+ // 直接驗收。CWARM_AI_MSG 可覆蓋成固定一句(設成 "hi" 即等於關掉本模式)。1h 檔位一步一
273
+ // 小時,一輪約半天。指令預設英文(省 token),會自動讀 Claude Code 的 language 設定
274
+ //(/config 那個欄位)決定用 EN 還是 ZH 版;明確設定的 CWARM_AI_MSG/CWARM_AI_MSG_FILE
275
+ // 優先序不變,一律蓋過語言自動判斷。
276
+ // fixedAiMsg 已在上面(writeAiState 之前)宣告——語言判斷跟 statusline 顯示都要用到它。
277
+ // 每次注入前現算,交給 pickAiMsgAndBriefing(keepalive.mjs,純函式、有測試):langOverride
278
+ // (語言熱鍵按過就固定)優先於 configuredLang(/config 自動偵測),fixedAiMsg(CWARM_AI_MSG/
279
+ // CWARM_AI_MSG_FILE)存在時完全不附 briefing(那段文字講的是 cwarm 內建固定檢查清單,對
280
+ // 自訂循環連內容都文不對題,不只是語言選不對)。回傳的 aiMsg/briefing 直接餵給 pickInjectMsg。
287
281
  // 使用者敲鍵後需靜默這麼久才可注入(預設 5 分鐘)。CWARM_HUMAN_QUIET_S 可調;
288
282
  // 打錯成非數字(如 "5m")會記警告並退回預設,不會悄悄關掉這個安全機制。
289
283
  const humanQuietMs = envNumber('CWARM_HUMAN_QUIET_S', 300, LOG) * 1000;
@@ -301,7 +295,30 @@ export function startHost(opts = {}) {
301
295
  let limitLogged = false;
302
296
  let noUsageWarned = false; // --ai 但讀不到額度橋接檔時警告一次(額度閘門全數失效)
303
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;
304
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;
305
322
  writeAiState(); // 心跳:statusline 靠 ts 新鮮度判斷 host 還活著才顯示開關段
306
323
  // credits / API 計費(/login 切到 Console 帳號、或只用 ANTHROPIC_API_KEY)時,每次注入
307
324
  // 都是實際花錢,保溫沒有意義 → 自動暫停。每個 tick 重新偵測,session 中 /login 切回
@@ -310,20 +327,20 @@ export function startHost(opts = {}) {
310
327
  if (billing === 'credits') {
311
328
  if (!creditsGuardLogged) {
312
329
  creditsGuardLogged = true;
313
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} skip: credits/API billing detected — keepalive suspended (CWARM_BILLING=subscription to override)\n`); } catch {}
330
+ logLine('skip: credits/API billing detected — keepalive suspended (CWARM_BILLING=subscription to override)');
314
331
  }
315
332
  return;
316
333
  }
317
334
  if (creditsGuardLogged) {
318
335
  creditsGuardLogged = false;
319
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} resume: subscription billing detected — keepalive re-enabled\n`); } catch {}
336
+ logLine('resume: subscription billing detected — keepalive re-enabled');
320
337
  }
321
338
  // 信任對話框在畫面上時,這輪完全不動作(連 Esc 都不送)——那是使用者本人該回答的框,被保溫
322
339
  // Esc 掉會留下 hasTrustDialogAccepted:false,害該資料夾 settings.local.json 權限失效、之後不再跳框。
323
340
  if (looksLikeTrustPrompt(screenBuf)) {
324
341
  if (!trustGuardLogged) {
325
342
  trustGuardLogged = true;
326
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} skip: trust dialog on screen — left for the user to answer\n`); } catch {}
343
+ logLine('skip: trust dialog on screen — left for the user to answer');
327
344
  }
328
345
  return;
329
346
  }
@@ -335,7 +352,7 @@ export function startHost(opts = {}) {
335
352
  //(cwarm setup)或 payload 沒有 rate_limits 時所有閘門都是 unknown——明講,別靜默裸奔。
336
353
  if (aiEnabled && usage == null && !noUsageWarned) {
337
354
  noUsageWarned = true;
338
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} warn: ai mode has no usage data (run \`cwarm setup\` to install the statusline bridge) — quota gates inactive\n`); } catch {}
355
+ logLine('warn: ai mode has no usage data (run `cwarm setup` to install the statusline bridge) — quota gates inactive');
339
356
  }
340
357
  if (usage != null) noUsageWarned = false;
341
358
  const nowSec = Date.now() / 1000;
@@ -344,7 +361,7 @@ export function startHost(opts = {}) {
344
361
  limitResetsAt = usage.resetsAt;
345
362
  if (!limitLogged) {
346
363
  limitLogged = true;
347
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} skip: usage limit reached (${Math.round(usage.usedPct)}%) — waiting for reset at ${new Date(usage.resetsAt * 1000).toISOString()}\n`); } catch {}
364
+ logLine(`skip: usage limit reached (${Math.round(usage.usedPct)}%) — waiting for reset at ${new Date(usage.resetsAt * 1000).toISOString()}`);
348
365
  }
349
366
  return; // 撞牆期間注入到不了伺服器,暫停等 reset
350
367
  }
@@ -352,7 +369,7 @@ export function startHost(opts = {}) {
352
369
  limitResetsAt = null;
353
370
  limitLogged = false;
354
371
  pendingResume = true; // 視窗已重置:下一發改敲 resumeMsg,回溫 cache 兼續跑被打斷的任務
355
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} window reset — next inject will send "${resumeMsg()}"\n`); } catch {}
372
+ logLine(`window reset — next inject will send "${resumeMsg()}"`);
356
373
  }
357
374
  // session bridge 有落地時針定本 session 的 transcript(多分頁同資料夾不互相干擾);
358
375
  // 沒有(未跑 cwarm setup 裝 statusline、或 payload 還沒刷新過)回退舊的「資料夾最新」猜法。
@@ -380,36 +397,41 @@ export function startHost(opts = {}) {
380
397
  fastMaxPct: envNumber('CWARM_AI_FAST_PCT', 70, LOG),
381
398
  });
382
399
  const now = Date.now();
383
- const idleMs = ownTranscript
384
- ? transcriptIdleMsAt(ownTranscript, now, startedMs)
385
- : 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);
386
406
  // idleMs 找不到 transcript 時保溫整個靜默失效,且原本沒有任何提示——啟動超過 2 分鐘
387
407
  // (早期本來就會是 null,Context 0% 正常現象,不必吵)仍找不到才警告一次,提示可能是
388
408
  // cwd 與 Claude Code 建立的 transcript 資料夾對不上(磁碟代號大小寫、junction 等)。
389
409
  if (idleMs == null) {
390
410
  if (!noTranscriptWarned && now - startedMs > 120_000) {
391
411
  noTranscriptWarned = true;
392
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} warn: no transcript found for this project after 2min — keepalive inactive (check for a cwd / drive-letter mismatch against Claude Code's project folder)\n`); } catch {}
412
+ logLine("warn: no transcript found for this project after 2min — keepalive inactive (check for a cwd / drive-letter mismatch against Claude Code's project folder)");
393
413
  }
394
414
  } else if (noTranscriptWarned) {
395
415
  noTranscriptWarned = false;
396
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} resume: transcript found — keepalive active\n`); } catch {}
416
+ logLine('resume: transcript found — keepalive active');
397
417
  }
398
418
  const screenIdleMs = now - lastOutputMs;
399
419
  // reset 後若使用者自己先回來發了訊息(transcript 剛更新過),就不必再補 "go on"
400
420
  if (pendingResume && idleMs != null && idleMs < 60_000) {
401
421
  pendingResume = false;
402
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} resume skipped — user already active after reset\n`); } catch {}
422
+ logLine('resume skipped — user already active after reset');
403
423
  }
404
424
  const humanIdleMs = now - lastHumanMs;
405
425
  // humanQuiet 鉗在 idleThreshold-60s 內:short 檔(TTL 300s/門檻 240s)若照吃 300s
406
426
  // 靜默,注入會落在 cache 冷掉之後——保命的提早注入餘裕不能被這個門檻吃掉。
407
427
  const hqMs = clampHumanQuiet(humanQuietMs, idleThreshold);
408
- 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)) {
409
431
  // 先送 Esc:把任何「必答」modal(權限/選單/計畫批准)收掉、退回輸入框,後面那個 Enter 才不會誤選預設項;
410
432
  // 空輸入框時 Esc 等同 no-op。隔一小段再送訊息——讓 claude 先把 modal 收乾淨,也避免 ESC 與字元被併成 Meta 鍵。
411
433
  const aiAllowed = aiEnabled && wgate !== 'off';
412
- const { aiMsg, briefing } = currentAiMsgAndBriefing();
434
+ const { aiMsg, briefing } = pickAiMsgAndBriefing(fixedAiMsg, langOverride, configuredLang);
413
435
  const injectMsg = pickInjectMsg({ pendingResume, consecInjects, aiStep, resumeMsg: resumeMsg(), aiMsg, msg, aiAllowed, briefing });
414
436
  // 只有真的敲出 AI 循環裡的一步才推進 aiStep;被 wgate='off' 等閘門擋下、退回一般
415
437
  // "hi" 或 resume 的那些發送不算,避免恢復後循環整段跳號(見上方 aiStep 宣告的說明)。
@@ -425,7 +447,28 @@ export function startHost(opts = {}) {
425
447
  }
426
448
  const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
427
449
  const msgLabel = injectMsg.length > 24 ? `${injectMsg.slice(0, 24)}…` : injectMsg;
428
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msgLabel}" regime=${regime ?? 'unknown'} idle=${idle}s screenIdle=${Math.round(screenIdleMs / 1000)}s consec=${consecInjects} aiStep=${aiStep} weekly=${wgate}\n`); } catch {}
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
+ }
429
472
  }
430
473
  }, tickMs);
431
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 的
@@ -316,13 +403,17 @@ export function weeklyGate({ usedPct, resetsAt, nowSec, graceDays = 1 } = {}) {
316
403
  // ---- 週配速具現化(給人看的球號 + 剩餘天數均可用量)----
317
404
  // weeklyGate 只吐三檔給 aiPacing 悄悄降速,人完全看不到;這裡把「超前進度線多少」
318
405
  // 換算成球色:
319
- // <2% 🟢(配速正常)/3~5% 🟡/6~8% 🟠/9~11% 🩷(Unicode 無粉紅圓形,借粉紅
320
- // 愛心最接近)/≥12% 🔴。2~3% 之間留白不顯示球(維持安靜,避免在正常/警示邊界
321
- // 上抖動)。
406
+ // <3% 🟢(配速正常)/3~5% 🟡/6~8% 🟠/9~11% 🩷(Unicode 無粉紅圓形,借粉紅
407
+ // 愛心最接近)/≥12% 🔴。
408
+ // 2026-08-30(無人值守:驗證 README 快速上手,逐條核對文件跟程式碼)修正:這段原本寫
409
+ // 「<2% 綠/2~3% 之間留白不顯示球」,是 0.1.15 修 bug(7978127)之前的舊行為——當時
410
+ // 超前 2~3% 時球會留白、然後整段 return null,害週用量段從 statusline 整個消失。
411
+ // 0.1.15 把門檻改成 3% 以下一律綠球,不留空窗,但這段註解沒有同步更新,一路錯到今晚
412
+ // 才被這個驗證步驟抓到。segment.mjs 裡對應的另一份說明也同步修正過。
322
413
  // avgPerDayRemaining:把剩餘額度 (100-usedPct) 均攤到視窗剩餘天數,讓使用者知道
323
414
  // 「接下來每天還能燒多少」而不是只看到一個抽象的超前百分點。
324
415
  // recoverySec:不再新增用量的前提下,進度線每秒自然爬升 100/WEEK% ——excess 會跟著
325
- // 等速下降,算出降到 <2%(回綠球門檻)還要「休息」幾秒;已經是綠球則為 0。算出來若
416
+ // 等速下降,算出降到 <3%(回綠球門檻)還要「休息」幾秒;已經是綠球則為 0。算出來若
326
417
  // 超過視窗剩餘時間,代表撐到 reset 都回不了綠,直接鉗在剩餘時間(reset 一到整組歸零,
327
418
  // 保證回綠)。
328
419
  export function weeklyPaceInfo({ usedPct, resetsAt, nowSec } = {}) {
@@ -431,7 +522,8 @@ export function clampHumanQuiet(humanQuietMs, idleThresholdS) {
431
522
  // 當循環索引,暫停後恢復會整段跳號、破壞步驟間的依賴(如「依建議執行」承接前一步的發現)。
432
523
  // 故循環位置改用呼叫端維護、只在真的敲出 AI 循環訊息時才遞增的 aiStep。
433
524
  // briefing:每輪無人值守的第一步(aiStep===0)附加一次性說明,讓被驅動的 Claude 知道自己
434
- // 在被 cwarm 自動驅動、規則是什麼(見 host.mjs 的 AI_BRIEFING);同一輪其餘步驟不重複附加。
525
+ // 在被 cwarm 自動驅動、規則是什麼(見本檔案下方的 AI_BRIEFING_EN/AI_BRIEFING_ZH);
526
+ // 同一輪其餘步驟不重複附加。
435
527
  // 只在真的選中陣列循環的第一格時才附加——固定字串 aiMsg(CWARM_AI_MSG)或非陣列不適用。
436
528
  export function pickInjectMsg({ pendingResume, consecInjects, aiStep = 0, resumeMsg, aiMsg, msg, aiAllowed = true, briefing = null }) {
437
529
  if (pendingResume) return resumeMsg;
@@ -451,6 +543,12 @@ export function pickInjectMsg({ pendingResume, consecInjects, aiStep = 0, resume
451
543
  // 送出,卻沒有任何測試會發現——因為寫死在 host.mjs 裡、要真的起一個 pty 才碰得到這段路徑。
452
544
  // 回傳 [{delayMs 相對於觸發時刻, data}],呼叫端(host.mjs)自己排 setTimeout;純函式好斷言:
453
545
  // 內容那筆不可以含 '\r'(一旦含了,等於又把兩者黏回同一包,bug 就會回來),Enter 必須獨立一筆。
546
+ // 2026-08-30 code review 提醒:Esc 這筆現在也是 delayMs:0 排進 host.mjs 的 setTimeout(...,
547
+ // exiting 檢查) 迴圈,不再是修 bug 前那種「decideInject 判定要發,馬上同步 write('\x1b')、
548
+ // 保證送出」的寫法——理論上若 shutdown() 在「這輪排程完」跟「0ms timer 真的觸發」之間那個
549
+ // event loop 縫隙把 exiting 設成 true,這筆 Esc 會被跳過而不送。刻意接受這個極窄的
550
+ // race:反正這個時間點整個 pty 馬上就要被關掉,少送一個 Esc 不影響任何後續行為,換來三筆
551
+ // 寫入共用同一套排程/測試邏輯比較值得。
454
552
  export function injectWriteSequence(injectMsg, escDelayMs, enterDelayMs) {
455
553
  return [
456
554
  { delayMs: 0, data: '\x1b' },
@@ -501,10 +599,17 @@ export function readAiState(claudeDir, cwd, { maxAgeMs = 60_000, now = Date.now(
501
599
  return {
502
600
  enabled: !!o.enabled,
503
601
  key: typeof o.key === 'string' ? o.key : null,
504
- // 2026-08-30:內建循環目前生效的語言('zh'/'en',不管是自動偵測還是熱鍵覆蓋出來的結果)
505
- // 與該熱鍵標籤——statusline aiSegment 拿這個顯示,不然使用者按了 Ctrl+L 完全看不到有沒有生效。
602
+ // 2026-08-30:內建循環目前生效的語言('zh'/'en',不管是自動偵測還是熱鍵覆蓋出來的結果;
603
+ // 設了 CWARM_AI_MSG_FILE 自訂循環時 host.mjs 寫的是 null,語言不適用)與該熱鍵標籤——
604
+ // statusline 的 aiSegment 拿這個顯示,不然使用者按了 Ctrl+L 完全看不到有沒有生效。
506
605
  lang: o.lang === 'zh' || o.lang === 'en' ? o.lang : null,
507
606
  langKey: typeof o.langKey === 'string' ? o.langKey : null,
607
+ // langTouched:這個 session 有沒有真的按過語言熱鍵——AI 模式關著時 aiSegment 預設不顯示
608
+ // 語言段,但按過熱鍵表示使用者想先確認有沒有生效,這種情況即使 AI 還沒開也該顯示。
609
+ langTouched: !!o.langTouched,
610
+ // langHotkeyLive:這把熱鍵這個 session 是否真的可能觸發(見 host.mjs 撞鍵偵測的說明)。
611
+ // 預設 true——舊版狀態檔沒這個欄位,代表「當時沒有撞鍵這回事」,不該被誤判成撞鍵。
612
+ langHotkeyLive: o.langHotkeyLive !== false,
508
613
  };
509
614
  }
510
615
 
@@ -521,11 +626,91 @@ export function readAiMsgFile(p) {
521
626
  return lines.length ? lines : null;
522
627
  }
523
628
 
629
+ // ---- 內建無人值守循環內容 ----(原本是 host.mjs 裡的 local const,2026-08-30 code review
630
+ // 抓到:兩份陣列長度/順序要不要保持一致完全沒人守著——host.mjs 是要真的起一個 pty 才測得到
631
+ // 的那層,測試沒辦法 import 裡面的 local const 去斷言。搬來這裡(跟 pickInjectMsg/
632
+ // injectWriteSequence 一樣,無 pty/timer,好測試),host.mjs 改成 import 這幾個常數。
633
+ // 18 階段中文對照:
634
+ // 檢視段:review → 批判 review → TODO/FIXME 掃描 → 保守建議
635
+ // 執行段:依建議執行 → 測試覆蓋 → mutation check(測試有效性)→ 錯誤處理稽核 →
636
+ // 資安自查 → 相依套件體檢(只報告不升級)→ 效能低垂果實 → 小步重構 → 跨平台審視
637
+ // 收斂段:文件同步 → README 快速上手驗證 → 對照方案探索(防路徑依賴)→
638
+ // 經驗蒸餾(寫進 memory/CLAUDE.md,複利最高)→ 收尾報告+決策佇列(睡醒五分鐘拍板解鎖下一天)
639
+ export const AI_MSG_EN = [
640
+ 'Unattended: review the work done in this session; briefly list problems and possible improvements.',
641
+ 'Unattended: critical review — challenge earlier assumptions and approaches; point out risks, blind spots, and missing test scenarios.',
642
+ 'Unattended: sweep the code for TODO / FIXME / HACK markers; triage into quick-fix / should-fix / ignore, and fix the quick ones.',
643
+ 'Unattended: based on the findings above, propose a conservative, low-risk improvement list with priorities.',
644
+ 'Unattended: execute the safest suggested items one by one, verifying each. No deploys, no destructive operations, no large new work.',
645
+ 'Unattended: check test coverage — find uncovered branches and edge cases, add the needed tests, run the full suite.',
646
+ 'Unattended: mutation check — mentally (or on a scratch copy) break one small piece of logic and verify existing tests would catch it; where they would not, that coverage is fake — add a real test.',
647
+ 'Unattended: audit error handling — walk the failure paths (bad input, missing files, timeouts, permissions); patch gaps minimally, with tests.',
648
+ 'Unattended: light security self-check — leaked secrets in code/logs, injection risks, over-broad permissions; fix only clear issues.',
649
+ 'Unattended: dependency health check — outdated or vulnerable dependencies (e.g. npm outdated / npm audit); report findings only, do not upgrade.',
650
+ 'Unattended: performance pass — find low-hanging fruit (repeated IO, needless polling, cacheable recomputation); small certain wins only, no big refactors.',
651
+ 'Unattended: small refactors — naming, duplication, readability; behavior must not change, tests guard every step.',
652
+ 'Unattended: cross-platform review — check assumptions about paths, line endings, shells and permissions across Windows/macOS/Linux; list suspicious spots and add guards where clearly needed.',
653
+ 'Unattended: sync docs — check README, comments and usage notes against reality; update stale parts, fill gaps.',
654
+ 'Unattended: verify the README quickstart — follow the install/usage steps literally from scratch and note where documentation and reality diverge; fix the docs.',
655
+ "Unattended: devil's advocate — pick one design decision made in this session, seriously sketch a different approach, compare trade-offs, and conclude; analysis only, no code changes.",
656
+ 'Unattended: distill lessons — write the non-obvious, durable lessons from this session into project memory / CLAUDE.md so every future session benefits; skip anything already recorded.',
657
+ 'Unattended: wrap up — write a concise report of all changes and open items, plus a decision queue: questions only the user can answer, each with context, options, and your recommendation.',
658
+ ];
659
+ // 繁體中文版,逐句對照 AI_MSG_EN(順序與含意一致——test/keepalive.test.mjs 有斷言兩者
660
+ // 長度相同,防止改一邊忘了改另一邊)。目前只有這一版中文(不分簡繁),isChineseLanguage
661
+ // 判斷是中文語系設定就一律用這份。
662
+ export const AI_MSG_ZH = [
663
+ '無人值守:檢視這次 session 已完成的工作,簡短列出問題與可能的改進方向。',
664
+ '無人值守:批判式檢視——挑戰先前的假設與做法,指出風險、盲點,以及遺漏的測試情境。',
665
+ '無人值守:掃描程式碼裡的 TODO / FIXME / HACK 標記,分成「順手修」/「該修」/「可忽略」三類,順手修的直接修掉。',
666
+ '無人值守:根據上面的發現,提出一份保守、低風險的改進清單並排出優先序。',
667
+ '無人值守:依序執行最安全的建議項目,每一步都驗證過再往下走。不部署、不做破壞性操作、不展開大型新工作。',
668
+ '無人值守:檢查測試覆蓋率——找出沒覆蓋到的分支與邊界情況,補上需要的測試,跑過整套測試。',
669
+ '無人值守:mutation check(測試有效性檢查)——找一小段邏輯,在腦中(或草稿副本上)故意弄壞,確認既有測試抓得到;抓不到的話,那份覆蓋率是假的,補一個真正有效的測試。',
670
+ '無人值守:稽核錯誤處理——走過各種失敗路徑(錯誤輸入、檔案缺失、逾時、權限不足),用測試補上明顯的漏洞,改動盡量小。',
671
+ '無人值守:輕量資安自查——檢查程式碼/log 裡有沒有外洩的機密、注入風險、過寬的權限;只修明確有問題的部分。',
672
+ '無人值守:相依套件體檢——檢查有沒有過時或有漏洞的相依套件(如 npm outdated/npm audit),只回報發現,不主動升級。',
673
+ '無人值守:效能低垂果實——找容易改善的地方(重複 IO、不必要的輪詢、可快取的重複計算),只做有把握的小改善,不做大重構。',
674
+ '無人值守:小步重構——命名、重複程式碼、可讀性;行為不能改變,每一步都要有測試守著。',
675
+ '無人值守:跨平台審視——檢查路徑、換行符、shell、權限在 Windows/macOS/Linux 上的假設是否成立,列出可疑之處,明顯需要防護的地方補上。',
676
+ '無人值守:文件同步——對照 README、註解與實際狀況,更新過時的部分、補上缺口。',
677
+ '無人值守:驗證 README 的快速上手——從頭照著安裝/使用步驟原樣操作一次,記下文件跟實際不符的地方,修正文件。',
678
+ '無人值守:唱反調——挑這次 session 裡的一個設計決策,認真想一個不同的做法,比較利弊並下結論;只做分析,不改程式碼。',
679
+ '無人值守:蒸餾經驗——把這次 session 裡不明顯、但長期有用的心得寫進專案的 memory/CLAUDE.md,讓以後每個 session 都受惠;已經記過的就跳過。',
680
+ '無人值守:收尾——寫一份簡短的變更與待辦報告,附上只有使用者能回答的決策清單(每項附脈絡、選項、你的建議)。',
681
+ ];
682
+ // 無人值守循環第一步附帶一次性簡報:被驅動的 Claude 本身看不到 cwarm 的原始碼與這段機制,
683
+ // 光靠 "Unattended: review …" 猜不出「為什麼會收到這句」「還會不會繼續來」「額度用完會
684
+ // 怎樣」「使用者真的回來打字算不算數」。實際判斷(只在陣列循環剛回到第 0 步時附加一次,
685
+ // 不是每步都附)交給 pickInjectMsg——這裡只需提供文字本身。跟著目前生效的內建循環語言選——
686
+ // 用了中文循環卻附英文簡報會混雜。CWARM_AI_MSG_FILE 的自訂循環完全不附簡報(見下方
687
+ // pickAiMsgAndBriefing——這段文字講的是 cwarm 內建的固定檢查清單,對自訂循環連內容都文
688
+ // 不對題,不是語言選錯而已,選哪個語言附都不合適,故乾脆不附)。
689
+ export const AI_BRIEFING_EN = "Unattended: heads up — cwarm's AI mode is now driving this session on its own. "
690
+ + 'Whenever this terminal sits idle past a threshold with no human keystrokes, it injects one instruction '
691
+ + "like this automatically, cycling through a fixed checklist (review, tests, docs, refactors, …); it's not "
692
+ + "you deciding to keep going, it's the tool. Pace is quota-aware — faster when there's headroom, paused near "
693
+ + "your usage limit and resumed with a plain 'go on' once it resets. If real human input ever shows up in this "
694
+ + 'session, that always takes priority over anything below. Stay conservative: no deploys, no destructive '
695
+ + 'operations, no large new scope — verify each step before moving to the next.';
696
+ export const AI_BRIEFING_ZH = '無人值守提醒:cwarm 的 AI 模式現在正在自主驅動這個 session。只要這個終端閒置超過門檻、'
697
+ + '又沒有人為按鍵,就會像這樣自動注入一則指令,依序輪過固定的檢查清單(review、測試、文件、重構……);'
698
+ + '接下去做的決定不是你自己要繼續,而是這個工具在推動。節奏會看額度調整——額度充裕時較快,接近用量上限時'
699
+ + '暫停,額度重置後用一句普通的『go on』恢復。如果這個 session 裡真的出現人為輸入,那一律優先於下面任何'
700
+ + '內容。保持保守:不部署、不做破壞性操作、不展開大型新範圍——每一步都驗證過再往下走。';
701
+
524
702
  // ---- 內建循環的語言選擇 ----(2026-08-30,使用者要求「內建循環能不能自動跟 /config 語言走」)
525
703
  // 內建循環預設英文(省 token),但使用者已經在 Claude Code 的 language 設定裡選了中文時,
526
704
  // 沒理由還要手動去帶 CWARM_AI_MSG_FILE 才看得到中文——這裡讀同一份設定自動跟著切換。
527
705
  // 只判斷「是不是中文」,不分繁簡(沒有簡體翻譯,一律給繁體版);不是中文一律回退英文原版。
528
- const CHINESE_LANG_RE = /chinese|中文|zh[-_]?(tw|hant|cn|hans)/i;
706
+ // 2026-08-30 code review 抓到:原本 zh[-_]?(tw|hant|cn|hans) 要求 "zh" 後面一定要接
707
+ // 地區/文字系統後綴,使用者若把 language 設成單純的裸字串 "zh"(合法值,且上面註解本來就
708
+ // 宣稱「只判斷是不是中文」涵蓋這種情況)反而不會命中——加一個獨立分支專門吃裸 "zh"(用
709
+ // \b 詞界避免誤配到其他字串中間剛好出現的 "zh")。副作用(第二輪 review 追問過、確認是
710
+ // 刻意接受):這個 \bzh\b 分支連帶會吃到不在前面那份白名單裡的其他 "zh-<地區>" 代碼,如
711
+ // "zh-HK"——這其實是好事:BCP47 底下任何 zh-XX 都是中文的地區變體,比原本那份不完整的
712
+ // 白名單更準確;測試見 test/keepalive.test.mjs 的對應案例。
713
+ const CHINESE_LANG_RE = /chinese|中文|zh[-_]?(tw|hant|cn|hans)|\bzh\b/i;
529
714
  export function isChineseLanguage(lang) {
530
715
  return typeof lang === 'string' && CHINESE_LANG_RE.test(lang);
531
716
  }
@@ -557,12 +742,53 @@ export function detectConfiguredLanguage(claudeDir, cwd) {
557
742
  // /config 的自然語言字串設計的正規表達式比對,根本認不出 'zh'/'en' 這種內部短碼(bare
558
743
  // "zh"/"en" 兩個都不匹配 CHINESE_LANG_RE),於是按過熱鍵一次之後不管切成 'zh' 還是 'en'
559
744
  // 都被判成「不是中文」,往後注入永遠固定英文、切不回中文——症狀正是「能切到 EN,切不回中」。
560
- // 呼叫端(toggleBuiltinLang、host.mjs 的 writeAiStatecurrentAiMsgAndBriefing)都必須
745
+ // 呼叫端(toggleBuiltinLang、下方的 resolvePersistedLangpickAiMsgAndBriefing)都必須
561
746
  // 統一走這支,不要各自兜判斷式。
562
747
  export function effectiveIsChinese(override, autoDetectedLang) {
563
748
  return override ? override === 'zh' : isChineseLanguage(autoDetectedLang);
564
749
  }
565
750
 
751
+ // 2026-08-30(第三輪 code review):host.mjs 原本的 writeAiState 跟一支叫
752
+ // currentAiMsgAndBriefing 的內部函式(第三輪已搬走,函式名不再存在,邏輯改在下面兩支)
753
+ // 各自算一次「fixedAiMsg 存在時 lang/briefing 都不適用」,兩處各自兜等於又把第一輪/第二輪
754
+ // 修的那個「兩處算出不一致答案」的病根留著(這次只是還沒真的分裂,但沒有任何機制保證以後
755
+ // 不會)。抽成這兩支純函式,host.mjs 兩處都改呼叫這裡——順便讓這條「自訂循環時語言不適用」
756
+ // 的規則第一次有測試罩得到(原本活在 host.mjs 沒 export 的 closure 裡,要真的起一個 pty
757
+ // 才測得到)。
758
+
759
+ // 狀態檔要寫的 lang 欄位:自訂循環(CWARM_AI_MSG/CWARM_AI_MSG_FILE)語言未知,一律 null;
760
+ // 否則照 override(語言熱鍵)優先於 configuredLang(/config 自動偵測)判斷。
761
+ export function resolvePersistedLang(fixedAiMsg, langOverride, configuredLang) {
762
+ if (fixedAiMsg) return null;
763
+ return effectiveIsChinese(langOverride, configuredLang) ? 'zh' : 'en';
764
+ }
765
+
766
+ // 實際要注入的 aiMsg/briefing:自訂循環一律不附 briefing(briefing 講的是「cwarm 內建固定
767
+ // 檢查清單」,對自訂循環而言連內容都文不對題,不只是語言選不對——見 host.mjs 原本這段的說明)。
768
+ export function pickAiMsgAndBriefing(fixedAiMsg, langOverride, configuredLang) {
769
+ if (fixedAiMsg) return { aiMsg: fixedAiMsg, briefing: null };
770
+ const zh = effectiveIsChinese(langOverride, configuredLang);
771
+ return { aiMsg: zh ? AI_MSG_ZH : AI_MSG_EN, briefing: zh ? AI_BRIEFING_ZH : AI_BRIEFING_EN };
772
+ }
773
+
774
+ // statusline 的 AI 段顯示文字:`🤖AI on/off (熱鍵)`,AI 模式開著、或這個 session 已經按過語言
775
+ // 熱鍵(st.langTouched,讓「開 AI 之前先試按 Ctrl+L 確認有沒有生效」的人也看得到回饋——見
776
+ // host.mjs 的 langTouched 說明)時,後面再附一段語言。st.lang 是 null(自訂循環生效,
777
+ // 或舊版狀態檔沒這個欄位)時完全不顯示語言段。傳 null/undefined 進來一律回傳空字串。
778
+ // 熱鍵標籤(第四輪 code review 補):st.langHotkeyLive===false 代表 CWARM_TOGGLE_KEY 跟
779
+ // CWARM_LANG_TOGGLE_KEY 撞在一起,這把語言熱鍵這個 session 永遠不會觸發——這種時候只顯示
780
+ // 語言本身(如「中」),不附「(Ctrl+L)」,不能讓使用者以為按那顆鍵有用。
781
+ export function aiSegmentText(st) {
782
+ if (!st) return '';
783
+ const key = st.key || 'Ctrl+\\';
784
+ let out = `\u{1F916}AI ${st.enabled ? 'on' : 'off'} (${key})`; // 🤖
785
+ if (st.lang && (st.enabled || st.langTouched)) {
786
+ const label = st.lang === 'zh' ? '中' : 'EN';
787
+ out += st.langHotkeyLive === false ? ` ${label}` : ` ${label}(${st.langKey || 'Ctrl+L'})`;
788
+ }
789
+ return out;
790
+ }
791
+
566
792
  // 語言熱鍵(2026-08-30,使用者要求「能不能自己切換」):在「目前實際生效的語言」跟另一種
567
793
  // 之間切換一次。回傳值永遠是明確的 'zh'/'en'——按過一次熱鍵後就固定住,不再跟著 /config
568
794
  // 變動,除非再按一次切回去。呼叫端(host.mjs)拿這個值決定要用 AI_MSG_ZH 還是 AI_MSG_EN。
@@ -582,14 +808,42 @@ export function toggleBuiltinLang(currentOverride, autoDetectedLang) {
582
808
  // humanIdleMs/humanQuietMs:距使用者最後一次敲鍵多久/需靜多久才放行。打字中途停下來
583
809
  // 想事情(畫面靜止 >quietMs)不代表人不在——半句草稿 + "hi" + Enter 一起送出就是事故。
584
810
  // 故人剛敲過鍵(預設 5 分鐘內)一律不注入;null 不套用(相容既有呼叫者)。
585
- export function decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled, screenIdleMs, quietMs, humanIdleMs, humanQuietMs }) {
586
- if (disabled) return false; // 暫停開關
587
- if (idleMs == null) return false; // 找不到 transcript → 保守不發
588
- if (idleMs < idleThreshold * 1000) return false; // 距上次訊息還不夠久
589
- if (now - lastFire < ttl * 1000) return false; // 冷卻未滿一個 TTL
590
- if (quietMs != null && screenIdleMs != null && screenIdleMs < quietMs) return false; // 畫面還在動(提示/生成/打字中)
591
- if (humanQuietMs != null && humanIdleMs != null && humanIdleMs < humanQuietMs) return false; // 人剛敲過鍵(可能在打字)
592
- 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;
593
847
  }
594
848
 
595
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 } 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`; // 🟡 快過期
@@ -54,8 +59,10 @@ function payloadCwd(payload) {
54
59
  // 回傳提醒文字(可能兩段用 │ 接起來):
55
60
  // 5h — 用量 ≥95% 且離 reset 還超過 1 小時 → 建議使用者暫時中斷;不滿 1 小時就不提醒,
56
61
  // 尾巴額度照常燒到撞牆。
57
- // 週 — weeklyPaceInfo 依超前「按時間比例均攤」進度線幾個百分點顯示球號(<2🟢/3~5🟡/
58
- // 6~8🟠/9~11🩷/≥12🔴),並附上剩餘天數的日均可用量。
62
+ // 週 — weeklyPaceInfo 依超前「按時間比例均攤」進度線幾個百分點顯示球號(<3🟢/3~5🟡/
63
+ // 6~8🟠/9~11🩷/≥12🔴,見 keepalive.mjs 該函式上方註解——2026-08-30 驗證 README
64
+ // 快速上手時發現這裡跟 keepalive.mjs 都還寫著 0.1.15 修 bug 前的舊門檻 <2,一併修正),
65
+ // 並附上剩餘天數的日均可用量。
59
66
  function usageBridgeAndWarn(payload, cwd) {
60
67
  const fh = payload?.rate_limits?.five_hour;
61
68
  const sd = payload?.rate_limits?.seven_day;
@@ -65,12 +72,14 @@ function usageBridgeAndWarn(payload, cwd) {
65
72
  const resetsAt = fh?.resets_at ?? null;
66
73
  if (cwd) {
67
74
  try {
68
- fs.writeFileSync(usageBridgePath(claudeDir, cwd), JSON.stringify({
75
+ // 同樣原子寫入:host 每個 tick 讀這個檔,讀到半截會被 readJsonSafe 吞成 null,
76
+ // 額度/週配速閘門就整組變 unknown 而且完全不出聲。
77
+ writeJsonAtomic(usageBridgePath(claudeDir, cwd), {
69
78
  used_percentage: usedPct,
70
79
  resets_at: resetsAt,
71
80
  seven_day: { used_percentage: sd?.used_percentage ?? null, resets_at: sd?.resets_at ?? null },
72
81
  ts: Date.now(),
73
- }));
82
+ });
74
83
  } catch { /* 落地失敗不影響顯示 */ }
75
84
  }
76
85
  const nowSec = Date.now() / 1000;
@@ -94,19 +103,15 @@ function usageBridgeAndWarn(payload, cwd) {
94
103
  return { warn, weekly: '' };
95
104
  }
96
105
 
97
- // AI 模式開關段:顯示無人值守 AI 模式 on/off 與切換熱鍵。狀態檔以 cwd 編碼命名
98
- //(多 host 並行各寫各的),用 payload 的專案路徑對回;ts 過舊(>60s,沒有
99
- // cwarm host 在跑或已退出)就不顯示,避免殘留假狀態。
106
+ // AI 模式開關段:顯示無人值守 AI 模式 on/off、切換熱鍵,與內建循環目前的語言(EN/ZH)。
107
+ // 狀態檔以 cwd 編碼命名(多 host 並行各寫各的),用 payload 的專案路徑對回;ts 過舊(>60s
108
+ // 沒有 cwarm host 在跑或已退出)readAiState 會回 null,不顯示,避免殘留假狀態。
109
+ // 顯示文字本身(含語言段的顯示門檻)交給 aiSegmentText(keepalive.mjs,純函式、有測試——
110
+ // 2026-08-30 第三輪 code review 抓到這段邏輯之前活在這支沒 export 的函式裡,要真的起一個
111
+ // statusline 子行程才測得到)。
100
112
  function aiSegment(cwd) {
101
113
  if (!cwd) return '';
102
- const st = readAiState(claudeDir, cwd);
103
- if (!st) return '';
104
- const key = st.key || 'Ctrl+\\';
105
- let out = `\u{1F916}AI ${st.enabled ? 'on' : 'off'} (${key})`; // 🤖
106
- // 2026-08-30:內建循環目前用哪個語言(EN/ZH),連同切換熱鍵一起附在後面——不然按了
107
- // Ctrl+L 完全沒地方看得出有沒有生效。st.lang 是舊版狀態檔(沒有這個欄位)時就不顯示。
108
- if (st.lang) out += ` ${st.lang === 'zh' ? '中' : 'EN'}(${st.langKey || 'Ctrl+L'})`;
109
- return out;
114
+ return aiSegmentText(readAiState(claudeDir, cwd));
110
115
  }
111
116
 
112
117
  // 跑使用者原本存起來的 statusLine 指令(餵同一份 stdin),回傳其輸出;沒有就 null。
@@ -136,8 +141,10 @@ try { payload = JSON.parse(raw); } catch { /* 空/壞就用空物件 */ }
136
141
  // 沒有這個變數代表這個 session 不是 cwarm 帶起來的(純 claude),不落地。
137
142
  if (process.env.CWARM_HOST_ID && payload?.transcript_path) {
138
143
  try {
139
- fs.writeFileSync(sessionBridgePath(claudeDir, process.env.CWARM_HOST_ID),
140
- 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() });
141
148
  } catch { /* 落地失敗不影響顯示 */ }
142
149
  }
143
150