claude-cache-keepalive 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fifthadj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # claude-cache-keepalive (`cwarm`)
2
+
3
+ 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.
4
+
5
+ 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.
6
+
7
+ 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.
8
+
9
+ > ⚠️ **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.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install -g claude-cache-keepalive
15
+ ```
16
+
17
+ This puts a `cwarm` command on your PATH (npm creates both the Unix and Windows shims automatically).
18
+
19
+ ## Usage
20
+
21
+ ```sh
22
+ cwarm # = claude --continue, inside the keepalive host
23
+ cwarm new # any claude args are passed straight through
24
+ cwarm resume
25
+ cwarm -p "..."
26
+ cwarm --version # (passed through → prints claude's version)
27
+ cwarm help # cwarm's own help
28
+ ```
29
+
30
+ It's transparent — type and use claude exactly as normal (no `Ctrl-b` prefix, no new keys). Exit claude (`/exit` or Ctrl‑C) and the host exits with it.
31
+
32
+ **Pause keepalive:** `touch ~/.claude/cwarm.disabled` (delete to resume).
33
+ **Log:** `~/.claude/cwarm-keepalive.log`.
34
+
35
+ ## How it works
36
+
37
+ - **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.
38
+ - **Idle detection** — idle = time since your last keystroke. Self‑contained, no external files. When you step away, idle grows; when you type, it resets.
39
+ - **Plan‑aware** — reads your plan from `~/.claude/.credentials.json`:
40
+ - **Max** → cache TTL 1 h → inject after ~58 min idle, cooldown 1 h.
41
+ - **Pro** → cache TTL 5 min → inject after ~4 min idle, cooldown 5 min.
42
+ - **Focus/minimize independent** — injection is an in‑process `pty.write`, unrelated to window state. Only closing the window (ending the host process) stops it.
43
+
44
+ ## Optional: cache‑countdown statusline
45
+
46
+ A small statusline add‑on shows the live countdown the keepalive is protecting:
47
+
48
+ ```
49
+ [Opus 4.8] │ my-project │ ♻️ cache 58m12s
50
+ ```
51
+
52
+ It's **opt‑in** and never clobbers your existing statusline — it **wraps** it (runs yours, then appends the `♻️ cache …` segment), backs up `settings.json` first, and is fully restorable:
53
+
54
+ ```sh
55
+ cwarm setup # interactive; asks before editing settings.json
56
+ cwarm setup --remove # restores your previous statusline
57
+ ```
58
+
59
+ (Written in Node — no Python dependency, no Windows codepage issues.)
60
+
61
+ ## Configuration
62
+
63
+ Environment variables (mostly for testing / advanced use):
64
+
65
+ | Var | Meaning |
66
+ |-----|---------|
67
+ | `CWARM_MSG` | keepalive message (default `hi`) |
68
+ | `CWARM_TICK_MS` | check interval (default `20000`) |
69
+ | `CWARM_THRESHOLD_S` | override idle threshold (seconds) |
70
+ | `CWARM_TTL_S` | override cooldown (seconds) |
71
+ | `CWARM_CLAUDE` | path to the `claude` executable (otherwise auto‑detected via `which`/`where`) |
72
+ | `CLAUDE_CONFIG_DIR` | Claude config dir (default `~/.claude`) |
73
+
74
+ ## Limitations
75
+
76
+ - **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.
77
+ - **Single session.** Designed for one `cwarm` session at a time.
78
+ - If you walk away mid‑typing, an injected `hi` is appended to whatever's in the input box. Rare and harmless.
79
+
80
+ ## Platform support
81
+
82
+ - **Windows** (Git Bash / PowerShell / cmd / Windows Terminal): verified, including non‑ASCII (CJK) input.
83
+ - **Linux arm64 / aarch64**: verified on a Raspberry Pi 4 (Debian, Node 22) — global install (node-pty compiled cleanly), `cwarm` launch, and live keepalive injection all confirmed. x64 expected to behave the same.
84
+ - **macOS**: same cross‑platform mechanism (node-pty + your shell's `claude`); expected to work, not yet tested. Reports welcome.
85
+
86
+ ## 繁體中文
87
+
88
+ 讓 [Claude Code](https://claude.com/claude-code) 的 **prompt cache 在你離開時保持溫熱**,回來時就不必再付一次完整的 cache‑miss。
89
+
90
+ `cwarm` 把 `claude` 跑在自己控制的 PTY 裡;當你閒置超過方案的 cache TTL 時,注入一個極小的 keepalive 訊息讓 cache 不過期。因為注入是行程內部的 PTY 寫入,**視窗非焦點、縮小、在背景都照常運作**——只有關閉視窗才會停。跨平台、**不需要 tmux**。
91
+
92
+ > ⚠️ **誠實說明**:保溫=閒置時送一則小訊息(`hi`),會消耗你的方案用量、並在對話留下 `hi` 紀錄。只在長時間閒置後才觸發(Max 約 58 分、Pro 約 4 分)且有冷卻,屬保守設計、明確 opt‑in。不接受這個取捨就別用。
93
+
94
+ - **安裝**:`npm install -g claude-cache-keepalive`
95
+ - **使用**:`cwarm`(=`claude --continue` 跑在保溫 host 裡;其餘參數原樣轉給 claude)
96
+ - **暫停**:`touch ~/.claude/cwarm.disabled`;**紀錄**:`~/.claude/cwarm-keepalive.log`
97
+ - **選配 statusline**(顯示 `♻️ cache 58m12s` 倒數;會先備份、包裝既有 statusline、可一鍵還原):`cwarm setup` / `cwarm setup --remove`
98
+ - **限制**:不能 detach(關視窗=結束,但縮小/背景照常保溫)。
99
+
100
+ ## License
101
+
102
+ MIT
103
+
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "claude-cache-keepalive",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "cwarm": "src/cli.mjs"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "author": "fifthadj",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/fifthadj/claude-cache-keepalive.git"
18
+ },
19
+ "homepage": "https://github.com/fifthadj/claude-cache-keepalive#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/fifthadj/claude-cache-keepalive/issues"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "keywords": [
30
+ "claude",
31
+ "claude-code",
32
+ "prompt-cache",
33
+ "keepalive",
34
+ "pty",
35
+ "node-pty"
36
+ ],
37
+ "license": "MIT",
38
+ "dependencies": {
39
+ "node-pty": "^1.0.0"
40
+ }
41
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ // cli.mjs — `cwarm` 指令。攔截 `setup` 子指令,其餘原樣轉給 claude。
3
+ import { startHost } from './host.mjs';
4
+
5
+ const argv = process.argv.slice(2);
6
+ const first = argv[0];
7
+
8
+ if (first === 'setup') {
9
+ const { runSetup } = await import('./setup.mjs');
10
+ process.exit((await runSetup(argv.slice(1))) || 0);
11
+ } else if (first === 'help' || first === '--cwarm-help') {
12
+ printHelp();
13
+ process.exit(0);
14
+ } else {
15
+ startHost({ args: argv }); // 無參數時 host 內部預設 --continue
16
+ }
17
+
18
+ function printHelp() {
19
+ process.stdout.write(`cwarm — keep Claude Code's prompt cache warm while idle.
20
+
21
+ Usage:
22
+ cwarm [claude args...] Launch claude (default: --continue) inside the keepalive host.
23
+ cwarm setup Optionally install the cache-countdown statusline (opt-in).
24
+ cwarm setup --remove Remove the statusline this tool installed.
25
+ cwarm help Show this help.
26
+
27
+ Everything except the 'setup' subcommand is passed straight to claude
28
+ (e.g. \`cwarm --version\`, \`cwarm resume\`, \`cwarm -p "..."\`).
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
32
+ Log: ~/.claude/cwarm-keepalive.log
33
+ `);
34
+ }
package/src/host.mjs ADDED
@@ -0,0 +1,96 @@
1
+ // host.mjs — 把 claude 跑在自己控制的 PTY 裡,做透明 I/O 多工 + 內建 prompt cache 保溫。
2
+ // 注入是行程內部的 pty.write,與視窗焦點/縮小無關;只要 host 行程活著就保溫。
3
+ // 跨平台:靠 node-pty(Windows=ConPTY、mac/Linux=forkpty),不需 tmux。
4
+ import { createRequire } from 'node:module';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { defaultClaudeDir, planParams, detectPlan, decideInject } from './keepalive.mjs';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const isWin = process.platform === 'win32';
12
+
13
+ // 跨平台找出 claude 執行檔的完整路徑(Windows 的 node-pty 不會自己補 .exe,需給全路徑)。
14
+ export function resolveClaude() {
15
+ if (process.env.CWARM_CLAUDE) return process.env.CWARM_CLAUDE;
16
+ try {
17
+ const r = spawnSync(isWin ? 'where' : 'which', ['claude'], { encoding: 'utf8' });
18
+ const first = (r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
19
+ if (first) return first;
20
+ } catch { /* fall through */ }
21
+ return 'claude'; // 最後手段:交給 node-pty 用 PATH 試
22
+ }
23
+
24
+ // 算出要 spawn 的 (file, args)。Windows 上 .cmd/.bat shim 無法被 node-pty 直接 exec,改用 cmd.exe 包。
25
+ function spawnSpec(claudeBin, args) {
26
+ if (isWin && /\.(cmd|bat)$/i.test(claudeBin)) {
27
+ return { file: process.env.ComSpec || 'cmd.exe', args: ['/c', claudeBin, ...args] };
28
+ }
29
+ return { file: claudeBin, args };
30
+ }
31
+
32
+ export function startHost(opts = {}) {
33
+ const pty = require('node-pty');
34
+ const claudeDir = defaultClaudeDir();
35
+ const LOG = path.join(claudeDir, 'cwarm-keepalive.log');
36
+ const DISABLE = path.join(claudeDir, 'cwarm.disabled');
37
+
38
+ const args = opts.args && opts.args.length ? opts.args : ['--continue'];
39
+ const { file, args: spawnArgs } = spawnSpec(resolveClaude(), args);
40
+
41
+ const ptyProc = pty.spawn(file, spawnArgs, {
42
+ name: process.env.TERM || 'xterm-256color',
43
+ cols: process.stdout.columns || 80,
44
+ rows: process.stdout.rows || 24,
45
+ cwd: process.cwd(),
46
+ env: process.env,
47
+ });
48
+
49
+ // ---- 透明 I/O 多工 ----
50
+ // 直接寫 Buffer(純位元組轉送):UTF-8 多位元組(中文等)才不會被重編碼弄壞。
51
+ let lastInput = Date.now();
52
+ if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
53
+ process.stdin.resume();
54
+ process.stdin.on('data', (d) => { lastInput = Date.now(); ptyProc.write(d); });
55
+ ptyProc.onData((d) => process.stdout.write(d));
56
+ process.stdout.on('resize', () => {
57
+ try { ptyProc.resize(process.stdout.columns || 80, process.stdout.rows || 24); } catch {}
58
+ });
59
+
60
+ // ---- 內建保溫 ----
61
+ const tickMs = Number(process.env.CWARM_TICK_MS) || 20_000;
62
+ const msg = process.env.CWARM_MSG || opts.msg || 'hi';
63
+ const overrides = {};
64
+ const thr = process.env.CWARM_THRESHOLD_S ?? opts.thresholdS;
65
+ const ttlO = process.env.CWARM_TTL_S ?? opts.ttlS;
66
+ if (thr != null) overrides.idleThreshold = Number(thr);
67
+ if (ttlO != null) overrides.ttl = Number(ttlO);
68
+
69
+ let lastFire = 0;
70
+ const timer = setInterval(() => {
71
+ const plan = detectPlan(claudeDir);
72
+ const { ttl, idleThreshold } = planParams(plan, overrides);
73
+ const now = Date.now();
74
+ if (decideInject({ now, lastInput, lastFire, idleThreshold, ttl, disabled: fs.existsSync(DISABLE) })) {
75
+ ptyProc.write(msg + '\r');
76
+ lastFire = now;
77
+ const idle = Math.round((now - lastInput) / 1000);
78
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} inject "${msg}" plan=${plan} idle=${idle}s\n`); } catch {}
79
+ }
80
+ }, tickMs);
81
+
82
+ // ---- 收尾 ----
83
+ function restore() {
84
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
85
+ try { process.stdin.pause(); } catch {}
86
+ }
87
+ ptyProc.onExit(({ exitCode }) => {
88
+ clearInterval(timer);
89
+ restore();
90
+ process.exit(exitCode || 0); // node-pty 會卡住 event loop,必須主動退出
91
+ });
92
+ process.on('exit', () => { try { ptyProc.kill(); } catch {} });
93
+ process.on('SIGTERM', () => { try { ptyProc.kill(); } catch {} process.exit(0); });
94
+
95
+ return ptyProc;
96
+ }
@@ -0,0 +1,41 @@
1
+ // keepalive.mjs — 純決策邏輯 + plan 偵測(無 PTY、無副作用,便於單元測試)。
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
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 },
10
+ };
11
+
12
+ export function planParams(plan, overrides = {}) {
13
+ const base = PLAN_PARAMS[plan] || PLAN_PARAMS.pro;
14
+ return {
15
+ ttl: overrides.ttl ?? base.ttl,
16
+ idleThreshold: overrides.idleThreshold ?? base.idleThreshold,
17
+ };
18
+ }
19
+
20
+ export function defaultClaudeDir() {
21
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
22
+ }
23
+
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
+ // 純決策:現在該不該注入 keepalive?
35
+ // idle = now - lastInput(使用者最後一次按鍵到現在)。自包含、跨平台、不依賴外部檔。
36
+ export function decideInject({ now, lastInput, lastFire, idleThreshold, ttl, disabled }) {
37
+ if (disabled) return false; // 暫停開關
38
+ if (now - lastInput < idleThreshold * 1000) return false; // 使用者最近還在操作
39
+ if (now - lastFire < ttl * 1000) return false; // 冷卻未滿一個 TTL
40
+ return true;
41
+ }
package/src/setup.mjs ADDED
@@ -0,0 +1,80 @@
1
+ // setup.mjs — opt-in 安裝/移除 cache-countdown statusline。
2
+ // 原則:會動 settings.json → 先備份、互動確認、絕不偷蓋既有 statusline(改成「包裝」它)、可一鍵還原。
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import readline from 'node:readline/promises';
7
+ import { defaultClaudeDir } from './keepalive.mjs';
8
+
9
+ const claudeDir = defaultClaudeDir();
10
+ const SETTINGS = path.join(claudeDir, 'settings.json');
11
+ const ORIG = path.join(claudeDir, 'cwarm-statusline-orig.json');
12
+ const SEGMENT = fileURLToPath(new URL('./statusline/segment.mjs', import.meta.url));
13
+
14
+ const readJson = (file, dflt) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return dflt; } };
15
+ const cwarmCommand = () => `node "${SEGMENT}"`;
16
+ const isCwarmStatusline = (sl) =>
17
+ !!(sl && sl.type === 'command' && typeof sl.command === 'string' && sl.command.includes('segment.mjs'));
18
+
19
+ async function confirm(q) {
20
+ if (process.argv.includes('--yes') || process.env.CWARM_YES) return true;
21
+ if (!process.stdin.isTTY) { console.log('(non-interactive; re-run with --yes to proceed)'); return false; }
22
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
23
+ const a = (await rl.question(q)).trim().toLowerCase();
24
+ rl.close();
25
+ return a === 'y' || a === 'yes';
26
+ }
27
+
28
+ function backup() {
29
+ if (!fs.existsSync(SETTINGS)) return null;
30
+ const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
31
+ const bak = `${SETTINGS}.cwarm-bak.${ts}`;
32
+ fs.copyFileSync(SETTINGS, bak);
33
+ return bak;
34
+ }
35
+
36
+ export async function runSetup(args = []) {
37
+ return args.includes('--remove') ? removeStatusline() : installStatusline();
38
+ }
39
+
40
+ async function installStatusline() {
41
+ if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
42
+ const settings = readJson(SETTINGS, {});
43
+ const existing = settings.statusLine;
44
+
45
+ if (isCwarmStatusline(existing)) { console.log('cwarm statusline already installed. Nothing to do.'); return 0; }
46
+
47
+ console.log('\ncwarm setup — cache-countdown statusline (opt-in)\n');
48
+ if (existing) {
49
+ console.log('You already have a statusLine. cwarm will WRAP it (run yours, then append');
50
+ console.log('a "♻️ cache …" countdown). Your original is saved and fully restorable.');
51
+ } else {
52
+ console.log('No existing statusLine. cwarm installs a minimal one: [model] │ ♻️ cache …');
53
+ }
54
+ console.log(`\nThis edits ${SETTINGS} (a timestamped backup is made first).`);
55
+ if (!(await confirm('Proceed? [y/N] '))) { console.log('Aborted. Nothing changed.'); return 1; }
56
+
57
+ const bak = backup();
58
+ fs.writeFileSync(ORIG, JSON.stringify(existing ?? null, null, 2)); // 存原 statusLine 物件供還原
59
+ settings.statusLine = { type: 'command', command: cwarmCommand(), refreshInterval: 3 };
60
+ fs.writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + '\n');
61
+
62
+ console.log(`\n✓ Installed.${bak ? ' Backup: ' + path.basename(bak) : ''}`);
63
+ console.log(' Remove anytime: cwarm setup --remove');
64
+ return 0;
65
+ }
66
+
67
+ async function removeStatusline() {
68
+ const settings = readJson(SETTINGS, null);
69
+ if (!settings) { console.log('No settings.json found; nothing to remove.'); return 0; }
70
+ if (!isCwarmStatusline(settings.statusLine)) { console.log("Current statusLine isn't cwarm's; leaving it alone."); return 0; }
71
+
72
+ backup();
73
+ const orig = readJson(ORIG, null); // 還原成安裝前的物件(或無)
74
+ if (orig) settings.statusLine = orig; else delete settings.statusLine;
75
+ fs.writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + '\n');
76
+ try { fs.unlinkSync(ORIG); } catch {}
77
+
78
+ console.log('✓ Removed cwarm statusline; restored your previous setting.');
79
+ return 0;
80
+ }
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // segment.mjs — cwarm 的 statusLine:跑使用者原本的 statusline(若有)再接上「cache 保溫倒數」。
3
+ // 純 Node、跨平台(不依賴 Python);emoji 走 Node UTF-8 stdout,沒有 Windows cp950 問題。
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { defaultClaudeDir, detectPlan } from '../keepalive.mjs';
8
+
9
+ const claudeDir = defaultClaudeDir();
10
+ const ORIG = path.join(claudeDir, 'cwarm-statusline-orig.json');
11
+
12
+ function readStdin() {
13
+ try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
14
+ }
15
+
16
+ // cache 倒數段:用 transcript mtime 當 idle、plan 決定 TTL(max 1h / pro 5min)。
17
+ function cacheSegment(payload) {
18
+ const tp = payload?.transcript_path;
19
+ if (!tp) return '';
20
+ let mtimeMs;
21
+ try { mtimeMs = fs.statSync(tp).mtimeMs; } catch { return ''; }
22
+ const plan = detectPlan(claudeDir);
23
+ const ttl = plan === 'max' ? 3600 : 300;
24
+ const idle = Math.max(0, Math.floor((Date.now() - mtimeMs) / 1000));
25
+ const rem = ttl - idle;
26
+ if (rem <= 0) return `\u{1F534} cache ${Math.floor(idle / 60)}m`; // 🔴 已冷
27
+ if (rem <= 60) return `\u{1F7E1} cache ${rem}s`; // 🟡 快過期
28
+ return `♻️ cache ${Math.floor(rem / 60)}m${rem % 60}s`; // ♻️ 倒數
29
+ }
30
+
31
+ // 跑使用者原本存起來的 statusLine 指令(餵同一份 stdin),回傳其輸出;沒有就 null。
32
+ function runOriginal(raw) {
33
+ let orig;
34
+ try { orig = JSON.parse(fs.readFileSync(ORIG, 'utf8')); } catch { return null; }
35
+ if (!orig || orig.type !== 'command' || !orig.command) return null;
36
+ try {
37
+ const r = spawnSync(orig.command, { shell: true, input: raw, encoding: 'utf8', timeout: 3000, windowsHide: true });
38
+ const out = (r.stdout || '').replace(/\s+$/, '');
39
+ return out || null;
40
+ } catch { return null; }
41
+ }
42
+
43
+ function minimalBase(payload) {
44
+ const model = payload?.model?.display_name;
45
+ return model ? `[${model}]` : 'claude';
46
+ }
47
+
48
+ const raw = readStdin();
49
+ let payload = {};
50
+ try { payload = JSON.parse(raw); } catch { /* 空/壞就用空物件 */ }
51
+
52
+ const seg = cacheSegment(payload);
53
+ let base = runOriginal(raw);
54
+ if (base == null) base = minimalBase(payload);
55
+ // 接在末行尾(base 末端即最後一行末端)
56
+ const out = seg ? `${base} │ ${seg}` : base;
57
+ process.stdout.write(out + '\n');