claude-cache-keepalive 0.1.15 → 0.1.16

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
 
@@ -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
 
@@ -279,6 +283,10 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
279
283
 
280
284
  ## Changelog
281
285
 
286
+ ### 0.1.16
287
+ - **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
+ - **修正:** 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 顯示情況)。
289
+
282
290
  ### 0.1.15
283
291
  - **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
292
  - **修正:** 週配速球(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.16",
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,7 +21,7 @@
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/keepalive.test.mjs test/host.test.mjs"
25
25
  },
26
26
  "engines": {
27
27
  "node": ">=18"
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 } 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,13 @@ 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
+ function logLine(msg) {
60
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`); } catch {}
61
+ }
51
62
 
52
63
  // 完全透傳:cwarm [args] === claude [args]。不帶參數就是乾淨的 `claude`(全新 session);
53
64
  // 要接續上次請自己打 `cwarm --continue`。不再隱含補 --continue。
@@ -104,8 +115,14 @@ export function startHost(opts = {}) {
104
115
  // 語言熱鍵:預設 Ctrl+L('l' → 0x0C)——跟 AI 開關熱鍵一樣是 toggleKeySpec 算出來的單一
105
116
  // control code,只是預設字元不同,才不會撞在一起。CWARM_LANG_TOGGLE_KEY 可換單一字元。
106
117
  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 {}
118
+ // 2026-08-30 code review(第四輪)補:兩把熱鍵撞同一個 control code 時,langToggle 那段
119
+ // 檢查(見下方 stdin handler)永遠拿不到 rest 可比對,語言熱鍵形同死掉——原本只寫進 log,
120
+ // 但 statusline 完全不知道,還是照樣印出「EN(Ctrl+L)」,讓使用者以為那顆鍵真的有用。
121
+ // langHotkeyLive 記下這個 session 的熱鍵是否真的可能觸發,餵進 writeAiState/
122
+ // aiSegmentText,撞鍵時只顯示語言本身、不再附上一個按了沒用的熱鍵提示。
123
+ const langHotkeyLive = langToggleKey.code !== toggleKey.code;
124
+ if (!langHotkeyLive) {
125
+ logLine(`warn: CWARM_TOGGLE_KEY and CWARM_LANG_TOGGLE_KEY both resolve to ${toggleKey.label} — the language hotkey will never fire`);
109
126
  }
110
127
  // 目前生效的內建循環語言覆蓋:null=沒按過熱鍵,跟著 /config 自動偵測;'zh'/'en'=按過熱鍵
111
128
  // 手動固定住。只在這個 session 內生效,不持久化(見 keepalive.mjs 的 toggleBuiltinLang 註解)。
@@ -113,14 +130,28 @@ export function startHost(opts = {}) {
113
130
  // /config 的 language 設定,啟動時讀一次即可(跟 aiEnabled 一樣不會在 session 中途被外部改掉;
114
131
  // 使用者要中途換語言請用 langToggleKey,不是去改 settings.json 期待熱重載)。
115
132
  const configuredLang = detectConfiguredLanguage(claudeDir, hostCwd);
133
+ // CWARM_AI_MSG(固定一句)> CWARM_AI_MSG_FILE(自訂循環,一行一條,語言/領域不限)> 內建
134
+ // 循環(依語言選 EN/ZH)——這個優先序全程固定,啟動時讀一次即可,跟語言熱鍵無關,故提到
135
+ // writeAiState 之前宣告:語言判斷(下面)跟 statusline 顯示都要知道「現在是不是自訂循環在
136
+ // 跑」,自訂循環時語言判斷完全不適用(見 keepalive.mjs 的 pickAiMsgAndBriefing/
137
+ // resolvePersistedLang,兩處都吃 fixedAiMsg 當第一個參數短路掉語言判斷)。
138
+ const fixedAiMsg = process.env.CWARM_AI_MSG || readAiMsgFile(process.env.CWARM_AI_MSG_FILE);
116
139
  function writeAiState() {
117
- // lang 永遠寫「目前實際生效」的值(覆蓋優先,沒覆蓋就用自動偵測),不是 langOverride 本身
118
- // (那個未按過熱鍵時是 null)——statusline 才不用自己重算一次。務必用 effectiveIsChinese,
119
- // 不能直接把 langOverride 丟給 isChineseLanguage(見該函式註解的事故紀錄)。
120
- const lang = effectiveIsChinese(langOverride, configuredLang) ? 'zh' : 'en';
140
+ // lang 算法交給 resolvePersistedLang(keepalive.mjs,純函式、有測試)——自訂循環
141
+ //(CWARM_AI_MSG/CWARM_AI_MSG_FILE)語言未知時回傳 nullstatusline aiSegmentText
142
+ // 本來就會在 lang falsy 時跳過整段,等於誠實承認「這裡不知道/不適用哪個語言」。
143
+ const lang = resolvePersistedLang(fixedAiMsg, langOverride, configuredLang);
121
144
  try {
122
145
  fs.writeFileSync(aiStatePath(claudeDir, hostCwd), JSON.stringify({
123
146
  enabled: aiEnabled, ts: Date.now(), key: toggleKey.label, lang, langKey: langToggleKey.label,
147
+ // langTouched:這個 session 裡有沒有真的按過語言熱鍵——AI 模式關著時語言段預設不顯示
148
+ // (見 segment.mjs),但使用者若想在開 AI 之前先試按 Ctrl+L 確認有沒有生效,需要一個
149
+ // 例外:按過就算「已表達意圖」,就算 AI 還關著也讓 statusline 顯示,不然熱鍵看起來像壞的。
150
+ langTouched: langOverride != null,
151
+ // langHotkeyLive:這把熱鍵是否真的可能觸發(見上方同名變數的說明)。明確寫 false
152
+ // (而非藉由拿掉 langKey)才能跟「舊版狀態檔沒這個欄位」區分開——舊檔案沒寫代表
153
+ // 「當時沒有撞鍵這回事」,理當預設為 true,不能讓兩種「沒資訊」的情況長得一樣。
154
+ langHotkeyLive,
124
155
  }));
125
156
  } catch {}
126
157
  }
@@ -144,16 +175,25 @@ export function startHost(opts = {}) {
144
175
  consecInjects = 0;
145
176
  aiStep = 0;
146
177
  lastHumanMs = Date.now(); // 按熱鍵也是人為活動
147
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} ai mode ${aiEnabled ? 'ON' : 'OFF'} (${toggleKey.label} toggle)\n`); } catch {}
178
+ logLine(`ai mode ${aiEnabled ? 'ON' : 'OFF'} (${toggleKey.label} toggle)`);
148
179
  }
149
180
  // 語言熱鍵吃掉 AI 開關熱鍵剩下的 rest——同一個 chunk 理論上只會含其中一個熱鍵,
150
181
  // 兩個都檢查一遍即可,extractAiToggle 本來就是通用的單一 control code 偵測。
151
182
  const langToggle = extractAiToggle(aiToggle.rest, langToggleKey.code);
152
183
  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 {}
184
+ lastHumanMs = Date.now(); // 按熱鍵是人為活動,不管下面這次按鍵有沒有實際生效都算
185
+ // 2026-08-30(第三輪 code review):fixedAiMsg 生效時,內建循環的語言完全不適用
186
+ //(pickAiMsgAndBriefing/resolvePersistedLang 都會忽略 langOverride)——與其讓
187
+ // langOverride 悄悄變了、卻對實際注入內容和 statusline 都沒有任何可見效果(使用者
188
+ // 按了熱鍵、log 卻寫「切換成功」,正是這次要修的「熱鍵看起來像壞的」同一類問題),
189
+ // 乾脆完全不處理這次按鍵,log 留一行清楚的「被忽略」訊息,誠實反映沒有東西可切。
190
+ if (fixedAiMsg) {
191
+ logLine(`ai-mode language hotkey (${langToggleKey.label}) ignored — CWARM_AI_MSG/CWARM_AI_MSG_FILE is set, no built-in cycle to switch`);
192
+ } else {
193
+ langOverride = toggleBuiltinLang(langOverride, configuredLang);
194
+ writeAiState(); // 立刻更新,statusline 不用等下一次心跳(tick 間隔可能到 20 秒)才看到新語言
195
+ logLine(`ai-mode builtin cycle language -> ${langOverride} (${langToggleKey.label} toggle)`);
196
+ }
157
197
  }
158
198
  const rest = langToggle.rest;
159
199
  if (rest.length === 0) return;
@@ -179,7 +219,11 @@ export function startHost(opts = {}) {
179
219
  const tickMs = Number(process.env.CWARM_TICK_MS) || 20_000;
180
220
  const msg = process.env.CWARM_MSG || opts.msg || 'hi';
181
221
  const quietMs = Number(process.env.CWARM_QUIET_MS) || 2500; // 畫面需靜止這麼久才注入
182
- const escDelayMs = Number(process.env.CWARM_ESC_DELAY_MS) || 250; // Esc 與訊息之間的間隔
222
+ // 2026-08-30 code review(第四輪)抓到:原本 Number(env) || 250 這個寫法,跟同檔案的
223
+ // envNumber() 特地防的坑一模一樣——CWARM_ESC_DELAY_MS=0(合法值,代表使用者要 Esc 跟內容
224
+ // 幾乎同時送出)會被 falsy 判斷悄悄吃掉、退回預設 250,且完全沒有警告。改用 envNumber,
225
+ // 跟下面的 enterDelayMs 一致,也跟 CWARM_AI_FAST_PCT=0 那類合法 0 值的既有處理方式對齊。
226
+ const escDelayMs = envNumber('CWARM_ESC_DELAY_MS', 250, LOG); // Esc 與訊息之間的間隔
183
227
  // 2026-08-30 訊息本體與結尾 Enter 之間的間隔:AI 循環訊息(尤其附 briefing 的第一步,
184
228
  // 常常 700+ 字元)一次 pty.write() 連內容帶 \r 整包送出時,Claude Code 的輸入框會把
185
229
  // 這種遠超人類打字速度的長串位元組判成「貼上」,貼上內容裡夾帶的 \r 只變成貼上文字
@@ -187,7 +231,7 @@ export function startHost(opts = {}) {
187
231
  // 短訊息("hi"、"go on")因為位元組數少、大多數判斷式不會誤觸,才會正常送出。
188
232
  // 修法:仿照使用者手感——先寫內容,隔一小段再單獨寫 \r,讓終端把 Enter 當成獨立按鍵
189
233
  // 事件而不是貼上內容的尾巴。
190
- const enterDelayMs = Number(process.env.CWARM_ENTER_DELAY_MS) || 80;
234
+ const enterDelayMs = envNumber('CWARM_ENTER_DELAY_MS', 80, LOG); // 同上,0 是合法值不能被吃掉
191
235
  const overrides = {};
192
236
  const thr = process.env.CWARM_THRESHOLD_S ?? opts.thresholdS;
193
237
  const ttlO = process.env.CWARM_TTL_S ?? opts.ttlS;
@@ -197,93 +241,19 @@ export function startHost(opts = {}) {
197
241
  // reset 後的回溫訊息:"go on"(續跑任務)也算 AI 自主行為,未開 AI 模式時只補一般訊息回溫。
198
242
  // 做成函式:Ctrl+] 隨時切換 aiEnabled,用時才取值。
199
243
  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
- }
244
+ // 無人值守 AI 模式:連兩發 "hi" 都沒人碰鍵盤後,第三發起改敲 18 階段循環工作流(內容見
245
+ // keepalive.mjs 的 AI_MSG_EN/AI_MSG_ZH/AI_BRIEFING_EN/AI_BRIEFING_ZH),讓閒置視窗產生
246
+ // 實際價值。設計原則:每步「安全、有界、可驗證」——檢視類在前、執行類帶安全閥(不可部署、
247
+ // 不可破壞性操作、不可展開大型新工作,對齊全域 SOP)、末段自我迭代與收尾報告,使用者回來
248
+ // 直接驗收。CWARM_AI_MSG 可覆蓋成固定一句(設成 "hi" 即等於關掉本模式)。1h 檔位一步一
249
+ // 小時,一輪約半天。指令預設英文(省 token),會自動讀 Claude Code 的 language 設定
250
+ //(/config 那個欄位)決定用 EN 還是 ZH 版;明確設定的 CWARM_AI_MSG/CWARM_AI_MSG_FILE
251
+ // 優先序不變,一律蓋過語言自動判斷。
252
+ // fixedAiMsg 已在上面(writeAiState 之前)宣告——語言判斷跟 statusline 顯示都要用到它。
253
+ // 每次注入前現算,交給 pickAiMsgAndBriefing(keepalive.mjs,純函式、有測試):langOverride
254
+ // (語言熱鍵按過就固定)優先於 configuredLang(/config 自動偵測),fixedAiMsg(CWARM_AI_MSG/
255
+ // CWARM_AI_MSG_FILE)存在時完全不附 briefing(那段文字講的是 cwarm 內建固定檢查清單,對
256
+ // 自訂循環連內容都文不對題,不只是語言選不對)。回傳的 aiMsg/briefing 直接餵給 pickInjectMsg。
287
257
  // 使用者敲鍵後需靜默這麼久才可注入(預設 5 分鐘)。CWARM_HUMAN_QUIET_S 可調;
288
258
  // 打錯成非數字(如 "5m")會記警告並退回預設,不會悄悄關掉這個安全機制。
289
259
  const humanQuietMs = envNumber('CWARM_HUMAN_QUIET_S', 300, LOG) * 1000;
@@ -310,20 +280,20 @@ export function startHost(opts = {}) {
310
280
  if (billing === 'credits') {
311
281
  if (!creditsGuardLogged) {
312
282
  creditsGuardLogged = true;
313
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} skip: credits/API billing detected — keepalive suspended (CWARM_BILLING=subscription to override)\n`); } catch {}
283
+ logLine('skip: credits/API billing detected — keepalive suspended (CWARM_BILLING=subscription to override)');
314
284
  }
315
285
  return;
316
286
  }
317
287
  if (creditsGuardLogged) {
318
288
  creditsGuardLogged = false;
319
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} resume: subscription billing detected — keepalive re-enabled\n`); } catch {}
289
+ logLine('resume: subscription billing detected — keepalive re-enabled');
320
290
  }
321
291
  // 信任對話框在畫面上時,這輪完全不動作(連 Esc 都不送)——那是使用者本人該回答的框,被保溫
322
292
  // Esc 掉會留下 hasTrustDialogAccepted:false,害該資料夾 settings.local.json 權限失效、之後不再跳框。
323
293
  if (looksLikeTrustPrompt(screenBuf)) {
324
294
  if (!trustGuardLogged) {
325
295
  trustGuardLogged = true;
326
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} skip: trust dialog on screen — left for the user to answer\n`); } catch {}
296
+ logLine('skip: trust dialog on screen — left for the user to answer');
327
297
  }
328
298
  return;
329
299
  }
@@ -335,7 +305,7 @@ export function startHost(opts = {}) {
335
305
  //(cwarm setup)或 payload 沒有 rate_limits 時所有閘門都是 unknown——明講,別靜默裸奔。
336
306
  if (aiEnabled && usage == null && !noUsageWarned) {
337
307
  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 {}
308
+ logLine('warn: ai mode has no usage data (run `cwarm setup` to install the statusline bridge) — quota gates inactive');
339
309
  }
340
310
  if (usage != null) noUsageWarned = false;
341
311
  const nowSec = Date.now() / 1000;
@@ -344,7 +314,7 @@ export function startHost(opts = {}) {
344
314
  limitResetsAt = usage.resetsAt;
345
315
  if (!limitLogged) {
346
316
  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 {}
317
+ logLine(`skip: usage limit reached (${Math.round(usage.usedPct)}%) — waiting for reset at ${new Date(usage.resetsAt * 1000).toISOString()}`);
348
318
  }
349
319
  return; // 撞牆期間注入到不了伺服器,暫停等 reset
350
320
  }
@@ -352,7 +322,7 @@ export function startHost(opts = {}) {
352
322
  limitResetsAt = null;
353
323
  limitLogged = false;
354
324
  pendingResume = true; // 視窗已重置:下一發改敲 resumeMsg,回溫 cache 兼續跑被打斷的任務
355
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} window reset — next inject will send "${resumeMsg()}"\n`); } catch {}
325
+ logLine(`window reset — next inject will send "${resumeMsg()}"`);
356
326
  }
357
327
  // session bridge 有落地時針定本 session 的 transcript(多分頁同資料夾不互相干擾);
358
328
  // 沒有(未跑 cwarm setup 裝 statusline、或 payload 還沒刷新過)回退舊的「資料夾最新」猜法。
@@ -389,17 +359,17 @@ export function startHost(opts = {}) {
389
359
  if (idleMs == null) {
390
360
  if (!noTranscriptWarned && now - startedMs > 120_000) {
391
361
  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 {}
362
+ 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
363
  }
394
364
  } else if (noTranscriptWarned) {
395
365
  noTranscriptWarned = false;
396
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} resume: transcript found — keepalive active\n`); } catch {}
366
+ logLine('resume: transcript found — keepalive active');
397
367
  }
398
368
  const screenIdleMs = now - lastOutputMs;
399
369
  // reset 後若使用者自己先回來發了訊息(transcript 剛更新過),就不必再補 "go on"
400
370
  if (pendingResume && idleMs != null && idleMs < 60_000) {
401
371
  pendingResume = false;
402
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} resume skipped — user already active after reset\n`); } catch {}
372
+ logLine('resume skipped — user already active after reset');
403
373
  }
404
374
  const humanIdleMs = now - lastHumanMs;
405
375
  // humanQuiet 鉗在 idleThreshold-60s 內:short 檔(TTL 300s/門檻 240s)若照吃 300s
@@ -409,7 +379,7 @@ export function startHost(opts = {}) {
409
379
  // 先送 Esc:把任何「必答」modal(權限/選單/計畫批准)收掉、退回輸入框,後面那個 Enter 才不會誤選預設項;
410
380
  // 空輸入框時 Esc 等同 no-op。隔一小段再送訊息——讓 claude 先把 modal 收乾淨,也避免 ESC 與字元被併成 Meta 鍵。
411
381
  const aiAllowed = aiEnabled && wgate !== 'off';
412
- const { aiMsg, briefing } = currentAiMsgAndBriefing();
382
+ const { aiMsg, briefing } = pickAiMsgAndBriefing(fixedAiMsg, langOverride, configuredLang);
413
383
  const injectMsg = pickInjectMsg({ pendingResume, consecInjects, aiStep, resumeMsg: resumeMsg(), aiMsg, msg, aiAllowed, briefing });
414
384
  // 只有真的敲出 AI 循環裡的一步才推進 aiStep;被 wgate='off' 等閘門擋下、退回一般
415
385
  // "hi" 或 resume 的那些發送不算,避免恢復後循環整段跳號(見上方 aiStep 宣告的說明)。
@@ -425,7 +395,7 @@ export function startHost(opts = {}) {
425
395
  }
426
396
  const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
427
397
  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 {}
398
+ logLine(`inject "${msgLabel}" regime=${regime ?? 'unknown'} idle=${idle}s screenIdle=${Math.round(screenIdleMs / 1000)}s consec=${consecInjects} aiStep=${aiStep} weekly=${wgate}`);
429
399
  }
430
400
  }, tickMs);
431
401
 
package/src/keepalive.mjs CHANGED
@@ -316,13 +316,17 @@ export function weeklyGate({ usedPct, resetsAt, nowSec, graceDays = 1 } = {}) {
316
316
  // ---- 週配速具現化(給人看的球號 + 剩餘天數均可用量)----
317
317
  // weeklyGate 只吐三檔給 aiPacing 悄悄降速,人完全看不到;這裡把「超前進度線多少」
318
318
  // 換算成球色:
319
- // <2% 🟢(配速正常)/3~5% 🟡/6~8% 🟠/9~11% 🩷(Unicode 無粉紅圓形,借粉紅
320
- // 愛心最接近)/≥12% 🔴。2~3% 之間留白不顯示球(維持安靜,避免在正常/警示邊界
321
- // 上抖動)。
319
+ // <3% 🟢(配速正常)/3~5% 🟡/6~8% 🟠/9~11% 🩷(Unicode 無粉紅圓形,借粉紅
320
+ // 愛心最接近)/≥12% 🔴。
321
+ // 2026-08-30(無人值守:驗證 README 快速上手,逐條核對文件跟程式碼)修正:這段原本寫
322
+ // 「<2% 綠/2~3% 之間留白不顯示球」,是 0.1.15 修 bug(7978127)之前的舊行為——當時
323
+ // 超前 2~3% 時球會留白、然後整段 return null,害週用量段從 statusline 整個消失。
324
+ // 0.1.15 把門檻改成 3% 以下一律綠球,不留空窗,但這段註解沒有同步更新,一路錯到今晚
325
+ // 才被這個驗證步驟抓到。segment.mjs 裡對應的另一份說明也同步修正過。
322
326
  // avgPerDayRemaining:把剩餘額度 (100-usedPct) 均攤到視窗剩餘天數,讓使用者知道
323
327
  // 「接下來每天還能燒多少」而不是只看到一個抽象的超前百分點。
324
328
  // recoverySec:不再新增用量的前提下,進度線每秒自然爬升 100/WEEK% ——excess 會跟著
325
- // 等速下降,算出降到 <2%(回綠球門檻)還要「休息」幾秒;已經是綠球則為 0。算出來若
329
+ // 等速下降,算出降到 <3%(回綠球門檻)還要「休息」幾秒;已經是綠球則為 0。算出來若
326
330
  // 超過視窗剩餘時間,代表撐到 reset 都回不了綠,直接鉗在剩餘時間(reset 一到整組歸零,
327
331
  // 保證回綠)。
328
332
  export function weeklyPaceInfo({ usedPct, resetsAt, nowSec } = {}) {
@@ -431,7 +435,8 @@ export function clampHumanQuiet(humanQuietMs, idleThresholdS) {
431
435
  // 當循環索引,暫停後恢復會整段跳號、破壞步驟間的依賴(如「依建議執行」承接前一步的發現)。
432
436
  // 故循環位置改用呼叫端維護、只在真的敲出 AI 循環訊息時才遞增的 aiStep。
433
437
  // briefing:每輪無人值守的第一步(aiStep===0)附加一次性說明,讓被驅動的 Claude 知道自己
434
- // 在被 cwarm 自動驅動、規則是什麼(見 host.mjs 的 AI_BRIEFING);同一輪其餘步驟不重複附加。
438
+ // 在被 cwarm 自動驅動、規則是什麼(見本檔案下方的 AI_BRIEFING_EN/AI_BRIEFING_ZH);
439
+ // 同一輪其餘步驟不重複附加。
435
440
  // 只在真的選中陣列循環的第一格時才附加——固定字串 aiMsg(CWARM_AI_MSG)或非陣列不適用。
436
441
  export function pickInjectMsg({ pendingResume, consecInjects, aiStep = 0, resumeMsg, aiMsg, msg, aiAllowed = true, briefing = null }) {
437
442
  if (pendingResume) return resumeMsg;
@@ -451,6 +456,12 @@ export function pickInjectMsg({ pendingResume, consecInjects, aiStep = 0, resume
451
456
  // 送出,卻沒有任何測試會發現——因為寫死在 host.mjs 裡、要真的起一個 pty 才碰得到這段路徑。
452
457
  // 回傳 [{delayMs 相對於觸發時刻, data}],呼叫端(host.mjs)自己排 setTimeout;純函式好斷言:
453
458
  // 內容那筆不可以含 '\r'(一旦含了,等於又把兩者黏回同一包,bug 就會回來),Enter 必須獨立一筆。
459
+ // 2026-08-30 code review 提醒:Esc 這筆現在也是 delayMs:0 排進 host.mjs 的 setTimeout(...,
460
+ // exiting 檢查) 迴圈,不再是修 bug 前那種「decideInject 判定要發,馬上同步 write('\x1b')、
461
+ // 保證送出」的寫法——理論上若 shutdown() 在「這輪排程完」跟「0ms timer 真的觸發」之間那個
462
+ // event loop 縫隙把 exiting 設成 true,這筆 Esc 會被跳過而不送。刻意接受這個極窄的
463
+ // race:反正這個時間點整個 pty 馬上就要被關掉,少送一個 Esc 不影響任何後續行為,換來三筆
464
+ // 寫入共用同一套排程/測試邏輯比較值得。
454
465
  export function injectWriteSequence(injectMsg, escDelayMs, enterDelayMs) {
455
466
  return [
456
467
  { delayMs: 0, data: '\x1b' },
@@ -501,10 +512,17 @@ export function readAiState(claudeDir, cwd, { maxAgeMs = 60_000, now = Date.now(
501
512
  return {
502
513
  enabled: !!o.enabled,
503
514
  key: typeof o.key === 'string' ? o.key : null,
504
- // 2026-08-30:內建循環目前生效的語言('zh'/'en',不管是自動偵測還是熱鍵覆蓋出來的結果)
505
- // 與該熱鍵標籤——statusline aiSegment 拿這個顯示,不然使用者按了 Ctrl+L 完全看不到有沒有生效。
515
+ // 2026-08-30:內建循環目前生效的語言('zh'/'en',不管是自動偵測還是熱鍵覆蓋出來的結果;
516
+ // 設了 CWARM_AI_MSG_FILE 自訂循環時 host.mjs 寫的是 null,語言不適用)與該熱鍵標籤——
517
+ // statusline 的 aiSegment 拿這個顯示,不然使用者按了 Ctrl+L 完全看不到有沒有生效。
506
518
  lang: o.lang === 'zh' || o.lang === 'en' ? o.lang : null,
507
519
  langKey: typeof o.langKey === 'string' ? o.langKey : null,
520
+ // langTouched:這個 session 有沒有真的按過語言熱鍵——AI 模式關著時 aiSegment 預設不顯示
521
+ // 語言段,但按過熱鍵表示使用者想先確認有沒有生效,這種情況即使 AI 還沒開也該顯示。
522
+ langTouched: !!o.langTouched,
523
+ // langHotkeyLive:這把熱鍵這個 session 是否真的可能觸發(見 host.mjs 撞鍵偵測的說明)。
524
+ // 預設 true——舊版狀態檔沒這個欄位,代表「當時沒有撞鍵這回事」,不該被誤判成撞鍵。
525
+ langHotkeyLive: o.langHotkeyLive !== false,
508
526
  };
509
527
  }
510
528
 
@@ -521,11 +539,91 @@ export function readAiMsgFile(p) {
521
539
  return lines.length ? lines : null;
522
540
  }
523
541
 
542
+ // ---- 內建無人值守循環內容 ----(原本是 host.mjs 裡的 local const,2026-08-30 code review
543
+ // 抓到:兩份陣列長度/順序要不要保持一致完全沒人守著——host.mjs 是要真的起一個 pty 才測得到
544
+ // 的那層,測試沒辦法 import 裡面的 local const 去斷言。搬來這裡(跟 pickInjectMsg/
545
+ // injectWriteSequence 一樣,無 pty/timer,好測試),host.mjs 改成 import 這幾個常數。
546
+ // 18 階段中文對照:
547
+ // 檢視段:review → 批判 review → TODO/FIXME 掃描 → 保守建議
548
+ // 執行段:依建議執行 → 測試覆蓋 → mutation check(測試有效性)→ 錯誤處理稽核 →
549
+ // 資安自查 → 相依套件體檢(只報告不升級)→ 效能低垂果實 → 小步重構 → 跨平台審視
550
+ // 收斂段:文件同步 → README 快速上手驗證 → 對照方案探索(防路徑依賴)→
551
+ // 經驗蒸餾(寫進 memory/CLAUDE.md,複利最高)→ 收尾報告+決策佇列(睡醒五分鐘拍板解鎖下一天)
552
+ export const AI_MSG_EN = [
553
+ 'Unattended: review the work done in this session; briefly list problems and possible improvements.',
554
+ 'Unattended: critical review — challenge earlier assumptions and approaches; point out risks, blind spots, and missing test scenarios.',
555
+ 'Unattended: sweep the code for TODO / FIXME / HACK markers; triage into quick-fix / should-fix / ignore, and fix the quick ones.',
556
+ 'Unattended: based on the findings above, propose a conservative, low-risk improvement list with priorities.',
557
+ 'Unattended: execute the safest suggested items one by one, verifying each. No deploys, no destructive operations, no large new work.',
558
+ 'Unattended: check test coverage — find uncovered branches and edge cases, add the needed tests, run the full suite.',
559
+ '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.',
560
+ 'Unattended: audit error handling — walk the failure paths (bad input, missing files, timeouts, permissions); patch gaps minimally, with tests.',
561
+ 'Unattended: light security self-check — leaked secrets in code/logs, injection risks, over-broad permissions; fix only clear issues.',
562
+ 'Unattended: dependency health check — outdated or vulnerable dependencies (e.g. npm outdated / npm audit); report findings only, do not upgrade.',
563
+ 'Unattended: performance pass — find low-hanging fruit (repeated IO, needless polling, cacheable recomputation); small certain wins only, no big refactors.',
564
+ 'Unattended: small refactors — naming, duplication, readability; behavior must not change, tests guard every step.',
565
+ '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.',
566
+ 'Unattended: sync docs — check README, comments and usage notes against reality; update stale parts, fill gaps.',
567
+ 'Unattended: verify the README quickstart — follow the install/usage steps literally from scratch and note where documentation and reality diverge; fix the docs.',
568
+ "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.",
569
+ '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.',
570
+ '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.',
571
+ ];
572
+ // 繁體中文版,逐句對照 AI_MSG_EN(順序與含意一致——test/keepalive.test.mjs 有斷言兩者
573
+ // 長度相同,防止改一邊忘了改另一邊)。目前只有這一版中文(不分簡繁),isChineseLanguage
574
+ // 判斷是中文語系設定就一律用這份。
575
+ export const AI_MSG_ZH = [
576
+ '無人值守:檢視這次 session 已完成的工作,簡短列出問題與可能的改進方向。',
577
+ '無人值守:批判式檢視——挑戰先前的假設與做法,指出風險、盲點,以及遺漏的測試情境。',
578
+ '無人值守:掃描程式碼裡的 TODO / FIXME / HACK 標記,分成「順手修」/「該修」/「可忽略」三類,順手修的直接修掉。',
579
+ '無人值守:根據上面的發現,提出一份保守、低風險的改進清單並排出優先序。',
580
+ '無人值守:依序執行最安全的建議項目,每一步都驗證過再往下走。不部署、不做破壞性操作、不展開大型新工作。',
581
+ '無人值守:檢查測試覆蓋率——找出沒覆蓋到的分支與邊界情況,補上需要的測試,跑過整套測試。',
582
+ '無人值守:mutation check(測試有效性檢查)——找一小段邏輯,在腦中(或草稿副本上)故意弄壞,確認既有測試抓得到;抓不到的話,那份覆蓋率是假的,補一個真正有效的測試。',
583
+ '無人值守:稽核錯誤處理——走過各種失敗路徑(錯誤輸入、檔案缺失、逾時、權限不足),用測試補上明顯的漏洞,改動盡量小。',
584
+ '無人值守:輕量資安自查——檢查程式碼/log 裡有沒有外洩的機密、注入風險、過寬的權限;只修明確有問題的部分。',
585
+ '無人值守:相依套件體檢——檢查有沒有過時或有漏洞的相依套件(如 npm outdated/npm audit),只回報發現,不主動升級。',
586
+ '無人值守:效能低垂果實——找容易改善的地方(重複 IO、不必要的輪詢、可快取的重複計算),只做有把握的小改善,不做大重構。',
587
+ '無人值守:小步重構——命名、重複程式碼、可讀性;行為不能改變,每一步都要有測試守著。',
588
+ '無人值守:跨平台審視——檢查路徑、換行符、shell、權限在 Windows/macOS/Linux 上的假設是否成立,列出可疑之處,明顯需要防護的地方補上。',
589
+ '無人值守:文件同步——對照 README、註解與實際狀況,更新過時的部分、補上缺口。',
590
+ '無人值守:驗證 README 的快速上手——從頭照著安裝/使用步驟原樣操作一次,記下文件跟實際不符的地方,修正文件。',
591
+ '無人值守:唱反調——挑這次 session 裡的一個設計決策,認真想一個不同的做法,比較利弊並下結論;只做分析,不改程式碼。',
592
+ '無人值守:蒸餾經驗——把這次 session 裡不明顯、但長期有用的心得寫進專案的 memory/CLAUDE.md,讓以後每個 session 都受惠;已經記過的就跳過。',
593
+ '無人值守:收尾——寫一份簡短的變更與待辦報告,附上只有使用者能回答的決策清單(每項附脈絡、選項、你的建議)。',
594
+ ];
595
+ // 無人值守循環第一步附帶一次性簡報:被驅動的 Claude 本身看不到 cwarm 的原始碼與這段機制,
596
+ // 光靠 "Unattended: review …" 猜不出「為什麼會收到這句」「還會不會繼續來」「額度用完會
597
+ // 怎樣」「使用者真的回來打字算不算數」。實際判斷(只在陣列循環剛回到第 0 步時附加一次,
598
+ // 不是每步都附)交給 pickInjectMsg——這裡只需提供文字本身。跟著目前生效的內建循環語言選——
599
+ // 用了中文循環卻附英文簡報會混雜。CWARM_AI_MSG_FILE 的自訂循環完全不附簡報(見下方
600
+ // pickAiMsgAndBriefing——這段文字講的是 cwarm 內建的固定檢查清單,對自訂循環連內容都文
601
+ // 不對題,不是語言選錯而已,選哪個語言附都不合適,故乾脆不附)。
602
+ export const AI_BRIEFING_EN = "Unattended: heads up — cwarm's AI mode is now driving this session on its own. "
603
+ + 'Whenever this terminal sits idle past a threshold with no human keystrokes, it injects one instruction '
604
+ + "like this automatically, cycling through a fixed checklist (review, tests, docs, refactors, …); it's not "
605
+ + "you deciding to keep going, it's the tool. Pace is quota-aware — faster when there's headroom, paused near "
606
+ + "your usage limit and resumed with a plain 'go on' once it resets. If real human input ever shows up in this "
607
+ + 'session, that always takes priority over anything below. Stay conservative: no deploys, no destructive '
608
+ + 'operations, no large new scope — verify each step before moving to the next.';
609
+ export const AI_BRIEFING_ZH = '無人值守提醒:cwarm 的 AI 模式現在正在自主驅動這個 session。只要這個終端閒置超過門檻、'
610
+ + '又沒有人為按鍵,就會像這樣自動注入一則指令,依序輪過固定的檢查清單(review、測試、文件、重構……);'
611
+ + '接下去做的決定不是你自己要繼續,而是這個工具在推動。節奏會看額度調整——額度充裕時較快,接近用量上限時'
612
+ + '暫停,額度重置後用一句普通的『go on』恢復。如果這個 session 裡真的出現人為輸入,那一律優先於下面任何'
613
+ + '內容。保持保守:不部署、不做破壞性操作、不展開大型新範圍——每一步都驗證過再往下走。';
614
+
524
615
  // ---- 內建循環的語言選擇 ----(2026-08-30,使用者要求「內建循環能不能自動跟 /config 語言走」)
525
616
  // 內建循環預設英文(省 token),但使用者已經在 Claude Code 的 language 設定裡選了中文時,
526
617
  // 沒理由還要手動去帶 CWARM_AI_MSG_FILE 才看得到中文——這裡讀同一份設定自動跟著切換。
527
618
  // 只判斷「是不是中文」,不分繁簡(沒有簡體翻譯,一律給繁體版);不是中文一律回退英文原版。
528
- const CHINESE_LANG_RE = /chinese|中文|zh[-_]?(tw|hant|cn|hans)/i;
619
+ // 2026-08-30 code review 抓到:原本 zh[-_]?(tw|hant|cn|hans) 要求 "zh" 後面一定要接
620
+ // 地區/文字系統後綴,使用者若把 language 設成單純的裸字串 "zh"(合法值,且上面註解本來就
621
+ // 宣稱「只判斷是不是中文」涵蓋這種情況)反而不會命中——加一個獨立分支專門吃裸 "zh"(用
622
+ // \b 詞界避免誤配到其他字串中間剛好出現的 "zh")。副作用(第二輪 review 追問過、確認是
623
+ // 刻意接受):這個 \bzh\b 分支連帶會吃到不在前面那份白名單裡的其他 "zh-<地區>" 代碼,如
624
+ // "zh-HK"——這其實是好事:BCP47 底下任何 zh-XX 都是中文的地區變體,比原本那份不完整的
625
+ // 白名單更準確;測試見 test/keepalive.test.mjs 的對應案例。
626
+ const CHINESE_LANG_RE = /chinese|中文|zh[-_]?(tw|hant|cn|hans)|\bzh\b/i;
529
627
  export function isChineseLanguage(lang) {
530
628
  return typeof lang === 'string' && CHINESE_LANG_RE.test(lang);
531
629
  }
@@ -557,12 +655,53 @@ export function detectConfiguredLanguage(claudeDir, cwd) {
557
655
  // /config 的自然語言字串設計的正規表達式比對,根本認不出 'zh'/'en' 這種內部短碼(bare
558
656
  // "zh"/"en" 兩個都不匹配 CHINESE_LANG_RE),於是按過熱鍵一次之後不管切成 'zh' 還是 'en'
559
657
  // 都被判成「不是中文」,往後注入永遠固定英文、切不回中文——症狀正是「能切到 EN,切不回中」。
560
- // 呼叫端(toggleBuiltinLang、host.mjs 的 writeAiStatecurrentAiMsgAndBriefing)都必須
658
+ // 呼叫端(toggleBuiltinLang、下方的 resolvePersistedLangpickAiMsgAndBriefing)都必須
561
659
  // 統一走這支,不要各自兜判斷式。
562
660
  export function effectiveIsChinese(override, autoDetectedLang) {
563
661
  return override ? override === 'zh' : isChineseLanguage(autoDetectedLang);
564
662
  }
565
663
 
664
+ // 2026-08-30(第三輪 code review):host.mjs 原本的 writeAiState 跟一支叫
665
+ // currentAiMsgAndBriefing 的內部函式(第三輪已搬走,函式名不再存在,邏輯改在下面兩支)
666
+ // 各自算一次「fixedAiMsg 存在時 lang/briefing 都不適用」,兩處各自兜等於又把第一輪/第二輪
667
+ // 修的那個「兩處算出不一致答案」的病根留著(這次只是還沒真的分裂,但沒有任何機制保證以後
668
+ // 不會)。抽成這兩支純函式,host.mjs 兩處都改呼叫這裡——順便讓這條「自訂循環時語言不適用」
669
+ // 的規則第一次有測試罩得到(原本活在 host.mjs 沒 export 的 closure 裡,要真的起一個 pty
670
+ // 才測得到)。
671
+
672
+ // 狀態檔要寫的 lang 欄位:自訂循環(CWARM_AI_MSG/CWARM_AI_MSG_FILE)語言未知,一律 null;
673
+ // 否則照 override(語言熱鍵)優先於 configuredLang(/config 自動偵測)判斷。
674
+ export function resolvePersistedLang(fixedAiMsg, langOverride, configuredLang) {
675
+ if (fixedAiMsg) return null;
676
+ return effectiveIsChinese(langOverride, configuredLang) ? 'zh' : 'en';
677
+ }
678
+
679
+ // 實際要注入的 aiMsg/briefing:自訂循環一律不附 briefing(briefing 講的是「cwarm 內建固定
680
+ // 檢查清單」,對自訂循環而言連內容都文不對題,不只是語言選不對——見 host.mjs 原本這段的說明)。
681
+ export function pickAiMsgAndBriefing(fixedAiMsg, langOverride, configuredLang) {
682
+ if (fixedAiMsg) return { aiMsg: fixedAiMsg, briefing: null };
683
+ const zh = effectiveIsChinese(langOverride, configuredLang);
684
+ return { aiMsg: zh ? AI_MSG_ZH : AI_MSG_EN, briefing: zh ? AI_BRIEFING_ZH : AI_BRIEFING_EN };
685
+ }
686
+
687
+ // statusline 的 AI 段顯示文字:`🤖AI on/off (熱鍵)`,AI 模式開著、或這個 session 已經按過語言
688
+ // 熱鍵(st.langTouched,讓「開 AI 之前先試按 Ctrl+L 確認有沒有生效」的人也看得到回饋——見
689
+ // host.mjs 的 langTouched 說明)時,後面再附一段語言。st.lang 是 null(自訂循環生效,
690
+ // 或舊版狀態檔沒這個欄位)時完全不顯示語言段。傳 null/undefined 進來一律回傳空字串。
691
+ // 熱鍵標籤(第四輪 code review 補):st.langHotkeyLive===false 代表 CWARM_TOGGLE_KEY 跟
692
+ // CWARM_LANG_TOGGLE_KEY 撞在一起,這把語言熱鍵這個 session 永遠不會觸發——這種時候只顯示
693
+ // 語言本身(如「中」),不附「(Ctrl+L)」,不能讓使用者以為按那顆鍵有用。
694
+ export function aiSegmentText(st) {
695
+ if (!st) return '';
696
+ const key = st.key || 'Ctrl+\\';
697
+ let out = `\u{1F916}AI ${st.enabled ? 'on' : 'off'} (${key})`; // 🤖
698
+ if (st.lang && (st.enabled || st.langTouched)) {
699
+ const label = st.lang === 'zh' ? '中' : 'EN';
700
+ out += st.langHotkeyLive === false ? ` ${label}` : ` ${label}(${st.langKey || 'Ctrl+L'})`;
701
+ }
702
+ return out;
703
+ }
704
+
566
705
  // 語言熱鍵(2026-08-30,使用者要求「能不能自己切換」):在「目前實際生效的語言」跟另一種
567
706
  // 之間切換一次。回傳值永遠是明確的 'zh'/'en'——按過一次熱鍵後就固定住,不再跟著 /config
568
707
  // 變動,除非再按一次切回去。呼叫端(host.mjs)拿這個值決定要用 AI_MSG_ZH 還是 AI_MSG_EN。
@@ -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 } from '../keepalive.mjs';
8
8
 
9
9
  const claudeDir = defaultClaudeDir();
10
10
  const ORIG = path.join(claudeDir, 'cwarm-statusline-orig.json');
@@ -54,8 +54,10 @@ function payloadCwd(payload) {
54
54
  // 回傳提醒文字(可能兩段用 │ 接起來):
55
55
  // 5h — 用量 ≥95% 且離 reset 還超過 1 小時 → 建議使用者暫時中斷;不滿 1 小時就不提醒,
56
56
  // 尾巴額度照常燒到撞牆。
57
- // 週 — weeklyPaceInfo 依超前「按時間比例均攤」進度線幾個百分點顯示球號(<2🟢/3~5🟡/
58
- // 6~8🟠/9~11🩷/≥12🔴),並附上剩餘天數的日均可用量。
57
+ // 週 — weeklyPaceInfo 依超前「按時間比例均攤」進度線幾個百分點顯示球號(<3🟢/3~5🟡/
58
+ // 6~8🟠/9~11🩷/≥12🔴,見 keepalive.mjs 該函式上方註解——2026-08-30 驗證 README
59
+ // 快速上手時發現這裡跟 keepalive.mjs 都還寫著 0.1.15 修 bug 前的舊門檻 <2,一併修正),
60
+ // 並附上剩餘天數的日均可用量。
59
61
  function usageBridgeAndWarn(payload, cwd) {
60
62
  const fh = payload?.rate_limits?.five_hour;
61
63
  const sd = payload?.rate_limits?.seven_day;
@@ -94,19 +96,15 @@ function usageBridgeAndWarn(payload, cwd) {
94
96
  return { warn, weekly: '' };
95
97
  }
96
98
 
97
- // AI 模式開關段:顯示無人值守 AI 模式 on/off 與切換熱鍵。狀態檔以 cwd 編碼命名
98
- //(多 host 並行各寫各的),用 payload 的專案路徑對回;ts 過舊(>60s,沒有
99
- // cwarm host 在跑或已退出)就不顯示,避免殘留假狀態。
99
+ // AI 模式開關段:顯示無人值守 AI 模式 on/off、切換熱鍵,與內建循環目前的語言(EN/ZH)。
100
+ // 狀態檔以 cwd 編碼命名(多 host 並行各寫各的),用 payload 的專案路徑對回;ts 過舊(>60s
101
+ // 沒有 cwarm host 在跑或已退出)readAiState 會回 null,不顯示,避免殘留假狀態。
102
+ // 顯示文字本身(含語言段的顯示門檻)交給 aiSegmentText(keepalive.mjs,純函式、有測試——
103
+ // 2026-08-30 第三輪 code review 抓到這段邏輯之前活在這支沒 export 的函式裡,要真的起一個
104
+ // statusline 子行程才測得到)。
100
105
  function aiSegment(cwd) {
101
106
  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;
107
+ return aiSegmentText(readAiState(claudeDir, cwd));
110
108
  }
111
109
 
112
110
  // 跑使用者原本存起來的 statusLine 指令(餵同一份 stdin),回傳其輸出;沒有就 null。