claude-cache-keepalive 0.1.12 → 0.1.14
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 +37 -3
- package/package.json +1 -1
- package/src/host.mjs +30 -4
- package/src/keepalive.mjs +70 -0
- package/src/statusline/segment.mjs +36 -6
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ It's transparent — type and use claude exactly as normal (no `Ctrl-b` prefix,
|
|
|
39
39
|
## How it works
|
|
40
40
|
|
|
41
41
|
- **PTY host** — `cwarm` spawns `claude` inside a pseudo‑terminal it owns and transparently pipes your keyboard ↔ claude ↔ screen (and window resizes). This is the same approach tmux / expect / VS Code's terminal use, and the only robust way to inject input into a terminal program.
|
|
42
|
-
- **Idle detection** — idle = time since your last **message**, measured from
|
|
42
|
+
- **Idle detection** — idle = time since your last **message**, measured from this session's transcript file under `~/.claude/projects/`. This is what actually governs cache age: scrolling, arrow‑key reading, or a half‑typed prompt are terminal input but don't refresh the cache, so they must *not* count as activity. (Earlier versions timed keystrokes, which let the cache go cold while you were reading.) With the statusline add‑on installed (`cwarm setup`), the host **pins its own session's transcript** via a tiny per‑session bridge file (`~/.claude/cwarm-session-<id>.json`, written from the statusline's `transcript_path`, removed on exit) — so several tabs running sessions in the *same folder* no longer confuse each other. Without the statusline it falls back to "newest transcript in this project's folder".
|
|
43
43
|
- **TTL‑aware (measured, not guessed)** — the cache TTL is read straight from the transcript's `message.usage.cache_creation`, not inferred from your subscription:
|
|
44
44
|
- any recent turn wrote `ephemeral_1h_input_tokens` → **1 h cache** → inject after ~58 min idle, cooldown 1 h.
|
|
45
45
|
- only `ephemeral_5m_input_tokens` (or no evidence yet) → **5 min cache** (conservative) → inject after ~4 min idle, cooldown 5 min.
|
|
@@ -64,6 +64,19 @@ cwarm setup --remove # restores your previous statusline
|
|
|
64
64
|
|
|
65
65
|
(Written in Node — no Python dependency, no Windows codepage issues.)
|
|
66
66
|
|
|
67
|
+
### Quota segments
|
|
68
|
+
|
|
69
|
+
Once you run `cwarm setup`, the statusline also surfaces two quota warnings sourced straight from Claude Code's own `rate_limits` payload — no extra config needed:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
⚠️ 96%、離reset還2h,建議暫停 │ 🔴 週超前15.0%,剩3.5d均10.0%/天,休息21.8h回綠
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- **5-hour window** — once usage crosses 95% *and* the reset is still more than an hour away, an `⚠️ NN%、離reset還Nh,建議暫停` warning appears suggesting you pause. Nothing shows below 95%, or once you're within an hour of reset (the tail is going to burn either way).
|
|
76
|
+
- **7-day window** — a colored ball tracks how far ahead of a straight-line pace budget (100%/7 ≈ 14.3%/day) you're running: 🟢 under 2 points ahead (or behind), a quiet 2-3 point gap with no ball, 🟡 3-5, 🟠 6-8, 🩷 9-11 (no pink circle emoji exists, so it borrows the pink heart), 🔴 12+. Next to the ball: how many days are left in the window, the average %/day you can still spend if usage stays flat for the rest of it, and — once you're more than 2 points ahead — how long you'd need to rest (no further usage) before the pace line catches up and the ball turns green again.
|
|
77
|
+
|
|
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
|
+
|
|
67
80
|
## Unattended AI mode (opt-in, off by default)
|
|
68
81
|
|
|
69
82
|
Plain keepalive only sends `hi` — harmless, but it doesn't make idle time *useful*. `--ai` mode does: once cwarm decides you've genuinely stepped away, instead of `hi` it starts feeding Claude a repeating checklist of safe, self-verifying work (review the session, sweep for TODOs, add missing tests, sync docs, distill lessons into memory, …), so idle windows turn into progress instead of dead air.
|
|
@@ -134,7 +147,7 @@ Environment variables (mostly for testing / advanced use):
|
|
|
134
147
|
## Limitations
|
|
135
148
|
|
|
136
149
|
- **No detach.** Closing the window ends the session — there's no tmux‑style detach/reattach (that would mean reimplementing a terminal multiplexer; out of scope). But minimize / background / unfocused all keep working.
|
|
137
|
-
- **
|
|
150
|
+
- **Concurrent sessions in the same folder need the statusline.** With the statusline add‑on installed (`cwarm setup`), each `cwarm` tab pins its own session's transcript and multiple tabs coexist cleanly — even in the same folder. Without it, two sessions in the *same* folder will read each other's transcript activity and the keepalive misfires (sessions in different folders are always fine).
|
|
138
151
|
- If you walk away with a half‑typed draft and stay idle past the threshold, the keepalive's `Esc` clears the draft before sending `hi`. Rare.
|
|
139
152
|
- **A genuinely blocking prompt can't be kept warm.** While Claude Code waits on a mandatory answer, no API turn can happen, so the cache may cool during that window; warming resumes automatically once you answer.
|
|
140
153
|
|
|
@@ -158,10 +171,23 @@ Environment variables (mostly for testing / advanced use):
|
|
|
158
171
|
- **選配 statusline**(顯示 `♻️ cache 58m12s` 倒數;會先備份、包裝既有 statusline、可一鍵還原):`cwarm setup` / `cwarm setup --remove`
|
|
159
172
|
- **限制**:不能 detach(關視窗=結束,但縮小/背景照常保溫)。
|
|
160
173
|
|
|
174
|
+
### 額度段位(statusline)
|
|
175
|
+
|
|
176
|
+
裝了 `cwarm setup` 之後,statusline 會直接從 Claude Code 自帶的 `rate_limits` payload 秀出兩段額度提醒,不用額外設定:
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
⚠️ 96%、離reset還2h,建議暫停 │ 🔴 週超前15.0%,剩3.5d均10.0%/天,休息21.8h回綠
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- **5 小時視窗**:用量超過 95% 且離 reset 還超過 1 小時,才會出現 `⚠️ NN%、離reset還Nh,建議暫停`;不到 95%、或已經進入最後一小時(尾巴額度反正燒到撞牆),都不顯示。
|
|
183
|
+
- **7 天視窗**:用一顆彩色球表示「超前按時間比例均攤的日均進度線(100%/7 ≈ 14.3%/天)多少個百分點」:不到 2 個百分點(含落後)是 🟢,2~3 之間留白不顯示,3~5 是 🟡,6~8 是 🟠,9~11 是 🩷(Unicode 沒有粉紅圓形,借粉紅愛心最接近),12 以上是 🔴。球旁邊接著顯示視窗還剩幾天、照這剩餘天數均攤接下來每天還能燒多少 %,以及——只要超前 2 個百分點以上——要「休息」(不再新增用量)多久,進度線才會爬上來讓球回綠。
|
|
184
|
+
|
|
185
|
+
這兩段都只是顯示,不會擋掉保溫注入;`--ai` 模式才是拿同一組數字真的去踩剎車(見下方)。
|
|
186
|
+
|
|
161
187
|
### 運作原理
|
|
162
188
|
|
|
163
189
|
- **PTY host**:`cwarm` 把 `claude` spawn 在一個它自己擁有的 pseudo-terminal 裡,透明地把你的鍵盤 ↔ claude ↔ 畫面(含視窗 resize)接起來。這跟 tmux/expect/VS Code 終端的做法相同,也是唯一穩健、能把輸入注入終端程式的方式。
|
|
164
|
-
- **閒置偵測**:閒置=距你上次**訊息**多久,量自 `~/.claude/projects/`
|
|
190
|
+
- **閒置偵測**:閒置=距你上次**訊息**多久,量自 `~/.claude/projects/` 底下本 session 的 transcript 檔。這才是決定 cache 年齡的訊號——捲動、用方向鍵讀、打到一半沒送出,都是終端輸入但不會刷新 cache,所以不該算成活動。(早期版本計時鍵盤輸入,會讓你在閱讀時 cache 冷掉。)裝了 statusline 附加元件(`cwarm setup`)後,host 會透過一個極小的 per-session 橋接檔(`~/.claude/cwarm-session-<id>.json`,由 statusline 的 `transcript_path` 落地、退出時自動刪除)**針定自己 session 的 transcript**——多個分頁在**同一個資料夾**各開 session 也不會互相干擾。沒裝 statusline 則回退「本專案資料夾裡最新的 transcript」猜法。
|
|
165
191
|
- **TTL 感知(實測,非猜測)**:cache TTL 直接讀自 transcript 的 `message.usage.cache_creation`,不從訂閱方案推斷——最近有寫 `ephemeral_1h_input_tokens` → 1h cache(閒置約 58 分才注入、冷卻 1h);只有 `ephemeral_5m_input_tokens`(或還沒證據)→ 5m cache(保守,約 4 分注入、冷卻 5m)。這能撐過 client 版本、環境變數、伺服器旗標的變動(例如 Pro 帳號也可能拿到 1h cache)。
|
|
166
192
|
- **提示安全注入**:keepalive 只在 PTY **靜止一小段時間後**才觸發(`CWARM_QUIET_MS`,預設 2.5 秒)。必答提示(工具權限、`AskUserQuestion`、計畫批准)的 spinner 會一直動,忙著跑工具時也持續輸出,兩者都「不安靜」,所以 keepalive 不會送進去(不會誤選選單預設項、也不會打斷長工具執行)。而且注入時會**先送 `Esc`** 退回輸入框,那個 Enter 永遠落不到提示上。(提示真的卡住時 cache 本來就無法保溫,等你回答後會自動恢復。)
|
|
167
193
|
- **與焦點/縮小無關**:注入是行程內部的 `pty.write`,跟視窗狀態無關。只有關閉視窗(結束 host 行程)才會停。
|
|
@@ -241,6 +267,14 @@ CWARM_AI=1 cwarm # 效果相同,走環境變數
|
|
|
241
267
|
|
|
242
268
|
## Changelog
|
|
243
269
|
|
|
270
|
+
### 0.1.14
|
|
271
|
+
- **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
|
+
- **功能:** statusline 的 7 天額度提醒從一個原始數字變成真正的配速計。彩色球顯示超前「按時間比例均攤」的日均進度線(100%/7 ≈ 14.3%/天)多少個百分點——🟢 不到 2pt、🟡 3~5pt、🟠 6~8pt、🩷 9~11pt、🔴 12pt+——旁邊接著顯示視窗剩幾天、照這個配速接下來每天還能燒多少 %,以及(超前 2pt 以上時)要休息多久球才會回綠。詳見上方新增的「額度段位(statusline)」章節。
|
|
273
|
+
|
|
274
|
+
### 0.1.13
|
|
275
|
+
- **Fix:** running multiple Claude Code sessions in tabs **in the same folder** no longer corrupts each other's keepalive timing. The host used to measure idle from the *newest* transcript in the project folder — with a busier sibling session next door, its own idle never crossed the threshold, the keepalive never fired, and the cache countdown ran cold (🔴). Now the statusline lands each session's exact `transcript_path` into a per‑session bridge file (`~/.claude/cwarm-session-<id>.json`, keyed by a `CWARM_HOST_ID` the host passes through the PTY environment), and the host pins idle *and* TTL‑regime detection to its own transcript. Bridge files are deleted on exit; stale orphans (crashes) are swept on startup. Without the statusline installed, behavior falls back to the previous folder‑newest heuristic.
|
|
276
|
+
- **修正:** 用分頁在**同一個資料夾**開多個 Claude Code session 時,保溫計時不再互相干擾。過去 host 是拿專案資料夾裡 *mtime 最新* 的 transcript 判閒置——隔壁分頁還在活動時,自己的閒置永遠算不滿門檻、keepalive 不發、cache 倒數一路走到冷掉(🔴)。現在 statusline 會把每個 session 精確的 `transcript_path` 落地成 per-session 橋接檔(`~/.claude/cwarm-session-<id>.json`,以 host 經 PTY 環境變數傳入的 `CWARM_HOST_ID` 為鍵),host 的閒置判定**與 TTL 檔位偵測**都針定自己的 transcript。橋接檔退出時自動刪除;crash 留下的孤兒檔啟動時順手清掉。沒裝 statusline 時回退原本「資料夾最新」的猜法。
|
|
277
|
+
|
|
244
278
|
### 0.1.12
|
|
245
279
|
- **Feature:** each fresh unattended stretch now opens with a one-time **briefing message** — the first checklist step of a new round (after human activity resets the cycle) is prefixed with a short explanation telling Claude that cwarm's AI mode is driving, why it's receiving instructions with no request from the user, how the quota pacing works, that a real human message always overrides it, and the built-in safety bounds. Mid-cycle wraparounds within the same unattended stretch don't repeat it. Implemented as a pure, tested `pickInjectMsg` option — no duplicated logic in the host loop.
|
|
246
280
|
- **Docs:** added a full **"Unattended AI mode"** section (English + 繁體中文) covering how it detects you've stepped away, how it paces itself against your 5h/weekly quota, when to turn it on vs. leave it off, how to toggle it, the built-in 18-step cycle, and every `CWARM_AI_*` config knob — previously this was only in `cwarm help` and the changelog, with no standalone explanation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-cache-keepalive",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Keep Claude Code's prompt cache warm while idle, by running claude inside a PTY host and injecting a tiny keepalive when you step away. Cross-platform, no tmux required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/host.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
|
-
import { defaultClaudeDir, regimeParams, detectTtlRegime, decideInject, transcriptIdleMs, looksLikeTrustPrompt, detectBillingMode, readUsageBridge, usageState, looksLikeHumanInput, pickInjectMsg, aiPacing, weeklyGate, readAiMsgFile, aiStatePath, extractAiToggle, clampHumanQuiet, toggleKeySpec, readAiState, initialAiEnabled } 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, sessionBridgePath, readSessionTranscript, readTtlRegime, transcriptIdleMsAt } from './keepalive.mjs';
|
|
9
9
|
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
11
11
|
const isWin = process.platform === 'win32';
|
|
@@ -57,12 +57,29 @@ export function startHost(opts = {}) {
|
|
|
57
57
|
// 固定取一次 cwd,全程共用(host 不會 chdir):writeAiState/usage bridge/transcript 查找
|
|
58
58
|
// 都用同一個值,避免不同時間點呼叫 process.cwd() 理論上不一致、間接影響 cwdKey 對應。
|
|
59
59
|
const hostCwd = process.cwd();
|
|
60
|
+
// session bridge 用的 host 識別碼:pid+啟動時刻,每次啟動唯一。經環境變數傳進 pty 裡的
|
|
61
|
+
// claude,statusline 子行程繼承後把本 session 的 transcript_path 落地成
|
|
62
|
+
// cwarm-session-<hostId>.json,host 讀回即可針定自己的 transcript——多分頁在同一個
|
|
63
|
+
// 資料夾各開 session 時,不再誤拿「資料夾裡最新的 .jsonl」(可能是隔壁 session 的)。
|
|
64
|
+
const hostId = `${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
|
65
|
+
// 孤兒橋接檔清理:host 正常退出會刪掉自己那份,但 crash / 斷電會留下殘檔,啟動時
|
|
66
|
+
// 順手清掉太舊的(>7 天,肯定不會再有 host 認領)。
|
|
67
|
+
try {
|
|
68
|
+
for (const name of fs.readdirSync(claudeDir)) {
|
|
69
|
+
if (!/^cwarm-session-.*\.json$/.test(name)) continue;
|
|
70
|
+
const p = path.join(claudeDir, name);
|
|
71
|
+
try {
|
|
72
|
+
const o = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
73
|
+
if (o == null || o.ts == null || Date.now() - o.ts > 7 * 86_400_000) fs.unlinkSync(p);
|
|
74
|
+
} catch { try { fs.unlinkSync(p); } catch {} }
|
|
75
|
+
}
|
|
76
|
+
} catch { /* claudeDir 讀不到就算了 */ }
|
|
60
77
|
const ptyProc = pty.spawn(file, spawnArgs, {
|
|
61
78
|
name: process.env.TERM || 'xterm-256color',
|
|
62
79
|
cols: process.stdout.columns || 80,
|
|
63
80
|
rows: process.stdout.rows || 24,
|
|
64
81
|
cwd: hostCwd,
|
|
65
|
-
env: process.env,
|
|
82
|
+
env: { ...process.env, CWARM_HOST_ID: hostId },
|
|
66
83
|
});
|
|
67
84
|
|
|
68
85
|
// ---- 透明 I/O 多工 ----
|
|
@@ -259,7 +276,12 @@ export function startHost(opts = {}) {
|
|
|
259
276
|
pendingResume = true; // 視窗已重置:下一發改敲 resumeMsg,回溫 cache 兼續跑被打斷的任務
|
|
260
277
|
try { fs.appendFileSync(LOG, `${new Date().toISOString()} window reset — next inject will send "${resumeMsg()}"\n`); } catch {}
|
|
261
278
|
}
|
|
262
|
-
|
|
279
|
+
// session bridge 有落地時針定本 session 的 transcript(多分頁同資料夾不互相干擾);
|
|
280
|
+
// 沒有(未跑 cwarm setup 裝 statusline、或 payload 還沒刷新過)回退舊的「資料夾最新」猜法。
|
|
281
|
+
const ownTranscript = readSessionTranscript(claudeDir, hostId);
|
|
282
|
+
const regime = ownTranscript
|
|
283
|
+
? readTtlRegime(ownTranscript) // 從自己 session 的 transcript 實測 1h/5m
|
|
284
|
+
: detectTtlRegime(claudeDir, hostCwd); // 回退:資料夾內最新 transcript
|
|
263
285
|
// 週配速:7 天視窗以日均(100/7 ≈ 14.3%/天)攤出進度線,ok=可快跑、slow=退回
|
|
264
286
|
// 一個 TTL 一步、off=超線一天日均以上 → 暫停 AI 工作只剩純保溫 "hi"。
|
|
265
287
|
const wgate = weeklyGate({
|
|
@@ -280,7 +302,9 @@ export function startHost(opts = {}) {
|
|
|
280
302
|
fastMaxPct: envNumber('CWARM_AI_FAST_PCT', 70, LOG),
|
|
281
303
|
});
|
|
282
304
|
const now = Date.now();
|
|
283
|
-
const idleMs =
|
|
305
|
+
const idleMs = ownTranscript
|
|
306
|
+
? transcriptIdleMsAt(ownTranscript, now, startedMs)
|
|
307
|
+
: transcriptIdleMs(claudeDir, hostCwd, now, startedMs);
|
|
284
308
|
// idleMs 找不到 transcript 時保溫整個靜默失效,且原本沒有任何提示——啟動超過 2 分鐘
|
|
285
309
|
// (早期本來就會是 null,Context 0% 正常現象,不必吵)仍找不到才警告一次,提示可能是
|
|
286
310
|
// cwd 與 Claude Code 建立的 transcript 資料夾對不上(磁碟代號大小寫、junction 等)。
|
|
@@ -337,6 +361,7 @@ export function startHost(opts = {}) {
|
|
|
337
361
|
if (exiting) return;
|
|
338
362
|
exiting = true;
|
|
339
363
|
clearInterval(timer);
|
|
364
|
+
try { fs.unlinkSync(sessionBridgePath(claudeDir, hostId)); } catch {} // 自己的 session bridge 檔用完即刪
|
|
340
365
|
try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
|
|
341
366
|
try { process.stdin.pause(); } catch {}
|
|
342
367
|
try { process.stdout.write(RESET, () => process.exit(code)); }
|
|
@@ -352,6 +377,7 @@ export function startHost(opts = {}) {
|
|
|
352
377
|
process.on('SIGHUP', () => shutdown(0));
|
|
353
378
|
// 任何路徑退出都殺掉 pty,並盡力(同步)還原終端,作為最後保險。
|
|
354
379
|
process.on('exit', () => {
|
|
380
|
+
try { fs.unlinkSync(sessionBridgePath(claudeDir, hostId)); } catch {} // shutdown 沒走到時的最後保險
|
|
355
381
|
try { ptyProc.kill(); } catch {}
|
|
356
382
|
try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
|
|
357
383
|
try { process.stdout.write(RESET); } catch {}
|
package/src/keepalive.mjs
CHANGED
|
@@ -106,6 +106,42 @@ export function transcriptIdleMs(claudeDir, cwd, now = Date.now(), sinceMs = nul
|
|
|
106
106
|
return now - m;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// ---- 本 session transcript 針定(session bridge)----
|
|
110
|
+
// 多分頁在「同一個資料夾」各開一個 session 時,上面「取 mtime 最新的 .jsonl」會抓到
|
|
111
|
+
// 隔壁分頁的 transcript:只要隔壁還在活動,自己這邊的 idle 永遠算不滿門檻,保溫不發、
|
|
112
|
+
// cache 冷掉(倒數走到🔴)。TTL 檔位偵測也可能讀到別人的檔。
|
|
113
|
+
// 解法:只有 statusline 拿得到 Claude Code payload 的 transcript_path(session 專屬、
|
|
114
|
+
// 絕對正確),由 segment.mjs 落地成 per-host 橋接檔(cwarm-session-<hostId>.json),
|
|
115
|
+
// host 每 tick 讀回,從此針定自己的 transcript。hostId 由 host 產生、經 CWARM_HOST_ID
|
|
116
|
+
// 環境變數傳進 pty 裡的 claude,statusline 子行程自然繼承。
|
|
117
|
+
// 沒裝 statusline(未跑 cwarm setup)就沒有橋接檔 → 回退舊行為(單 session 不受影響)。
|
|
118
|
+
export function sessionBridgePath(claudeDir, hostId) {
|
|
119
|
+
// hostId 進檔名前先消毒:值理論上只來自自家 host,但環境變數可被外部塞怪字元,不給路徑穿越機會。
|
|
120
|
+
return path.join(claudeDir, `cwarm-session-${String(hostId).replace(/[^a-zA-Z0-9_-]/g, '-')}.json`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 讀橋接檔取回本 session 的 transcript 路徑;hostId 空、檔案不存在、或路徑已失效 → null。
|
|
124
|
+
// 不做 ts 時效檢查:檔案是本 host 專屬(hostId 每次啟動唯一),內容只會被自己 session 的
|
|
125
|
+
// statusline 更新,路徑本身不會「過期」(/clear 換 session 檔時 statusline 下次刷新就改寫)。
|
|
126
|
+
export function readSessionTranscript(claudeDir, hostId) {
|
|
127
|
+
if (!hostId) return null;
|
|
128
|
+
const o = readJsonSafe(sessionBridgePath(claudeDir, hostId));
|
|
129
|
+
const p = o && typeof o.transcript_path === 'string' && o.transcript_path ? o.transcript_path : null;
|
|
130
|
+
if (!p) return null;
|
|
131
|
+
try { fs.statSync(p); } catch { return null; }
|
|
132
|
+
return p;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 針定版 idle:直接看指定 transcript 的 mtime,語意同 transcriptIdleMs(含 sinceMs 的
|
|
136
|
+
// 「Context 0% 不保溫」檢查——resume 舊 session 未寫入前 mtime 早於啟動時刻 → null 不注入)。
|
|
137
|
+
export function transcriptIdleMsAt(tpath, now = Date.now(), sinceMs = null) {
|
|
138
|
+
if (!tpath) return null;
|
|
139
|
+
let m;
|
|
140
|
+
try { m = fs.statSync(tpath).mtimeMs; } catch { return null; }
|
|
141
|
+
if (sinceMs != null && m < sinceMs) return null;
|
|
142
|
+
return now - m;
|
|
143
|
+
}
|
|
144
|
+
|
|
109
145
|
// 讀 transcript 尾端,判斷這個 session 實際拿到的 cache TTL 檔位。
|
|
110
146
|
// 回傳 'long'(1h) / 'short'(5m) / null(找不到可判讀的 cache_creation)。
|
|
111
147
|
// 為什麼讀 transcript 而非帳號方案:message.usage.cache_creation 的
|
|
@@ -277,6 +313,40 @@ export function weeklyGate({ usedPct, resetsAt, nowSec, graceDays = 1 } = {}) {
|
|
|
277
313
|
return 'ok';
|
|
278
314
|
}
|
|
279
315
|
|
|
316
|
+
// ---- 週配速具現化(給人看的球號 + 剩餘天數均可用量)----
|
|
317
|
+
// weeklyGate 只吐三檔給 aiPacing 悄悄降速,人完全看不到;這裡把「超前進度線多少」
|
|
318
|
+
// 換算成球色:
|
|
319
|
+
// <2% 🟢(配速正常)/3~5% 🟡/6~8% 🟠/9~11% 🩷(Unicode 無粉紅圓形,借粉紅
|
|
320
|
+
// 愛心最接近)/≥12% 🔴。2~3% 之間留白不顯示球(維持安靜,避免在正常/警示邊界
|
|
321
|
+
// 上抖動)。
|
|
322
|
+
// avgPerDayRemaining:把剩餘額度 (100-usedPct) 均攤到視窗剩餘天數,讓使用者知道
|
|
323
|
+
// 「接下來每天還能燒多少」而不是只看到一個抽象的超前百分點。
|
|
324
|
+
// recoverySec:不再新增用量的前提下,進度線每秒自然爬升 100/WEEK% ——excess 會跟著
|
|
325
|
+
// 等速下降,算出降到 <2%(回綠球門檻)還要「休息」幾秒;已經是綠球則為 0。算出來若
|
|
326
|
+
// 超過視窗剩餘時間,代表撐到 reset 都回不了綠,直接鉗在剩餘時間(reset 一到整組歸零,
|
|
327
|
+
// 保證回綠)。
|
|
328
|
+
export function weeklyPaceInfo({ usedPct, resetsAt, nowSec } = {}) {
|
|
329
|
+
if (usedPct == null || resetsAt == null) return null;
|
|
330
|
+
const WEEK = 7 * 86400;
|
|
331
|
+
const elapsed = Math.min(Math.max(nowSec - (resetsAt - WEEK), 0), WEEK);
|
|
332
|
+
const expectedPct = (elapsed / WEEK) * 100;
|
|
333
|
+
const excess = usedPct - expectedPct;
|
|
334
|
+
let ball = null;
|
|
335
|
+
if (excess >= 12) ball = '\u{1F534}'; // 🔴
|
|
336
|
+
else if (excess >= 9) ball = '\u{1FA77}'; // 🩷
|
|
337
|
+
else if (excess >= 6) ball = '\u{1F7E0}'; // 🟠
|
|
338
|
+
else if (excess >= 3) ball = '\u{1F7E1}'; // 🟡
|
|
339
|
+
else if (excess < 2) ball = '\u{1F7E2}'; // 🟢
|
|
340
|
+
if (!ball) return null;
|
|
341
|
+
const daysRemaining = Math.max(WEEK - elapsed, 0) / 86400;
|
|
342
|
+
const remainingPct = Math.max(100 - usedPct, 0);
|
|
343
|
+
const avgPerDayRemaining = daysRemaining > 0 ? remainingPct / daysRemaining : 0;
|
|
344
|
+
const remainingSec = Math.max(WEEK - elapsed, 0);
|
|
345
|
+
const rate = 100 / WEEK; // %/秒,進度線自然爬升速度
|
|
346
|
+
const recoverySec = excess > 2 ? Math.min((excess - 2) / rate, remainingSec) : 0;
|
|
347
|
+
return { ball, excess, daysRemaining, avgPerDayRemaining, recoverySec };
|
|
348
|
+
}
|
|
349
|
+
|
|
280
350
|
// ---- 無人值守 AI 模式 ----
|
|
281
351
|
// 連續兩次注入之間完全沒有人為鍵盤輸入 → 判定無人值守,第三發起改敲「能與 AI 互動」
|
|
282
352
|
// 的訊息(續推任務/回報進度),而不是傻傻的 "hi";使用者一敲鍵就歸零回到 "hi"。
|
|
@@ -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 } from '../keepalive.mjs';
|
|
7
|
+
import { defaultClaudeDir, readTtlRegime, regimeParams, billingModeFromSources, accountInfoFromSources, readAuthSources, usageState, usageBridgePath, readAiState, sessionBridgePath, weeklyPaceInfo } from '../keepalive.mjs';
|
|
8
8
|
|
|
9
9
|
const claudeDir = defaultClaudeDir();
|
|
10
10
|
const ORIG = path.join(claudeDir, 'cwarm-statusline-orig.json');
|
|
@@ -51,8 +51,11 @@ function payloadCwd(payload) {
|
|
|
51
51
|
// 額度橋接 + 提醒段:host(保溫迴圈)收不到 statusline payload,這裡把 rate_limits.five_hour
|
|
52
52
|
// 落地成 per-cwd 橋接檔給 host 讀(撞牆偵測與 reset 後改敲 "go on" 都靠它)——不同帳號/
|
|
53
53
|
// 專案的並行 session 各寫各的,不會互相覆蓋彼此的額度視窗。
|
|
54
|
-
//
|
|
55
|
-
//
|
|
54
|
+
// 回傳提醒文字(可能兩段用 │ 接起來):
|
|
55
|
+
// 5h — 用量 ≥95% 且離 reset 還超過 1 小時 → 建議使用者暫時中斷;不滿 1 小時就不提醒,
|
|
56
|
+
// 尾巴額度照常燒到撞牆。
|
|
57
|
+
// 週 — weeklyPaceInfo 依超前「按時間比例均攤」進度線幾個百分點顯示球號(<2🟢/3~5🟡/
|
|
58
|
+
// 6~8🟠/9~11🩷/≥12🔴,2~3% 留白),並附上剩餘天數的日均可用量。
|
|
56
59
|
function usageBridgeAndWarn(payload, cwd) {
|
|
57
60
|
const fh = payload?.rate_limits?.five_hour;
|
|
58
61
|
if (!fh || typeof fh !== 'object') return '';
|
|
@@ -69,9 +72,25 @@ function usageBridgeAndWarn(payload, cwd) {
|
|
|
69
72
|
}));
|
|
70
73
|
} catch { /* 落地失敗不影響顯示 */ }
|
|
71
74
|
}
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
+
const nowSec = Date.now() / 1000;
|
|
76
|
+
const parts = [];
|
|
77
|
+
if (usageState({ usedPct, resetsAt, nowSec }) === 'warn') {
|
|
78
|
+
const hLeft = Math.round((resetsAt - nowSec) / 3600);
|
|
79
|
+
parts.push(`⚠️ ${Math.round(usedPct)}%、離reset還${hLeft}h,建議暫停`); // ⚠️ NN%、離reset還Nh,建議暫停
|
|
80
|
+
}
|
|
81
|
+
const wp = weeklyPaceInfo({ usedPct: sd?.used_percentage ?? null, resetsAt: sd?.resets_at ?? null, nowSec });
|
|
82
|
+
if (wp) {
|
|
83
|
+
// 球 週超前X.X%,剩N.Nd均M.M%/天(,休息H.Hh回綠):X 是超出「按時間比例均攤」
|
|
84
|
+
// 進度線的百分點,均M.M%/天是把剩餘額度攤到視窗剩餘天數後接下來每天還能燒多少,
|
|
85
|
+
// 休息段只在非綠球時出現——不再新增用量、單純等進度線爬上來要等多久才回綠球。
|
|
86
|
+
let text = `${wp.ball} 週超前${wp.excess.toFixed(1)}%,剩${wp.daysRemaining.toFixed(1)}d均${wp.avgPerDayRemaining.toFixed(1)}%/天`;
|
|
87
|
+
if (wp.recoverySec > 0) {
|
|
88
|
+
const h = wp.recoverySec / 3600;
|
|
89
|
+
text += h >= 24 ? `,休息${(h / 24).toFixed(1)}d回綠` : `,休息${h.toFixed(1)}h回綠`;
|
|
90
|
+
}
|
|
91
|
+
parts.push(text);
|
|
92
|
+
}
|
|
93
|
+
return parts.join(' │ ');
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
// AI 模式開關段:顯示無人值守 AI 模式 on/off 與切換熱鍵。狀態檔以 cwd 編碼命名
|
|
@@ -106,6 +125,17 @@ const raw = readStdin();
|
|
|
106
125
|
let payload = {};
|
|
107
126
|
try { payload = JSON.parse(raw); } catch { /* 空/壞就用空物件 */ }
|
|
108
127
|
|
|
128
|
+
// 本 session transcript 落地給 host(session bridge):payload 的 transcript_path 是
|
|
129
|
+
// session 專屬的正確值,host 端只能猜「資料夾裡最新的 .jsonl」——多分頁同資料夾時會
|
|
130
|
+
// 猜到隔壁 session、保溫失準。host 產生的 CWARM_HOST_ID 經 pty 環境變數傳到這裡;
|
|
131
|
+
// 沒有這個變數代表這個 session 不是 cwarm 帶起來的(純 claude),不落地。
|
|
132
|
+
if (process.env.CWARM_HOST_ID && payload?.transcript_path) {
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(sessionBridgePath(claudeDir, process.env.CWARM_HOST_ID),
|
|
135
|
+
JSON.stringify({ transcript_path: payload.transcript_path, ts: Date.now() }));
|
|
136
|
+
} catch { /* 落地失敗不影響顯示 */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
109
139
|
const cwd = payloadCwd(payload);
|
|
110
140
|
const seg = cacheSegment(payload);
|
|
111
141
|
const warn = usageBridgeAndWarn(payload, cwd);
|