claude-cache-keepalive 0.1.14 → 0.1.15

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
@@ -73,7 +73,7 @@ Once you run `cwarm setup`, the statusline also surfaces two quota warnings sour
73
73
  ```
74
74
 
75
75
  - **5-hour window** — once usage crosses 95% *and* the reset is still more than an hour away, an `⚠️ NN%、離reset還Nh,建議暫停` warning appears suggesting you pause. Nothing shows below 95%, or once you're within an hour of reset (the tail is going to burn either way).
76
- - **7-day window** — a colored ball tracks how far ahead of a straight-line pace budget (100%/7 ≈ 14.3%/day) you're running: 🟢 under 2 points ahead (or behind), a quiet 2-3 point gap with no ball, 🟡 3-5, 🟠 6-8, 🩷 9-11 (no pink circle emoji exists, so it borrows the pink heart), 🔴 12+. Next to the ball: how many days are left in the window, the average %/day you can still spend if usage stays flat for the rest of it, and — once you're more than 2 points ahead — how long you'd need to rest (no further usage) before the pace line catches up and the ball turns green again.
76
+ - **7-day window** — a colored ball tracks how far ahead of a straight-line pace budget (100%/7 ≈ 14.3%/day) you're running: 🟢 under 3 points ahead (or behind), 🟡 3-5, 🟠 6-8, 🩷 9-11 (no pink circle emoji exists, so it borrows the pink heart), 🔴 12+. Next to the ball: how many days are left in the window, the average %/day you can still spend if usage stays flat for the rest of it, and — once you're 3 or more points ahead — how long you'd need to rest (no further usage) before the pace line catches up and the ball turns green again. It also shows even when the 5-hour numbers are missing from the payload.
77
77
 
78
78
  Both segments are pure display; they never stop the keepalive from firing. `--ai` mode reads the same underlying numbers to actually throttle itself (see below).
79
79
 
@@ -107,12 +107,16 @@ cwarm --ai # on for this run
107
107
  CWARM_AI=1 cwarm # same, via env var
108
108
  ```
109
109
 
110
- Or toggle **live**, anytime, with `Ctrl+\` (rebindable — see `CWARM_TOGGLE_KEY` below; useful if your IME steals the default). The toggle is **persisted per project** and survives restarts — `--ai` / `CWARM_AI=0` override the remembered state on the next launch. Current state shows in the statusline: `🤖AI on (Ctrl+\)`.
110
+ Or toggle **live**, anytime, with `Ctrl+\` (rebindable — see `CWARM_TOGGLE_KEY` below; useful if your IME steals the default). The toggle is **persisted per project** and survives restarts — `--ai` / `CWARM_AI=0` override the remembered state on the next launch. Current state shows in the statusline: `🤖AI on (Ctrl+\) 中(Ctrl+L)` — the trailing `中`/`EN` + hotkey is the built-in cycle's current language (see below), so pressing `Ctrl+L` has somewhere visible to confirm it actually switched.
111
111
 
112
112
  ### The built-in cycle
113
113
 
114
114
  18 steps, repeating: review → critical review → TODO/FIXME sweep → propose an improvement list → execute the safest items → test coverage → mutation-check the tests → error-handling audit → light security self-check → dependency health check (report only, no upgrades) → performance low-hanging fruit → small refactors → cross-platform review → sync docs → verify the README quickstart → devil's-advocate a design decision → distill lessons into project memory → wrap up with a report + decision queue. Every step is scoped to be safe, bounded, and verifiable — no deploys, no destructive operations, no large new work.
115
115
 
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
+
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.
119
+
116
120
  Replace it entirely with your own, or tune the pacing:
117
121
 
118
122
  | Var | Meaning |
@@ -120,7 +124,8 @@ Replace it entirely with your own, or tune the pacing:
120
124
  | `CWARM_AI` | `1`/`on`/`true`/`yes` to force on, `0`/`off` to force off (overrides the persisted toggle) |
121
125
  | `CWARM_AI_MSG` | send this single fixed message instead of the cycle |
122
126
  | `CWARM_AI_MSG_FILE` | path to a file with one instruction per line (`#` = comment) — swap out the whole cycle, e.g. for writing/research/translation work instead of software engineering |
123
- | `CWARM_TOGGLE_KEY` | rebind the hotkey from `Ctrl+\` to `Ctrl+<char>` |
127
+ | `CWARM_TOGGLE_KEY` | rebind the AI on/off hotkey from `Ctrl+\` to `Ctrl+<char>` |
128
+ | `CWARM_LANG_TOGGLE_KEY` | rebind the EN/ZH cycle-language hotkey from `Ctrl+L` to `Ctrl+<char>` |
124
129
  | `CWARM_HUMAN_QUIET_S` | seconds of silence required after a keystroke before injecting again (default `300`) |
125
130
  | `CWARM_AI_PACE_S` | fast-pace interval in seconds when quota allows (default `300`) |
126
131
  | `CWARM_AI_FAST_PCT` | 5h-usage ceiling below which fast pace applies (default `70`) |
@@ -138,6 +143,7 @@ Environment variables (mostly for testing / advanced use):
138
143
  | `CWARM_TICK_MS` | check interval (default `20000`) |
139
144
  | `CWARM_QUIET_MS` | screen must be silent this long before injecting (default `2500`) |
140
145
  | `CWARM_ESC_DELAY_MS` | gap between the `Esc` and the keepalive message (default `250`) |
146
+ | `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 |
141
147
  | `CWARM_THRESHOLD_S` | override idle threshold (seconds) |
142
148
  | `CWARM_TTL_S` | override cooldown (seconds) |
143
149
  | `CWARM_BILLING` | force billing mode: `subscription` (keep warming) or `credits` (suspend); otherwise auto‑detected |
@@ -180,7 +186,7 @@ Environment variables (mostly for testing / advanced use):
180
186
  ```
181
187
 
182
188
  - **5 小時視窗**:用量超過 95% 且離 reset 還超過 1 小時,才會出現 `⚠️ NN%、離reset還Nh,建議暫停`;不到 95%、或已經進入最後一小時(尾巴額度反正燒到撞牆),都不顯示。
183
- - **7 天視窗**:用一顆彩色球表示「超前按時間比例均攤的日均進度線(100%/7 ≈ 14.3%/天)多少個百分點」:不到 2 個百分點(含落後)是 🟢,2~3 之間留白不顯示,3~5 是 🟡,6~8 是 🟠,9~11 是 🩷(Unicode 沒有粉紅圓形,借粉紅愛心最接近),12 以上是 🔴。球旁邊接著顯示視窗還剩幾天、照這剩餘天數均攤接下來每天還能燒多少 %,以及——只要超前 2 個百分點以上——要「休息」(不再新增用量)多久,進度線才會爬上來讓球回綠。
189
+ - **7 天視窗**:用一顆彩色球表示「超前按時間比例均攤的日均進度線(100%/7 ≈ 14.3%/天)多少個百分點」:不到 3 個百分點(含落後)是 🟢,3~5 是 🟡,6~8 是 🟠,9~11 是 🩷(Unicode 沒有粉紅圓形,借粉紅愛心最接近),12 以上是 🔴。球旁邊接著顯示視窗還剩幾天、照這剩餘天數均攤接下來每天還能燒多少 %,以及——只要超前 3 個百分點以上——要「休息」(不再新增用量)多久,進度線才會爬上來讓球回綠。5 小時那組數字缺席時,這段也照樣會顯示。
184
190
 
185
191
  這兩段都只是顯示,不會擋掉保溫注入;`--ai` 模式才是拿同一組數字真的去踩剎車(見下方)。
186
192
 
@@ -222,12 +228,16 @@ cwarm --ai # 這次啟動就開
222
228
  CWARM_AI=1 cwarm # 效果相同,走環境變數
223
229
  ```
224
230
 
225
- 或執行中隨時按 `Ctrl+\` **即時切換**(可換鍵,見下方 `CWARM_TOGGLE_KEY`;IME 搶走預設鍵時很有用)。切換狀態**依專案持久化**、重啟沿用——`--ai` / `CWARM_AI=0` 會覆蓋下次啟動時記住的狀態。目前狀態顯示在 statusline:`🤖AI on (Ctrl+\)`。
231
+ 或執行中隨時按 `Ctrl+\` **即時切換**(可換鍵,見下方 `CWARM_TOGGLE_KEY`;IME 搶走預設鍵時很有用)。切換狀態**依專案持久化**、重啟沿用——`--ai` / `CWARM_AI=0` 會覆蓋下次啟動時記住的狀態。目前狀態顯示在 statusline:`🤖AI on (Ctrl+\) 中(Ctrl+L)`——後面那個「中」/「EN」+熱鍵,就是內建循環目前的語言(見下方),按 `Ctrl+L` 才有地方能確認真的切到了。
226
232
 
227
233
  **內建循環**
228
234
 
229
235
  18 步循環:review → 批判 review → TODO/FIXME 掃描 → 提出改進清單 → 執行最安全的項目 → 測試覆蓋 → mutation check(測試有效性)→ 錯誤處理稽核 → 輕量資安自查 → 相依套件體檢(只報告不升級)→ 效能低垂果實 → 小步重構 → 跨平台審視 → 文件同步 → README 快速上手驗證 → 對照方案探索 → 心得蒸餾進 project memory → 收尾報告+決策佇列。每一步都刻意設計成安全、有界、可驗證——不部署、不做破壞性操作、不展開大型新工作。
230
236
 
237
+ 循環預設英文(每次注入省 token),但當 Claude Code 自己的 `language` 設定(`/config` 寫進 `settings.json` 的那個欄位)是中文語系時(`繁體中文`、`Chinese`、`zh-TW`、`zh-Hant`… 不分大小寫;簡體變體也一律給繁體版,沒有另外的簡體翻譯),會自動切成手工翻譯的繁體中文版。這只改變**注入的觸發文字**本身——Claude 實際動手做事、回覆用什麼語言,本來就由你的 `CLAUDE.md` 控制、不受注入語言影響,所以這只是看起來順眼,不影響行為。也不打算為每一種可能的 `language` 值都做泛化支援——其他語言請用下面的 `CWARM_AI_MSG_FILE`:那才是通用解法,想要哪種語言、哪個領域的循環都自己寫一份。
238
+
239
+ 自動偵測猜錯了,或只是這次 session 想手動換一下?按 `Ctrl+L`(跟 AI 模式開關熱鍵一樣可換鍵,見下方 `CWARM_LANG_TOGGLE_KEY`)就能當場在英文/繁體中文之間切換內建循環,不用重開 cwarm。切的是「目前實際生效的語言」(這個 session 裡如果已經按過一次,就以那次按的結果為準;沒按過就以自動偵測為準),切完就固定住,直到再按一次;重開 cwarm 又會回到照 `language` 自動偵測。只影響內建的 EN/ZH 循環——如果你已經設了 `CWARM_AI_MSG`/`CWARM_AI_MSG_FILE`,這顆熱鍵沒東西可切。
240
+
231
241
  想整套換掉、或調節奏,可用:
232
242
 
233
243
  | 變數 | 意義 |
@@ -235,7 +245,8 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
235
245
  | `CWARM_AI` | `1`/`on`/`true`/`yes` 強制開、`0`/`off` 強制關(覆蓋持久化狀態) |
236
246
  | `CWARM_AI_MSG` | 改成固定敲這一句,取代整套循環 |
237
247
  | `CWARM_AI_MSG_FILE` | 自訂指令檔路徑,一行一條(`#` 開頭為註解)——整套換掉,例如換成寫作/研究/翻譯而非軟體工程 |
238
- | `CWARM_TOGGLE_KEY` | 把熱鍵從 `Ctrl+\` 換成 `Ctrl+<字元>` |
248
+ | `CWARM_TOGGLE_KEY` | AI 開關熱鍵從 `Ctrl+\` 換成 `Ctrl+<字元>` |
249
+ | `CWARM_LANG_TOGGLE_KEY` | 把 EN/ZH 循環語言熱鍵從 `Ctrl+L` 換成 `Ctrl+<字元>` |
239
250
  | `CWARM_HUMAN_QUIET_S` | 敲鍵後需靜默幾秒才可再注入(預設 `300`) |
240
251
  | `CWARM_AI_PACE_S` | 額度充裕時的快節奏間隔秒數(預設 `300`) |
241
252
  | `CWARM_AI_FAST_PCT` | 5h 用量低於此值才套用快節奏(預設 `70`) |
@@ -253,6 +264,7 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
253
264
  | `CWARM_TICK_MS` | 檢查間隔(預設 `20000`) |
254
265
  | `CWARM_QUIET_MS` | 畫面需靜止多久才注入(預設 `2500`) |
255
266
  | `CWARM_ESC_DELAY_MS` | `Esc` 與訊息之間的間隔(預設 `250`) |
267
+ | `CWARM_ENTER_DELAY_MS` | 注入內容與送出用的 `Enter` 之間的間隔(預設 `80`)——內容和 `Enter` 分成兩次 `pty.write()` 寫入,避免 AI 模式的長訊息因為一次寫入太快被判成「貼上」,導致夾在同一包裡的 `Enter` 只變成貼上內容的一部分而沒有真正送出 |
256
268
  | `CWARM_THRESHOLD_S` | 覆寫閒置門檻(秒) |
257
269
  | `CWARM_TTL_S` | 覆寫冷卻(秒) |
258
270
  | `CWARM_BILLING` | 強制指定計費模式:`subscription`(照常保溫)或 `credits`(暫停);否則自動偵測 |
@@ -267,6 +279,14 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
267
279
 
268
280
  ## Changelog
269
281
 
282
+ ### 0.1.15
283
+ - **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
+ - **修正:** 週配速球(0.1.14)原本可能整段消失,不只是變安靜。兩個各自獨立的原因:(1) statusline 只要 5 小時視窗的用量資料一時缺席,就會把**整段**額度區塊(不只週段)一起吞掉;現在兩個時間窗各自獨立讀取,缺一個只影響它自己那段。(2) `weeklyPaceInfo()` 在超前配速 2~3 個百分點時把球留白、然後直接回傳空值,整段消失而不是顯示綠球;現在 3% 以下一律顯示綠球,「休息多久回綠」的估算也同步對齊這條 3% 門檻,綠球不會被叫去休息。
285
+ - **Fix:** long unattended-mode injections (the AI-briefing message can run 700+ characters) could sit stuck in the input box, never actually sent. The content and the trailing Enter used to be written to the terminal as one burst — far faster than human typing — which Claude Code's input box could mistake for a paste, swallowing the Enter as part of the pasted text instead of treating it as a submit keystroke. Short pings (`hi`) rarely tripped this, which made it easy to miss. Content and Enter are now two separate writes, `CWARM_ENTER_DELAY_MS` (default 80ms) apart.
286
+ - **修正:** 無人值守模式的長訊息(附 AI briefing 常見 700+ 字元)可能整句卡在輸入框,實際上沒有送出去。內容跟結尾的 Enter 過去是當同一包位元組寫進終端——遠超人類打字速度——Claude Code 的輸入框可能把這整包判成「貼上」,夾在裡面的 Enter 就只變成貼上內容的一部分,沒有觸發送出。短訊息(`hi`)位元組數少,大多不會誤觸,才不容易被發現。現在內容跟 Enter 分兩次寫入,中間隔 `CWARM_ENTER_DELAY_MS`(預設 80ms)。
287
+ - **Feature:** the built-in 18-step unattended cycle now has a hand-translated Traditional Chinese version, auto-selected when Claude Code's own `language` setting (the `/config` field) is a Chinese locale — no more needing `CWARM_AI_MSG_FILE` just to see it in Chinese. Press `Ctrl+L` (rebindable via `CWARM_LANG_TOGGLE_KEY`) to flip the cycle's language for the rest of the session regardless of auto-detection; the statusline now shows which one is active, e.g. `🤖AI on (Ctrl+\) 中(Ctrl+L)`. `CWARM_AI_MSG`/`CWARM_AI_MSG_FILE` still take priority for any other language or domain.
288
+ - **功能:** 內建的 18 階段無人值守循環現在有一份手工翻譯的繁體中文版,會在 Claude Code 自己的 `language` 設定(`/config` 那個欄位)是中文語系時自動採用——不用再靠 `CWARM_AI_MSG_FILE` 才看得到中文。按 `Ctrl+L`(可透過 `CWARM_LANG_TOGGLE_KEY` 換鍵)能在這次 session 剩餘時間裡手動切換循環語言,不受自動偵測影響;statusline 現在會顯示目前是哪一版,例如 `🤖AI on (Ctrl+\) 中(Ctrl+L)`。其他語言或領域仍優先看 `CWARM_AI_MSG`/`CWARM_AI_MSG_FILE`。
289
+
270
290
  ### 0.1.14
271
291
  - **Feature:** the statusline's 7-day quota warning is now a real pace gauge, not just a raw number. A colored ball shows how far ahead of a straight-line pace budget (100%/7 ≈ 14.3%/day) you're running — 🟢 under 2pt ahead, 🟡 3-5pt, 🟠 6-8pt, 🩷 9-11pt, 🔴 12pt+ — alongside how many days are left in the window, the average %/day you can still spend at that pace, and (once you're 2pt+ ahead) how long you'd need to rest before the ball turns green again. See the new "Quota segments" section above.
272
292
  - **功能:** statusline 的 7 天額度提醒從一個原始數字變成真正的配速計。彩色球顯示超前「按時間比例均攤」的日均進度線(100%/7 ≈ 14.3%/天)多少個百分點——🟢 不到 2pt、🟡 3~5pt、🟠 6~8pt、🩷 9~11pt、🔴 12pt+——旁邊接著顯示視窗剩幾天、照這個配速接下來每天還能燒多少 %,以及(超前 2pt 以上時)要休息多久球才會回綠。詳見上方新增的「額度段位(statusline)」章節。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-cache-keepalive",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Keep Claude Code's prompt cache warm while idle, by running claude inside a PTY host and injecting a tiny keepalive when you step away. Cross-platform, no tmux required.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/host.mjs CHANGED
@@ -5,7 +5,7 @@ import { createRequire } from 'node:module';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { spawnSync } from 'node:child_process';
8
- import { defaultClaudeDir, regimeParams, detectTtlRegime, decideInject, transcriptIdleMs, looksLikeTrustPrompt, detectBillingMode, readUsageBridge, usageState, looksLikeHumanInput, pickInjectMsg, aiPacing, weeklyGate, readAiMsgFile, aiStatePath, extractAiToggle, clampHumanQuiet, toggleKeySpec, readAiState, initialAiEnabled, sessionBridgePath, readSessionTranscript, readTtlRegime, transcriptIdleMsAt } 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, effectiveIsChinese } from './keepalive.mjs';
9
9
 
10
10
  const require = createRequire(import.meta.url);
11
11
  const isWin = process.platform === 'win32';
@@ -101,8 +101,28 @@ export function startHost(opts = {}) {
101
101
  persisted: readAiState(claudeDir, hostCwd, { maxAgeMs: Infinity }),
102
102
  });
103
103
  const toggleKey = toggleKeySpec(process.env.CWARM_TOGGLE_KEY);
104
+ // 語言熱鍵:預設 Ctrl+L('l' → 0x0C)——跟 AI 開關熱鍵一樣是 toggleKeySpec 算出來的單一
105
+ // control code,只是預設字元不同,才不會撞在一起。CWARM_LANG_TOGGLE_KEY 可換單一字元。
106
+ 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 {}
109
+ }
110
+ // 目前生效的內建循環語言覆蓋:null=沒按過熱鍵,跟著 /config 自動偵測;'zh'/'en'=按過熱鍵
111
+ // 手動固定住。只在這個 session 內生效,不持久化(見 keepalive.mjs 的 toggleBuiltinLang 註解)。
112
+ let langOverride = null;
113
+ // /config 的 language 設定,啟動時讀一次即可(跟 aiEnabled 一樣不會在 session 中途被外部改掉;
114
+ // 使用者要中途換語言請用 langToggleKey,不是去改 settings.json 期待熱重載)。
115
+ const configuredLang = detectConfiguredLanguage(claudeDir, hostCwd);
104
116
  function writeAiState() {
105
- try { fs.writeFileSync(aiStatePath(claudeDir, hostCwd), JSON.stringify({ enabled: aiEnabled, ts: Date.now(), key: toggleKey.label })); } catch {}
117
+ // lang 永遠寫「目前實際生效」的值(覆蓋優先,沒覆蓋就用自動偵測),不是 langOverride 本身
118
+ // (那個未按過熱鍵時是 null)——statusline 才不用自己重算一次。務必用 effectiveIsChinese,
119
+ // 不能直接把 langOverride 丟給 isChineseLanguage(見該函式註解的事故紀錄)。
120
+ const lang = effectiveIsChinese(langOverride, configuredLang) ? 'zh' : 'en';
121
+ try {
122
+ fs.writeFileSync(aiStatePath(claudeDir, hostCwd), JSON.stringify({
123
+ enabled: aiEnabled, ts: Date.now(), key: toggleKey.label, lang, langKey: langToggleKey.label,
124
+ }));
125
+ } catch {}
106
126
  }
107
127
  writeAiState();
108
128
 
@@ -117,8 +137,8 @@ export function startHost(opts = {}) {
117
137
  let aiStep = 0;
118
138
  let lastHumanMs = 0;
119
139
  process.stdin.on('data', (d) => {
120
- const { toggled, rest } = extractAiToggle(d, toggleKey.code);
121
- if (toggled) {
140
+ const aiToggle = extractAiToggle(d, toggleKey.code);
141
+ if (aiToggle.toggled) {
122
142
  aiEnabled = !aiEnabled;
123
143
  writeAiState();
124
144
  consecInjects = 0;
@@ -126,6 +146,16 @@ export function startHost(opts = {}) {
126
146
  lastHumanMs = Date.now(); // 按熱鍵也是人為活動
127
147
  try { fs.appendFileSync(LOG, `${new Date().toISOString()} ai mode ${aiEnabled ? 'ON' : 'OFF'} (${toggleKey.label} toggle)\n`); } catch {}
128
148
  }
149
+ // 語言熱鍵吃掉 AI 開關熱鍵剩下的 rest——同一個 chunk 理論上只會含其中一個熱鍵,
150
+ // 兩個都檢查一遍即可,extractAiToggle 本來就是通用的單一 control code 偵測。
151
+ const langToggle = extractAiToggle(aiToggle.rest, langToggleKey.code);
152
+ 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 {}
157
+ }
158
+ const rest = langToggle.rest;
129
159
  if (rest.length === 0) return;
130
160
  if (looksLikeHumanInput(rest)) { consecInjects = 0; aiStep = 0; lastHumanMs = Date.now(); }
131
161
  ptyProc.write(rest);
@@ -150,6 +180,14 @@ export function startHost(opts = {}) {
150
180
  const msg = process.env.CWARM_MSG || opts.msg || 'hi';
151
181
  const quietMs = Number(process.env.CWARM_QUIET_MS) || 2500; // 畫面需靜止這麼久才注入
152
182
  const escDelayMs = Number(process.env.CWARM_ESC_DELAY_MS) || 250; // Esc 與訊息之間的間隔
183
+ // 2026-08-30 訊息本體與結尾 Enter 之間的間隔:AI 循環訊息(尤其附 briefing 的第一步,
184
+ // 常常 700+ 字元)一次 pty.write() 連內容帶 \r 整包送出時,Claude Code 的輸入框會把
185
+ // 這種遠超人類打字速度的長串位元組判成「貼上」,貼上內容裡夾帶的 \r 只變成貼上文字
186
+ // 的一部分、不觸發送出——訊息停在輸入框沒被送出,跟真人打完字另外按一下 Enter 不同。
187
+ // 短訊息("hi"、"go on")因為位元組數少、大多數判斷式不會誤觸,才會正常送出。
188
+ // 修法:仿照使用者手感——先寫內容,隔一小段再單獨寫 \r,讓終端把 Enter 當成獨立按鍵
189
+ // 事件而不是貼上內容的尾巴。
190
+ const enterDelayMs = Number(process.env.CWARM_ENTER_DELAY_MS) || 80;
153
191
  const overrides = {};
154
192
  const thr = process.env.CWARM_THRESHOLD_S ?? opts.thresholdS;
155
193
  const ttlO = process.env.CWARM_TTL_S ?? opts.ttlS;
@@ -159,22 +197,22 @@ export function startHost(opts = {}) {
159
197
  // reset 後的回溫訊息:"go on"(續跑任務)也算 AI 自主行為,未開 AI 模式時只補一般訊息回溫。
160
198
  // 做成函式:Ctrl+] 隨時切換 aiEnabled,用時才取值。
161
199
  const resumeMsg = () => process.env.CWARM_RESUME_MSG || (aiEnabled ? 'go on' : msg);
162
- // 無人值守 AI 模式:連兩發 "hi" 都沒人碰鍵盤後,第三發起改敲 12 階段循環工作流,
200
+ // 無人值守 AI 模式:連兩發 "hi" 都沒人碰鍵盤後,第三發起改敲 18 階段循環工作流,
163
201
  // 讓閒置視窗產生實際價值。設計原則:每步「安全、有界、可驗證」——檢視類在前、
164
202
  // 執行類帶安全閥(不可部署、不可破壞性操作、不可展開大型新工作,對齊全域 SOP)、
165
203
  // 末段自我迭代與收尾報告,使用者回來直接驗收。CWARM_AI_MSG 可覆蓋成固定一句
166
204
  //(設成 "hi" 即等於關掉本模式)。1h 檔位一步一小時,一輪約半天。
167
- // 指令用英文(注入訊息本來就是 hi / go on 一路英文,且較省 token);回覆語言由使用者的
168
- // CLAUDE.md 控制,不受注入語言影響。18 階段中文對照:
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 階段中文對照:
169
210
  // 檢視段:review → 批判 review → TODO/FIXME 掃描 → 保守建議
170
211
  // 執行段:依建議執行 → 測試覆蓋 → mutation check(測試有效性)→ 錯誤處理稽核 →
171
212
  // 資安自查 → 相依套件體檢(只報告不升級)→ 效能低垂果實 → 小步重構 → 跨平台審視
172
213
  // 收斂段:文件同步 → README 快速上手驗證 → 對照方案探索(防路徑依賴)→
173
214
  // 經驗蒸餾(寫進 memory/CLAUDE.md,複利最高)→ 收尾報告+決策佇列(睡醒五分鐘拍板解鎖下一天)
174
- // 指令來源優先序:CWARM_AI_MSG(固定一句)> CWARM_AI_MSG_FILE(自訂循環,一行一條)> 內建循環。
175
- const aiMsg = process.env.CWARM_AI_MSG
176
- || readAiMsgFile(process.env.CWARM_AI_MSG_FILE)
177
- || [
215
+ const AI_MSG_EN = [
178
216
  'Unattended: review the work done in this session; briefly list problems and possible improvements.',
179
217
  'Unattended: critical review — challenge earlier assumptions and approaches; point out risks, blind spots, and missing test scenarios.',
180
218
  'Unattended: sweep the code for TODO / FIXME / HACK markers; triage into quick-fix / should-fix / ignore, and fix the quick ones.',
@@ -194,18 +232,58 @@ export function startHost(opts = {}) {
194
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.',
195
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.',
196
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);
197
261
  // 無人值守循環第一步附帶一次性簡報:被驅動的 Claude 本身看不到 cwarm 的原始碼與這段機制,
198
262
  // 光靠 "Unattended: review …" 猜不出「為什麼會收到這句」「還會不會繼續來」「額度用完會
199
263
  // 怎樣」「使用者真的回來打字算不算數」。實際判斷(只在陣列循環剛回到第 0 步時附加一次,
200
264
  // 不是每步都附)交給 pickInjectMsg(keepalive.mjs)——那裡才有 aiStep/aiMsg 的完整脈絡,
201
- // 這裡只需把說明文字傳進去;CWARM_AI_MSG(固定一句)情境自動不適用(非陣列)。
202
- const AI_BRIEFING = "Unattended: heads up — cwarm's AI mode is now driving this session on its own. "
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. "
203
268
  + 'Whenever this terminal sits idle past a threshold with no human keystrokes, it injects one instruction '
204
269
  + "like this automatically, cycling through a fixed checklist (review, tests, docs, refactors, …); it's not "
205
270
  + "you deciding to keep going, it's the tool. Pace is quota-aware — faster when there's headroom, paused near "
206
271
  + "your usage limit and resumed with a plain 'go on' once it resets. If real human input ever shows up in this "
207
272
  + 'session, that always takes priority over anything below. Stay conservative: no deploys, no destructive '
208
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
+ }
209
287
  // 使用者敲鍵後需靜默這麼久才可注入(預設 5 分鐘)。CWARM_HUMAN_QUIET_S 可調;
210
288
  // 打錯成非數字(如 "5m")會記警告並退回預設,不會悄悄關掉這個安全機制。
211
289
  const humanQuietMs = envNumber('CWARM_HUMAN_QUIET_S', 300, LOG) * 1000;
@@ -331,16 +409,20 @@ export function startHost(opts = {}) {
331
409
  // 先送 Esc:把任何「必答」modal(權限/選單/計畫批准)收掉、退回輸入框,後面那個 Enter 才不會誤選預設項;
332
410
  // 空輸入框時 Esc 等同 no-op。隔一小段再送訊息——讓 claude 先把 modal 收乾淨,也避免 ESC 與字元被併成 Meta 鍵。
333
411
  const aiAllowed = aiEnabled && wgate !== 'off';
334
- const injectMsg = pickInjectMsg({ pendingResume, consecInjects, aiStep, resumeMsg: resumeMsg(), aiMsg, msg, aiAllowed, briefing: AI_BRIEFING });
412
+ const { aiMsg, briefing } = currentAiMsgAndBriefing();
413
+ const injectMsg = pickInjectMsg({ pendingResume, consecInjects, aiStep, resumeMsg: resumeMsg(), aiMsg, msg, aiAllowed, briefing });
335
414
  // 只有真的敲出 AI 循環裡的一步才推進 aiStep;被 wgate='off' 等閘門擋下、退回一般
336
415
  // "hi" 或 resume 的那些發送不算,避免恢復後循環整段跳號(見上方 aiStep 宣告的說明)。
337
416
  const firedAiStep = !pendingResume && consecInjects >= 2 && aiAllowed && Array.isArray(aiMsg);
338
417
  pendingResume = false;
339
418
  consecInjects++;
340
419
  if (firedAiStep) aiStep++;
341
- ptyProc.write('\x1b');
342
420
  lastFire = now;
343
- setTimeout(() => { if (!exiting) { try { ptyProc.write(injectMsg + '\r'); } catch {} } }, escDelayMs);
421
+ // 三筆寫入的時序(哪筆隔多久送)交給 injectWriteSequence(keepalive.mjs,純函式、有測試)
422
+ // 決定,這裡只負責照表排 setTimeout——實際 bug 曾經就出在這排程被兩次 write 合併成一次。
423
+ for (const { delayMs, data } of injectWriteSequence(injectMsg, escDelayMs, enterDelayMs)) {
424
+ setTimeout(() => { if (!exiting) { try { ptyProc.write(data); } catch {} } }, delayMs);
425
+ }
344
426
  const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
345
427
  const msgLabel = injectMsg.length > 24 ? `${injectMsg.slice(0, 24)}…` : injectMsg;
346
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 {}
package/src/keepalive.mjs CHANGED
@@ -331,19 +331,20 @@ export function weeklyPaceInfo({ usedPct, resetsAt, nowSec } = {}) {
331
331
  const elapsed = Math.min(Math.max(nowSec - (resetsAt - WEEK), 0), WEEK);
332
332
  const expectedPct = (elapsed / WEEK) * 100;
333
333
  const excess = usedPct - expectedPct;
334
- let ball = null;
334
+ let ball;
335
335
  if (excess >= 12) ball = '\u{1F534}'; // 🔴
336
336
  else if (excess >= 9) ball = '\u{1FA77}'; // 🩷
337
337
  else if (excess >= 6) ball = '\u{1F7E0}'; // 🟠
338
338
  else if (excess >= 3) ball = '\u{1F7E1}'; // 🟡
339
- else if (excess < 2) ball = '\u{1F7E2}'; // 🟢
340
- if (!ball) return null;
339
+ else ball = '\u{1F7E2}'; // 🟢——3% 以下一律綠球,不留空窗
340
+ // 舊版在 2~3% 之間讓 ball 留白後直接 return null,整段週用量會從 statusline 消失,
341
+ // 不只是球不見;空窗改成綠球,週段永遠有東西可顯示。
341
342
  const daysRemaining = Math.max(WEEK - elapsed, 0) / 86400;
342
343
  const remainingPct = Math.max(100 - usedPct, 0);
343
344
  const avgPerDayRemaining = daysRemaining > 0 ? remainingPct / daysRemaining : 0;
344
345
  const remainingSec = Math.max(WEEK - elapsed, 0);
345
346
  const rate = 100 / WEEK; // %/秒,進度線自然爬升速度
346
- const recoverySec = excess > 2 ? Math.min((excess - 2) / rate, remainingSec) : 0;
347
+ const recoverySec = excess >= 3 ? Math.min((excess - 3) / rate, remainingSec) : 0; // 休息到回綠球(<3%)所需
347
348
  return { ball, excess, daysRemaining, avgPerDayRemaining, recoverySec };
348
349
  }
349
350
 
@@ -444,6 +445,20 @@ export function pickInjectMsg({ pendingResume, consecInjects, aiStep = 0, resume
444
445
  return msg;
445
446
  }
446
447
 
448
+ // host.mjs 實際送進 pty 的三筆寫入,抽成純函式(無 timer、無 pty)才測得到——2026-08-30
449
+ // 修 bug 前這裡是 write(content + '\r') 一次送出,長訊息(AI briefing 可到 ~700 字元)在
450
+ // Claude Code 的輸入框裡被當成貼上,夾在同一包的 '\r' 只變成貼上內容的一部分、沒有真的
451
+ // 送出,卻沒有任何測試會發現——因為寫死在 host.mjs 裡、要真的起一個 pty 才碰得到這段路徑。
452
+ // 回傳 [{delayMs 相對於觸發時刻, data}],呼叫端(host.mjs)自己排 setTimeout;純函式好斷言:
453
+ // 內容那筆不可以含 '\r'(一旦含了,等於又把兩者黏回同一包,bug 就會回來),Enter 必須獨立一筆。
454
+ export function injectWriteSequence(injectMsg, escDelayMs, enterDelayMs) {
455
+ return [
456
+ { delayMs: 0, data: '\x1b' },
457
+ { delayMs: escDelayMs, data: injectMsg },
458
+ { delayMs: escDelayMs + enterDelayMs, data: '\r' },
459
+ ];
460
+ }
461
+
447
462
  // 無人值守 AI 模式的節奏:保溫節奏(一個 TTL 一發)是為省注入次數設計的,但 AI 模式
448
463
  // 的目標是推進工作——十分鐘做完一步不該空等五十分鐘。故額度充裕時改用快節奏
449
464
  //(paceS,預設 5 分鐘:transcript 靜止 + 冷卻都縮短到 paceS),下列任一條件則退回
@@ -483,7 +498,14 @@ export function readAiState(claudeDir, cwd, { maxAgeMs = 60_000, now = Date.now(
483
498
  const o = readJsonSafe(aiStatePath(claudeDir, cwd));
484
499
  if (!o || typeof o !== 'object' || o.ts == null) return null;
485
500
  if (now - o.ts > maxAgeMs) return null;
486
- return { enabled: !!o.enabled, key: typeof o.key === 'string' ? o.key : null };
501
+ return {
502
+ enabled: !!o.enabled,
503
+ key: typeof o.key === 'string' ? o.key : null,
504
+ // 2026-08-30:內建循環目前生效的語言('zh'/'en',不管是自動偵測還是熱鍵覆蓋出來的結果)
505
+ // 與該熱鍵標籤——statusline 的 aiSegment 拿這個顯示,不然使用者按了 Ctrl+L 完全看不到有沒有生效。
506
+ lang: o.lang === 'zh' || o.lang === 'en' ? o.lang : null,
507
+ langKey: typeof o.langKey === 'string' ? o.langKey : null,
508
+ };
487
509
  }
488
510
 
489
511
  // 讀自訂無人值守指令檔(CWARM_AI_MSG_FILE):一行一條指令、# 開頭與空行忽略。
@@ -499,6 +521,57 @@ export function readAiMsgFile(p) {
499
521
  return lines.length ? lines : null;
500
522
  }
501
523
 
524
+ // ---- 內建循環的語言選擇 ----(2026-08-30,使用者要求「內建循環能不能自動跟 /config 語言走」)
525
+ // 內建循環預設英文(省 token),但使用者已經在 Claude Code 的 language 設定裡選了中文時,
526
+ // 沒理由還要手動去帶 CWARM_AI_MSG_FILE 才看得到中文——這裡讀同一份設定自動跟著切換。
527
+ // 只判斷「是不是中文」,不分繁簡(沒有簡體翻譯,一律給繁體版);不是中文一律回退英文原版。
528
+ const CHINESE_LANG_RE = /chinese|中文|zh[-_]?(tw|hant|cn|hans)/i;
529
+ export function isChineseLanguage(lang) {
530
+ return typeof lang === 'string' && CHINESE_LANG_RE.test(lang);
531
+ }
532
+
533
+ // 純函式版本:從一串「可能是 settings.json 內容」的物件裡,依序找第一個有設過非空
534
+ // language 欄位的。呼叫端自己決定要傳哪些來源、什麼順序(見 detectConfiguredLanguage)。
535
+ export function configuredLanguageFromSources(sources) {
536
+ for (const o of sources) {
537
+ if (o && typeof o.language === 'string' && o.language.trim()) return o.language;
538
+ }
539
+ return null;
540
+ }
541
+
542
+ // 讀 Claude Code 的 language 設定,比照它自己「local 覆蓋 project 覆蓋 user」的疊加順序:
543
+ // cwd 的 .claude/settings.local.json → .claude/settings.json → claudeDir 的使用者全域
544
+ // settings.json。任一層讀不到/不是合法 JSON 都當沒設定,不拋錯(保守:找不到就用預設英文)。
545
+ export function detectConfiguredLanguage(claudeDir, cwd) {
546
+ return configuredLanguageFromSources([
547
+ readJsonSafe(path.join(cwd, '.claude', 'settings.local.json')),
548
+ readJsonSafe(path.join(cwd, '.claude', 'settings.json')),
549
+ readJsonSafe(path.join(claudeDir, 'settings.json')),
550
+ ]);
551
+ }
552
+
553
+ // 「目前實際生效的語言是不是中文」:override('zh'/'en',語言熱鍵按過後的內部代碼)用**直接
554
+ // 比對**;沒 override(null)才拿 autoDetectedLang(/config 的原始字串,如「繁體中文」/
555
+ // "zh-TW")丟給 isChineseLanguage 判斷。這兩種輸入絕對不能混用同一個判斷式——事故紀錄
556
+ // (2026-08-30):host.mjs 一度把 override 直接塞進 isChineseLanguage(),但那支函式是為
557
+ // /config 的自然語言字串設計的正規表達式比對,根本認不出 'zh'/'en' 這種內部短碼(bare
558
+ // "zh"/"en" 兩個都不匹配 CHINESE_LANG_RE),於是按過熱鍵一次之後不管切成 'zh' 還是 'en'
559
+ // 都被判成「不是中文」,往後注入永遠固定英文、切不回中文——症狀正是「能切到 EN,切不回中」。
560
+ // 呼叫端(toggleBuiltinLang、host.mjs 的 writeAiState/currentAiMsgAndBriefing)都必須
561
+ // 統一走這支,不要各自兜判斷式。
562
+ export function effectiveIsChinese(override, autoDetectedLang) {
563
+ return override ? override === 'zh' : isChineseLanguage(autoDetectedLang);
564
+ }
565
+
566
+ // 語言熱鍵(2026-08-30,使用者要求「能不能自己切換」):在「目前實際生效的語言」跟另一種
567
+ // 之間切換一次。回傳值永遠是明確的 'zh'/'en'——按過一次熱鍵後就固定住,不再跟著 /config
568
+ // 變動,除非再按一次切回去。呼叫端(host.mjs)拿這個值決定要用 AI_MSG_ZH 還是 AI_MSG_EN。
569
+ // 只在 session 內生效,不持久化——重開 cwarm 又回到 /config 自動偵測(比照 AI 開關的
570
+ // 持久化模式做起來不難,但這是次要的順手功能,先簡單版,有需要再仿 aiStatePath 加)。
571
+ export function toggleBuiltinLang(currentOverride, autoDetectedLang) {
572
+ return effectiveIsChinese(currentOverride, autoDetectedLang) ? 'en' : 'zh';
573
+ }
574
+
502
575
  // 純決策:現在該不該注入 keepalive?idleMs = 距上次訊息多久(由 transcriptIdleMs 算)。
503
576
  // screenIdleMs = 距 claude 最近一次「畫面輸出」多久;quietMs = 需靜止多久才放行。
504
577
  // 為什麼要這個畫面靜默門檻:transcript 在「等你回答必答提示(權限/選單/計畫批准)」與
@@ -55,13 +55,14 @@ function payloadCwd(payload) {
55
55
  // 5h — 用量 ≥95% 且離 reset 還超過 1 小時 → 建議使用者暫時中斷;不滿 1 小時就不提醒,
56
56
  // 尾巴額度照常燒到撞牆。
57
57
  // 週 — weeklyPaceInfo 依超前「按時間比例均攤」進度線幾個百分點顯示球號(<2🟢/3~5🟡/
58
- // 6~8🟠/9~11🩷/≥12🔴,2~3% 留白),並附上剩餘天數的日均可用量。
58
+ // 6~8🟠/9~11🩷/≥12🔴),並附上剩餘天數的日均可用量。
59
59
  function usageBridgeAndWarn(payload, cwd) {
60
60
  const fh = payload?.rate_limits?.five_hour;
61
- if (!fh || typeof fh !== 'object') return '';
62
- const usedPct = fh.used_percentage ?? null;
63
- const resetsAt = fh.resets_at ?? null;
64
61
  const sd = payload?.rate_limits?.seven_day;
62
+ // 5h 缺席不能連週段一起吞掉——兩個視窗各自獨立,缺哪個就只少哪個。
63
+ if ((!fh || typeof fh !== 'object') && (!sd || typeof sd !== 'object')) return { warn: '', weekly: '' };
64
+ const usedPct = fh?.used_percentage ?? null;
65
+ const resetsAt = fh?.resets_at ?? null;
65
66
  if (cwd) {
66
67
  try {
67
68
  fs.writeFileSync(usageBridgePath(claudeDir, cwd), JSON.stringify({
@@ -73,10 +74,10 @@ function usageBridgeAndWarn(payload, cwd) {
73
74
  } catch { /* 落地失敗不影響顯示 */ }
74
75
  }
75
76
  const nowSec = Date.now() / 1000;
76
- const parts = [];
77
+ let warn = '';
77
78
  if (usageState({ usedPct, resetsAt, nowSec }) === 'warn') {
78
79
  const hLeft = Math.round((resetsAt - nowSec) / 3600);
79
- parts.push(`⚠️ ${Math.round(usedPct)}%、離reset還${hLeft}h,建議暫停`); // ⚠️ NN%、離reset還Nh,建議暫停
80
+ warn = `⚠️ ${Math.round(usedPct)}%、離reset還${hLeft}h,建議暫停`; // ⚠️ NN%、離reset還Nh,建議暫停
80
81
  }
81
82
  const wp = weeklyPaceInfo({ usedPct: sd?.used_percentage ?? null, resetsAt: sd?.resets_at ?? null, nowSec });
82
83
  if (wp) {
@@ -88,9 +89,9 @@ function usageBridgeAndWarn(payload, cwd) {
88
89
  const h = wp.recoverySec / 3600;
89
90
  text += h >= 24 ? `,休息${(h / 24).toFixed(1)}d回綠` : `,休息${h.toFixed(1)}h回綠`;
90
91
  }
91
- parts.push(text);
92
+ return { warn, weekly: text };
92
93
  }
93
- return parts.join(' ');
94
+ return { warn, weekly: '' };
94
95
  }
95
96
 
96
97
  // AI 模式開關段:顯示無人值守 AI 模式 on/off 與切換熱鍵。狀態檔以 cwd 編碼命名
@@ -101,7 +102,11 @@ function aiSegment(cwd) {
101
102
  const st = readAiState(claudeDir, cwd);
102
103
  if (!st) return '';
103
104
  const key = st.key || 'Ctrl+\\';
104
- return `\u{1F916}AI ${st.enabled ? 'on' : 'off'} (${key})`; // 🤖
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;
105
110
  }
106
111
 
107
112
  // 跑使用者原本存起來的 statusLine 指令(餵同一份 stdin),回傳其輸出;沒有就 null。
@@ -138,13 +143,12 @@ if (process.env.CWARM_HOST_ID && payload?.transcript_path) {
138
143
 
139
144
  const cwd = payloadCwd(payload);
140
145
  const seg = cacheSegment(payload);
141
- const warn = usageBridgeAndWarn(payload, cwd);
146
+ const { warn, weekly } = usageBridgeAndWarn(payload, cwd);
142
147
  let base = runOriginal(raw);
143
148
  if (base == null) base = minimalBase(payload);
144
- // 順序:帳號段 │ base 第一行 │ cache 倒數 │ 🤖AI 開關 │ ⚠️額度提醒;base 其餘行原樣跟在後面。
145
- // 原 statusline(如 claude-hud)常是多行輸出,所有 cwarm 段一律掛在「第一行」頭尾,
149
+ // 第一行順序:帳號段 │ base 第一行 │ cache 倒數 │ 🤖AI 開關 │ ⚠️額度提醒;base 其餘行原樣跟著。
150
+ // 原 statusline(如 claude-hud)常是多行輸出,cwarm 段一律掛在「第一行」頭尾,
146
151
  // 不能傻傻接在字串末端(那會落到最後一行尾)。
147
152
  const lines = base.split('\n');
148
- const first = [accountSegment(), lines[0], seg, aiSegment(cwd), warn].filter(Boolean).join(' │ ');
149
- const out = [first, ...lines.slice(1)].join('\n');
150
- process.stdout.write(out + '\n');
153
+ lines[0] = [accountSegment(), lines[0], seg, aiSegment(cwd), warn, weekly].filter(Boolean).join(' │ ');
154
+ process.stdout.write(lines.join('\n') + '\n');