claude-cache-keepalive 0.1.14 → 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 +34 -6
- package/package.json +2 -2
- package/src/cli.mjs +11 -3
- package/src/host.mjs +119 -67
- package/src/keepalive.mjs +222 -10
- package/src/statusline/segment.mjs +25 -23
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
|
|
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,18 @@ 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 — 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.
|
|
121
|
+
|
|
116
122
|
Replace it entirely with your own, or tune the pacing:
|
|
117
123
|
|
|
118
124
|
| Var | Meaning |
|
|
@@ -120,7 +126,8 @@ Replace it entirely with your own, or tune the pacing:
|
|
|
120
126
|
| `CWARM_AI` | `1`/`on`/`true`/`yes` to force on, `0`/`off` to force off (overrides the persisted toggle) |
|
|
121
127
|
| `CWARM_AI_MSG` | send this single fixed message instead of the cycle |
|
|
122
128
|
| `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>` |
|
|
129
|
+
| `CWARM_TOGGLE_KEY` | rebind the AI on/off hotkey from `Ctrl+\` to `Ctrl+<char>` |
|
|
130
|
+
| `CWARM_LANG_TOGGLE_KEY` | rebind the EN/ZH cycle-language hotkey from `Ctrl+L` to `Ctrl+<char>` |
|
|
124
131
|
| `CWARM_HUMAN_QUIET_S` | seconds of silence required after a keystroke before injecting again (default `300`) |
|
|
125
132
|
| `CWARM_AI_PACE_S` | fast-pace interval in seconds when quota allows (default `300`) |
|
|
126
133
|
| `CWARM_AI_FAST_PCT` | 5h-usage ceiling below which fast pace applies (default `70`) |
|
|
@@ -138,6 +145,7 @@ Environment variables (mostly for testing / advanced use):
|
|
|
138
145
|
| `CWARM_TICK_MS` | check interval (default `20000`) |
|
|
139
146
|
| `CWARM_QUIET_MS` | screen must be silent this long before injecting (default `2500`) |
|
|
140
147
|
| `CWARM_ESC_DELAY_MS` | gap between the `Esc` and the keepalive message (default `250`) |
|
|
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 |
|
|
141
149
|
| `CWARM_THRESHOLD_S` | override idle threshold (seconds) |
|
|
142
150
|
| `CWARM_TTL_S` | override cooldown (seconds) |
|
|
143
151
|
| `CWARM_BILLING` | force billing mode: `subscription` (keep warming) or `credits` (suspend); otherwise auto‑detected |
|
|
@@ -180,7 +188,7 @@ Environment variables (mostly for testing / advanced use):
|
|
|
180
188
|
```
|
|
181
189
|
|
|
182
190
|
- **5 小時視窗**:用量超過 95% 且離 reset 還超過 1 小時,才會出現 `⚠️ NN%、離reset還Nh,建議暫停`;不到 95%、或已經進入最後一小時(尾巴額度反正燒到撞牆),都不顯示。
|
|
183
|
-
- **7 天視窗**:用一顆彩色球表示「超前按時間比例均攤的日均進度線(100%/7 ≈ 14.3%/天)多少個百分點」:不到
|
|
191
|
+
- **7 天視窗**:用一顆彩色球表示「超前按時間比例均攤的日均進度線(100%/7 ≈ 14.3%/天)多少個百分點」:不到 3 個百分點(含落後)是 🟢,3~5 是 🟡,6~8 是 🟠,9~11 是 🩷(Unicode 沒有粉紅圓形,借粉紅愛心最接近),12 以上是 🔴。球旁邊接著顯示視窗還剩幾天、照這剩餘天數均攤接下來每天還能燒多少 %,以及——只要超前 3 個百分點以上——要「休息」(不再新增用量)多久,進度線才會爬上來讓球回綠。5 小時那組數字缺席時,這段也照樣會顯示。
|
|
184
192
|
|
|
185
193
|
這兩段都只是顯示,不會擋掉保溫注入;`--ai` 模式才是拿同一組數字真的去踩剎車(見下方)。
|
|
186
194
|
|
|
@@ -222,12 +230,18 @@ cwarm --ai # 這次啟動就開
|
|
|
222
230
|
CWARM_AI=1 cwarm # 效果相同,走環境變數
|
|
223
231
|
```
|
|
224
232
|
|
|
225
|
-
或執行中隨時按 `Ctrl+\` **即時切換**(可換鍵,見下方 `CWARM_TOGGLE_KEY`;IME 搶走預設鍵時很有用)。切換狀態**依專案持久化**、重啟沿用——`--ai` / `CWARM_AI=0` 會覆蓋下次啟動時記住的狀態。目前狀態顯示在 statusline:`🤖AI on (Ctrl+\)
|
|
233
|
+
或執行中隨時按 `Ctrl+\` **即時切換**(可換鍵,見下方 `CWARM_TOGGLE_KEY`;IME 搶走預設鍵時很有用)。切換狀態**依專案持久化**、重啟沿用——`--ai` / `CWARM_AI=0` 會覆蓋下次啟動時記住的狀態。目前狀態顯示在 statusline:`🤖AI on (Ctrl+\) 中(Ctrl+L)`——後面那個「中」/「EN」+熱鍵,就是內建循環目前的語言(見下方),按 `Ctrl+L` 才有地方能確認真的切到了。
|
|
226
234
|
|
|
227
235
|
**內建循環**
|
|
228
236
|
|
|
229
237
|
18 步循環:review → 批判 review → TODO/FIXME 掃描 → 提出改進清單 → 執行最安全的項目 → 測試覆蓋 → mutation check(測試有效性)→ 錯誤處理稽核 → 輕量資安自查 → 相依套件體檢(只報告不升級)→ 效能低垂果實 → 小步重構 → 跨平台審視 → 文件同步 → README 快速上手驗證 → 對照方案探索 → 心得蒸餾進 project memory → 收尾報告+決策佇列。每一步都刻意設計成安全、有界、可驗證——不部署、不做破壞性操作、不展開大型新工作。
|
|
230
238
|
|
|
239
|
+
循環預設英文(每次注入省 token),但當 Claude Code 自己的 `language` 設定(`/config` 寫進 `settings.json` 的那個欄位)是中文語系時(`繁體中文`、`Chinese`、`zh-TW`、`zh-Hant`… 不分大小寫;簡體變體也一律給繁體版,沒有另外的簡體翻譯),會自動切成手工翻譯的繁體中文版。這只改變**注入的觸發文字**本身——Claude 實際動手做事、回覆用什麼語言,本來就由你的 `CLAUDE.md` 控制、不受注入語言影響,所以這只是看起來順眼,不影響行為。也不打算為每一種可能的 `language` 值都做泛化支援——其他語言請用下面的 `CWARM_AI_MSG_FILE`:那才是通用解法,想要哪種語言、哪個領域的循環都自己寫一份。
|
|
240
|
+
|
|
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)`),不會一直宣傳一顆按了沒用的鍵。遇到就把其中一顆換掉。
|
|
244
|
+
|
|
231
245
|
想整套換掉、或調節奏,可用:
|
|
232
246
|
|
|
233
247
|
| 變數 | 意義 |
|
|
@@ -235,7 +249,8 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
|
|
|
235
249
|
| `CWARM_AI` | `1`/`on`/`true`/`yes` 強制開、`0`/`off` 強制關(覆蓋持久化狀態) |
|
|
236
250
|
| `CWARM_AI_MSG` | 改成固定敲這一句,取代整套循環 |
|
|
237
251
|
| `CWARM_AI_MSG_FILE` | 自訂指令檔路徑,一行一條(`#` 開頭為註解)——整套換掉,例如換成寫作/研究/翻譯而非軟體工程 |
|
|
238
|
-
| `CWARM_TOGGLE_KEY` |
|
|
252
|
+
| `CWARM_TOGGLE_KEY` | 把 AI 開關熱鍵從 `Ctrl+\` 換成 `Ctrl+<字元>` |
|
|
253
|
+
| `CWARM_LANG_TOGGLE_KEY` | 把 EN/ZH 循環語言熱鍵從 `Ctrl+L` 換成 `Ctrl+<字元>` |
|
|
239
254
|
| `CWARM_HUMAN_QUIET_S` | 敲鍵後需靜默幾秒才可再注入(預設 `300`) |
|
|
240
255
|
| `CWARM_AI_PACE_S` | 額度充裕時的快節奏間隔秒數(預設 `300`) |
|
|
241
256
|
| `CWARM_AI_FAST_PCT` | 5h 用量低於此值才套用快節奏(預設 `70`) |
|
|
@@ -253,6 +268,7 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
|
|
|
253
268
|
| `CWARM_TICK_MS` | 檢查間隔(預設 `20000`) |
|
|
254
269
|
| `CWARM_QUIET_MS` | 畫面需靜止多久才注入(預設 `2500`) |
|
|
255
270
|
| `CWARM_ESC_DELAY_MS` | `Esc` 與訊息之間的間隔(預設 `250`) |
|
|
271
|
+
| `CWARM_ENTER_DELAY_MS` | 注入內容與送出用的 `Enter` 之間的間隔(預設 `80`)——內容和 `Enter` 分成兩次 `pty.write()` 寫入,避免 AI 模式的長訊息因為一次寫入太快被判成「貼上」,導致夾在同一包裡的 `Enter` 只變成貼上內容的一部分而沒有真正送出 |
|
|
256
272
|
| `CWARM_THRESHOLD_S` | 覆寫閒置門檻(秒) |
|
|
257
273
|
| `CWARM_TTL_S` | 覆寫冷卻(秒) |
|
|
258
274
|
| `CWARM_BILLING` | 強制指定計費模式:`subscription`(照常保溫)或 `credits`(暫停);否則自動偵測 |
|
|
@@ -267,6 +283,18 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
|
|
|
267
283
|
|
|
268
284
|
## Changelog
|
|
269
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
|
+
|
|
290
|
+
### 0.1.15
|
|
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.
|
|
292
|
+
- **修正:** 週配速球(0.1.14)原本可能整段消失,不只是變安靜。兩個各自獨立的原因:(1) statusline 只要 5 小時視窗的用量資料一時缺席,就會把**整段**額度區塊(不只週段)一起吞掉;現在兩個時間窗各自獨立讀取,缺一個只影響它自己那段。(2) `weeklyPaceInfo()` 在超前配速 2~3 個百分點時把球留白、然後直接回傳空值,整段消失而不是顯示綠球;現在 3% 以下一律顯示綠球,「休息多久回綠」的估算也同步對齊這條 3% 門檻,綠球不會被叫去休息。
|
|
293
|
+
- **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.
|
|
294
|
+
- **修正:** 無人值守模式的長訊息(附 AI briefing 常見 700+ 字元)可能整句卡在輸入框,實際上沒有送出去。內容跟結尾的 Enter 過去是當同一包位元組寫進終端——遠超人類打字速度——Claude Code 的輸入框可能把這整包判成「貼上」,夾在裡面的 Enter 就只變成貼上內容的一部分,沒有觸發送出。短訊息(`hi`)位元組數少,大多不會誤觸,才不容易被發現。現在內容跟 Enter 分兩次寫入,中間隔 `CWARM_ENTER_DELAY_MS`(預設 80ms)。
|
|
295
|
+
- **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.
|
|
296
|
+
- **功能:** 內建的 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`。
|
|
297
|
+
|
|
270
298
|
### 0.1.14
|
|
271
299
|
- **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
300
|
- **功能:** 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.
|
|
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
|
-
|
|
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
|
|
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, 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, 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
|
-
|
|
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。
|
|
@@ -101,8 +112,48 @@ export function startHost(opts = {}) {
|
|
|
101
112
|
persisted: readAiState(claudeDir, hostCwd, { maxAgeMs: Infinity }),
|
|
102
113
|
});
|
|
103
114
|
const toggleKey = toggleKeySpec(process.env.CWARM_TOGGLE_KEY);
|
|
115
|
+
// 語言熱鍵:預設 Ctrl+L('l' → 0x0C)——跟 AI 開關熱鍵一樣是 toggleKeySpec 算出來的單一
|
|
116
|
+
// control code,只是預設字元不同,才不會撞在一起。CWARM_LANG_TOGGLE_KEY 可換單一字元。
|
|
117
|
+
const langToggleKey = toggleKeySpec(process.env.CWARM_LANG_TOGGLE_KEY || 'l');
|
|
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`);
|
|
126
|
+
}
|
|
127
|
+
// 目前生效的內建循環語言覆蓋:null=沒按過熱鍵,跟著 /config 自動偵測;'zh'/'en'=按過熱鍵
|
|
128
|
+
// 手動固定住。只在這個 session 內生效,不持久化(見 keepalive.mjs 的 toggleBuiltinLang 註解)。
|
|
129
|
+
let langOverride = null;
|
|
130
|
+
// /config 的 language 設定,啟動時讀一次即可(跟 aiEnabled 一樣不會在 session 中途被外部改掉;
|
|
131
|
+
// 使用者要中途換語言請用 langToggleKey,不是去改 settings.json 期待熱重載)。
|
|
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);
|
|
104
139
|
function writeAiState() {
|
|
105
|
-
|
|
140
|
+
// lang 算法交給 resolvePersistedLang(keepalive.mjs,純函式、有測試)——自訂循環
|
|
141
|
+
//(CWARM_AI_MSG/CWARM_AI_MSG_FILE)語言未知時回傳 null,statusline 的 aiSegmentText
|
|
142
|
+
// 本來就會在 lang 是 falsy 時跳過整段,等於誠實承認「這裡不知道/不適用哪個語言」。
|
|
143
|
+
const lang = resolvePersistedLang(fixedAiMsg, langOverride, configuredLang);
|
|
144
|
+
try {
|
|
145
|
+
fs.writeFileSync(aiStatePath(claudeDir, hostCwd), JSON.stringify({
|
|
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,
|
|
155
|
+
}));
|
|
156
|
+
} catch {}
|
|
106
157
|
}
|
|
107
158
|
writeAiState();
|
|
108
159
|
|
|
@@ -117,15 +168,34 @@ export function startHost(opts = {}) {
|
|
|
117
168
|
let aiStep = 0;
|
|
118
169
|
let lastHumanMs = 0;
|
|
119
170
|
process.stdin.on('data', (d) => {
|
|
120
|
-
const
|
|
121
|
-
if (toggled) {
|
|
171
|
+
const aiToggle = extractAiToggle(d, toggleKey.code);
|
|
172
|
+
if (aiToggle.toggled) {
|
|
122
173
|
aiEnabled = !aiEnabled;
|
|
123
174
|
writeAiState();
|
|
124
175
|
consecInjects = 0;
|
|
125
176
|
aiStep = 0;
|
|
126
177
|
lastHumanMs = Date.now(); // 按熱鍵也是人為活動
|
|
127
|
-
|
|
178
|
+
logLine(`ai mode ${aiEnabled ? 'ON' : 'OFF'} (${toggleKey.label} toggle)`);
|
|
128
179
|
}
|
|
180
|
+
// 語言熱鍵吃掉 AI 開關熱鍵剩下的 rest——同一個 chunk 理論上只會含其中一個熱鍵,
|
|
181
|
+
// 兩個都檢查一遍即可,extractAiToggle 本來就是通用的單一 control code 偵測。
|
|
182
|
+
const langToggle = extractAiToggle(aiToggle.rest, langToggleKey.code);
|
|
183
|
+
if (langToggle.toggled) {
|
|
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
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const rest = langToggle.rest;
|
|
129
199
|
if (rest.length === 0) return;
|
|
130
200
|
if (looksLikeHumanInput(rest)) { consecInjects = 0; aiStep = 0; lastHumanMs = Date.now(); }
|
|
131
201
|
ptyProc.write(rest);
|
|
@@ -149,7 +219,19 @@ export function startHost(opts = {}) {
|
|
|
149
219
|
const tickMs = Number(process.env.CWARM_TICK_MS) || 20_000;
|
|
150
220
|
const msg = process.env.CWARM_MSG || opts.msg || 'hi';
|
|
151
221
|
const quietMs = Number(process.env.CWARM_QUIET_MS) || 2500; // 畫面需靜止這麼久才注入
|
|
152
|
-
|
|
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 與訊息之間的間隔
|
|
227
|
+
// 2026-08-30 訊息本體與結尾 Enter 之間的間隔:AI 循環訊息(尤其附 briefing 的第一步,
|
|
228
|
+
// 常常 700+ 字元)一次 pty.write() 連內容帶 \r 整包送出時,Claude Code 的輸入框會把
|
|
229
|
+
// 這種遠超人類打字速度的長串位元組判成「貼上」,貼上內容裡夾帶的 \r 只變成貼上文字
|
|
230
|
+
// 的一部分、不觸發送出——訊息停在輸入框沒被送出,跟真人打完字另外按一下 Enter 不同。
|
|
231
|
+
// 短訊息("hi"、"go on")因為位元組數少、大多數判斷式不會誤觸,才會正常送出。
|
|
232
|
+
// 修法:仿照使用者手感——先寫內容,隔一小段再單獨寫 \r,讓終端把 Enter 當成獨立按鍵
|
|
233
|
+
// 事件而不是貼上內容的尾巴。
|
|
234
|
+
const enterDelayMs = envNumber('CWARM_ENTER_DELAY_MS', 80, LOG); // 同上,0 是合法值不能被吃掉
|
|
153
235
|
const overrides = {};
|
|
154
236
|
const thr = process.env.CWARM_THRESHOLD_S ?? opts.thresholdS;
|
|
155
237
|
const ttlO = process.env.CWARM_TTL_S ?? opts.ttlS;
|
|
@@ -159,53 +241,19 @@ export function startHost(opts = {}) {
|
|
|
159
241
|
// reset 後的回溫訊息:"go on"(續跑任務)也算 AI 自主行為,未開 AI 模式時只補一般訊息回溫。
|
|
160
242
|
// 做成函式:Ctrl+] 隨時切換 aiEnabled,用時才取值。
|
|
161
243
|
const resumeMsg = () => process.env.CWARM_RESUME_MSG || (aiEnabled ? 'go on' : msg);
|
|
162
|
-
// 無人值守 AI 模式:連兩發 "hi" 都沒人碰鍵盤後,第三發起改敲
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
const aiMsg = process.env.CWARM_AI_MSG
|
|
176
|
-
|| readAiMsgFile(process.env.CWARM_AI_MSG_FILE)
|
|
177
|
-
|| [
|
|
178
|
-
'Unattended: review the work done in this session; briefly list problems and possible improvements.',
|
|
179
|
-
'Unattended: critical review — challenge earlier assumptions and approaches; point out risks, blind spots, and missing test scenarios.',
|
|
180
|
-
'Unattended: sweep the code for TODO / FIXME / HACK markers; triage into quick-fix / should-fix / ignore, and fix the quick ones.',
|
|
181
|
-
'Unattended: based on the findings above, propose a conservative, low-risk improvement list with priorities.',
|
|
182
|
-
'Unattended: execute the safest suggested items one by one, verifying each. No deploys, no destructive operations, no large new work.',
|
|
183
|
-
'Unattended: check test coverage — find uncovered branches and edge cases, add the needed tests, run the full suite.',
|
|
184
|
-
'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.',
|
|
185
|
-
'Unattended: audit error handling — walk the failure paths (bad input, missing files, timeouts, permissions); patch gaps minimally, with tests.',
|
|
186
|
-
'Unattended: light security self-check — leaked secrets in code/logs, injection risks, over-broad permissions; fix only clear issues.',
|
|
187
|
-
'Unattended: dependency health check — outdated or vulnerable dependencies (e.g. npm outdated / npm audit); report findings only, do not upgrade.',
|
|
188
|
-
'Unattended: performance pass — find low-hanging fruit (repeated IO, needless polling, cacheable recomputation); small certain wins only, no big refactors.',
|
|
189
|
-
'Unattended: small refactors — naming, duplication, readability; behavior must not change, tests guard every step.',
|
|
190
|
-
'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.',
|
|
191
|
-
'Unattended: sync docs — check README, comments and usage notes against reality; update stale parts, fill gaps.',
|
|
192
|
-
'Unattended: verify the README quickstart — follow the install/usage steps literally from scratch and note where documentation and reality diverge; fix the docs.',
|
|
193
|
-
"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.",
|
|
194
|
-
'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
|
-
'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
|
-
];
|
|
197
|
-
// 無人值守循環第一步附帶一次性簡報:被驅動的 Claude 本身看不到 cwarm 的原始碼與這段機制,
|
|
198
|
-
// 光靠 "Unattended: review …" 猜不出「為什麼會收到這句」「還會不會繼續來」「額度用完會
|
|
199
|
-
// 怎樣」「使用者真的回來打字算不算數」。實際判斷(只在陣列循環剛回到第 0 步時附加一次,
|
|
200
|
-
// 不是每步都附)交給 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. "
|
|
203
|
-
+ 'Whenever this terminal sits idle past a threshold with no human keystrokes, it injects one instruction '
|
|
204
|
-
+ "like this automatically, cycling through a fixed checklist (review, tests, docs, refactors, …); it's not "
|
|
205
|
-
+ "you deciding to keep going, it's the tool. Pace is quota-aware — faster when there's headroom, paused near "
|
|
206
|
-
+ "your usage limit and resumed with a plain 'go on' once it resets. If real human input ever shows up in this "
|
|
207
|
-
+ 'session, that always takes priority over anything below. Stay conservative: no deploys, no destructive '
|
|
208
|
-
+ 'operations, no large new scope — verify each step before moving to the next.';
|
|
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。
|
|
209
257
|
// 使用者敲鍵後需靜默這麼久才可注入(預設 5 分鐘)。CWARM_HUMAN_QUIET_S 可調;
|
|
210
258
|
// 打錯成非數字(如 "5m")會記警告並退回預設,不會悄悄關掉這個安全機制。
|
|
211
259
|
const humanQuietMs = envNumber('CWARM_HUMAN_QUIET_S', 300, LOG) * 1000;
|
|
@@ -232,20 +280,20 @@ export function startHost(opts = {}) {
|
|
|
232
280
|
if (billing === 'credits') {
|
|
233
281
|
if (!creditsGuardLogged) {
|
|
234
282
|
creditsGuardLogged = true;
|
|
235
|
-
|
|
283
|
+
logLine('skip: credits/API billing detected — keepalive suspended (CWARM_BILLING=subscription to override)');
|
|
236
284
|
}
|
|
237
285
|
return;
|
|
238
286
|
}
|
|
239
287
|
if (creditsGuardLogged) {
|
|
240
288
|
creditsGuardLogged = false;
|
|
241
|
-
|
|
289
|
+
logLine('resume: subscription billing detected — keepalive re-enabled');
|
|
242
290
|
}
|
|
243
291
|
// 信任對話框在畫面上時,這輪完全不動作(連 Esc 都不送)——那是使用者本人該回答的框,被保溫
|
|
244
292
|
// Esc 掉會留下 hasTrustDialogAccepted:false,害該資料夾 settings.local.json 權限失效、之後不再跳框。
|
|
245
293
|
if (looksLikeTrustPrompt(screenBuf)) {
|
|
246
294
|
if (!trustGuardLogged) {
|
|
247
295
|
trustGuardLogged = true;
|
|
248
|
-
|
|
296
|
+
logLine('skip: trust dialog on screen — left for the user to answer');
|
|
249
297
|
}
|
|
250
298
|
return;
|
|
251
299
|
}
|
|
@@ -257,7 +305,7 @@ export function startHost(opts = {}) {
|
|
|
257
305
|
//(cwarm setup)或 payload 沒有 rate_limits 時所有閘門都是 unknown——明講,別靜默裸奔。
|
|
258
306
|
if (aiEnabled && usage == null && !noUsageWarned) {
|
|
259
307
|
noUsageWarned = true;
|
|
260
|
-
|
|
308
|
+
logLine('warn: ai mode has no usage data (run `cwarm setup` to install the statusline bridge) — quota gates inactive');
|
|
261
309
|
}
|
|
262
310
|
if (usage != null) noUsageWarned = false;
|
|
263
311
|
const nowSec = Date.now() / 1000;
|
|
@@ -266,7 +314,7 @@ export function startHost(opts = {}) {
|
|
|
266
314
|
limitResetsAt = usage.resetsAt;
|
|
267
315
|
if (!limitLogged) {
|
|
268
316
|
limitLogged = true;
|
|
269
|
-
|
|
317
|
+
logLine(`skip: usage limit reached (${Math.round(usage.usedPct)}%) — waiting for reset at ${new Date(usage.resetsAt * 1000).toISOString()}`);
|
|
270
318
|
}
|
|
271
319
|
return; // 撞牆期間注入到不了伺服器,暫停等 reset
|
|
272
320
|
}
|
|
@@ -274,7 +322,7 @@ export function startHost(opts = {}) {
|
|
|
274
322
|
limitResetsAt = null;
|
|
275
323
|
limitLogged = false;
|
|
276
324
|
pendingResume = true; // 視窗已重置:下一發改敲 resumeMsg,回溫 cache 兼續跑被打斷的任務
|
|
277
|
-
|
|
325
|
+
logLine(`window reset — next inject will send "${resumeMsg()}"`);
|
|
278
326
|
}
|
|
279
327
|
// session bridge 有落地時針定本 session 的 transcript(多分頁同資料夾不互相干擾);
|
|
280
328
|
// 沒有(未跑 cwarm setup 裝 statusline、或 payload 還沒刷新過)回退舊的「資料夾最新」猜法。
|
|
@@ -311,17 +359,17 @@ export function startHost(opts = {}) {
|
|
|
311
359
|
if (idleMs == null) {
|
|
312
360
|
if (!noTranscriptWarned && now - startedMs > 120_000) {
|
|
313
361
|
noTranscriptWarned = true;
|
|
314
|
-
|
|
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)");
|
|
315
363
|
}
|
|
316
364
|
} else if (noTranscriptWarned) {
|
|
317
365
|
noTranscriptWarned = false;
|
|
318
|
-
|
|
366
|
+
logLine('resume: transcript found — keepalive active');
|
|
319
367
|
}
|
|
320
368
|
const screenIdleMs = now - lastOutputMs;
|
|
321
369
|
// reset 後若使用者自己先回來發了訊息(transcript 剛更新過),就不必再補 "go on"
|
|
322
370
|
if (pendingResume && idleMs != null && idleMs < 60_000) {
|
|
323
371
|
pendingResume = false;
|
|
324
|
-
|
|
372
|
+
logLine('resume skipped — user already active after reset');
|
|
325
373
|
}
|
|
326
374
|
const humanIdleMs = now - lastHumanMs;
|
|
327
375
|
// humanQuiet 鉗在 idleThreshold-60s 內:short 檔(TTL 300s/門檻 240s)若照吃 300s
|
|
@@ -331,19 +379,23 @@ export function startHost(opts = {}) {
|
|
|
331
379
|
// 先送 Esc:把任何「必答」modal(權限/選單/計畫批准)收掉、退回輸入框,後面那個 Enter 才不會誤選預設項;
|
|
332
380
|
// 空輸入框時 Esc 等同 no-op。隔一小段再送訊息——讓 claude 先把 modal 收乾淨,也避免 ESC 與字元被併成 Meta 鍵。
|
|
333
381
|
const aiAllowed = aiEnabled && wgate !== 'off';
|
|
334
|
-
const
|
|
382
|
+
const { aiMsg, briefing } = pickAiMsgAndBriefing(fixedAiMsg, langOverride, configuredLang);
|
|
383
|
+
const injectMsg = pickInjectMsg({ pendingResume, consecInjects, aiStep, resumeMsg: resumeMsg(), aiMsg, msg, aiAllowed, briefing });
|
|
335
384
|
// 只有真的敲出 AI 循環裡的一步才推進 aiStep;被 wgate='off' 等閘門擋下、退回一般
|
|
336
385
|
// "hi" 或 resume 的那些發送不算,避免恢復後循環整段跳號(見上方 aiStep 宣告的說明)。
|
|
337
386
|
const firedAiStep = !pendingResume && consecInjects >= 2 && aiAllowed && Array.isArray(aiMsg);
|
|
338
387
|
pendingResume = false;
|
|
339
388
|
consecInjects++;
|
|
340
389
|
if (firedAiStep) aiStep++;
|
|
341
|
-
ptyProc.write('\x1b');
|
|
342
390
|
lastFire = now;
|
|
343
|
-
|
|
391
|
+
// 三筆寫入的時序(哪筆隔多久送)交給 injectWriteSequence(keepalive.mjs,純函式、有測試)
|
|
392
|
+
// 決定,這裡只負責照表排 setTimeout——實際 bug 曾經就出在這排程被兩次 write 合併成一次。
|
|
393
|
+
for (const { delayMs, data } of injectWriteSequence(injectMsg, escDelayMs, enterDelayMs)) {
|
|
394
|
+
setTimeout(() => { if (!exiting) { try { ptyProc.write(data); } catch {} } }, delayMs);
|
|
395
|
+
}
|
|
344
396
|
const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
|
|
345
397
|
const msgLabel = injectMsg.length > 24 ? `${injectMsg.slice(0, 24)}…` : injectMsg;
|
|
346
|
-
|
|
398
|
+
logLine(`inject "${msgLabel}" regime=${regime ?? 'unknown'} idle=${idle}s screenIdle=${Math.round(screenIdleMs / 1000)}s consec=${consecInjects} aiStep=${aiStep} weekly=${wgate}`);
|
|
347
399
|
}
|
|
348
400
|
}, tickMs);
|
|
349
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
|
-
// <
|
|
320
|
-
// 愛心最接近)/≥12% 🔴。
|
|
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
|
-
// 等速下降,算出降到 <
|
|
329
|
+
// 等速下降,算出降到 <3%(回綠球門檻)還要「休息」幾秒;已經是綠球則為 0。算出來若
|
|
326
330
|
// 超過視窗剩餘時間,代表撐到 reset 都回不了綠,直接鉗在剩餘時間(reset 一到整組歸零,
|
|
327
331
|
// 保證回綠)。
|
|
328
332
|
export function weeklyPaceInfo({ usedPct, resetsAt, nowSec } = {}) {
|
|
@@ -331,19 +335,20 @@ export function weeklyPaceInfo({ usedPct, resetsAt, nowSec } = {}) {
|
|
|
331
335
|
const elapsed = Math.min(Math.max(nowSec - (resetsAt - WEEK), 0), WEEK);
|
|
332
336
|
const expectedPct = (elapsed / WEEK) * 100;
|
|
333
337
|
const excess = usedPct - expectedPct;
|
|
334
|
-
let ball
|
|
338
|
+
let ball;
|
|
335
339
|
if (excess >= 12) ball = '\u{1F534}'; // 🔴
|
|
336
340
|
else if (excess >= 9) ball = '\u{1FA77}'; // 🩷
|
|
337
341
|
else if (excess >= 6) ball = '\u{1F7E0}'; // 🟠
|
|
338
342
|
else if (excess >= 3) ball = '\u{1F7E1}'; // 🟡
|
|
339
|
-
else
|
|
340
|
-
|
|
343
|
+
else ball = '\u{1F7E2}'; // 🟢——3% 以下一律綠球,不留空窗
|
|
344
|
+
// 舊版在 2~3% 之間讓 ball 留白後直接 return null,整段週用量會從 statusline 消失,
|
|
345
|
+
// 不只是球不見;空窗改成綠球,週段永遠有東西可顯示。
|
|
341
346
|
const daysRemaining = Math.max(WEEK - elapsed, 0) / 86400;
|
|
342
347
|
const remainingPct = Math.max(100 - usedPct, 0);
|
|
343
348
|
const avgPerDayRemaining = daysRemaining > 0 ? remainingPct / daysRemaining : 0;
|
|
344
349
|
const remainingSec = Math.max(WEEK - elapsed, 0);
|
|
345
350
|
const rate = 100 / WEEK; // %/秒,進度線自然爬升速度
|
|
346
|
-
const recoverySec = excess
|
|
351
|
+
const recoverySec = excess >= 3 ? Math.min((excess - 3) / rate, remainingSec) : 0; // 休息到回綠球(<3%)所需
|
|
347
352
|
return { ball, excess, daysRemaining, avgPerDayRemaining, recoverySec };
|
|
348
353
|
}
|
|
349
354
|
|
|
@@ -430,7 +435,8 @@ export function clampHumanQuiet(humanQuietMs, idleThresholdS) {
|
|
|
430
435
|
// 當循環索引,暫停後恢復會整段跳號、破壞步驟間的依賴(如「依建議執行」承接前一步的發現)。
|
|
431
436
|
// 故循環位置改用呼叫端維護、只在真的敲出 AI 循環訊息時才遞增的 aiStep。
|
|
432
437
|
// briefing:每輪無人值守的第一步(aiStep===0)附加一次性說明,讓被驅動的 Claude 知道自己
|
|
433
|
-
// 在被 cwarm
|
|
438
|
+
// 在被 cwarm 自動驅動、規則是什麼(見本檔案下方的 AI_BRIEFING_EN/AI_BRIEFING_ZH);
|
|
439
|
+
// 同一輪其餘步驟不重複附加。
|
|
434
440
|
// 只在真的選中陣列循環的第一格時才附加——固定字串 aiMsg(CWARM_AI_MSG)或非陣列不適用。
|
|
435
441
|
export function pickInjectMsg({ pendingResume, consecInjects, aiStep = 0, resumeMsg, aiMsg, msg, aiAllowed = true, briefing = null }) {
|
|
436
442
|
if (pendingResume) return resumeMsg;
|
|
@@ -444,6 +450,26 @@ export function pickInjectMsg({ pendingResume, consecInjects, aiStep = 0, resume
|
|
|
444
450
|
return msg;
|
|
445
451
|
}
|
|
446
452
|
|
|
453
|
+
// host.mjs 實際送進 pty 的三筆寫入,抽成純函式(無 timer、無 pty)才測得到——2026-08-30
|
|
454
|
+
// 修 bug 前這裡是 write(content + '\r') 一次送出,長訊息(AI briefing 可到 ~700 字元)在
|
|
455
|
+
// Claude Code 的輸入框裡被當成貼上,夾在同一包的 '\r' 只變成貼上內容的一部分、沒有真的
|
|
456
|
+
// 送出,卻沒有任何測試會發現——因為寫死在 host.mjs 裡、要真的起一個 pty 才碰得到這段路徑。
|
|
457
|
+
// 回傳 [{delayMs 相對於觸發時刻, data}],呼叫端(host.mjs)自己排 setTimeout;純函式好斷言:
|
|
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
|
+
// 寫入共用同一套排程/測試邏輯比較值得。
|
|
465
|
+
export function injectWriteSequence(injectMsg, escDelayMs, enterDelayMs) {
|
|
466
|
+
return [
|
|
467
|
+
{ delayMs: 0, data: '\x1b' },
|
|
468
|
+
{ delayMs: escDelayMs, data: injectMsg },
|
|
469
|
+
{ delayMs: escDelayMs + enterDelayMs, data: '\r' },
|
|
470
|
+
];
|
|
471
|
+
}
|
|
472
|
+
|
|
447
473
|
// 無人值守 AI 模式的節奏:保溫節奏(一個 TTL 一發)是為省注入次數設計的,但 AI 模式
|
|
448
474
|
// 的目標是推進工作——十分鐘做完一步不該空等五十分鐘。故額度充裕時改用快節奏
|
|
449
475
|
//(paceS,預設 5 分鐘:transcript 靜止 + 冷卻都縮短到 paceS),下列任一條件則退回
|
|
@@ -483,7 +509,21 @@ export function readAiState(claudeDir, cwd, { maxAgeMs = 60_000, now = Date.now(
|
|
|
483
509
|
const o = readJsonSafe(aiStatePath(claudeDir, cwd));
|
|
484
510
|
if (!o || typeof o !== 'object' || o.ts == null) return null;
|
|
485
511
|
if (now - o.ts > maxAgeMs) return null;
|
|
486
|
-
return {
|
|
512
|
+
return {
|
|
513
|
+
enabled: !!o.enabled,
|
|
514
|
+
key: typeof o.key === 'string' ? o.key : null,
|
|
515
|
+
// 2026-08-30:內建循環目前生效的語言('zh'/'en',不管是自動偵測還是熱鍵覆蓋出來的結果;
|
|
516
|
+
// 設了 CWARM_AI_MSG_FILE 自訂循環時 host.mjs 寫的是 null,語言不適用)與該熱鍵標籤——
|
|
517
|
+
// statusline 的 aiSegment 拿這個顯示,不然使用者按了 Ctrl+L 完全看不到有沒有生效。
|
|
518
|
+
lang: o.lang === 'zh' || o.lang === 'en' ? o.lang : null,
|
|
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,
|
|
526
|
+
};
|
|
487
527
|
}
|
|
488
528
|
|
|
489
529
|
// 讀自訂無人值守指令檔(CWARM_AI_MSG_FILE):一行一條指令、# 開頭與空行忽略。
|
|
@@ -499,6 +539,178 @@ export function readAiMsgFile(p) {
|
|
|
499
539
|
return lines.length ? lines : null;
|
|
500
540
|
}
|
|
501
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
|
+
|
|
615
|
+
// ---- 內建循環的語言選擇 ----(2026-08-30,使用者要求「內建循環能不能自動跟 /config 語言走」)
|
|
616
|
+
// 內建循環預設英文(省 token),但使用者已經在 Claude Code 的 language 設定裡選了中文時,
|
|
617
|
+
// 沒理由還要手動去帶 CWARM_AI_MSG_FILE 才看得到中文——這裡讀同一份設定自動跟著切換。
|
|
618
|
+
// 只判斷「是不是中文」,不分繁簡(沒有簡體翻譯,一律給繁體版);不是中文一律回退英文原版。
|
|
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;
|
|
627
|
+
export function isChineseLanguage(lang) {
|
|
628
|
+
return typeof lang === 'string' && CHINESE_LANG_RE.test(lang);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// 純函式版本:從一串「可能是 settings.json 內容」的物件裡,依序找第一個有設過非空
|
|
632
|
+
// language 欄位的。呼叫端自己決定要傳哪些來源、什麼順序(見 detectConfiguredLanguage)。
|
|
633
|
+
export function configuredLanguageFromSources(sources) {
|
|
634
|
+
for (const o of sources) {
|
|
635
|
+
if (o && typeof o.language === 'string' && o.language.trim()) return o.language;
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// 讀 Claude Code 的 language 設定,比照它自己「local 覆蓋 project 覆蓋 user」的疊加順序:
|
|
641
|
+
// cwd 的 .claude/settings.local.json → .claude/settings.json → claudeDir 的使用者全域
|
|
642
|
+
// settings.json。任一層讀不到/不是合法 JSON 都當沒設定,不拋錯(保守:找不到就用預設英文)。
|
|
643
|
+
export function detectConfiguredLanguage(claudeDir, cwd) {
|
|
644
|
+
return configuredLanguageFromSources([
|
|
645
|
+
readJsonSafe(path.join(cwd, '.claude', 'settings.local.json')),
|
|
646
|
+
readJsonSafe(path.join(cwd, '.claude', 'settings.json')),
|
|
647
|
+
readJsonSafe(path.join(claudeDir, 'settings.json')),
|
|
648
|
+
]);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// 「目前實際生效的語言是不是中文」:override('zh'/'en',語言熱鍵按過後的內部代碼)用**直接
|
|
652
|
+
// 比對**;沒 override(null)才拿 autoDetectedLang(/config 的原始字串,如「繁體中文」/
|
|
653
|
+
// "zh-TW")丟給 isChineseLanguage 判斷。這兩種輸入絕對不能混用同一個判斷式——事故紀錄
|
|
654
|
+
// (2026-08-30):host.mjs 一度把 override 直接塞進 isChineseLanguage(),但那支函式是為
|
|
655
|
+
// /config 的自然語言字串設計的正規表達式比對,根本認不出 'zh'/'en' 這種內部短碼(bare
|
|
656
|
+
// "zh"/"en" 兩個都不匹配 CHINESE_LANG_RE),於是按過熱鍵一次之後不管切成 'zh' 還是 'en'
|
|
657
|
+
// 都被判成「不是中文」,往後注入永遠固定英文、切不回中文——症狀正是「能切到 EN,切不回中」。
|
|
658
|
+
// 呼叫端(toggleBuiltinLang、下方的 resolvePersistedLang/pickAiMsgAndBriefing)都必須
|
|
659
|
+
// 統一走這支,不要各自兜判斷式。
|
|
660
|
+
export function effectiveIsChinese(override, autoDetectedLang) {
|
|
661
|
+
return override ? override === 'zh' : isChineseLanguage(autoDetectedLang);
|
|
662
|
+
}
|
|
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
|
+
|
|
705
|
+
// 語言熱鍵(2026-08-30,使用者要求「能不能自己切換」):在「目前實際生效的語言」跟另一種
|
|
706
|
+
// 之間切換一次。回傳值永遠是明確的 'zh'/'en'——按過一次熱鍵後就固定住,不再跟著 /config
|
|
707
|
+
// 變動,除非再按一次切回去。呼叫端(host.mjs)拿這個值決定要用 AI_MSG_ZH 還是 AI_MSG_EN。
|
|
708
|
+
// 只在 session 內生效,不持久化——重開 cwarm 又回到 /config 自動偵測(比照 AI 開關的
|
|
709
|
+
// 持久化模式做起來不難,但這是次要的順手功能,先簡單版,有需要再仿 aiStatePath 加)。
|
|
710
|
+
export function toggleBuiltinLang(currentOverride, autoDetectedLang) {
|
|
711
|
+
return effectiveIsChinese(currentOverride, autoDetectedLang) ? 'en' : 'zh';
|
|
712
|
+
}
|
|
713
|
+
|
|
502
714
|
// 純決策:現在該不該注入 keepalive?idleMs = 距上次訊息多久(由 transcriptIdleMs 算)。
|
|
503
715
|
// screenIdleMs = 距 claude 最近一次「畫面輸出」多久;quietMs = 需靜止多久才放行。
|
|
504
716
|
// 為什麼要這個畫面靜默門檻:transcript 在「等你回答必答提示(權限/選單/計畫批准)」與
|
|
@@ -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,14 +54,17 @@ function payloadCwd(payload) {
|
|
|
54
54
|
// 回傳提醒文字(可能兩段用 │ 接起來):
|
|
55
55
|
// 5h — 用量 ≥95% 且離 reset 還超過 1 小時 → 建議使用者暫時中斷;不滿 1 小時就不提醒,
|
|
56
56
|
// 尾巴額度照常燒到撞牆。
|
|
57
|
-
// 週 — weeklyPaceInfo 依超前「按時間比例均攤」進度線幾個百分點顯示球號(<
|
|
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
|
-
if (!fh || typeof fh !== 'object') return '';
|
|
62
|
-
const usedPct = fh.used_percentage ?? null;
|
|
63
|
-
const resetsAt = fh.resets_at ?? null;
|
|
64
63
|
const sd = payload?.rate_limits?.seven_day;
|
|
64
|
+
// 5h 缺席不能連週段一起吞掉——兩個視窗各自獨立,缺哪個就只少哪個。
|
|
65
|
+
if ((!fh || typeof fh !== 'object') && (!sd || typeof sd !== 'object')) return { warn: '', weekly: '' };
|
|
66
|
+
const usedPct = fh?.used_percentage ?? null;
|
|
67
|
+
const resetsAt = fh?.resets_at ?? null;
|
|
65
68
|
if (cwd) {
|
|
66
69
|
try {
|
|
67
70
|
fs.writeFileSync(usageBridgePath(claudeDir, cwd), JSON.stringify({
|
|
@@ -73,10 +76,10 @@ function usageBridgeAndWarn(payload, cwd) {
|
|
|
73
76
|
} catch { /* 落地失敗不影響顯示 */ }
|
|
74
77
|
}
|
|
75
78
|
const nowSec = Date.now() / 1000;
|
|
76
|
-
|
|
79
|
+
let warn = '';
|
|
77
80
|
if (usageState({ usedPct, resetsAt, nowSec }) === 'warn') {
|
|
78
81
|
const hLeft = Math.round((resetsAt - nowSec) / 3600);
|
|
79
|
-
|
|
82
|
+
warn = `⚠️ ${Math.round(usedPct)}%、離reset還${hLeft}h,建議暫停`; // ⚠️ NN%、離reset還Nh,建議暫停
|
|
80
83
|
}
|
|
81
84
|
const wp = weeklyPaceInfo({ usedPct: sd?.used_percentage ?? null, resetsAt: sd?.resets_at ?? null, nowSec });
|
|
82
85
|
if (wp) {
|
|
@@ -88,20 +91,20 @@ function usageBridgeAndWarn(payload, cwd) {
|
|
|
88
91
|
const h = wp.recoverySec / 3600;
|
|
89
92
|
text += h >= 24 ? `,休息${(h / 24).toFixed(1)}d回綠` : `,休息${h.toFixed(1)}h回綠`;
|
|
90
93
|
}
|
|
91
|
-
|
|
94
|
+
return { warn, weekly: text };
|
|
92
95
|
}
|
|
93
|
-
return
|
|
96
|
+
return { warn, weekly: '' };
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
// AI 模式開關段:顯示無人值守 AI 模式 on/off
|
|
97
|
-
|
|
98
|
-
// 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 子行程才測得到)。
|
|
99
105
|
function aiSegment(cwd) {
|
|
100
106
|
if (!cwd) return '';
|
|
101
|
-
|
|
102
|
-
if (!st) return '';
|
|
103
|
-
const key = st.key || 'Ctrl+\\';
|
|
104
|
-
return `\u{1F916}AI ${st.enabled ? 'on' : 'off'} (${key})`; // 🤖
|
|
107
|
+
return aiSegmentText(readAiState(claudeDir, cwd));
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
// 跑使用者原本存起來的 statusLine 指令(餵同一份 stdin),回傳其輸出;沒有就 null。
|
|
@@ -138,13 +141,12 @@ if (process.env.CWARM_HOST_ID && payload?.transcript_path) {
|
|
|
138
141
|
|
|
139
142
|
const cwd = payloadCwd(payload);
|
|
140
143
|
const seg = cacheSegment(payload);
|
|
141
|
-
const warn = usageBridgeAndWarn(payload, cwd);
|
|
144
|
+
const { warn, weekly } = usageBridgeAndWarn(payload, cwd);
|
|
142
145
|
let base = runOriginal(raw);
|
|
143
146
|
if (base == null) base = minimalBase(payload);
|
|
144
|
-
//
|
|
145
|
-
// 原 statusline(如 claude-hud
|
|
147
|
+
// 第一行順序:帳號段 │ base 第一行 │ cache 倒數 │ 🤖AI 開關 │ ⚠️額度提醒;base 其餘行原樣跟著。
|
|
148
|
+
// 原 statusline(如 claude-hud)常是多行輸出,cwarm 段一律掛在「第一行」頭尾,
|
|
146
149
|
// 不能傻傻接在字串末端(那會落到最後一行尾)。
|
|
147
150
|
const lines = base.split('\n');
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
process.stdout.write(out + '\n');
|
|
151
|
+
lines[0] = [accountSegment(), lines[0], seg, aiSegment(cwd), warn, weekly].filter(Boolean).join(' │ ');
|
|
152
|
+
process.stdout.write(lines.join('\n') + '\n');
|