claude-cache-keepalive 0.1.10 → 0.1.11

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
@@ -139,6 +139,12 @@ Environment variables (mostly for testing / advanced use):
139
139
 
140
140
  ## Changelog
141
141
 
142
+ ### 0.1.11
143
+ - **Feature:** opt-in **unattended AI mode** (`cwarm --ai`, or `CWARM_AI=1`, or press `Ctrl+\` anytime to toggle — state persists per project and shows in the statusline). After two keepalive pings with no human keystrokes, cwarm injects a cycling set of safe work instructions (review → tests → docs → …) instead of plain `hi`, pacing itself by your 5h and weekly quota headroom (a bridge file written by the statusline feeds the host live `rate_limits`, since the host process can't see the statusline payload directly). Customize the cycle with `CWARM_AI_MSG` (single message) or `CWARM_AI_MSG_FILE` (one instruction per line, `#` = comment). Statusline also gains an account/plan segment (`👤you·Max 5x`, useful when switching accounts with `/login`) and a 95%-quota warning.
144
+ - **Hardening:** the AI-state and usage-bridge files are now isolated per project directory (they were briefly global during development, which would have let two accounts' quota windows overwrite each other); bracketed-paste and Windows batch-paste content can no longer be misread as the toggle hotkey or counted as "unattended activity"; a mistyped `CWARM_HUMAN_QUIET_S` now falls back to the 5-minute default with a logged warning instead of silently disabling the anti-half-typed-draft guard; the AI instruction cycle advances on its own step counter so a quota-gated pause can't skip steps; the cwd key used for cwarm's own state files only folds case/slashes on Windows, so it can't collapse two distinct directories that differ only by case on a case-sensitive filesystem.
145
+ - **功能:** 選配的**無人值守 AI 模式**(`cwarm --ai`、或 `CWARM_AI=1`、或執行中隨時按 `Ctrl+\` 切換——狀態依專案持久化、statusline 會顯示)。連兩發保溫 `hi` 都沒人碰鍵盤後,第三發起改敲一組循環式安全工作指令(檢視→測試→文件→……)取代單純的 `hi`,並依 5 小時與週額度餘裕自動調節節奏(額度資料由 statusline 落地成橋接檔給 host 讀,因為 host 行程本身收不到 statusline 的 payload)。可用 `CWARM_AI_MSG`(固定一句)或 `CWARM_AI_MSG_FILE`(一行一條指令,`#` 開頭為註解)自訂循環內容。statusline 同時新增帳號/訂閱段位(如 `👤you·Max 5x`,`/login` 切帳號時很好用)與 95% 額度警示。
146
+ - **強化:** AI 狀態檔與額度橋接檔改成依專案目錄各自隔離(開發過程中曾短暫全域共用,會讓兩個帳號的額度視窗互相覆蓋);bracketed-paste 與 Windows 批次貼上內容不會再被誤判成切換熱鍵或算成「無人值守活動」;`CWARM_HUMAN_QUIET_S` 打錯字時會退回 5 分鐘預設值並記警告,不再靜默關掉防止半句草稿被送出的安全閥;AI 指令循環改用獨立的步進計數器推進,額度閘門暫停期間不會讓循環跳號;cwarm 自家狀態檔使用的路徑鍵只在 Windows 上做大小寫/斜線正規化,避免在大小寫敏感的檔案系統上把兩個真正不同的目錄錯誤地合併成同一份狀態。
147
+
142
148
  ### 0.1.10
143
149
  - **Fix:** the optional statusline segment now reflects the 0.1.9 billing guard. On credits/API billing the keepalive is suspended, but the `♻️ cache …` countdown kept ticking as if warming were still active — misleading. The segment now shows `⏸️ cwarm off (API)` while `detectBillingMode` reports credits, and returns to the normal countdown once you're back on a subscription account.
144
150
  - **修正:** 選配的 statusline 區段現在會反映 0.1.9 的計費防護。credits/API 計費時 keepalive 已暫停,但 `♻️ cache …` 倒數仍照跑,彷彿還在保溫——會造成誤導。現在 `detectBillingMode` 回報 credits 期間會改顯示 `⏸️ cwarm off (API)`,切回訂閱帳號後自動恢復正常倒數。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-cache-keepalive",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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/cli.mjs CHANGED
@@ -12,7 +12,11 @@ if (first === 'setup') {
12
12
  printHelp();
13
13
  process.exit(0);
14
14
  } else {
15
- startHost({ args: argv }); // 完全透傳給 claude;不帶參數就是乾淨的 claude(不再隱含 --continue)
15
+ // `--ai` cwarm 自己的旗標(開啟無人值守 AI 模式),要在透傳前攔掉,其餘原樣給 claude
16
+ // filter 在沒有 '--ai' 時本就回傳等價陣列,不必額外用三元式判斷要不要濾。
17
+ const ai = argv.includes('--ai');
18
+ const args = argv.filter((a) => a !== '--ai');
19
+ startHost({ args, ai }); // 完全透傳給 claude;不帶參數就是乾淨的 claude(不再隱含 --continue)
16
20
  }
17
21
 
18
22
  function printHelp() {
@@ -29,6 +33,21 @@ Usage:
29
33
  Everything except the 'setup' subcommand is passed straight to claude
30
34
  (e.g. \`cwarm --version\`, \`cwarm resume\`, \`cwarm -p "..."\`).
31
35
 
36
+ Unattended AI mode (opt-in, off by default):
37
+ cwarm --ai [claude args] After two keepalive pings with no human keystrokes,
38
+ inject a cycling set of safe work instructions
39
+ (review / tests / docs / ...) instead of plain "hi",
40
+ pacing itself by your 5h + weekly quota headroom.
41
+ Also: CWARM_AI=1, or press Ctrl+\\ anytime to toggle
42
+ (state shows in the statusline; CWARM_TOGGLE_KEY=]
43
+ etc. rebinds it if your IME steals the default).
44
+ The toggle persists across restarts, per project;
45
+ --ai / CWARM_AI=0 override the remembered state.
46
+ Customize the cycle
47
+ with CWARM_AI_MSG (single message) or
48
+ CWARM_AI_MSG_FILE (one instruction per line, # = comment)
49
+ — the built-in cycle is software-engineering oriented.
50
+
32
51
  Keepalive only fires after you've been idle past the cache-TTL threshold.
33
52
  The TTL is auto-detected from the transcript's cache_creation (1h cache ->
34
53
  fire after ~58min idle; 5m cache -> ~4min), not guessed from your plan.
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 } from './keepalive.mjs';
8
+ import { defaultClaudeDir, regimeParams, detectTtlRegime, decideInject, transcriptIdleMs, looksLikeTrustPrompt, detectBillingMode, readUsageBridge, usageState, looksLikeHumanInput, pickInjectMsg, aiPacing, weeklyGate, readAiMsgFile, aiStatePath, extractAiToggle, clampHumanQuiet, toggleKeySpec, readAiState, initialAiEnabled } from './keepalive.mjs';
9
9
 
10
10
  const require = createRequire(import.meta.url);
11
11
  const isWin = process.platform === 'win32';
@@ -21,6 +21,20 @@ export function resolveClaude() {
21
21
  return 'claude'; // 最後手段:交給 node-pty 用 PATH 試
22
22
  }
23
23
 
24
+ // 讀數字型環境變數:空/未設回 def;非數字(如打錯成 "5m")回 def 並記警告(不可靜默失效
25
+ // 掉安全機制,如 CWARM_HUMAN_QUIET_S 打錯字就悄悄關掉打字防護);用 isFinite 而非 `|| def`,
26
+ // 讓合法的 "0"(如使用者故意要 CWARM_AI_FAST_PCT=0 關閉快節奏)不會被 falsy 誤當未設定。
27
+ function envNumber(name, def, log) {
28
+ const raw = process.env[name];
29
+ if (raw == null || raw === '') return def;
30
+ const n = Number(raw);
31
+ if (!Number.isFinite(n)) {
32
+ if (log) { try { fs.appendFileSync(log, `${new Date().toISOString()} warn: ${name}=${JSON.stringify(raw)} is not a number, using default ${def}\n`); } catch {} }
33
+ return def;
34
+ }
35
+ return n;
36
+ }
37
+
24
38
  // 算出要 spawn 的 (file, args)。Windows 上 .cmd/.bat shim 無法被 node-pty 直接 exec,改用 cmd.exe 包。
25
39
  function spawnSpec(claudeBin, args) {
26
40
  if (isWin && /\.(cmd|bat)$/i.test(claudeBin)) {
@@ -40,11 +54,14 @@ export function startHost(opts = {}) {
40
54
  const args = opts.args || [];
41
55
  const { file, args: spawnArgs } = spawnSpec(resolveClaude(), args);
42
56
 
57
+ // 固定取一次 cwd,全程共用(host 不會 chdir):writeAiState/usage bridge/transcript 查找
58
+ // 都用同一個值,避免不同時間點呼叫 process.cwd() 理論上不一致、間接影響 cwdKey 對應。
59
+ const hostCwd = process.cwd();
43
60
  const ptyProc = pty.spawn(file, spawnArgs, {
44
61
  name: process.env.TERM || 'xterm-256color',
45
62
  cols: process.stdout.columns || 80,
46
63
  rows: process.stdout.rows || 24,
47
- cwd: process.cwd(),
64
+ cwd: hostCwd,
48
65
  env: process.env,
49
66
  });
50
67
 
@@ -54,7 +71,48 @@ export function startHost(opts = {}) {
54
71
  // 終端輸入(捲動/讀回覆/打到一半沒送出)不會刷新 cache,拿來計時會誤判成「使用者還在忙」。
55
72
  if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
56
73
  process.stdin.resume();
57
- process.stdin.on('data', (d) => { ptyProc.write(d); });
74
+ // 無人值守 AI 模式總開關:預設關(其他使用者多半只要純保溫)。
75
+ // 開法三種:`cwarm --ai`、CWARM_AI=1、或執行中按熱鍵隨時切換——預設 Ctrl+\
76
+ //(Ctrl+] 會被中文輸入法攔成全形】),CWARM_TOGGLE_KEY 可換;
77
+ //(Unix=裸控制位元組、Windows=win32-input-mode 封包,偵測與剝除都在 extractAiToggle)。
78
+ // 狀態寫進 cwarm-ai-<cwd>.json(含 ts 心跳、per-cwd 檔名——多 host 並行不互蓋、
79
+ // key=熱鍵標籤供 statusline 顯示提示),statusline 用 payload 的 cwd 對回同一份。
80
+ // 初值持久化:Ctrl+\ 切過的狀態記在 per-cwd 狀態檔,重啟沿用(--ai / CWARM_AI 可覆蓋)。
81
+ let aiEnabled = initialAiEnabled({
82
+ flagAi: opts.ai,
83
+ envAi: process.env.CWARM_AI,
84
+ persisted: readAiState(claudeDir, hostCwd, { maxAgeMs: Infinity }),
85
+ });
86
+ const toggleKey = toggleKeySpec(process.env.CWARM_TOGGLE_KEY);
87
+ function writeAiState() {
88
+ try { fs.writeFileSync(aiStatePath(claudeDir, hostCwd), JSON.stringify({ enabled: aiEnabled, ts: Date.now(), key: toggleKey.label })); } catch {}
89
+ }
90
+ writeAiState();
91
+
92
+ // consecInjects:連續注入計數(無人值守偵測用)。人一敲鍵就歸零;判斷含 win32-input-mode
93
+ // 封包解析(Windows 所有按鍵都以 ESC[..._ 封包送達,不能用「非 ESC 開頭」判人)。
94
+ // lastHumanMs:使用者最後一次敲鍵時刻——敲鍵後一段時間內(humanQuietMs)絕不注入,
95
+ // 避免把打到一半的草稿連同 "hi" 一起 Enter 送出。
96
+ // aiStep:無人值守 18 階段循環的實際位置,只在真的敲出 AI 循環訊息時遞增(見下方 tick)。
97
+ // 與 consecInjects 分開維護:consecInjects 只用來判定「是否已無人值守」,暫停期間
98
+ // 送出的普通 "hi" 仍會累加它——若拿它直接當循環索引,暫停恢復後會整段跳號。
99
+ let consecInjects = 0;
100
+ let aiStep = 0;
101
+ let lastHumanMs = 0;
102
+ process.stdin.on('data', (d) => {
103
+ const { toggled, rest } = extractAiToggle(d, toggleKey.code);
104
+ if (toggled) {
105
+ aiEnabled = !aiEnabled;
106
+ writeAiState();
107
+ consecInjects = 0;
108
+ aiStep = 0;
109
+ lastHumanMs = Date.now(); // 按熱鍵也是人為活動
110
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} ai mode ${aiEnabled ? 'ON' : 'OFF'} (${toggleKey.label} toggle)\n`); } catch {}
111
+ }
112
+ if (rest.length === 0) return;
113
+ if (looksLikeHumanInput(rest)) { consecInjects = 0; aiStep = 0; lastHumanMs = Date.now(); }
114
+ ptyProc.write(rest);
115
+ });
58
116
  // 追蹤 claude 最近一次有輸出到畫面的時刻:閒置在輸入框時畫面靜止;提示等待回答時 spinner 在動、
59
117
  // 生成/跑工具時持續輸出。注入前要求畫面已靜止一段時間(見下方 quietMs),就能避開「忙/卡」狀態。
60
118
  let lastOutputMs = Date.now();
@@ -81,10 +139,63 @@ export function startHost(opts = {}) {
81
139
  if (thr != null) overrides.idleThreshold = Number(thr);
82
140
  if (ttlO != null) overrides.ttl = Number(ttlO);
83
141
 
84
- let lastFire = 0;
142
+ // reset 後的回溫訊息:"go on"(續跑任務)也算 AI 自主行為,未開 AI 模式時只補一般訊息回溫。
143
+ // 做成函式:Ctrl+] 隨時切換 aiEnabled,用時才取值。
144
+ const resumeMsg = () => process.env.CWARM_RESUME_MSG || (aiEnabled ? 'go on' : msg);
145
+ // 無人值守 AI 模式:連兩發 "hi" 都沒人碰鍵盤後,第三發起改敲 12 階段循環工作流,
146
+ // 讓閒置視窗產生實際價值。設計原則:每步「安全、有界、可驗證」——檢視類在前、
147
+ // 執行類帶安全閥(不可部署、不可破壞性操作、不可展開大型新工作,對齊全域 SOP)、
148
+ // 末段自我迭代與收尾報告,使用者回來直接驗收。CWARM_AI_MSG 可覆蓋成固定一句
149
+ //(設成 "hi" 即等於關掉本模式)。1h 檔位一步一小時,一輪約半天。
150
+ // 指令用英文(注入訊息本來就是 hi / go on 一路英文,且較省 token);回覆語言由使用者的
151
+ // CLAUDE.md 控制,不受注入語言影響。18 階段中文對照:
152
+ // 檢視段:review → 批判 review → TODO/FIXME 掃描 → 保守建議
153
+ // 執行段:依建議執行 → 測試覆蓋 → mutation check(測試有效性)→ 錯誤處理稽核 →
154
+ // 資安自查 → 相依套件體檢(只報告不升級)→ 效能低垂果實 → 小步重構 → 跨平台審視
155
+ // 收斂段:文件同步 → README 快速上手驗證 → 對照方案探索(防路徑依賴)→
156
+ // 經驗蒸餾(寫進 memory/CLAUDE.md,複利最高)→ 收尾報告+決策佇列(睡醒五分鐘拍板解鎖下一天)
157
+ // 指令來源優先序:CWARM_AI_MSG(固定一句)> CWARM_AI_MSG_FILE(自訂循環,一行一條)> 內建循環。
158
+ const aiMsg = process.env.CWARM_AI_MSG
159
+ || readAiMsgFile(process.env.CWARM_AI_MSG_FILE)
160
+ || [
161
+ 'Unattended: review the work done in this session; briefly list problems and possible improvements.',
162
+ 'Unattended: critical review — challenge earlier assumptions and approaches; point out risks, blind spots, and missing test scenarios.',
163
+ 'Unattended: sweep the code for TODO / FIXME / HACK markers; triage into quick-fix / should-fix / ignore, and fix the quick ones.',
164
+ 'Unattended: based on the findings above, propose a conservative, low-risk improvement list with priorities.',
165
+ 'Unattended: execute the safest suggested items one by one, verifying each. No deploys, no destructive operations, no large new work.',
166
+ 'Unattended: check test coverage — find uncovered branches and edge cases, add the needed tests, run the full suite.',
167
+ '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.',
168
+ 'Unattended: audit error handling — walk the failure paths (bad input, missing files, timeouts, permissions); patch gaps minimally, with tests.',
169
+ 'Unattended: light security self-check — leaked secrets in code/logs, injection risks, over-broad permissions; fix only clear issues.',
170
+ 'Unattended: dependency health check — outdated or vulnerable dependencies (e.g. npm outdated / npm audit); report findings only, do not upgrade.',
171
+ 'Unattended: performance pass — find low-hanging fruit (repeated IO, needless polling, cacheable recomputation); small certain wins only, no big refactors.',
172
+ 'Unattended: small refactors — naming, duplication, readability; behavior must not change, tests guard every step.',
173
+ '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.',
174
+ 'Unattended: sync docs — check README, comments and usage notes against reality; update stale parts, fill gaps.',
175
+ 'Unattended: verify the README quickstart — follow the install/usage steps literally from scratch and note where documentation and reality diverge; fix the docs.',
176
+ "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.",
177
+ '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.',
178
+ '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.',
179
+ ];
180
+ // 使用者敲鍵後需靜默這麼久才可注入(預設 5 分鐘)。CWARM_HUMAN_QUIET_S 可調;
181
+ // 打錯成非數字(如 "5m")會記警告並退回預設,不會悄悄關掉這個安全機制。
182
+ const humanQuietMs = envNumber('CWARM_HUMAN_QUIET_S', 300, LOG) * 1000;
183
+ // startedMs:只認啟動後有寫入的 transcript(Context 0% 不保溫;見 transcriptIdleMs 註解)。
184
+ // lastFire 也從啟動起算當第二道保險:即使第一句話立刻寫入,仍需滿一個冷卻週期才可能注入。
185
+ const startedMs = Date.now();
186
+ let lastFire = startedMs;
85
187
  let trustGuardLogged = false;
86
188
  let creditsGuardLogged = false;
189
+ // 額度視窗狀態:撞牆時記下 resets_at 並暫停注入;reset 過後第一發改敲 resumeMsg
190
+ //("go on")——既回溫 cache,又讓被額度打斷的任務自動續跑。資料來自 statusline
191
+ // 落地的橋接檔(cwarm-usage.json);95% 提醒由 statusline 顯示,host 不改行為(照用 "hi")。
192
+ let limitResetsAt = null;
193
+ let pendingResume = false;
194
+ let limitLogged = false;
195
+ let noUsageWarned = false; // --ai 但讀不到額度橋接檔時警告一次(額度閘門全數失效)
196
+ let noTranscriptWarned = false; // 啟動夠久了還是找不到本專案 transcript,警告一次(診斷路徑不符等問題)
87
197
  const timer = setInterval(() => {
198
+ writeAiState(); // 心跳:statusline 靠 ts 新鮮度判斷 host 還活著才顯示開關段
88
199
  // credits / API 計費(/login 切到 Console 帳號、或只用 ANTHROPIC_API_KEY)時,每次注入
89
200
  // 都是實際花錢,保溫沒有意義 → 自動暫停。每個 tick 重新偵測,session 中 /login 切回
90
201
  // 訂閱帳號會自動恢復保溫。要強制指定:CWARM_BILLING=subscription|credits。
@@ -110,20 +221,93 @@ export function startHost(opts = {}) {
110
221
  return;
111
222
  }
112
223
  trustGuardLogged = false;
113
- const cwd = process.cwd();
114
- const regime = detectTtlRegime(claudeDir, cwd); // 從 transcript 實測 1h/5m,不再猜方案
115
- const { ttl, idleThreshold } = regimeParams(regime, overrides);
224
+ // ---- 額度視窗(5h window)----
225
+ // hostCwd 為鍵:不同帳號/專案的並行 host 各讀各的橋接檔,不會互相覆蓋額度視窗。
226
+ const usage = readUsageBridge(claudeDir, hostCwd);
227
+ // AI 模式的撞牆偵測/週配速/reset 續跑全靠 statusline 落地的橋接檔;沒裝 statusline
228
+ //(cwarm setup)或 payload 沒有 rate_limits 時所有閘門都是 unknown——明講,別靜默裸奔。
229
+ if (aiEnabled && usage == null && !noUsageWarned) {
230
+ noUsageWarned = true;
231
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} warn: ai mode has no usage data (run \`cwarm setup\` to install the statusline bridge) — quota gates inactive\n`); } catch {}
232
+ }
233
+ if (usage != null) noUsageWarned = false;
234
+ const nowSec = Date.now() / 1000;
235
+ const ustate = usage ? usageState({ usedPct: usage.usedPct, resetsAt: usage.resetsAt, nowSec }) : 'unknown';
236
+ if (ustate === 'limited') {
237
+ limitResetsAt = usage.resetsAt;
238
+ if (!limitLogged) {
239
+ limitLogged = true;
240
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} skip: usage limit reached (${Math.round(usage.usedPct)}%) — waiting for reset at ${new Date(usage.resetsAt * 1000).toISOString()}\n`); } catch {}
241
+ }
242
+ return; // 撞牆期間注入到不了伺服器,暫停等 reset
243
+ }
244
+ if (limitResetsAt != null && nowSec > limitResetsAt) {
245
+ limitResetsAt = null;
246
+ limitLogged = false;
247
+ pendingResume = true; // 視窗已重置:下一發改敲 resumeMsg,回溫 cache 兼續跑被打斷的任務
248
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} window reset — next inject will send "${resumeMsg()}"\n`); } catch {}
249
+ }
250
+ const regime = detectTtlRegime(claudeDir, hostCwd); // 從 transcript 實測 1h/5m,不再猜方案
251
+ // 週配速:7 天視窗以日均(100/7 ≈ 14.3%/天)攤出進度線,ok=可快跑、slow=退回
252
+ // 一個 TTL 一步、off=超線一天日均以上 → 暫停 AI 工作只剩純保溫 "hi"。
253
+ const wgate = weeklyGate({
254
+ usedPct: usage ? usage.weekPct : null,
255
+ resetsAt: usage ? usage.weekResetsAt : null,
256
+ nowSec,
257
+ });
258
+ // AI 模式且額度充裕(5h < CWARM_AI_FAST_PCT 預設 70%,且週配速在線內)時改快節奏
259
+ //(CWARM_AI_PACE_S,預設 5 分),讓 12 階段循環按完工速度推進而不是空等 TTL。
260
+ const { ttl, idleThreshold } = aiPacing({
261
+ enabled: aiEnabled,
262
+ consecInjects,
263
+ usedPct: usage ? usage.usedPct : null,
264
+ ustate,
265
+ weekly: wgate,
266
+ ...regimeParams(regime, overrides),
267
+ paceS: envNumber('CWARM_AI_PACE_S', 300, LOG),
268
+ fastMaxPct: envNumber('CWARM_AI_FAST_PCT', 70, LOG),
269
+ });
116
270
  const now = Date.now();
117
- const idleMs = transcriptIdleMs(claudeDir, cwd, now);
271
+ const idleMs = transcriptIdleMs(claudeDir, hostCwd, now, startedMs);
272
+ // idleMs 找不到 transcript 時保溫整個靜默失效,且原本沒有任何提示——啟動超過 2 分鐘
273
+ // (早期本來就會是 null,Context 0% 正常現象,不必吵)仍找不到才警告一次,提示可能是
274
+ // cwd 與 Claude Code 建立的 transcript 資料夾對不上(磁碟代號大小寫、junction 等)。
275
+ if (idleMs == null) {
276
+ if (!noTranscriptWarned && now - startedMs > 120_000) {
277
+ noTranscriptWarned = true;
278
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} warn: no transcript found for this project after 2min — keepalive inactive (check for a cwd / drive-letter mismatch against Claude Code's project folder)\n`); } catch {}
279
+ }
280
+ } else if (noTranscriptWarned) {
281
+ noTranscriptWarned = false;
282
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} resume: transcript found — keepalive active\n`); } catch {}
283
+ }
118
284
  const screenIdleMs = now - lastOutputMs;
119
- if (decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE), screenIdleMs, quietMs })) {
285
+ // reset 後若使用者自己先回來發了訊息(transcript 剛更新過),就不必再補 "go on"
286
+ if (pendingResume && idleMs != null && idleMs < 60_000) {
287
+ pendingResume = false;
288
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} resume skipped — user already active after reset\n`); } catch {}
289
+ }
290
+ const humanIdleMs = now - lastHumanMs;
291
+ // humanQuiet 鉗在 idleThreshold-60s 內:short 檔(TTL 300s/門檻 240s)若照吃 300s
292
+ // 靜默,注入會落在 cache 冷掉之後——保命的提早注入餘裕不能被這個門檻吃掉。
293
+ const hqMs = clampHumanQuiet(humanQuietMs, idleThreshold);
294
+ if (decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE), screenIdleMs, quietMs, humanIdleMs, humanQuietMs: hqMs })) {
120
295
  // 先送 Esc:把任何「必答」modal(權限/選單/計畫批准)收掉、退回輸入框,後面那個 Enter 才不會誤選預設項;
121
296
  // 空輸入框時 Esc 等同 no-op。隔一小段再送訊息——讓 claude 先把 modal 收乾淨,也避免 ESC 與字元被併成 Meta 鍵。
297
+ const aiAllowed = aiEnabled && wgate !== 'off';
298
+ const injectMsg = pickInjectMsg({ pendingResume, consecInjects, aiStep, resumeMsg: resumeMsg(), aiMsg, msg, aiAllowed });
299
+ // 只有真的敲出 AI 循環裡的一步才推進 aiStep;被 wgate='off' 等閘門擋下、退回一般
300
+ // "hi" 或 resume 的那些發送不算,避免恢復後循環整段跳號(見上方 aiStep 宣告的說明)。
301
+ const firedAiStep = !pendingResume && consecInjects >= 2 && aiAllowed && Array.isArray(aiMsg);
302
+ pendingResume = false;
303
+ consecInjects++;
304
+ if (firedAiStep) aiStep++;
122
305
  ptyProc.write('\x1b');
123
306
  lastFire = now;
124
- setTimeout(() => { if (!exiting) { try { ptyProc.write(msg + '\r'); } catch {} } }, escDelayMs);
307
+ setTimeout(() => { if (!exiting) { try { ptyProc.write(injectMsg + '\r'); } catch {} } }, escDelayMs);
125
308
  const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
126
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}" regime=${regime ?? 'unknown'} idle=${idle}s screenIdle=${Math.round(screenIdleMs / 1000)}s\n`); } catch {}
309
+ const msgLabel = injectMsg.length > 24 ? `${injectMsg.slice(0, 24)}…` : injectMsg;
310
+ 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 {}
127
311
  }
128
312
  }, tickMs);
129
313
 
package/src/keepalive.mjs CHANGED
@@ -24,10 +24,22 @@ export function defaultClaudeDir() {
24
24
 
25
25
  // Claude Code 把 transcript 存在 ~/.claude/projects/<編碼後的 cwd>/<uuid>.jsonl,
26
26
  // 編碼規則為「非英數字元一律換成 '-'」(例:C:\temp\scripts\cwarm → C--temp-scripts-cwarm)。
27
+ // 必須原樣比照 Claude Code 的編碼(不可正規化大小寫),否則對不上它真正建立的資料夾。
27
28
  export function encodeProjectDir(cwd) {
28
29
  return String(cwd).replace(/[^a-zA-Z0-9]/g, '-');
29
30
  }
30
31
 
32
+ // cwarm 自家協定檔(AI 狀態、額度橋接)的 cwd 鍵:寫入方(host 的 process.cwd())與
33
+ // 讀取方(statusline payload 回報的 cwd)可能只有大小寫或斜線方向不同(Windows 常見),
34
+ // 這裡雙方都不必比對 Claude Code 的真實資料夾,故先正規化再編碼,讓這類差異不致兜不起來。
35
+ // 大小寫/斜線方向正規化僅限 Windows:類 Unix 檔案系統多半大小寫敏感,硬轉小寫會把
36
+ // 兩個真正不同的目錄(如 /home/Alice 與 /home/alice)錯誤地映成同一把 key,狀態互相污染。
37
+ export function cwdKey(cwd) {
38
+ let s = String(cwd).replace(/[\\/]+$/, ''); // 結尾斜線(正/反皆可)先去掉,兩平台通用
39
+ if (os.platform() === 'win32') s = s.replace(/\//g, '\\').toLowerCase();
40
+ return encodeProjectDir(s);
41
+ }
42
+
31
43
  // 某資料夾內 mtime 最新的 *.jsonl 完整路徑;沒有則 null。
32
44
  function newestJsonlPath(dir) {
33
45
  let entries;
@@ -74,9 +86,24 @@ export function transcriptMtimeMs(claudeDir, cwd) {
74
86
  // 距上次訊息(transcript 寫入)多久(毫秒);找不到 transcript 回 null。
75
87
  // 這才是 prompt cache 年齡的正確訊號——終端「輸入」(捲動/讀回覆/打到一半沒送出)
76
88
  // 都不會刷新 cache,故不以 stdin 計時,改看 transcript mtime。
77
- export function transcriptIdleMs(claudeDir, cwd, now = Date.now()) {
78
- const m = transcriptMtimeMs(claudeDir, cwd);
79
- return m == null ? null : now - m;
89
+ // sinceMs(通常給 host 啟動時刻):mtime 早於它的 transcript 不算數 → 回 null。
90
+ // 語意 = 「Context 0% 不保溫」:本 session 還沒有任何內容時沒有 cache 可保,注入無意義;
91
+ // 也避免把上一個 session 舊檔的 mtime 誤當 idle(曾造成剛啟動就亂敲 "hi")。
92
+ // 第一句話寫入 transcript(Context > 0%)後,保溫自然啟用。
93
+ // 給了 sinceMs 時**只認 cwd 自己的專案資料夾**、不走跨專案 fallback——否則別的專案
94
+ // 有並行 session 在跑時,其 transcript mtime 晚於啟動時刻、照樣通過 sinceMs 檢查,
95
+ // 等於借別人的活動對自己 0% context 的 session 亂敲。編碼對不上頂多 idle=null 不注入(保守正確)。
96
+ export function transcriptIdleMs(claudeDir, cwd, now = Date.now(), sinceMs = null) {
97
+ let m;
98
+ if (sinceMs != null) {
99
+ const p = newestJsonlPath(path.join(claudeDir, 'projects', encodeProjectDir(cwd)));
100
+ try { m = p == null ? null : fs.statSync(p).mtimeMs; } catch { m = null; }
101
+ } else {
102
+ m = transcriptMtimeMs(claudeDir, cwd);
103
+ }
104
+ if (m == null) return null;
105
+ if (sinceMs != null && m < sinceMs) return null;
106
+ return now - m;
80
107
  }
81
108
 
82
109
  // 讀 transcript 尾端,判斷這個 session 實際拿到的 cache TTL 檔位。
@@ -153,16 +180,249 @@ function readJsonSafe(p) {
153
180
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
154
181
  }
155
182
 
156
- // IO 版:讀 claudeDir 下的 .credentials.json 與 .claude.json(CLAUDE_CONFIG_DIR 佈局),
183
+ // 讀取憑證/設定來源(billing account 偵測共用,statusline 每次刷新只需讀一遍):
184
+ // claudeDir 下的 .credentials.json 與 .claude.json(CLAUDE_CONFIG_DIR 佈局),
157
185
  // 後者找不到再退回 homedir 的 ~/.claude.json(預設佈局)。macOS 憑證在 Keychain、
158
186
  // 沒有 .credentials.json 檔,會自然落到 config 判斷。
159
- export function detectBillingMode(claudeDir, { env = process.env, homedir = os.homedir() } = {}) {
187
+ export function readAuthSources(claudeDir, { homedir = os.homedir() } = {}) {
160
188
  const credentials = readJsonSafe(path.join(claudeDir, '.credentials.json'));
161
189
  const config = readJsonSafe(path.join(claudeDir, '.claude.json'))
162
190
  ?? readJsonSafe(path.join(homedir, '.claude.json'));
191
+ return { credentials, config };
192
+ }
193
+
194
+ export function detectBillingMode(claudeDir, { env = process.env, homedir } = {}) {
195
+ const { credentials, config } = readAuthSources(claudeDir, { homedir });
163
196
  return billingModeFromSources({ env, credentials, config });
164
197
  }
165
198
 
199
+ // ---- 帳號/訂閱偵測(statusline 顯示用)----
200
+ // 常在兩個帳號間 /login 切換時,光看模型名不知道現在燒的是哪個帳號的額度。
201
+ // email 取自 .claude.json 的 oauthAccount(/login 當下改寫);訂閱層級取自
202
+ // .credentials.json 的 claudeAiOauth.subscriptionType,rateLimitTier
203
+ //(如 default_claude_max_5x)尾碼可再補上倍率 → "Max 5x"。
204
+ export function accountInfoFromSources({ credentials = null, config = null } = {}) {
205
+ const email = (config && config.oauthAccount && config.oauthAccount.emailAddress) || null;
206
+ const oauth = credentials && credentials.claudeAiOauth;
207
+ let plan = null;
208
+ const sub = oauth && typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : '';
209
+ if (sub) {
210
+ plan = sub.charAt(0).toUpperCase() + sub.slice(1);
211
+ const m = /_(\d+x)$/.exec((oauth && oauth.rateLimitTier) || '');
212
+ if (m) plan += ` ${m[1]}`;
213
+ }
214
+ return { email, plan };
215
+ }
216
+
217
+ // IO 版:與 detectBillingMode 共用 readAuthSources(macOS 憑證在 Keychain 時 plan 會是 null,僅顯示 email)。
218
+ export function detectAccountInfo(claudeDir, { homedir } = {}) {
219
+ return accountInfoFromSources(readAuthSources(claudeDir, { homedir }));
220
+ }
221
+
222
+ // ---- 額度視窗(5h window)狀態 ----
223
+ // 資料來源:Claude Code 餵給 statusline 的 payload 有 rate_limits.five_hour 的
224
+ // used_percentage / resets_at(epoch 秒),但 host 收不到 payload,故由 segment.mjs
225
+ // 每次刷新落地成 claudeDir/cwarm-usage.json 橋接檔,host 每 tick 讀取。
226
+ // 狀態語意:
227
+ // warn — 用量 ≥ warnPct 且離 reset 還超過 warnGapS(1h)→ 提醒使用者暫時中斷;
228
+ // 不滿 1h 就不提醒,讓尾巴額度照常燒到撞牆(反正 reset 後就作廢)
229
+ // (保溫照常用 "hi",不改行為,提醒顯示在 statusline)
230
+ // limited — 已撞牆(≥ limitPct)→ 注入無意義,暫停等 reset
231
+ // reset — 橋接檔裡的 resets_at 已過(撞牆後視窗重置)→ host 下一發改敲 "go on"
232
+ export function usageState({ usedPct, resetsAt, nowSec }, { warnPct = 95, warnGapS = 3600, limitPct = 99 } = {}) {
233
+ if (usedPct == null || resetsAt == null) return 'unknown';
234
+ if (nowSec >= resetsAt) return 'reset';
235
+ if (usedPct >= limitPct) return 'limited';
236
+ if (usedPct >= warnPct && (resetsAt - nowSec) > warnGapS) return 'warn';
237
+ return 'normal';
238
+ }
239
+
240
+ // 以 cwd 編碼進檔名(同 aiStatePath):不同帳號/專案的並行 session 各寫各的橋接檔,
241
+ // 不會互相覆蓋彼此的額度視窗(曾經全域共用一份,A 帳號會讀到 B 帳號的用量)。
242
+ export function usageBridgePath(claudeDir, cwd) {
243
+ return path.join(claudeDir, `cwarm-usage-${cwdKey(cwd)}.json`);
244
+ }
245
+
246
+ // 讀橋接檔;太舊(statusline 停更,如 claude 已關)視同沒有 → null。
247
+ // weekPct / weekResetsAt 為 7 天視窗(rate_limits.seven_day),供週配速管控用。
248
+ export function readUsageBridge(claudeDir, cwd, { maxAgeMs = 600_000, now = Date.now() } = {}) {
249
+ const o = readJsonSafe(usageBridgePath(claudeDir, cwd));
250
+ if (!o || typeof o !== 'object' || o.ts == null) return null;
251
+ if (now - o.ts > maxAgeMs) return null;
252
+ const sd = o.seven_day && typeof o.seven_day === 'object' ? o.seven_day : {};
253
+ return {
254
+ usedPct: o.used_percentage ?? null,
255
+ resetsAt: o.resets_at ?? null,
256
+ weekPct: sd.used_percentage ?? null,
257
+ weekResetsAt: sd.resets_at ?? null,
258
+ };
259
+ }
260
+
261
+ // ---- 週配速管控(7 天視窗)----
262
+ // 5h 視窗管當下衝刺,7 天視窗管整週配速:週額度日均只有 100/7 ≈ 14.3%,無人值守
263
+ // 快跑不能只看 5h 有空間。以「按時間比例均攤的進度線」為基準(開窗至今應該用掉
264
+ // elapsed/7d × 100%)分三檔:
265
+ // ok — 用量在進度線內 → 快跑無妨
266
+ // slow — 超線但不到一個日均量 → 退回一個 TTL 一步的慢節奏
267
+ // off — 超線一個日均量以上 → 暫停 AI 工作(只剩 "hi" 純保溫),別把週額度燒穿
268
+ // 資料缺失回 'unknown'(交由 5h 檔決策,維持既有行為)。
269
+ export function weeklyGate({ usedPct, resetsAt, nowSec, graceDays = 1 } = {}) {
270
+ if (usedPct == null || resetsAt == null) return 'unknown';
271
+ const WEEK = 7 * 86400;
272
+ const elapsed = Math.min(Math.max(nowSec - (resetsAt - WEEK), 0), WEEK);
273
+ const expectedPct = (elapsed / WEEK) * 100;
274
+ const dailyPct = 100 / 7;
275
+ if (usedPct >= expectedPct + graceDays * dailyPct) return 'off';
276
+ if (usedPct > expectedPct) return 'slow';
277
+ return 'ok';
278
+ }
279
+
280
+ // ---- 無人值守 AI 模式 ----
281
+ // 連續兩次注入之間完全沒有人為鍵盤輸入 → 判定無人值守,第三發起改敲「能與 AI 互動」
282
+ // 的訊息(續推任務/回報進度),而不是傻傻的 "hi";使用者一敲鍵就歸零回到 "hi"。
283
+ //
284
+ // 人為輸入判斷分兩個世界:
285
+ // - Unix raw mode:純打字(含 Enter)不以 ESC 開頭;終端自動回報(DSR/DA 等 CSI 序列)
286
+ // 都以 ESC 開頭 → 「首位元組不是 ESC」即是人。方向鍵等 ESC 序列被漏算可接受。
287
+ // - Windows:node-pty 對真終端開 win32-input-mode(?9001h),**所有**按鍵都以
288
+ // ESC[Vk;Sc;Uc;Kd;Cs;Rc_ 封包送達(首位元組必為 ESC)。此時要解封包:
289
+ // key-down(Kd=1)且帶實際字元(Uc≠0)才算人——這樣終端合成的回報與 key-up 都不誤判。
290
+ const WIN32_INPUT_PACKET_RE = /\x1b\[([0-9;]*)_/g;
291
+ function* win32InputPackets(s) {
292
+ WIN32_INPUT_PACKET_RE.lastIndex = 0;
293
+ let m;
294
+ while ((m = WIN32_INPUT_PACKET_RE.exec(s)) !== null) {
295
+ const f = m[1].split(';').map((x) => Number(x || 0));
296
+ yield { vk: f[0] || 0, sc: f[1] || 0, uc: f[2] || 0, kd: f[3] || 0, raw: m[0] };
297
+ }
298
+ }
299
+
300
+ // Bracketed paste(\x1b[200~…\x1b[201~)一定是人為貼上——終端自動回報(DSR/DA 等)
301
+ // 不會用這個包法送達,故不論內容為何,含這對標記即視為人為輸入,讓 humanQuiet 正確
302
+ // 為「貼了草稿還沒送出」武裝,不會被開頭的 ESC 誤判成非人。
303
+ const BRACKETED_PASTE_RE = /\x1b\[20[01]~/;
304
+
305
+ export function looksLikeHumanInput(chunk) {
306
+ if (!chunk || chunk.length === 0) return false;
307
+ const s = typeof chunk === 'string' ? chunk : chunk.toString('latin1');
308
+ if (BRACKETED_PASTE_RE.test(s)) return true;
309
+ if (s.charCodeAt(0) !== 0x1b) return true;
310
+ for (const p of win32InputPackets(s)) {
311
+ if (p.kd === 1 && p.uc !== 0) return true; // win32-input-mode 的實際按鍵
312
+ }
313
+ return false;
314
+ }
315
+
316
+ // AI 模式切換熱鍵。預設 Ctrl+\(0x1C)——Ctrl+] 會被中文輸入法攔成全形】,故棄用;
317
+ // CWARM_TOGGLE_KEY 給單一字元(如 ']'、'\\'、'g')換成 Ctrl+該鍵,再撞衝突不必改 code。
318
+ // 控制碼算法:字元碼 & 0x1F(']'→0x1D、'\'→0x1C、字母 g→0x07)。無效輸入回預設。
319
+ export function toggleKeySpec(ch) {
320
+ const c = String(ch ?? '')[0] || '\\';
321
+ const code = c.charCodeAt(0) & 0x1f;
322
+ if (code < 1 || code > 31) return toggleKeySpec('\\');
323
+ return { code, label: `Ctrl+${c.toUpperCase()}` };
324
+ }
325
+
326
+ // 熱鍵偵測 + 剝除(code = toggleKeySpec().code)。兩種形態,都要求「這個 chunk 恰好
327
+ // 就是熱鍵本身」才觸發,同一標準套用到 Windows:
328
+ // - Unix raw mode:裸控制位元組——**僅當 chunk 恰為單一位元組**才視為熱鍵;
329
+ // 貼上內容夾帶該位元組(log/CSV/條碼 dump)不得誤觸、更不得剝除毀損內容。
330
+ // - win32-input-mode:ConPTY 對真終端永遠開著,大量貼上內容也會被轉成逐字元封包——
331
+ // 若只挑出 Uc=code 的封包來剝除,貼上內容裡剛好含這個字元就會被誤觸且毀損。
332
+ // 故要求**整個 chunk 解出的封包全部都是該熱鍵**(單鍵的 down(+up),至多 2 個
333
+ // 封包)才算熱鍵;只要混雜其他按鍵封包(含批次貼上),整包原樣放行、不觸發也不剝除。
334
+ // 回傳 { toggled, rest }:rest 為應轉送給 claude 的剩餘位元組。
335
+ export function extractAiToggle(chunk, code = 0x1c) {
336
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'latin1');
337
+ if (buf.length === 1 && buf[0] === code) return { toggled: true, rest: Buffer.alloc(0) };
338
+ const s = buf.toString('latin1');
339
+ if (!/^(?:\x1b\[[0-9;]*_)+$/.test(s)) return { toggled: false, rest: buf };
340
+ const packets = [...win32InputPackets(s)];
341
+ if (packets.length > 0 && packets.every((p) => p.uc === code)) {
342
+ return { toggled: packets.some((p) => p.kd === 1), rest: Buffer.alloc(0) };
343
+ }
344
+ return { toggled: false, rest: buf }; // 混雜其他按鍵/批次貼上 → 一律原樣放行
345
+ }
346
+
347
+ // humanQuiet 門檻不得吃掉「提早注入」的保命餘裕:short 檔 idleThreshold 240s 就是為了
348
+ // 趕在 300s TTL 前 60s 注入,若 humanQuiet 比它還長,注入會被拖到 cache 冷掉之後。
349
+ // 故上限鉗在 idleThreshold - 60s(不足時歸零,等同不套用)。
350
+ export function clampHumanQuiet(humanQuietMs, idleThresholdS) {
351
+ return Math.min(humanQuietMs, Math.max(0, idleThresholdS * 1000 - 60_000));
352
+ }
353
+
354
+ // 這一發該敲什麼:reset 後續跑 > 無人值守 AI 訊息 > 一般 "hi"。
355
+ // aiMsg 可以是字串(固定一句)或陣列(循環工作流:第 3 發起依序輪替,如
356
+ // review → critical review → 保守建議 → 依建議執行 → 回到 review …)。
357
+ // aiAllowed=false(如週配速 'off')時即使無人值守也只敲一般 "hi",暫停 AI 工作。
358
+ // aiStep(獨立於 consecInjects 的循環位置計數):consecInjects 統計「連續注入次數」,
359
+ // 用來判定是否進入無人值守(≥2),但暫停期間送出的普通 "hi" 也會讓它累加——若拿它直接
360
+ // 當循環索引,暫停後恢復會整段跳號、破壞步驟間的依賴(如「依建議執行」承接前一步的發現)。
361
+ // 故循環位置改用呼叫端維護、只在真的敲出 AI 循環訊息時才遞增的 aiStep。
362
+ export function pickInjectMsg({ pendingResume, consecInjects, aiStep = 0, resumeMsg, aiMsg, msg, aiAllowed = true }) {
363
+ if (pendingResume) return resumeMsg;
364
+ if (consecInjects >= 2 && aiAllowed) {
365
+ if (Array.isArray(aiMsg)) return aiMsg[aiStep % aiMsg.length];
366
+ return aiMsg;
367
+ }
368
+ return msg;
369
+ }
370
+
371
+ // 無人值守 AI 模式的節奏:保溫節奏(一個 TTL 一發)是為省注入次數設計的,但 AI 模式
372
+ // 的目標是推進工作——十分鐘做完一步不該空等五十分鐘。故額度充裕時改用快節奏
373
+ //(paceS,預設 5 分鐘:transcript 靜止 + 冷卻都縮短到 paceS),下列任一條件則退回
374
+ // 原節奏:尚未進入 AI 模式、無橋接資料(保守)、用量 ≥ fastMaxPct、或額度狀態非
375
+ // normal(warn/limited/reset 自有其處理)。快節奏注入間隔遠小於 TTL,cache 恆熱。
376
+ // weekly(weeklyGate 結果):'slow'/'off' 都不快跑;'unknown' 交由 5h 條件決定。
377
+ // enabled=false(未開 --ai/CWARM_AI,純保溫使用者)時永遠維持原節奏。
378
+ export function aiPacing({ enabled = true, consecInjects, usedPct, ustate, weekly = 'unknown', ttl, idleThreshold, paceS = 300, fastMaxPct = 70 }) {
379
+ const fast = enabled
380
+ && consecInjects >= 2
381
+ && usedPct != null && usedPct < fastMaxPct
382
+ && ustate === 'normal'
383
+ && (weekly === 'ok' || weekly === 'unknown');
384
+ if (!fast) return { ttl, idleThreshold };
385
+ return { ttl: Math.min(ttl, paceS), idleThreshold: Math.min(idleThreshold, paceS) };
386
+ }
387
+
388
+ // ---- AI 模式開關狀態檔(host ↔ statusline 溝通用)----
389
+ // host 在啟動、每個 tick、與 Ctrl+] 切換時寫入 {enabled, ts};statusline 讀取顯示
390
+ // 開關狀態與熱鍵提示。ts 同時是心跳——太舊代表沒有 cwarm host 在跑(或已退出),
391
+ // statusline 就不顯示這一段(避免殘留假狀態)。
392
+ // 以 cwd 編碼進檔名(cwarm-ai-<proj>.json):多個 cwarm host 並行時各寫各的,
393
+ // statusline 用 payload 的 cwd 對回自己那份,不會 last-writer-wins 互蓋。
394
+ export function aiStatePath(claudeDir, cwd) {
395
+ return path.join(claudeDir, `cwarm-ai-${cwdKey(cwd)}.json`);
396
+ }
397
+
398
+ // AI 模式開機初值:--ai 旗標 > CWARM_AI 環境變數(=0/off 可強制關)> 上次持久化狀態
399
+ //(同一個 per-cwd 狀態檔、忽略心跳時效——Ctrl+\ 切過的選擇跨重啟記住)> 預設關。
400
+ export function initialAiEnabled({ flagAi, envAi, persisted } = {}) {
401
+ if (flagAi === true) return true;
402
+ if (envAi != null && envAi !== '') return /^(1|on|true|yes)$/i.test(String(envAi));
403
+ return !!(persisted && persisted.enabled);
404
+ }
405
+
406
+ export function readAiState(claudeDir, cwd, { maxAgeMs = 60_000, now = Date.now() } = {}) {
407
+ const o = readJsonSafe(aiStatePath(claudeDir, cwd));
408
+ if (!o || typeof o !== 'object' || o.ts == null) return null;
409
+ if (now - o.ts > maxAgeMs) return null;
410
+ return { enabled: !!o.enabled, key: typeof o.key === 'string' ? o.key : null };
411
+ }
412
+
413
+ // 讀自訂無人值守指令檔(CWARM_AI_MSG_FILE):一行一條指令、# 開頭與空行忽略。
414
+ // 內建循環是軟體工程導向的;其他領域(寫作/研究/翻譯…)用這個換掉整套。
415
+ // 檔案不存在或沒有有效行 → null(回退內建循環)。
416
+ export function readAiMsgFile(p) {
417
+ if (!p) return null;
418
+ let text;
419
+ try { text = fs.readFileSync(p, 'utf8'); } catch { return null; }
420
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // 去掉 UTF-8 BOM(Windows Notepad 常見),
421
+ // 否則第一行的 '#' 判斷會被隱形的 BOM 字元擋掉,該行不被當註解、悄悄混進指令循環。
422
+ const lines = text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !s.startsWith('#'));
423
+ return lines.length ? lines : null;
424
+ }
425
+
166
426
  // 純決策:現在該不該注入 keepalive?idleMs = 距上次訊息多久(由 transcriptIdleMs 算)。
167
427
  // screenIdleMs = 距 claude 最近一次「畫面輸出」多久;quietMs = 需靜止多久才放行。
168
428
  // 為什麼要這個畫面靜默門檻:transcript 在「等你回答必答提示(權限/選單/計畫批准)」與
@@ -170,12 +430,16 @@ export function detectBillingMode(claudeDir, { env = process.env, homedir = os.h
170
430
  // 但畫面活動可以:閒置在輸入框時畫面靜止;提示等待時 spinner 在動、生成中持續輸出。
171
431
  // 故只有畫面靜止夠久才注入——避免把 hi 的 Enter 送進 modal 誤選預設項,也避免打斷長工具執行。
172
432
  // quietMs 省略(null)時不套此門檻(保持純 idle 決策,供既有測試/呼叫者使用)。
173
- export function decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled, screenIdleMs, quietMs }) {
433
+ // humanIdleMs/humanQuietMs:距使用者最後一次敲鍵多久/需靜多久才放行。打字中途停下來
434
+ // 想事情(畫面靜止 >quietMs)不代表人不在——半句草稿 + "hi" + Enter 一起送出就是事故。
435
+ // 故人剛敲過鍵(預設 5 分鐘內)一律不注入;null 不套用(相容既有呼叫者)。
436
+ export function decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled, screenIdleMs, quietMs, humanIdleMs, humanQuietMs }) {
174
437
  if (disabled) return false; // 暫停開關
175
438
  if (idleMs == null) return false; // 找不到 transcript → 保守不發
176
439
  if (idleMs < idleThreshold * 1000) return false; // 距上次訊息還不夠久
177
440
  if (now - lastFire < ttl * 1000) return false; // 冷卻未滿一個 TTL
178
441
  if (quietMs != null && screenIdleMs != null && screenIdleMs < quietMs) return false; // 畫面還在動(提示/生成/打字中)
442
+ if (humanQuietMs != null && humanIdleMs != null && humanIdleMs < humanQuietMs) return false; // 人剛敲過鍵(可能在打字)
179
443
  return true;
180
444
  }
181
445
 
@@ -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, detectBillingMode } from '../keepalive.mjs';
7
+ import { defaultClaudeDir, readTtlRegime, regimeParams, billingModeFromSources, accountInfoFromSources, readAuthSources, usageState, usageBridgePath, readAiState } from '../keepalive.mjs';
8
8
 
9
9
  const claudeDir = defaultClaudeDir();
10
10
  const ORIG = path.join(claudeDir, 'cwarm-statusline-orig.json');
@@ -13,10 +13,13 @@ function readStdin() {
13
13
  try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
14
14
  }
15
15
 
16
+ // 憑證/設定來源整份只讀一次(billing 與帳號段共用),render 路徑不重複 IO。
17
+ const auth = readAuthSources(claudeDir);
18
+
16
19
  // cache 倒數段:用 transcript mtime 當 idle、用 transcript 實測的 cache_creation 判 TTL(1h / 5m)。
17
20
  function cacheSegment(payload) {
18
21
  // credits/API 計費時 keepalive 已暫停,倒數沒有意義且會誤導 → 顯示暫停標記
19
- if (detectBillingMode(claudeDir) === 'credits') return '⏸️ cwarm off (API)';
22
+ if (billingModeFromSources({ env: process.env, ...auth }) === 'credits') return '⏸️ cwarm off (API)';
20
23
  const tp = payload?.transcript_path;
21
24
  if (!tp) return '';
22
25
  let mtimeMs;
@@ -30,6 +33,58 @@ function cacheSegment(payload) {
30
33
  return `♻️ cache ${Math.floor(rem / 60)}m${rem % 60}s`; // ♻️ 倒數
31
34
  }
32
35
 
36
+ // 帳號段:兩個帳號間 /login 切換時,顯示現在是哪個帳號、什麼訂閱(如 👤cycompassion·Max 5x)。
37
+ // 每次 statusline 刷新重讀檔案,/login 切完下一次刷新就會反映。
38
+ function accountSegment() {
39
+ const { email, plan } = accountInfoFromSources(auth);
40
+ if (!email && !plan) return '';
41
+ const name = email ? email.split('@')[0] : '?';
42
+ return plan ? `\u{1F464}${name}·${plan}` : `\u{1F464}${name}`;
43
+ }
44
+
45
+ // payload 裡可能報 cwd 的幾個欄位,依序嘗試——host 那邊用同一套 cwdKey() 正規化
46
+ // (忽略大小寫/斜線方向),故這裡選到哪個候選字串不影響對得上與否,只要有值即可。
47
+ function payloadCwd(payload) {
48
+ return payload?.workspace?.project_dir || payload?.cwd || payload?.workspace?.current_dir || null;
49
+ }
50
+
51
+ // 額度橋接 + 提醒段:host(保溫迴圈)收不到 statusline payload,這裡把 rate_limits.five_hour
52
+ // 落地成 per-cwd 橋接檔給 host 讀(撞牆偵測與 reset 後改敲 "go on" 都靠它)——不同帳號/
53
+ // 專案的並行 session 各寫各的,不會互相覆蓋彼此的額度視窗。
54
+ // 回傳提醒文字:用量 ≥95% 且離 reset 還超過 1 小時 → 建議使用者暫時中斷(接在 cache 倒數之後);
55
+ // 不滿 1 小時就不提醒,尾巴額度照常燒到撞牆。
56
+ function usageBridgeAndWarn(payload, cwd) {
57
+ const fh = payload?.rate_limits?.five_hour;
58
+ if (!fh || typeof fh !== 'object') return '';
59
+ const usedPct = fh.used_percentage ?? null;
60
+ const resetsAt = fh.resets_at ?? null;
61
+ const sd = payload?.rate_limits?.seven_day;
62
+ if (cwd) {
63
+ try {
64
+ fs.writeFileSync(usageBridgePath(claudeDir, cwd), JSON.stringify({
65
+ used_percentage: usedPct,
66
+ resets_at: resetsAt,
67
+ seven_day: { used_percentage: sd?.used_percentage ?? null, resets_at: sd?.resets_at ?? null },
68
+ ts: Date.now(),
69
+ }));
70
+ } catch { /* 落地失敗不影響顯示 */ }
71
+ }
72
+ if (usageState({ usedPct, resetsAt, nowSec: Date.now() / 1000 }) !== 'warn') return '';
73
+ const hLeft = Math.round((resetsAt - Date.now() / 1000) / 3600);
74
+ return `⚠️ ${Math.round(usedPct)}%、離reset還${hLeft}h,建議暫停`; // ⚠️ NN%、離reset還Nh,建議暫停
75
+ }
76
+
77
+ // AI 模式開關段:顯示無人值守 AI 模式 on/off 與切換熱鍵。狀態檔以 cwd 編碼命名
78
+ //(多 host 並行各寫各的),用 payload 的專案路徑對回;ts 過舊(>60s,沒有
79
+ // cwarm host 在跑或已退出)就不顯示,避免殘留假狀態。
80
+ function aiSegment(cwd) {
81
+ if (!cwd) return '';
82
+ const st = readAiState(claudeDir, cwd);
83
+ if (!st) return '';
84
+ const key = st.key || 'Ctrl+\\';
85
+ return `\u{1F916}AI ${st.enabled ? 'on' : 'off'} (${key})`; // 🤖
86
+ }
87
+
33
88
  // 跑使用者原本存起來的 statusLine 指令(餵同一份 stdin),回傳其輸出;沒有就 null。
34
89
  function runOriginal(raw) {
35
90
  let orig;
@@ -51,9 +106,15 @@ const raw = readStdin();
51
106
  let payload = {};
52
107
  try { payload = JSON.parse(raw); } catch { /* 空/壞就用空物件 */ }
53
108
 
109
+ const cwd = payloadCwd(payload);
54
110
  const seg = cacheSegment(payload);
111
+ const warn = usageBridgeAndWarn(payload, cwd);
55
112
  let base = runOriginal(raw);
56
113
  if (base == null) base = minimalBase(payload);
57
- // 接在末行尾(base 末端即最後一行末端)
58
- const out = seg ? `${base} │ ${seg}` : base;
114
+ // 順序:帳號段 │ base 第一行 │ cache 倒數 │ 🤖AI 開關 │ ⚠️額度提醒;base 其餘行原樣跟在後面。
115
+ // statusline(如 claude-hud)常是多行輸出,所有 cwarm 段一律掛在「第一行」頭尾,
116
+ // 不能傻傻接在字串末端(那會落到最後一行尾)。
117
+ const lines = base.split('\n');
118
+ const first = [accountSegment(), lines[0], seg, aiSegment(cwd), warn].filter(Boolean).join(' │ ');
119
+ const out = [first, ...lines.slice(1)].join('\n');
59
120
  process.stdout.write(out + '\n');