claude-cache-keepalive 0.1.3 → 0.1.5

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
@@ -44,6 +44,7 @@ It's transparent — type and use claude exactly as normal (no `Ctrl-b` prefix,
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.
46
46
  - This survives client‑version, env‑var and server‑flag changes that the plan string can't see (e.g. a Pro account can still get a 1 h cache).
47
+ - **Prompt‑safe injection** — the keepalive only fires once the PTY has been **silent for a moment** (`CWARM_QUIET_MS`, default 2.5 s). A mandatory prompt (tool‑permission, `AskUserQuestion`, plan approval) keeps animating its spinner, and a busy tool‑run keeps streaming output — both are "not silent", so the keepalive won't fire into them (no accidental menu‑default selection, no interrupting a long tool‑run). And when it does fire it's **`Esc`‑prefixed**: it backs out to the input box first, so the keepalive's Enter can never land on a prompt and auto‑select. (While a prompt is genuinely blocking, the cache can't be kept warm regardless — no API turn can happen until you answer — so it simply resumes once you do.)
47
48
  - **Focus/minimize independent** — injection is an in‑process `pty.write`, unrelated to window state. Only closing the window (ending the host process) stops it.
48
49
 
49
50
  ## Optional: cache‑countdown statusline
@@ -71,6 +72,8 @@ Environment variables (mostly for testing / advanced use):
71
72
  |-----|---------|
72
73
  | `CWARM_MSG` | keepalive message (default `hi`) |
73
74
  | `CWARM_TICK_MS` | check interval (default `20000`) |
75
+ | `CWARM_QUIET_MS` | screen must be silent this long before injecting (default `2500`) |
76
+ | `CWARM_ESC_DELAY_MS` | gap between the `Esc` and the keepalive message (default `250`) |
74
77
  | `CWARM_THRESHOLD_S` | override idle threshold (seconds) |
75
78
  | `CWARM_TTL_S` | override cooldown (seconds) |
76
79
  | `CWARM_CLAUDE` | path to the `claude` executable (otherwise auto‑detected via `which`/`where`) |
@@ -80,7 +83,8 @@ Environment variables (mostly for testing / advanced use):
80
83
 
81
84
  - **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.
82
85
  - **Single session.** Designed for one `cwarm` session at a time.
83
- - If you walk away midtyping, an injected `hi` is appended to whatever's in the input box. Rare and harmless.
86
+ - If you walk away with a halftyped draft and stay idle past the threshold, the keepalive's `Esc` clears the draft before sending `hi`. Rare.
87
+ - **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.
84
88
 
85
89
  ## Platform support
86
90
 
@@ -104,6 +108,12 @@ Environment variables (mostly for testing / advanced use):
104
108
 
105
109
  ## Changelog
106
110
 
111
+ ### 0.1.5
112
+ - **Fix:** the keepalive could fire while Claude Code was showing a **mandatory prompt** (tool‑permission, `AskUserQuestion`, plan approval). Because the injected `hi␍` ends in Enter, that Enter landed on the prompt and selected its highlighted default — e.g. **auto‑approving a tool** — instead of sending a message (the reported "can't send `hi`"). Two layers fix it: **(1)** injection now waits for the PTY to be **quiet** (`CWARM_QUIET_MS`, default 2.5 s) — an animating prompt and a busy tool‑run both keep emitting output, so the keepalive no longer fires into either (this also stops it interrupting a long tool‑run, which the transcript‑mtime idle timer can't see); **(2)** the keepalive is now **`Esc`‑prefixed** (`CWARM_ESC_DELAY_MS` gap, default 250 ms) — it backs out of any prompt to the input box before sending `hi`, so the Enter can never select a menu default. Investigated empirically: a pending tool turn isn't written to the transcript while blocked (so transcript inspection can't detect this state), but the screen reliably distinguishes idle (silent) from prompt/busy (animating). While a prompt is genuinely blocking the cache can't be kept warm regardless; warming resumes once you answer.
113
+
114
+ ### 0.1.4
115
+ - **Fix:** the terminal could be left unusable after `/exit` or Ctrl‑C (keystrokes garbled / no usable input). The PTY host now restores the terminal on every exit path: it emits an explicit reset (disabling alt‑screen, bracketed‑paste, mouse, cursor‑hide, and — critically on Windows — `win32‑input‑mode` `?9001` and focus‑reporting `?1004`, which otherwise make the shell receive keystrokes as unparseable `ESC[…_` packets) and flushes stdout before exiting. Adds a `SIGINT` handler that forwards `0x03` to claude instead of letting the host be killed before cleanup, plus `SIGHUP`/`exit` safety restores.
116
+
107
117
  ### 0.1.3
108
118
  - **Change:** the cache TTL is now **measured from the transcript** (`message.usage.cache_creation`'s `ephemeral_1h` / `ephemeral_5m` tokens) instead of being guessed from your subscription plan. A recent 1h write → 1h regime; only 5m writes (or no evidence) → 5m regime (conservative). This drops the `~/.claude/.credentials.json` read entirely and is correct even when a Pro account gets a 1h cache. Adds `transcriptPath` / `readTtlRegime` / `detectTtlRegime` / `regimeParams`; removes `detectPlan` / `planParams`.
109
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-cache-keepalive",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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
@@ -53,7 +53,10 @@ export function startHost(opts = {}) {
53
53
  if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
54
54
  process.stdin.resume();
55
55
  process.stdin.on('data', (d) => { ptyProc.write(d); });
56
- ptyProc.onData((d) => process.stdout.write(d));
56
+ // 追蹤 claude 最近一次有輸出到畫面的時刻:閒置在輸入框時畫面靜止;提示等待回答時 spinner 在動、
57
+ // 生成/跑工具時持續輸出。注入前要求畫面已靜止一段時間(見下方 quietMs),就能避開「忙/卡」狀態。
58
+ let lastOutputMs = Date.now();
59
+ ptyProc.onData((d) => { lastOutputMs = Date.now(); process.stdout.write(d); });
57
60
  process.stdout.on('resize', () => {
58
61
  try { ptyProc.resize(process.stdout.columns || 80, process.stdout.rows || 24); } catch {}
59
62
  });
@@ -61,6 +64,8 @@ export function startHost(opts = {}) {
61
64
  // ---- 內建保溫 ----
62
65
  const tickMs = Number(process.env.CWARM_TICK_MS) || 20_000;
63
66
  const msg = process.env.CWARM_MSG || opts.msg || 'hi';
67
+ const quietMs = Number(process.env.CWARM_QUIET_MS) || 2500; // 畫面需靜止這麼久才注入
68
+ const escDelayMs = Number(process.env.CWARM_ESC_DELAY_MS) || 250; // Esc 與訊息之間的間隔
64
69
  const overrides = {};
65
70
  const thr = process.env.CWARM_THRESHOLD_S ?? opts.thresholdS;
66
71
  const ttlO = process.env.CWARM_TTL_S ?? opts.ttlS;
@@ -74,26 +79,51 @@ export function startHost(opts = {}) {
74
79
  const { ttl, idleThreshold } = regimeParams(regime, overrides);
75
80
  const now = Date.now();
76
81
  const idleMs = transcriptIdleMs(claudeDir, cwd, now);
77
- if (decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE) })) {
78
- ptyProc.write(msg + '\r');
82
+ const screenIdleMs = now - lastOutputMs;
83
+ if (decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE), screenIdleMs, quietMs })) {
84
+ // 先送 Esc:把任何「必答」modal(權限/選單/計畫批准)收掉、退回輸入框,後面那個 Enter 才不會誤選預設項;
85
+ // 空輸入框時 Esc 等同 no-op。隔一小段再送訊息——讓 claude 先把 modal 收乾淨,也避免 ESC 與字元被併成 Meta 鍵。
86
+ ptyProc.write('\x1b');
79
87
  lastFire = now;
88
+ setTimeout(() => { if (!exiting) { try { ptyProc.write(msg + '\r'); } catch {} } }, escDelayMs);
80
89
  const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
81
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}" regime=${regime ?? 'unknown'} idle=${idle}s\n`); } catch {}
90
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}" regime=${regime ?? 'unknown'} idle=${idle}s screenIdle=${Math.round(screenIdleMs / 1000)}s\n`); } catch {}
82
91
  }
83
92
  }, tickMs);
84
93
 
85
94
  // ---- 收尾 ----
86
- function restore() {
95
+ // claude(TUI)會開 alt-screen / bracketed-paste / mouse / 隱藏游標等終端模式。正常 /exit 時它自己會還原,
96
+ // 但 (1) 被 Ctrl-C 中斷時來不及還原;(2) 在 onExit 內立刻 process.exit() 會跟 claude 最後一段輸出(含還原
97
+ // 序列)賽跑而把它截斷。任一情況都會讓真終端卡在這些模式 → 回到 shell 後「鍵盤輸入不正常」。
98
+ // 故 shutdown():還原 raw mode + 主動補一份終端還原序列 + 等 stdout flush 再退出。
99
+ // 關鍵(Windows):node-pty 啟動時對真終端開了 ?9001h(win32-input-mode) 與 ?1004h(focus reporting),
100
+ // 若沒關掉,回到 shell 後終端會把每個鍵碼以 ESC[…_ 封包送出,readline 無法解析 → 鍵盤輸入全亂。
101
+ // ?2004=bracketed paste、?1000/1002/1003/1006=mouse、?25=cursor、?1049=alt-screen、?9001=win32-input、?1004=focus。
102
+ const RESET = '\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?25h\x1b[?1049l\x1b[?9001l\x1b[?1004l\x1b[0m';
103
+ let exiting = false;
104
+ function shutdown(code) {
105
+ if (exiting) return;
106
+ exiting = true;
107
+ clearInterval(timer);
87
108
  try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
88
109
  try { process.stdin.pause(); } catch {}
110
+ try { process.stdout.write(RESET, () => process.exit(code)); }
111
+ catch { process.exit(code); }
112
+ setTimeout(() => process.exit(code), 200).unref(); // 保險:flush callback 沒回也強制退出
89
113
  }
90
- ptyProc.onExit(({ exitCode }) => {
91
- clearInterval(timer);
92
- restore();
93
- process.exit(exitCode || 0); // node-pty 會卡住 event loop,必須主動退出
114
+ ptyProc.onExit(({ exitCode }) => shutdown(exitCode || 0)); // node-pty 會卡住 event loop,必須主動退出
115
+ // Windows:真主控台的 Ctrl-C 以 SIGINT 送到 host(claude 在獨立 ConPTY、收不到真主控台的 Ctrl-C),
116
+ // 轉成 0x03 寫進 pty 交給 claude 自己處理;不要讓 host 被預設行為直接殺掉(那會跳過終端還原 → 卡 raw mode)。
117
+ // Unix raw mode 下 Ctrl-C 是位元組(0x03)、不觸發 SIGINT,故此 handler 不影響 Unix。
118
+ process.on('SIGINT', () => { try { ptyProc.write('\x03'); } catch {} });
119
+ process.on('SIGTERM', () => shutdown(0));
120
+ process.on('SIGHUP', () => shutdown(0));
121
+ // 任何路徑退出都殺掉 pty,並盡力(同步)還原終端,作為最後保險。
122
+ process.on('exit', () => {
123
+ try { ptyProc.kill(); } catch {}
124
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
125
+ try { process.stdout.write(RESET); } catch {}
94
126
  });
95
- process.on('exit', () => { try { ptyProc.kill(); } catch {} });
96
- process.on('SIGTERM', () => { try { ptyProc.kill(); } catch {} process.exit(0); });
97
127
 
98
128
  return ptyProc;
99
129
  }
package/src/keepalive.mjs CHANGED
@@ -127,10 +127,17 @@ export function detectTtlRegime(claudeDir, cwd, opts) {
127
127
  }
128
128
 
129
129
  // 純決策:現在該不該注入 keepalive?idleMs = 距上次訊息多久(由 transcriptIdleMs 算)。
130
- export function decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled }) {
130
+ // screenIdleMs = claude 最近一次「畫面輸出」多久;quietMs = 需靜止多久才放行。
131
+ // 為什麼要這個畫面靜默門檻:transcript 在「等你回答必答提示(權限/選單/計畫批准)」與
132
+ // 「跑長工具/生成中」時都不會更新,光看 idleMs 無法分辨這兩種「忙/卡」狀態與「真的閒置在輸入框」。
133
+ // 但畫面活動可以:閒置在輸入框時畫面靜止;提示等待時 spinner 在動、生成中持續輸出。
134
+ // 故只有畫面靜止夠久才注入——避免把 hi 的 Enter 送進 modal 誤選預設項,也避免打斷長工具執行。
135
+ // quietMs 省略(null)時不套此門檻(保持純 idle 決策,供既有測試/呼叫者使用)。
136
+ export function decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled, screenIdleMs, quietMs }) {
131
137
  if (disabled) return false; // 暫停開關
132
138
  if (idleMs == null) return false; // 找不到 transcript → 保守不發
133
139
  if (idleMs < idleThreshold * 1000) return false; // 距上次訊息還不夠久
134
140
  if (now - lastFire < ttl * 1000) return false; // 冷卻未滿一個 TTL
141
+ if (quietMs != null && screenIdleMs != null && screenIdleMs < quietMs) return false; // 畫面還在動(提示/生成/打字中)
135
142
  return true;
136
143
  }