claude-cache-keepalive 0.1.11 → 0.1.13

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
@@ -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 the newest 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.)
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,57 @@ cwarm setup --remove # restores your previous statusline
64
64
 
65
65
  (Written in Node — no Python dependency, no Windows codepage issues.)
66
66
 
67
+ ## Unattended AI mode (opt-in, off by default)
68
+
69
+ 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.
70
+
71
+ ### How it decides "you're gone"
72
+
73
+ - Two consecutive keepalive pings pass with **zero human keystrokes** in between → the third one switches from `hi` to the first step of the work cycle.
74
+ - Typing anything — even a single keystroke — resets it straight back to plain `hi`. Bracketed-paste and Windows' `win32-input-mode` batch-paste packets are recognized as what they are, so a large paste can never be mistaken for real typing, for the toggle hotkey, or corrupted in transit.
75
+ - A configurable quiet window (`CWARM_HUMAN_QUIET_S`, default 5 min) blocks injection entirely right after you type, so a half-finished draft you paused to think about can't get Enter-submitted by an untimely `hi`.
76
+ - The very first instruction of each fresh unattended stretch is prefixed with a one-time briefing telling Claude *why* it's receiving this — that cwarm is driving, not its own initiative, what the safety rules are, and that real human input always overrides it.
77
+
78
+ ### How it paces itself
79
+
80
+ - **5-hour window:** below `CWARM_AI_FAST_PCT` (default 70%) usage and no other quota concern → fast pace (`CWARM_AI_PACE_S`, default 5 min) instead of waiting a full cache-TTL between injections, so the checklist actually gets somewhere. Above that — or once you're within an hour of the hard limit — it backs off; once you're actually rate-limited it stops injecting entirely and waits for the window to reset.
81
+ - **7-day window:** pro-rated against a flat daily budget (100%/7 ≈ 14.3%/day, with one day of grace). Running ahead of that pace pauses AI-mode work (falls back to plain `hi`) so a long unattended stretch can't burn a week's quota in a day.
82
+ - Once a quota window resets, the next injection is a plain `go on` instead of the next checklist step — resuming both the cache and whatever task was interrupted — unless you've already come back and sent something yourself.
83
+
84
+ ### Should you turn it on?
85
+
86
+ Turn it **on** if: you're fine with Claude doing small, verifiable maintenance work (reviews, tests, docs, refactors) on its own while you're away, you trust the built-in bounds (no deploys, no destructive operations, no large new scope — see the cycle below), and you want idle time to produce something instead of just a warm cache.
87
+
88
+ Leave it **off** (the default) if: you only want the cache kept warm and nothing else to happen; you're on a tight quota and don't want background token spend; the session touches anything sensitive or production-adjacent where you'd rather nothing runs without you watching; or you simply haven't reviewed what the built-in checklist does yet.
89
+
90
+ ### Turning it on/off
91
+
92
+ ```sh
93
+ cwarm --ai # on for this run
94
+ CWARM_AI=1 cwarm # same, via env var
95
+ ```
96
+
97
+ 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+\)`.
98
+
99
+ ### The built-in cycle
100
+
101
+ 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.
102
+
103
+ Replace it entirely with your own, or tune the pacing:
104
+
105
+ | Var | Meaning |
106
+ |-----|---------|
107
+ | `CWARM_AI` | `1`/`on`/`true`/`yes` to force on, `0`/`off` to force off (overrides the persisted toggle) |
108
+ | `CWARM_AI_MSG` | send this single fixed message instead of the cycle |
109
+ | `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 |
110
+ | `CWARM_TOGGLE_KEY` | rebind the hotkey from `Ctrl+\` to `Ctrl+<char>` |
111
+ | `CWARM_HUMAN_QUIET_S` | seconds of silence required after a keystroke before injecting again (default `300`) |
112
+ | `CWARM_AI_PACE_S` | fast-pace interval in seconds when quota allows (default `300`) |
113
+ | `CWARM_AI_FAST_PCT` | 5h-usage ceiling below which fast pace applies (default `70`) |
114
+ | `CWARM_RESUME_MSG` | override the post-quota-reset resume message (default `go on`) |
115
+
116
+ Everything is logged to `~/.claude/cwarm-keepalive.log` — which step fired, why (or why not), and the quota state at the time — so you can audit what happened while you were away.
117
+
67
118
  ## Configuration
68
119
 
69
120
  Environment variables (mostly for testing / advanced use):
@@ -83,7 +134,7 @@ Environment variables (mostly for testing / advanced use):
83
134
  ## Limitations
84
135
 
85
136
  - **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.
86
- - **Single session.** Designed for one `cwarm` session at a time.
137
+ - **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).
87
138
  - 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.
88
139
  - **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.
89
140
 
@@ -110,11 +161,62 @@ Environment variables (mostly for testing / advanced use):
110
161
  ### 運作原理
111
162
 
112
163
  - **PTY host**:`cwarm` 把 `claude` spawn 在一個它自己擁有的 pseudo-terminal 裡,透明地把你的鍵盤 ↔ claude ↔ 畫面(含視窗 resize)接起來。這跟 tmux/expect/VS Code 終端的做法相同,也是唯一穩健、能把輸入注入終端程式的方式。
113
- - **閒置偵測**:閒置=距你上次**訊息**多久,量自 `~/.claude/projects/` 底下最新的 transcript 檔。這才是決定 cache 年齡的訊號——捲動、用方向鍵讀、打到一半沒送出,都是終端輸入但不會刷新 cache,所以不該算成活動。(早期版本計時鍵盤輸入,會讓你在閱讀時 cache 冷掉。)
164
+ - **閒置偵測**:閒置=距你上次**訊息**多久,量自 `~/.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」猜法。
114
165
  - **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)。
115
166
  - **提示安全注入**:keepalive 只在 PTY **靜止一小段時間後**才觸發(`CWARM_QUIET_MS`,預設 2.5 秒)。必答提示(工具權限、`AskUserQuestion`、計畫批准)的 spinner 會一直動,忙著跑工具時也持續輸出,兩者都「不安靜」,所以 keepalive 不會送進去(不會誤選選單預設項、也不會打斷長工具執行)。而且注入時會**先送 `Esc`** 退回輸入框,那個 Enter 永遠落不到提示上。(提示真的卡住時 cache 本來就無法保溫,等你回答後會自動恢復。)
116
167
  - **與焦點/縮小無關**:注入是行程內部的 `pty.write`,跟視窗狀態無關。只有關閉視窗(結束 host 行程)才會停。
117
168
 
169
+ ### 無人值守 AI 模式(選配,預設關)
170
+
171
+ 純保溫只會送 `hi`——無害,但沒讓閒置時間產生任何價值。`--ai` 模式會:一旦 cwarm 判定你真的離開了,就不再送 `hi`,改成餵給 Claude 一組循環式的安全自我驗證工作(檢視這個 session、掃 TODO、補缺的測試、同步文件、把心得寫進 memory……),讓閒置視窗變成實際進度,而不只是空白等待。
172
+
173
+ **怎麼判定「你不在了」**
174
+
175
+ - 連續兩發保溫都完全沒有人為鍵盤輸入 → 第三發起,從 `hi` 改敲工作循環的第一步。
176
+ - 只要打了任何一個字(哪怕只有一個按鍵),就立刻歸零回到普通 `hi`。Bracketed-paste 與 Windows 的 `win32-input-mode` 批次貼上封包都會被正確識別,大量貼上內容不會被誤判成真人打字、不會誤觸切換熱鍵,也不會在轉送過程中被毀損。
177
+ - 可調的靜默窗(`CWARM_HUMAN_QUIET_S`,預設 5 分鐘)在你剛打完字之後完全封鎖注入——避免你停下來想事情時,半句沒送出的草稿被一發不合時宜的 `hi` 連 Enter 一起送出去。
178
+ - 每一輪全新的無人值守,第一句指令都會附上一次性簡報,跟 Claude 說明「為什麼會收到這句」——這是 cwarm 在驅動,不是它自己的主動行為,安全規則是什麼,以及真人輸入永遠優先於這些指令。
179
+
180
+ **怎麼配速**
181
+
182
+ - **5 小時視窗:**用量低於 `CWARM_AI_FAST_PCT`(預設 70%)且沒有其他額度疑慮 → 用快節奏(`CWARM_AI_PACE_S`,預設 5 分鐘),不必每次都空等一整個 cache TTL,循環才推得動。超過這個門檻、或離硬性上限不到一小時,就退回原節奏;真的撞到額度上限就整個暫停注入,等視窗重置。
183
+ - **7 天視窗:**按時間比例攤成一條日均進度線(100%/7 ≈ 14.3%/天,留一天緩衝)。用量跑到進度線前面就暫停 AI 工作(退回普通 `hi`),避免一段長時間的無人值守把一整週的額度燒穿。
184
+ - 額度視窗重置後,下一發改敲普通的 `go on` 而不是循環的下一步——同時回溫 cache 與接續被額度打斷的任務——除非你自己已經先回來發了訊息。
185
+
186
+ **要不要開?**
187
+
188
+ **開**的情境:你能接受 Claude 在你不在時做一些小而可驗證的維護工作(檢視、測試、文件、重構),信任內建的邊界(不部署、不做破壞性操作、不擴大範圍——見下方循環內容),而且希望閒置時間能產出東西,不只是保溫。
189
+
190
+ **維持關**(預設)的情境:你只想保溫、不想有任何額外動作;額度吃緊、不想有背景耗用;這個 session 涉及敏感或接近正式環境的內容,寧可沒人看著就什麼都不跑;或者你還沒看過內建循環到底會做什麼。
191
+
192
+ **開關方式**
193
+
194
+ ```sh
195
+ cwarm --ai # 這次啟動就開
196
+ CWARM_AI=1 cwarm # 效果相同,走環境變數
197
+ ```
198
+
199
+ 或執行中隨時按 `Ctrl+\` **即時切換**(可換鍵,見下方 `CWARM_TOGGLE_KEY`;IME 搶走預設鍵時很有用)。切換狀態**依專案持久化**、重啟沿用——`--ai` / `CWARM_AI=0` 會覆蓋下次啟動時記住的狀態。目前狀態顯示在 statusline:`🤖AI on (Ctrl+\)`。
200
+
201
+ **內建循環**
202
+
203
+ 18 步循環:review → 批判 review → TODO/FIXME 掃描 → 提出改進清單 → 執行最安全的項目 → 測試覆蓋 → mutation check(測試有效性)→ 錯誤處理稽核 → 輕量資安自查 → 相依套件體檢(只報告不升級)→ 效能低垂果實 → 小步重構 → 跨平台審視 → 文件同步 → README 快速上手驗證 → 對照方案探索 → 心得蒸餾進 project memory → 收尾報告+決策佇列。每一步都刻意設計成安全、有界、可驗證——不部署、不做破壞性操作、不展開大型新工作。
204
+
205
+ 想整套換掉、或調節奏,可用:
206
+
207
+ | 變數 | 意義 |
208
+ |-----|------|
209
+ | `CWARM_AI` | `1`/`on`/`true`/`yes` 強制開、`0`/`off` 強制關(覆蓋持久化狀態) |
210
+ | `CWARM_AI_MSG` | 改成固定敲這一句,取代整套循環 |
211
+ | `CWARM_AI_MSG_FILE` | 自訂指令檔路徑,一行一條(`#` 開頭為註解)——整套換掉,例如換成寫作/研究/翻譯而非軟體工程 |
212
+ | `CWARM_TOGGLE_KEY` | 把熱鍵從 `Ctrl+\` 換成 `Ctrl+<字元>` |
213
+ | `CWARM_HUMAN_QUIET_S` | 敲鍵後需靜默幾秒才可再注入(預設 `300`) |
214
+ | `CWARM_AI_PACE_S` | 額度充裕時的快節奏間隔秒數(預設 `300`) |
215
+ | `CWARM_AI_FAST_PCT` | 5h 用量低於此值才套用快節奏(預設 `70`) |
216
+ | `CWARM_RESUME_MSG` | 覆寫額度重置後的續跑訊息(預設 `go on`) |
217
+
218
+ 所有動作都會記進 `~/.claude/cwarm-keepalive.log`——哪一步觸發、為什麼(或為什麼沒有)、當下的額度狀態——回來後可以稽核你不在的這段時間發生了什麼。
219
+
118
220
  ### 設定
119
221
 
120
222
  環境變數(多為測試/進階用途):
@@ -139,6 +241,16 @@ Environment variables (mostly for testing / advanced use):
139
241
 
140
242
  ## Changelog
141
243
 
244
+ ### 0.1.13
245
+ - **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.
246
+ - **修正:** 用分頁在**同一個資料夾**開多個 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 時回退原本「資料夾最新」的猜法。
247
+
248
+ ### 0.1.12
249
+ - **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.
250
+ - **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.
251
+ - **功能:** 每一輪全新的無人值守,開頭第一步(使用者活動歸零後、重新進入無人值守時)現在會附上一次性**簡報訊息**,跟 Claude 說明 cwarm 的 AI 模式正在驅動、為什麼會平白收到指令、額度配速怎麼運作、真人訊息永遠優先於這些指令、以及內建的安全邊界。同一輪無人值守中途繞圈不會重複附加。實作成 `pickInjectMsg` 的一個純函式選項(有測試涵蓋),host 迴圈裡沒有重複判斷邏輯。
252
+ - **文件:** 新增完整的**「無人值守 AI 模式」**專節(英文+繁體中文),涵蓋怎麼判定「你不在了」、怎麼依 5 小時/週額度配速、什麼時候該開/該關、怎麼切換、內建 18 步循環內容、以及每一個 `CWARM_AI_*` 設定變數——先前這些只在 `cwarm help` 與 changelog 裡零散提過,沒有獨立完整說明。
253
+
142
254
  ### 0.1.11
143
255
  - **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
256
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-cache-keepalive",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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 多工 ----
@@ -177,6 +194,18 @@ export function startHost(opts = {}) {
177
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.',
178
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.',
179
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.';
180
209
  // 使用者敲鍵後需靜默這麼久才可注入(預設 5 分鐘)。CWARM_HUMAN_QUIET_S 可調;
181
210
  // 打錯成非數字(如 "5m")會記警告並退回預設,不會悄悄關掉這個安全機制。
182
211
  const humanQuietMs = envNumber('CWARM_HUMAN_QUIET_S', 300, LOG) * 1000;
@@ -247,7 +276,12 @@ export function startHost(opts = {}) {
247
276
  pendingResume = true; // 視窗已重置:下一發改敲 resumeMsg,回溫 cache 兼續跑被打斷的任務
248
277
  try { fs.appendFileSync(LOG, `${new Date().toISOString()} window reset — next inject will send "${resumeMsg()}"\n`); } catch {}
249
278
  }
250
- const regime = detectTtlRegime(claudeDir, hostCwd); // transcript 實測 1h/5m,不再猜方案
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
251
285
  // 週配速:7 天視窗以日均(100/7 ≈ 14.3%/天)攤出進度線,ok=可快跑、slow=退回
252
286
  // 一個 TTL 一步、off=超線一天日均以上 → 暫停 AI 工作只剩純保溫 "hi"。
253
287
  const wgate = weeklyGate({
@@ -268,7 +302,9 @@ export function startHost(opts = {}) {
268
302
  fastMaxPct: envNumber('CWARM_AI_FAST_PCT', 70, LOG),
269
303
  });
270
304
  const now = Date.now();
271
- const idleMs = transcriptIdleMs(claudeDir, hostCwd, now, startedMs);
305
+ const idleMs = ownTranscript
306
+ ? transcriptIdleMsAt(ownTranscript, now, startedMs)
307
+ : transcriptIdleMs(claudeDir, hostCwd, now, startedMs);
272
308
  // idleMs 找不到 transcript 時保溫整個靜默失效,且原本沒有任何提示——啟動超過 2 分鐘
273
309
  // (早期本來就會是 null,Context 0% 正常現象,不必吵)仍找不到才警告一次,提示可能是
274
310
  // cwd 與 Claude Code 建立的 transcript 資料夾對不上(磁碟代號大小寫、junction 等)。
@@ -295,7 +331,7 @@ export function startHost(opts = {}) {
295
331
  // 先送 Esc:把任何「必答」modal(權限/選單/計畫批准)收掉、退回輸入框,後面那個 Enter 才不會誤選預設項;
296
332
  // 空輸入框時 Esc 等同 no-op。隔一小段再送訊息——讓 claude 先把 modal 收乾淨,也避免 ESC 與字元被併成 Meta 鍵。
297
333
  const aiAllowed = aiEnabled && wgate !== 'off';
298
- const injectMsg = pickInjectMsg({ pendingResume, consecInjects, aiStep, resumeMsg: resumeMsg(), aiMsg, msg, aiAllowed });
334
+ const injectMsg = pickInjectMsg({ pendingResume, consecInjects, aiStep, resumeMsg: resumeMsg(), aiMsg, msg, aiAllowed, briefing: AI_BRIEFING });
299
335
  // 只有真的敲出 AI 循環裡的一步才推進 aiStep;被 wgate='off' 等閘門擋下、退回一般
300
336
  // "hi" 或 resume 的那些發送不算,避免恢復後循環整段跳號(見上方 aiStep 宣告的說明)。
301
337
  const firedAiStep = !pendingResume && consecInjects >= 2 && aiAllowed && Array.isArray(aiMsg);
@@ -325,6 +361,7 @@ export function startHost(opts = {}) {
325
361
  if (exiting) return;
326
362
  exiting = true;
327
363
  clearInterval(timer);
364
+ try { fs.unlinkSync(sessionBridgePath(claudeDir, hostId)); } catch {} // 自己的 session bridge 檔用完即刪
328
365
  try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
329
366
  try { process.stdin.pause(); } catch {}
330
367
  try { process.stdout.write(RESET, () => process.exit(code)); }
@@ -340,6 +377,7 @@ export function startHost(opts = {}) {
340
377
  process.on('SIGHUP', () => shutdown(0));
341
378
  // 任何路徑退出都殺掉 pty,並盡力(同步)還原終端,作為最後保險。
342
379
  process.on('exit', () => {
380
+ try { fs.unlinkSync(sessionBridgePath(claudeDir, hostId)); } catch {} // shutdown 沒走到時的最後保險
343
381
  try { ptyProc.kill(); } catch {}
344
382
  try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
345
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 的
@@ -359,10 +395,16 @@ export function clampHumanQuiet(humanQuietMs, idleThresholdS) {
359
395
  // 用來判定是否進入無人值守(≥2),但暫停期間送出的普通 "hi" 也會讓它累加——若拿它直接
360
396
  // 當循環索引,暫停後恢復會整段跳號、破壞步驟間的依賴(如「依建議執行」承接前一步的發現)。
361
397
  // 故循環位置改用呼叫端維護、只在真的敲出 AI 循環訊息時才遞增的 aiStep。
362
- export function pickInjectMsg({ pendingResume, consecInjects, aiStep = 0, resumeMsg, aiMsg, msg, aiAllowed = true }) {
398
+ // briefing:每輪無人值守的第一步(aiStep===0)附加一次性說明,讓被驅動的 Claude 知道自己
399
+ // 在被 cwarm 自動驅動、規則是什麼(見 host.mjs 的 AI_BRIEFING);同一輪其餘步驟不重複附加。
400
+ // 只在真的選中陣列循環的第一格時才附加——固定字串 aiMsg(CWARM_AI_MSG)或非陣列不適用。
401
+ export function pickInjectMsg({ pendingResume, consecInjects, aiStep = 0, resumeMsg, aiMsg, msg, aiAllowed = true, briefing = null }) {
363
402
  if (pendingResume) return resumeMsg;
364
403
  if (consecInjects >= 2 && aiAllowed) {
365
- if (Array.isArray(aiMsg)) return aiMsg[aiStep % aiMsg.length];
404
+ if (Array.isArray(aiMsg)) {
405
+ const step = aiMsg[aiStep % aiMsg.length];
406
+ return briefing && aiStep === 0 ? `${briefing} ${step}` : step;
407
+ }
366
408
  return aiMsg;
367
409
  }
368
410
  return msg;
@@ -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 } from '../keepalive.mjs';
8
8
 
9
9
  const claudeDir = defaultClaudeDir();
10
10
  const ORIG = path.join(claudeDir, 'cwarm-statusline-orig.json');
@@ -106,6 +106,17 @@ const raw = readStdin();
106
106
  let payload = {};
107
107
  try { payload = JSON.parse(raw); } catch { /* 空/壞就用空物件 */ }
108
108
 
109
+ // 本 session transcript 落地給 host(session bridge):payload 的 transcript_path 是
110
+ // session 專屬的正確值,host 端只能猜「資料夾裡最新的 .jsonl」——多分頁同資料夾時會
111
+ // 猜到隔壁 session、保溫失準。host 產生的 CWARM_HOST_ID 經 pty 環境變數傳到這裡;
112
+ // 沒有這個變數代表這個 session 不是 cwarm 帶起來的(純 claude),不落地。
113
+ if (process.env.CWARM_HOST_ID && payload?.transcript_path) {
114
+ try {
115
+ fs.writeFileSync(sessionBridgePath(claudeDir, process.env.CWARM_HOST_ID),
116
+ JSON.stringify({ transcript_path: payload.transcript_path, ts: Date.now() }));
117
+ } catch { /* 落地失敗不影響顯示 */ }
118
+ }
119
+
109
120
  const cwd = payloadCwd(payload);
110
121
  const seg = cacheSegment(payload);
111
122
  const warn = usageBridgeAndWarn(payload, cwd);