claude-cache-keepalive 0.1.2 → 0.1.4

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
@@ -6,11 +6,11 @@
6
6
 
7
7
  Keep [Claude Code](https://claude.com/claude-code)'s **prompt cache warm while you're idle**, so coming back to a session you stepped away from doesn't pay a full cache‑miss.
8
8
 
9
- It runs `claude` inside a PTY it controls (via [node-pty](https://github.com/microsoft/node-pty)) and, when you've been idle past your plan's cache TTL, injects a tiny keepalive so the cache stays warm. Because injection is an in‑process PTY write, **it keeps working when the window is unfocused, minimized, or in the background** — only closing the window stops it.
9
+ It runs `claude` inside a PTY it controls (via [node-pty](https://github.com/microsoft/node-pty)) and, when you've been idle past your cache's TTL, injects a tiny keepalive so the cache stays warm. Because injection is an in‑process PTY write, **it keeps working when the window is unfocused, minimized, or in the background** — only closing the window stops it.
10
10
 
11
11
  Cross‑platform, **no tmux required**. This is the missing piece for setups (Windows / Git Bash, plain terminals) where the usual tmux‑based keepalive isn't available.
12
12
 
13
- > ⚠️ **Honest note — this uses your usage/quota.** Keeping the cache warm means sending a small message (`hi`) when you go idle, which counts against your plan usage and leaves `hi` turns in the conversation. It only fires after a long idle (≈58 min on Max, ≈4 min on Pro) with a one‑TTL cooldown, so it's conservative — but it is opt‑in by design. If that trade‑off isn't for you, don't use it.
13
+ > ⚠️ **Honest note — this uses your usage/quota.** Keeping the cache warm means sending a small message (`hi`) when you go idle, which counts against your plan usage and leaves `hi` turns in the conversation. It only fires after a long idle (≈58 min on a 1‑hour cache, ≈4 min on a 5‑minute cache) with a one‑TTL cooldown, so it's conservative — but it is opt‑in by design. If that trade‑off isn't for you, don't use it.
14
14
 
15
15
  ## Install
16
16
 
@@ -40,9 +40,10 @@ It's transparent — type and use claude exactly as normal (no `Ctrl-b` prefix,
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
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.)
43
- - **Plan‑aware** — reads your plan from `~/.claude/.credentials.json`:
44
- - **Max** cache TTL 1 h → inject after ~58 min idle, cooldown 1 h.
45
- - **Pro** cache TTL 5 min → inject after ~4 min idle, cooldown 5 min.
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
+ - any recent turn wrote `ephemeral_1h_input_tokens` → **1 h cache** → inject after ~58 min idle, cooldown 1 h.
45
+ - only `ephemeral_5m_input_tokens` (or no evidence yet) → **5 min cache** (conservative) → inject after ~4 min idle, cooldown 5 min.
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).
46
47
  - **Focus/minimize independent** — injection is an in‑process `pty.write`, unrelated to window state. Only closing the window (ending the host process) stops it.
47
48
 
48
49
  ## Optional: cache‑countdown statusline
@@ -91,9 +92,9 @@ Environment variables (mostly for testing / advanced use):
91
92
 
92
93
  讓 [Claude Code](https://claude.com/claude-code) 的 **prompt cache 在你離開時保持溫熱**,回來時就不必再付一次完整的 cache‑miss。
93
94
 
94
- `cwarm` 把 `claude` 跑在自己控制的 PTY 裡;當你閒置超過方案的 cache TTL 時,注入一個極小的 keepalive 訊息讓 cache 不過期。因為注入是行程內部的 PTY 寫入,**視窗非焦點、縮小、在背景都照常運作**——只有關閉視窗才會停。跨平台、**不需要 tmux**。
95
+ `cwarm` 把 `claude` 跑在自己控制的 PTY 裡;當你閒置超過 cache TTL 時,注入一個極小的 keepalive 訊息讓 cache 不過期。因為注入是行程內部的 PTY 寫入,**視窗非焦點、縮小、在背景都照常運作**——只有關閉視窗才會停。跨平台、**不需要 tmux**。
95
96
 
96
- > ⚠️ **誠實說明**:保溫=閒置時送一則小訊息(`hi`),會消耗你的方案用量、並在對話留下 `hi` 紀錄。只在長時間閒置後才觸發(Max 約 58 分、Pro 約 4 分)且有冷卻,屬保守設計、明確 opt‑in。不接受這個取捨就別用。
97
+ > ⚠️ **誠實說明**:保溫=閒置時送一則小訊息(`hi`),會消耗你的方案用量、並在對話留下 `hi` 紀錄。只在長時間閒置後才觸發(1h cache 約 58 分、5m cache 約 4 分)且有冷卻,屬保守設計、明確 opt‑in。不接受這個取捨就別用。
97
98
 
98
99
  - **安裝**:`npm install -g claude-cache-keepalive`
99
100
  - **使用**:`cwarm`(=`claude --continue` 跑在保溫 host 裡;其餘參數原樣轉給 claude)
@@ -101,6 +102,20 @@ Environment variables (mostly for testing / advanced use):
101
102
  - **選配 statusline**(顯示 `♻️ cache 58m12s` 倒數;會先備份、包裝既有 statusline、可一鍵還原):`cwarm setup` / `cwarm setup --remove`
102
103
  - **限制**:不能 detach(關視窗=結束,但縮小/背景照常保溫)。
103
104
 
105
+ ## Changelog
106
+
107
+ ### 0.1.4
108
+ - **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.
109
+
110
+ ### 0.1.3
111
+ - **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`.
112
+
113
+ ### 0.1.2
114
+ - **Fix:** idle is now measured from the newest transcript file's mtime — i.e. time since your last *message* — instead of keystrokes. Scrolling, arrow‑key reading, or a half‑typed prompt no longer reset the idle timer, so the keepalive actually fires while you're reading and the cache stops going cold. Adds `encodeProjectDir` / `transcriptMtimeMs` / `transcriptIdleMs`.
115
+
116
+ ### 0.1.0
117
+ - Initial release. (0.1.1 was a version‑only bump and was never published to npm.)
118
+
104
119
  ## License
105
120
 
106
121
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-cache-keepalive",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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
@@ -27,8 +27,10 @@ Usage:
27
27
  Everything except the 'setup' subcommand is passed straight to claude
28
28
  (e.g. \`cwarm --version\`, \`cwarm resume\`, \`cwarm -p "..."\`).
29
29
 
30
- Keepalive only fires after you've been idle past the plan threshold
31
- (max ~58min / pro ~4min). Pause anytime: touch ~/.claude/cwarm.disabled
30
+ Keepalive only fires after you've been idle past the cache-TTL threshold.
31
+ The TTL is auto-detected from the transcript's cache_creation (1h cache ->
32
+ fire after ~58min idle; 5m cache -> ~4min), not guessed from your plan.
33
+ Pause anytime: touch ~/.claude/cwarm.disabled
32
34
  Log: ~/.claude/cwarm-keepalive.log
33
35
  `);
34
36
  }
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, planParams, detectPlan, decideInject, transcriptIdleMs } from './keepalive.mjs';
8
+ import { defaultClaudeDir, regimeParams, detectTtlRegime, decideInject, transcriptIdleMs } from './keepalive.mjs';
9
9
 
10
10
  const require = createRequire(import.meta.url);
11
11
  const isWin = process.platform === 'win32';
@@ -69,30 +69,52 @@ export function startHost(opts = {}) {
69
69
 
70
70
  let lastFire = 0;
71
71
  const timer = setInterval(() => {
72
- const plan = detectPlan(claudeDir);
73
- const { ttl, idleThreshold } = planParams(plan, overrides);
72
+ const cwd = process.cwd();
73
+ const regime = detectTtlRegime(claudeDir, cwd); // 從 transcript 實測 1h/5m,不再猜方案
74
+ const { ttl, idleThreshold } = regimeParams(regime, overrides);
74
75
  const now = Date.now();
75
- const idleMs = transcriptIdleMs(claudeDir, process.cwd(), now);
76
+ const idleMs = transcriptIdleMs(claudeDir, cwd, now);
76
77
  if (decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE) })) {
77
78
  ptyProc.write(msg + '\r');
78
79
  lastFire = now;
79
80
  const idle = idleMs == null ? -1 : Math.round(idleMs / 1000);
80
- try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}" plan=${plan} idle=${idle}s\n`); } catch {}
81
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}" regime=${regime ?? 'unknown'} idle=${idle}s\n`); } catch {}
81
82
  }
82
83
  }, tickMs);
83
84
 
84
85
  // ---- 收尾 ----
85
- function restore() {
86
+ // claude(TUI)會開 alt-screen / bracketed-paste / mouse / 隱藏游標等終端模式。正常 /exit 時它自己會還原,
87
+ // 但 (1) 被 Ctrl-C 中斷時來不及還原;(2) 在 onExit 內立刻 process.exit() 會跟 claude 最後一段輸出(含還原
88
+ // 序列)賽跑而把它截斷。任一情況都會讓真終端卡在這些模式 → 回到 shell 後「鍵盤輸入不正常」。
89
+ // 故 shutdown():還原 raw mode + 主動補一份終端還原序列 + 等 stdout flush 再退出。
90
+ // 關鍵(Windows):node-pty 啟動時對真終端開了 ?9001h(win32-input-mode) 與 ?1004h(focus reporting),
91
+ // 若沒關掉,回到 shell 後終端會把每個鍵碼以 ESC[…_ 封包送出,readline 無法解析 → 鍵盤輸入全亂。
92
+ // ?2004=bracketed paste、?1000/1002/1003/1006=mouse、?25=cursor、?1049=alt-screen、?9001=win32-input、?1004=focus。
93
+ const RESET = '\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?25h\x1b[?1049l\x1b[?9001l\x1b[?1004l\x1b[0m';
94
+ let exiting = false;
95
+ function shutdown(code) {
96
+ if (exiting) return;
97
+ exiting = true;
98
+ clearInterval(timer);
86
99
  try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
87
100
  try { process.stdin.pause(); } catch {}
101
+ try { process.stdout.write(RESET, () => process.exit(code)); }
102
+ catch { process.exit(code); }
103
+ setTimeout(() => process.exit(code), 200).unref(); // 保險:flush callback 沒回也強制退出
88
104
  }
89
- ptyProc.onExit(({ exitCode }) => {
90
- clearInterval(timer);
91
- restore();
92
- process.exit(exitCode || 0); // node-pty 會卡住 event loop,必須主動退出
105
+ ptyProc.onExit(({ exitCode }) => shutdown(exitCode || 0)); // node-pty 會卡住 event loop,必須主動退出
106
+ // Windows:真主控台的 Ctrl-C 以 SIGINT 送到 host(claude 在獨立 ConPTY、收不到真主控台的 Ctrl-C),
107
+ // 轉成 0x03 寫進 pty 交給 claude 自己處理;不要讓 host 被預設行為直接殺掉(那會跳過終端還原 → 卡 raw mode)。
108
+ // Unix raw mode 下 Ctrl-C 是位元組(0x03)、不觸發 SIGINT,故此 handler 不影響 Unix。
109
+ process.on('SIGINT', () => { try { ptyProc.write('\x03'); } catch {} });
110
+ process.on('SIGTERM', () => shutdown(0));
111
+ process.on('SIGHUP', () => shutdown(0));
112
+ // 任何路徑退出都殺掉 pty,並盡力(同步)還原終端,作為最後保險。
113
+ process.on('exit', () => {
114
+ try { ptyProc.kill(); } catch {}
115
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
116
+ try { process.stdout.write(RESET); } catch {}
93
117
  });
94
- process.on('exit', () => { try { ptyProc.kill(); } catch {} });
95
- process.on('SIGTERM', () => { try { ptyProc.kill(); } catch {} process.exit(0); });
96
118
 
97
119
  return ptyProc;
98
120
  }
package/src/keepalive.mjs CHANGED
@@ -1,16 +1,17 @@
1
- // keepalive.mjs — 決策邏輯 + plan / 閒置偵測(無 PTY,便於單元測試)。
1
+ // keepalive.mjs — 決策邏輯 + TTL 檔位 / 閒置偵測(無 PTY,便於單元測試)。
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
- // plan → { ttl(秒), idleThreshold(秒) }。對齊各家慣例:max 提早 ~2 分注入、pro 提早 1 分。
7
- export const PLAN_PARAMS = {
8
- max: { ttl: 3600, idleThreshold: 3480 },
9
- pro: { ttl: 300, idleThreshold: 240 },
6
+ // TTL 檔位 → { ttl(秒), idleThreshold(秒) }。long=1h cache、short=5m cache。
7
+ // 提早注入幅度對齊各家慣例:long 提早 ~2 分、short 提早 1 分。
8
+ export const REGIME_PARAMS = {
9
+ long: { ttl: 3600, idleThreshold: 3480 },
10
+ short: { ttl: 300, idleThreshold: 240 },
10
11
  };
11
12
 
12
- export function planParams(plan, overrides = {}) {
13
- const base = PLAN_PARAMS[plan] || PLAN_PARAMS.pro;
13
+ export function regimeParams(regime, overrides = {}) {
14
+ const base = REGIME_PARAMS[regime] || REGIME_PARAMS.short; // 未知一律 short(保守,門檻短不讓 cache 冷掉)
14
15
  return {
15
16
  ttl: overrides.ttl ?? base.ttl,
16
17
  idleThreshold: overrides.idleThreshold ?? base.idleThreshold,
@@ -21,52 +22,53 @@ export function defaultClaudeDir() {
21
22
  return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
22
23
  }
23
24
 
24
- // 從 Claude Code 憑證判方案;讀不到一律當 'pro'(保守)。
25
- export function detectPlan(claudeDir = defaultClaudeDir()) {
26
- try {
27
- const cred = JSON.parse(fs.readFileSync(path.join(claudeDir, '.credentials.json'), 'utf8'));
28
- return cred?.claudeAiOauth?.subscriptionType === 'max' ? 'max' : 'pro';
29
- } catch {
30
- return 'pro';
31
- }
32
- }
33
-
34
25
  // Claude Code 把 transcript 存在 ~/.claude/projects/<編碼後的 cwd>/<uuid>.jsonl,
35
26
  // 編碼規則為「非英數字元一律換成 '-'」(例:C:\temp\scripts\cwarm → C--temp-scripts-cwarm)。
36
27
  export function encodeProjectDir(cwd) {
37
28
  return String(cwd).replace(/[^a-zA-Z0-9]/g, '-');
38
29
  }
39
30
 
40
- // 某資料夾內所有 *.jsonl 的最新 mtime(毫秒);沒有則 null。
41
- function newestJsonlMtime(dir) {
31
+ // 某資料夾內 mtime 最新的 *.jsonl 完整路徑;沒有則 null。
32
+ function newestJsonlPath(dir) {
42
33
  let entries;
43
34
  try { entries = fs.readdirSync(dir); } catch { return null; }
44
- let newest = null;
35
+ let best = null, bestM = null;
45
36
  for (const name of entries) {
46
37
  if (!name.endsWith('.jsonl')) continue;
47
38
  try {
48
39
  const m = fs.statSync(path.join(dir, name)).mtimeMs;
49
- if (newest == null || m > newest) newest = m;
40
+ if (bestM == null || m > bestM) { bestM = m; best = path.join(dir, name); }
50
41
  } catch { /* skip unreadable */ }
51
42
  }
52
- return newest;
43
+ return best;
53
44
  }
54
45
 
55
- // 本 session transcript 的最新 mtime(毫秒):優先 cwd 對應的 project 資料夾,
46
+ // 本 session transcript 的完整路徑:優先 cwd 對應的 project 資料夾,
56
47
  // 找不到再退回 projects 底下全域最新(cwarm 本就假設單一 session)。null = 完全找不到。
57
- export function transcriptMtimeMs(claudeDir, cwd) {
48
+ export function transcriptPath(claudeDir, cwd) {
58
49
  const projects = path.join(claudeDir, 'projects');
59
- const direct = newestJsonlMtime(path.join(projects, encodeProjectDir(cwd)));
50
+ const direct = newestJsonlPath(path.join(projects, encodeProjectDir(cwd)));
60
51
  if (direct != null) return direct;
61
52
  let subdirs;
62
53
  try { subdirs = fs.readdirSync(projects, { withFileTypes: true }); } catch { return null; }
63
- let newest = null;
54
+ let best = null, bestM = null;
64
55
  for (const d of subdirs) {
65
56
  if (!d.isDirectory()) continue;
66
- const m = newestJsonlMtime(path.join(projects, d.name));
67
- if (m != null && (newest == null || m > newest)) newest = m;
57
+ const p = newestJsonlPath(path.join(projects, d.name));
58
+ if (p == null) continue;
59
+ try {
60
+ const m = fs.statSync(p).mtimeMs;
61
+ if (bestM == null || m > bestM) { bestM = m; best = p; }
62
+ } catch { /* skip */ }
68
63
  }
69
- return newest;
64
+ return best;
65
+ }
66
+
67
+ // 本 session transcript 的最新 mtime(毫秒);null = 完全找不到。
68
+ export function transcriptMtimeMs(claudeDir, cwd) {
69
+ const p = transcriptPath(claudeDir, cwd);
70
+ if (!p) return null;
71
+ try { return fs.statSync(p).mtimeMs; } catch { return null; }
70
72
  }
71
73
 
72
74
  // 距上次訊息(transcript 寫入)多久(毫秒);找不到 transcript 回 null。
@@ -77,6 +79,53 @@ export function transcriptIdleMs(claudeDir, cwd, now = Date.now()) {
77
79
  return m == null ? null : now - m;
78
80
  }
79
81
 
82
+ // 讀 transcript 尾端,判斷這個 session 實際拿到的 cache TTL 檔位。
83
+ // 回傳 'long'(1h) / 'short'(5m) / null(找不到可判讀的 cache_creation)。
84
+ // 為什麼讀 transcript 而非帳號方案:message.usage.cache_creation 的
85
+ // ephemeral_1h_input_tokens / ephemeral_5m_input_tokens 是 API 親口回報「這批 token
86
+ // 寫進哪個 TTL 桶」的實測值;subscriptionType 只是間接猜測,且會被 client 版本、
87
+ // 環境變數、伺服器端旗標影響而失準(實測 pro 帳號也可能拿到 1h)。
88
+ export function readTtlRegime(tpath, { maxBytes = 65536, scanTurns = 8 } = {}) {
89
+ if (!tpath) return null;
90
+ let size, buf;
91
+ let fd = null;
92
+ try {
93
+ size = fs.statSync(tpath).size;
94
+ const start = Math.max(0, size - maxBytes);
95
+ const len = size - start;
96
+ buf = Buffer.alloc(len);
97
+ fd = fs.openSync(tpath, 'r');
98
+ fs.readSync(fd, buf, 0, len, start);
99
+ } catch {
100
+ return null;
101
+ } finally {
102
+ if (fd != null) { try { fs.closeSync(fd); } catch {} }
103
+ }
104
+ let lines = buf.toString('utf8').split(/\r?\n/);
105
+ if (size > maxBytes && lines.length) lines = lines.slice(1); // 丟掉被切半的第一行
106
+ let sawShort = false;
107
+ let examined = 0;
108
+ for (let i = lines.length - 1; i >= 0; i--) {
109
+ const line = lines[i].trim();
110
+ if (!line) continue;
111
+ let obj;
112
+ try { obj = JSON.parse(line); } catch { continue; }
113
+ if (!obj || obj.type !== 'assistant') continue;
114
+ const cc = obj.message && obj.message.usage && obj.message.usage.cache_creation;
115
+ if (!cc || typeof cc !== 'object') continue;
116
+ const h = cc.ephemeral_1h_input_tokens || 0;
117
+ const m = cc.ephemeral_5m_input_tokens || 0;
118
+ if (h > 0) return 'long'; // 最近任一回合寫過 1h → 1h 檔位(決定性)
119
+ if (m > 0) { sawShort = true; if (++examined >= scanTurns) break; } // 只看最近數筆有寫 cache 的回合
120
+ }
121
+ return sawShort ? 'short' : null;
122
+ }
123
+
124
+ // 找出本 session transcript 並判斷 TTL 檔位。'long' / 'short' / null。
125
+ export function detectTtlRegime(claudeDir, cwd, opts) {
126
+ return readTtlRegime(transcriptPath(claudeDir, cwd), opts);
127
+ }
128
+
80
129
  // 純決策:現在該不該注入 keepalive?idleMs = 距上次訊息多久(由 transcriptIdleMs 算)。
81
130
  export function decideInject({ now, idleMs, lastFire, idleThreshold, ttl, disabled }) {
82
131
  if (disabled) return false; // 暫停開關
@@ -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, detectPlan } from '../keepalive.mjs';
7
+ import { defaultClaudeDir, readTtlRegime, regimeParams } from '../keepalive.mjs';
8
8
 
9
9
  const claudeDir = defaultClaudeDir();
10
10
  const ORIG = path.join(claudeDir, 'cwarm-statusline-orig.json');
@@ -13,14 +13,14 @@ function readStdin() {
13
13
  try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
14
14
  }
15
15
 
16
- // cache 倒數段:用 transcript mtime 當 idle、plan 決定 TTL(max 1h / pro 5min)。
16
+ // cache 倒數段:用 transcript mtime 當 idle、用 transcript 實測的 cache_creation 判 TTL(1h / 5m)。
17
17
  function cacheSegment(payload) {
18
18
  const tp = payload?.transcript_path;
19
19
  if (!tp) return '';
20
20
  let mtimeMs;
21
21
  try { mtimeMs = fs.statSync(tp).mtimeMs; } catch { return ''; }
22
- const plan = detectPlan(claudeDir);
23
- const ttl = plan === 'max' ? 3600 : 300;
22
+ const { ttl } = regimeParams(readTtlRegime(tp)); // 'long'→3600 / 'short'|null→300
23
+
24
24
  const idle = Math.max(0, Math.floor((Date.now() - mtimeMs) / 1000));
25
25
  const rem = ttl - idle;
26
26
  if (rem <= 0) return `\u{1F534} cache ${Math.floor(idle / 60)}m`; // 🔴 已冷