Idle-session guard for Claude Code's 1-hour prompt cache (macOS): about 50 idle minutes after the last turn of a large session it notifies you, optionally…

English · 中文
An idle-session guard for Claude Code's 1-hour prompt cache, written as a Claude Code mod (a hooks-module plugin). macOS only. The interface text is Chinese for now.
In a terminal session of Claude Code (tested on 2.1.296):
/plugin install keepwarm --marketplace luwux/keepwarm
Answer y to add the marketplace, then pick the user scope. Installed at the user scope it also loads in the desktop app's Code tab.
On a Claude subscription the main conversation's prompt cache lives for an hour after the last request. Come back later and the whole context is written again: a 300K-token Opus 5.5 session costs about $2.4 to rewrite, against about $0.06 to read it warm. keepwarm watches each session and speaks up before the hour runs out.
缓存 42m ("cache 42m") until this session's cache lapses, refreshed on every model request, tool-result requests included.remindMinContext): a desktop notification that jumps back to the session, plus a band above the prompt. With idleCheck: judge a fork of the session first decides whether to compact, keep warm or leave it; the fork reads the cache (about $0.03–0.10 at 100–400K tokens) and refreshes it./keepwarm on 8h pings the cache whenever 55 minutes have passed since the last request, for 8 hours, whatever the session is doing. Only /keepwarm off ends it. A window opened from a reminder's button or by the model covers an absence, so it ends when you write in that session. At most pingLimit pings per session in any pingLimitHours.reply_topic; the session polls that topic every 20 seconds while it has buttons out, does what was tapped and pushes a confirmation. The three buttons of one notification share one token group: the first tap voids them all, so a second tap does nothing. Tokens expire after 3 hours; a tap after the cache has lapsed is answered, not acted on./keepwarm alert on, off by default): only for what should not happen: a window whose pings could not go out, the cache rewritten within the hour on the same model, the ping limit reached, or keepwarm's own daily budget (dailyBudgetUsd, $3) exceeded./keepwarm leave 2h asks every live, warm session for its own verdict and shows one summary.guard refuse on.All requests (forks, pings) and changes (notifications, compact, files it writes) go through one audit layer that records the real usage returned by the API and its dollar cost. /keepwarm log shows it; python3 tests/check-audit.py fails if any effectful call bypasses it.
| Command | What it does | |||
|---|---|---|---|---|
/keepwarm or status | current state | |||
on 8h / off / auto | keep warm for 8 hours / stop and forbid the model to start one / allow it again | |||
check | judge now: compact, keep warm or leave it | |||
| `leave 2h on\ | off` | tell all sessions you are away (Mac on / lid closed) | ||
| `idle off\ | notify\ | judge` | what the 50-minute idle check does | |
| `alert on\ | off\ | test` | alerts | |
| `policy ask\ | auto\ | warm\ | off` | per-project idle policy |
| `guard refuse\ | warn\ | off` | when you write to a session whose cache is already cold | |
log, ledger | audit log, cold-write ledger | |||
push, panel | test push with buttons, button pane on the Mac |
In /config, or pluginConfigs.keepwarm.options in ~/.claude/settings.json: idleCheck (notify | judge | off), remindMinContext, pingLimit, pingLimitHours, dailyBudgetUsd, awayMinutes, ntfyConfigFile.
ntfyConfigFile points to a JSON file:
{ "server": "https://ntfy.sh", "topic": "<push topic>", "reply_topic": "<button topic>" }
Without reply_topic the pushes carry no buttons; an empty path disables phone pushes. On the public ntfy.sh server anyone who knows a topic name can read it, so use long random names: the push carries session titles and summaries. A forged tap cannot act, since tokens are random. Desktop notifications use terminal-notifier when it is installed.
keepwarm spends your quota to save it. Each fork or ping is a real request billed at the cache-read rate; a 3-hour window on a 300K session costs about $0.20, which pays off only if you come back within it. Defaults are conservative: idleCheck is notify (free), nothing is automatic, and pings stop at the cap or when the cache is found cold. Anthropic can change cache behaviour at any time; check /keepwarm log against your usage. Prices are list prices of September 2026, hard-coded in hooks/register.tsx.
claude --plugin-dir .
claude plugin validate .
claude plugin test .
python3 tests/check-audit.py
Keep-warm pings are derived from cache-tax (MIT, Karan Bansal); see LICENSE.cache-tax. keepwarm itself is MIT; see LICENSE.
hooks/register.tsx 1693 lines1// keepwarm: an idle-session guard for the 1-hour prompt cache.
2//
3// 1. Context reminder: ~50 minutes after the last request, if the context is
4// large, a tool-less fork of the session's own transcript (billed at the
5// cache-read rate, written nowhere, and refreshing the cache as it reads)
6// judges whether to compact, keep warm or ignore. The verdict shows as a band
7// above the prompt with buttons, a toast and a macOS notification; per-project
8// policy can apply it automatically.
9// 2. Keep-warm: the same fork, sent when 55 minutes have passed since the last
10// request, inside a window. The person's /keepwarm on 8h holds for its hours
11// whatever happens; the model may also start one with the
12// mcp__keepwarm__keepwarm tool, and the person's /keepwarm overrides the model.
13//
14// Keep-warm is derived from cache-tax (MIT, Karan Bansal, github.com/karanb192/cache-tax).
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, Register, TurnUsage } from 'claude-code'
17
18import type { KeepwarmAdvice, KeepwarmLeaveAll, KeepwarmLeaveItem } from '../types'
19
20const TTL_MS = 60 * 60 * 1000
21// Cross-session: every session's keepwarm polls the shared $.store for a leave request.
22const POLL_MS = 10_000
23const LEAVE_COLLECT_MS = 45_000
24const LEAVE_REQ_TTL_MS = 10 * 60 * 1000
25const RT_KEEP_MS = 3 * 24 * 60 * 60 * 1000
26// Settings the person picks in /config (plugin.json userConfig); register() fills them in.
27type Idle = 'off' | 'notify' | 'judge'
28const cfg = {
29 // A JSON file {"server": "https://ntfy.sh", "topic": "..."}; empty means no phone push.
30 ntfyConfigFile: '',
31 remindMinCtx: 250_000, // below this a cold rewrite is not worth an interruption
32 awayIdleS: 5 * 60,
33 // A safety net against a runaway timer: per session, at most pingLimit pings in any
34 // pingLimitHours (one every 55 minutes is 13 in 12 hours).
35 pingLimit: 15,
36 pingLimitHours: 12,
37 // What keepwarm may spend a day on its own (idle judges, pings of windows the model opened);
38 // past it the idle check falls back to the free notice until midnight. 0: no limit.
39 dailyBudgetUsd: 3,
40 idleDefault: 'notify' as Idle,
41}
42// Ping when the hour has five minutes left, whatever the session is doing (working, waiting on
43// background work, waiting on the person): only the time since the last request counts.
44const PING_AFTER_MS = 55 * 60 * 1000
45const CHECK_AFTER_MS = 50 * 60 * 1000
46const MIN_PING_MS = 60 * 1000
47const DEFAULT_WINDOW_MS = 3 * 60 * 60 * 1000
48const MAX_WINDOW_MS = 12 * 60 * 60 * 1000
49const MIN_CTX = 30_000
50const END_WARN_MS = 10 * 60 * 1000
51// Hard brakes on pings, whatever the timers say.
52const PING_MIN_GAP_MS = 40 * 60 * 1000
53const GUARD_MIN_CTX = 50_000
54const PING_PROMPT = 'Reply with the single word: warm'
55const TOOL = 'mcp__keepwarm__keepwarm'
56const LOG_MAX = 500
57
58// $ per million tokens: [cache read, 1h cache write, output], list prices September 2026.
59// Longer family names first: a model id matches the first row it contains.
60const PRICES: Array<[string, number, number, number]> = [
61 ['fable-5-1', 0.25, 20, 50],
62 ['fable-5', 1, 20, 50],
63 ['opus-5-5', 0.2, 8, 20],
64 ['opus-5', 0.5, 10, 25],
65 ['opus-4', 0.5, 10, 25],
66 ['sonnet-5', 0.2, 4, 10],
67 ['sonnet', 0.3, 6, 15],
68 ['haiku', 0.1, 2, 5],
69]
70
71type Owner = 'user' | 'model'
72type Policy = 'ask' | 'auto' | 'warm' | 'off'
73type Guard = 'refuse' | 'warn' | 'off'
74// Why a request rewrote the cache: the hour lapsed, the model changed, or something else
75// changed the prompt's prefix (system prompt, tool list).
76type MissCause = 'expired' | 'model' | 'other'
77type Miss = { at: number; tokens: number; usd: number | null; cause: MissCause; gapMs: number }
78type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
79type PingRecord = { at: number; read: number; write: number; usd: number | null; warm: boolean }
80type Timer = { cancel: () => void }
81
82type State = {
83 sid: string
84 cwd: string
85 policy: Policy
86 guard: Guard
87 ackedAt: number
88 // What the last main-loop request carried, and on which model: the next one is held to it.
89 stepCtx: number
90 stepModel: string | null
91 // The first request after a compaction writes the summary; that is not a miss.
92 skipMiss: boolean
93 // The first request after this process started: a real miss is logged without a toast, since
94 // opening a cold session already said so.
95 quietMiss: boolean
96 misses: Miss[]
97 deadline: number
98 every: number
99 owner: Owner | null
100 reason: string
101 wakePrompt: string
102 userOff: boolean
103 // Opened with /keepwarm on: it holds for its hours whatever the person does, and only
104 // /keepwarm off ends it. A window from a reminder or the model covers an absence, so it ends
105 // when the person writes.
106 sticky: boolean
107 lastRequestAt: number
108 lastModel: string | null
109 ctx: number
110 window: number
111 compacted: boolean
112 compactions: number // seen since this load: a command worked when it went up
113 pending: Timer | null
114 check: Timer | null
115 expire: Timer | null
116 endWarn: Timer | null
117 endWarnedFor: number
118 tick: Timer | null
119 caff: AsyncGenerator<unknown, unknown> | null
120 checkedFor: number
121 // Last real turn. Forks refresh lastRequestAt but are not the person coming back.
122 turnAt: number
123 poll: Timer | null
124 collect: Timer | null
125 leaveSeen: string
126 // The 50-minute idle check: off, a free notice, or a judging fork.
127 idle: Idle
128 last: PingRecord | null
129 pings: number
130 pingUsd: number
131 stopped: string | null
132 // The panel (/keepwarm panel) redraws its countdown each minute while open.
133 paneTick: Timer | null
134 // Buttons on a phone push: polling the reply topic while one of them may still be tapped.
135 phonePoll: Timer | null
136 phoneBusy: boolean
137}
138
139const advice = atom({ plugin: 'keepwarm', key: 'advice' } as const, null as KeepwarmAdvice | null)
140const leaveAll = atom({ plugin: 'keepwarm', key: 'leaveAll' } as const, null as KeepwarmLeaveAll | null)
141const panel = atom({ plugin: 'keepwarm', key: 'panel' } as const, 0)
142
143type LeaveReq = { id: string; at: number; from: string; hours: number; machineOn: boolean }
144type Runtime = { lastRequestAt: number; turnAt: number; ctx: number; window: number; model: string | null; compacted: boolean }
145
146function priceOf(model: string | null): [number, number, number] | null {
147 const m = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
148 for (const [family, r, w, o] of PRICES) if (m.includes(family)) return [r, w, o]
149 return null
150}
151
152function parseDuration(text: string): number | null {
153 const m = /^(?:(\d+(?:\.\d+)?)h)?(?:(\d+)m)?$/.exec(text.trim())
154 if (!m || (m[1] === undefined && m[2] === undefined)) return null
155 return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60 * 1000
156}
157
158function fmtDuration(ms: number): string {
159 const total = Math.max(0, Math.round(ms / 60000))
160 const h = Math.floor(total / 60)
161 const m = total % 60
162 return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
163}
164
165const fmtUsd = (usd: number | null) => usd == null ? 'n/a' : '$' + (usd >= 10 ? usd.toFixed(1) : usd.toFixed(2))
166const fmtTok = (n: number) => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? Math.round(n / 1000) + 'K' : String(n)
167const coldUsd = (s: State) => { const p = priceOf(s.lastModel); return p ? s.ctx * p[1] / 1e6 : null }
168const hourUsd = (s: State) => { const p = priceOf(s.lastModel); return p ? s.ctx * p[0] / 1e6 * (TTL_MS / s.every) : null }
169const isCold = (s: State, now: number) => s.lastRequestAt > 0 && !s.compacted && now - s.lastRequestAt >= TTL_MS
170const key = (s: State, k: string) => `${k}:${s.sid}`
171const project = (s: State) => s.cwd.split('/').filter(Boolean).pop() ?? s.cwd
172
173function forkUsd(u: Usage, p: [number, number, number] | null): number | null {
174 return p ? (u.cache_read_input_tokens * p[0] + u.cache_creation_input_tokens * p[1] + u.input_tokens * p[1] / 2 + u.output_tokens * p[2]) / 1e6 : null
175}
176
177// A warm fork reads the prefix and writes only its own tail; a big write means the prefix broke.
178const forkWasWarm = (u: Usage) => u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < 0.1 * u.cache_read_input_tokens
179
180// The footer line: when this session's cache lapses, counted from the last request of any
181// kind (a turn, a judging fork, a ping), since each cache hit restarts the hour.
182function statusText(s: State, now: number): string | undefined {
183 if (s.stopped) return `保温已停:${s.stopped}`
184 if (!s.lastRequestAt || s.compacted) return undefined
185 if (isCold(s, now)) return `缓存已冷 ${fmtDuration(now - s.lastRequestAt - TTL_MS)}`
186 const cache = `缓存 ${fmtDuration(s.lastRequestAt + TTL_MS - now)}`
187 if (!s.deadline) return cache
188 const who = s.owner === 'model' ? '模型开的' : '你开的'
189 const last = s.last ? ` · 上次 ping ${fmtDuration(now - s.last.at)} 前 ${fmtUsd(s.last.usd)}` : ''
190 return `${cache} · 保温(${who})剩 ${fmtDuration(s.deadline - now)} · ${fmtDuration(s.lastRequestAt + s.every - now)} 后 ping${last}`
191}
192
193// Each label is two Chinese characters and a full-width space, so the lines stay aligned in the
194// desktop app's proportional font as well as in a terminal.
195async function card($: EngineInterface, s: State, now: number): Promise<string> {
196 const alertOn = (await $.store.get('alert')) === true
197 const spent = await spendToday($, now)
198 const over = await overBudget($)
199 const cache = !s.lastRequestAt ? '本会话还没有请求'
200 : s.compacted ? '刚 compact,等下一轮'
201 : isCold(s, now) ? `已冷(最后请求在 ${fmtDuration(now - s.lastRequestAt)} 前)`
202 : `热,还剩 ${fmtDuration(s.lastRequestAt + TTL_MS - now)}`
203 const warm = s.deadline
204 ? `开,还剩 ${fmtDuration(s.deadline - now)}(${s.sticky ? '/keepwarm on 开的,发消息不会结束' : `${s.owner === 'model' ? '模型' : '你'}开的,你回来发消息就结束`})` +
205 `${s.reason && !s.sticky ? `:${s.reason}` : ''}${s.wakePrompt ? `;唤醒任务:${s.wakePrompt}` : ''}`
206 : s.stopped ? `关(上次停止:${s.stopped})` : '关'
207 const remind = s.policy === 'off' || s.idle === 'off' ? '关'
208 : s.check ? `空闲 50 分钟时${s.idle === 'judge' && !over ? '判断一次(compact、保温还是不管)' : '免费提醒一次'}`
209 : s.deadline ? '保温期间不提醒'
210 : `不提醒(上下文不到 ${fmtTok(cfg.remindMinCtx)},或缓存已冷)`
211 const missUsd = s.misses.reduce((a, m) => a + (m.usd ?? 0), 0)
212 return [
213 `${project(s)}(项目策略 ${s.policy})`,
214 '',
215 `缓存 ${cache} · 上下文 ${fmtTok(s.ctx)} / ${fmtTok(s.window)} · ${s.lastModel ?? '模型未知'}`,
216 `保温 ${warm}${s.userOff ? ';你已禁止模型开启' : ''}`,
217 `提醒 ${remind}`,
218 `价格 过期后重写一次约 ${fmtUsd(coldUsd(s))},保温约 ${fmtUsd(hourUsd(s))}/小时`,
219 `记账 本会话冷写入 ${s.misses.length} 次 ${fmtUsd(missUsd)} · 本次运行 ping ${s.pings} 次 ${fmtUsd(s.pingUsd)}` +
220 `${s.last ? `(上次读 ${fmtTok(s.last.read)}、写 ${fmtTok(s.last.write)})` : ''}`,
221 `今天 keepwarm 共花 ${fmtUsd(spent.auto + spent.manual)}:自己主动的 ${fmtUsd(spent.auto)}` +
222 `${cfg.dailyBudgetUsd ? `(上限 $${cfg.dailyBudgetUsd}${over ? ',已超,改为免费提醒' : ''})` : ''},你要求的 ${fmtUsd(spent.manual)}`,
223 `报警 ${alertOn ? '开' : '关'}`,
224 `设置 空闲检查 ${s.idle} · 提醒门槛 ${fmtTok(cfg.remindMinCtx)} · ping 上限 ${cfg.pingLimit} 次/${cfg.pingLimitHours} 小时 · ` +
225 `冷发送${{ refuse: '先拦一次', warn: '只记账', off: '不管' }[s.guard]} · 项目策略 ${s.policy} · 手机推送${cfg.ntfyConfigFile ? `开(离开 ${cfg.awayIdleS / 60} 分钟后)` : '关'}`,
226 '',
227 '常用 /keepwarm on 8h · off · check · alert on · log · help',
228 ].join('\n')
229}
230
231const HELP = [
232 '/keepwarm 的用法',
233 '',
234 'on 8h:保温 8 小时(也可以写 90m),不管会话在干什么、你发什么消息,只有 off 能关',
235 'off:关掉保温,并禁止模型自己开;auto:重新允许模型自己开',
236 'status:看状态(直接输入 /keepwarm 也是)',
237 'check:现在判断一次:compact、保温还是不管',
238 'leave 2h on|off:告诉所有会话你要离开多久(on 电脑开着,off 合盖或关机)',
239 'idle off|notify|judge:空闲 50 分钟时什么都不做 / 免费提醒 / 判断一次',
240 'alert on|off|test:报警开关;test 发一条测试报警',
241 'policy ask|auto|warm|off:本项目空闲时问你 / 自动照办 / 总是保温 / 不管',
242 'guard refuse|warn|off:缓存已冷还发消息时先拦一次 / 只记账 / 不管',
243 'log [all] [requests|changes]:审计记录;ledger:冷写入账本',
244 'push:发一条带按钮的测试推送到手机(真实提醒在你离开 Mac 时才推,带 compact / 保温 / 不管 按钮)',
245 'panel:打开按钮面板(compact / 保温 / 关保温)',
246].join('\n')
247
248// ---------- audit: every request keepwarm pays for and every change it makes ----------
249// All side effects go through the audited* helpers below; nothing else calls $.model, $.mcp,
250// $.prompt.submit, $.session.compact/append or spawns a process that changes anything.
251const AUDIT_MAX = 3000
252type ForkResult = Awaited<ReturnType<EngineInterface['model']['fork']>>
253type CompleteResult = Awaited<ReturnType<EngineInterface['model']['complete']>>
254const errText = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 300)
255
256async function audit($: EngineInterface, s: State, kind: 'request' | 'change' | 'notify', what: string, data: Record<string, unknown> = {}) {
257 try {
258 const log = ((await $.store.get('audit')) as unknown[] | undefined) ?? []
259 log.push({ at: await $.clock.now(), sid: s.sid, kind, what, ...data })
260 await $.store.set('audit', log.slice(-AUDIT_MAX))
261 } catch (err) {
262 $.ui.log(`keepwarm:审计记录写入失败 ${errText(err)}`)
263 }
264}
265
266// manual: the person asked for this request (their window's ping, /keepwarm check, a leave);
267// everything else counts against the daily budget.
268async function auditedFork($: EngineInterface, s: State, purpose: string, prompt: string, manual = false): Promise<ForkResult> {
269 let r: ForkResult
270 try {
271 r = await $.model.fork({ prompt })
272 } catch (err) {
273 await audit($, s, 'request', purpose, { via: 'fork', model: s.lastModel, manual, error: errText(err) })
274 throw err
275 }
276 const u = 'usage' in r ? (r.usage as Usage) : null
277 await audit($, s, 'request', purpose, {
278 via: 'fork', model: s.lastModel, manual, answered: r.isAnswered, reason: r.isAnswered ? undefined : r.reason,
279 read: u?.cache_read_input_tokens ?? 0, write: u?.cache_creation_input_tokens ?? 0, in: u?.input_tokens ?? 0, out: u?.output_tokens ?? 0,
280 usd: u ? forkUsd(u, priceOf(s.lastModel)) : 0,
281 })
282 return r
283}
284
285async function auditedComplete($: EngineInterface, s: State, purpose: string, model: string, prompt: string, maxTokens: number, price: [number, number]): Promise<CompleteResult> {
286 let r: CompleteResult
287 try {
288 r = await $.model.complete({ model, prompt, maxTokens })
289 } catch (err) {
290 await audit($, s, 'request', purpose, { via: 'complete', model, error: errText(err) })
291 throw err
292 }
293 const u = 'usage' in r ? (r.usage as { input_tokens: number; output_tokens: number }) : null
294 await audit($, s, 'request', purpose, {
295 via: 'complete', model, answered: r.isAnswered, reason: r.isAnswered ? undefined : r.reason,
296 in: u?.input_tokens ?? 0, out: u?.output_tokens ?? 0, usd: u ? (u.input_tokens * price[0] + u.output_tokens * price[1]) / 1e6 : 0,
297 })
298 return r
299}
300
301async function auditedMcp($: EngineInterface, s: State, purpose: string, server: string, tool: string, args: Record<string, unknown>) {
302 try {
303 const r = await $.mcp.call(server, tool, args)
304 await audit($, s, 'change', purpose, { server, tool, args, ok: !r.isError, result: r.isError ? JSON.stringify(r.content).slice(0, 300) : undefined })
305 return r
306 } catch (err) {
307 await audit($, s, 'change', purpose, { server, tool, args, ok: false, error: errText(err) })
308 throw err
309 }
310}
311
312// A process that changes something (a notification, a push, a file write or delete).
313async function auditedRun($: EngineInterface, s: State, kind: 'change' | 'notify', purpose: string, argv: readonly string[], detail: Record<string, unknown> = {}) {
314 try {
315 const r = await $.process.run(argv)
316 await audit($, s, kind, purpose, { ...detail, exit: r.exitCode })
317 return r
318 } catch (err) {
319 await audit($, s, kind, purpose, { ...detail, ok: false, error: errText(err) })
320 throw err
321 }
322}
323
324async function auditedSubmit($: EngineInterface, s: State, purpose: string, text: string) {
325 try {
326 await $.prompt.submit({ text })
327 await audit($, s, 'change', purpose, { text: text.slice(0, 500) })
328 } catch (err) {
329 await audit($, s, 'change', purpose, { text: text.slice(0, 500), ok: false, error: errText(err) })
330 throw err
331 }
332}
333
334async function auditedCompact($: EngineInterface, s: State, instructions: string) {
335 try {
336 await $.session.compact(instructions ? { instructions } : undefined)
337 await audit($, s, 'change', 'compact', { instructions })
338 } catch (err) {
339 await audit($, s, 'change', 'compact', { instructions, ok: false, error: errText(err) })
340 throw err
341 }
342}
343
344// A slash command run as the user would type it (the desktop app compacts only this way).
345async function auditedCommand($: EngineInterface, s: State, purpose: string, command: string, args: string) {
346 const n = s.compactions
347 try {
348 const r = await $.command.run({ command, args })
349 await audit($, s, 'change', purpose, { command, args: args.slice(0, 500), text: r.text?.slice(0, 300), compacted: s.compactions > n })
350 return r
351 } catch (err) {
352 await audit($, s, 'change', purpose, { command, args: args.slice(0, 500), ok: false, error: errText(err) })
353 throw err
354 }
355}
356
357async function appendLog($: EngineInterface, name: string, row: Record<string, unknown>) {
358 const log = ((await $.store.get(name)) as unknown[] | undefined) ?? []
359 log.push(row)
360 await $.store.set(name, log.slice(-LOG_MAX))
361}
362
363// Seconds since the last keyboard or mouse input on this Mac.
364async function idleSeconds($: EngineInterface): Promise<number> {
365 try {
366 const r = await $.process.run(['ioreg', '-c', 'IOHIDSystem', '-d', '4'])
367 const m = /"HIDIdleTime" = (\d+)/.exec(r.stdout)
368 return m ? Number(m[1]) / 1e9 : 0
369 } catch {
370 return 0
371 }
372}
373
374// A desktop notification always; the phone too when nobody is at the Mac.
375async function alert($: EngineInterface, s: State, title: string, message: string, buttons: PhoneButton[] = []) {
376 await notify($, s, title, message)
377 if ((await idleSeconds($)) < cfg.awayIdleS) return
378 const c = await ntfyConfig($)
379 const actions = c ? await phoneActions($, s, buttons, c) : []
380 // The phone shows the session's own title (as the desktop sidebar does), not the folder.
381 await push($, s, `${await sessionName($, s)} · ${title}`,
382 message + (actions.length ? '\n\n点下面的按钮,Mac 上的这个会话约半分钟内照做,做完再推一条确认。' : '\n\n要处理请回到会话里。'), actions)
383}
384
385const sessionName = async ($: EngineInterface, s: State) => (await desktopSession($, s))?.title || project(s)
386
387// ---------- the panel (/keepwarm panel): the same buttons as the band, in a pane ----------
388
389const PANE = 'keepwarm'
390
391async function openPanel($: EngineInterface, s: State) {
392 const r = await $.ui.open({ id: PANE, title: 'keepwarm' })
393 // The countdown moves with the clock, not with any value the drawing reads.
394 if (s.paneTick) s.paneTick.cancel()
395 s.paneTick = $.clock.every(60_000, () => { void update($, panel, n => n + 1) })
396 return r
397}
398
399type PanelChoice = 'compact' | 'advised' | 'warm8' | 'off' | 'ignore'
400
401async function panelPress($: EngineInterface, s: State, choice: PanelChoice) {
402 const now = await $.clock.now()
403 const stored = await read($, advice)
404 const a = stored && !adviceStale(stored, now) ? stored : null
405 await audit($, s, 'change', '面板按钮', { choice, advised: a?.action ?? null })
406 if (choice === 'compact') {
407 await apply($, s, a ?? { action: 'compact', reason: '面板上手动 compact', topic: '', instructions: '', hours: 3, ctx: s.ctx, coldUsd: coldUsd(s), hourUsd: hourUsd(s), judgeUsd: null, at: now, auto: false }, 'compact', 'user')
408 } else if (choice === 'advised' && a) {
409 await apply($, s, a, 'keepwarm', 'user')
410 } else if (choice === 'ignore' && a) {
411 await apply($, s, a, 'ignore', 'user')
412 } else if (choice === 'warm8') {
413 // The same as /keepwarm on 8h: it holds whatever happens, until off.
414 if (a) {
415 await appendLog($, 'decisions', { sid: s.sid, cwd: s.cwd, at: now, ctx: a.ctx, advised: a.action, reason: a.reason, chosen: 'keepwarm', by: 'user' })
416 await update($, advice, () => null)
417 }
418 s.userOff = false
419 await startWindow($, s, 8 * 3_600_000, 'user', '面板:保温 8h', '', true)
420 } else if (choice === 'off') {
421 await stop($, s, '你在面板上关了保温')
422 }
423 $.ui.status(statusText(s, await $.clock.now()))
424 await update($, panel, n => n + 1)
425}
426
427// ---------- buttons on a phone push ----------
428// Each button is an ntfy http action that posts a one-time token to the reply topic. Only this
429// session honours its tokens, each notification once (one tap spends all its buttons), and only
430// for PHONE_KEEP_MS; the session polls the topic (a GET) while any of its tokens is live.
431
432type PhoneChoice = { kind: 'compact' } | { kind: 'warm'; hours: number } | { kind: 'ignore' }
433type PhoneButton = { label: string; choice: PhoneChoice }
434type PhoneToken = { token: string; group: string; choice: PhoneChoice; at: number; exp: number }
435type NtfyAction = { action: 'http'; label: string; url: string; method: 'POST'; body: string; clear: true }
436
437const PHONE_POLL_MS = 20_000
438const PHONE_KEEP_MS = 3 * 3_600_000
439
440const warmButton = (hours: number, verb = '保温'): PhoneButton => ({ label: `${verb} ${hours}h`, choice: { kind: 'warm', hours } })
441const COMPACT_BUTTON: PhoneButton = { label: 'compact', choice: { kind: 'compact' } }
442const IGNORE_BUTTON: PhoneButton = { label: '不管', choice: { kind: 'ignore' } }
443
444async function phoneTokens($: EngineInterface, s: State): Promise<PhoneToken[]> {
445 const v = await $.store.get(key(s, 'phone'))
446 return Array.isArray(v) ? (v as PhoneToken[]) : []
447}
448
449async function setPhoneTokens($: EngineInterface, s: State, list: PhoneToken[]) {
450 if (list.length) await $.store.set(key(s, 'phone'), list)
451 else await $.store.delete(key(s, 'phone'))
452}
453
454async function phoneActions($: EngineInterface, s: State, buttons: PhoneButton[], c: Ntfy): Promise<NtfyAction[]> {
455 if (!c.reply || !buttons.length) return []
456 const now = await $.clock.now()
457 const group = crypto.randomUUID()
458 const fresh = buttons.slice(0, 3).map(b => ({ label: b.label, t: { token: crypto.randomUUID().replace(/-/g, ''), group, choice: b.choice, at: now, exp: now + PHONE_KEEP_MS } }))
459 await setPhoneTokens($, s, [...(await phoneTokens($, s)).filter(t => t.exp > now), ...fresh.map(f => f.t)])
460 armPhonePoll($, s)
461 return fresh.map(({ label, t }) => ({ action: 'http', label, url: `${c.server}/${c.reply}`, method: 'POST', body: JSON.stringify({ keepwarm: t.token }), clear: true }))
462}
463
464function armPhonePoll($: EngineInterface, s: State) {
465 if (!s.phonePoll) s.phonePoll = $.clock.every(PHONE_POLL_MS, () => { void pollPhone($, s) })
466}
467
468// The taps since the oldest live token was handed out: a GET on the reply topic, nothing written.
469async function readReplies($: EngineInterface, c: Ntfy, since: number): Promise<{ id: string; token: string }[]> {
470 const r = await $.process.run(['curl', '-s', '-m', '10', `${c.server}/${c.reply}/json?poll=1&since=${since}`])
471 const out: { id: string; token: string }[] = []
472 for (const line of r.stdout.split('\n')) {
473 try {
474 const m = JSON.parse(line) as { id?: string; event?: string; message?: string }
475 const b = JSON.parse(m.message ?? '') as { keepwarm?: unknown }
476 if (m.event === 'message' && m.id && typeof b.keepwarm === 'string') out.push({ id: m.id, token: b.keepwarm })
477 } catch {
478 // another sender's message on the topic, or a partial line
479 }
480 }
481 return out
482}
483
484async function pollPhone($: EngineInterface, s: State) {
485 if (s.phoneBusy) return
486 s.phoneBusy = true
487 try {
488 const now = await $.clock.now()
489 const live = (await phoneTokens($, s)).filter(t => t.exp > now)
490 if (!live.length) {
491 if (s.phonePoll) s.phonePoll.cancel()
492 s.phonePoll = null
493 await setPhoneTokens($, s, [])
494 return
495 }
496 const c = await ntfyConfig($)
497 if (!c?.reply) return
498 const since = Math.floor(Math.min(...live.map(t => t.at)) / 1000) - 5
499 for (const r of await readReplies($, c, since)) {
500 const tokens = await phoneTokens($, s)
501 const t = tokens.find(x => x.token === r.token && x.exp > now)
502 if (!t) continue
503 await setPhoneTokens($, s, tokens.filter(x => x.group !== t.group))
504 await phoneDo($, s, t.choice)
505 }
506 } catch (err) {
507 $.ui.log(`keepwarm:读手机按钮失败 ${errText(err)}`, { to: 'debug' })
508 } finally {
509 s.phoneBusy = false
510 }
511}
512
513async function phoneDo($: EngineInterface, s: State, choice: PhoneChoice) {
514 const now = await $.clock.now()
515 const stored = await read($, advice)
516 const a = stored && !adviceStale(stored, now) ? stored : null
517 await audit($, s, 'change', '手机通知按钮', { choice, advised: a?.action ?? null })
518 const name = await sessionName($, s)
519 if (choice.kind === 'ignore') {
520 if (a) await apply($, s, a, 'ignore', 'user')
521 return
522 }
523 if (s.compacted) {
524 await push($, s, `${name} · 已经 compact 过了`, '这次按钮没有执行。')
525 return
526 }
527 if (isCold(s, now)) {
528 const late = `缓存在 ${fmtDuration(now - s.lastRequestAt - TTL_MS)} 前已经过期,`
529 await push($, s, `${name} · 缓存已过期,没执行`, choice.kind === 'compact'
530 ? `${late}现在 compact 要重新读整段上下文(约 ${fmtUsd(coldUsd(s))}),回到会话里再决定。`
531 : `${late}保温已经没有意义。`)
532 return
533 }
534 if (choice.kind === 'compact') {
535 const before = s.ctx
536 const n = s.compactions
537 await apply($, s, a ?? { action: 'compact', reason: '手机通知上的 compact', topic: '', instructions: '', hours: 3, ctx: s.ctx, coldUsd: coldUsd(s), hourUsd: hourUsd(s), judgeUsd: null, at: now, auto: false }, 'compact', 'user')
538 const done = s.compactions > n
539 await push($, s, `${name} · ${done ? '已 compact' : 'compact 没成功'}`,
540 done ? `原来上下文 ${fmtTok(before)},下一轮从摘要开始。` : '回到会话里手动 /compact。')
541 return
542 }
543 if (a) {
544 await appendLog($, 'decisions', { sid: s.sid, cwd: s.cwd, at: now, ctx: a.ctx, advised: a.action, reason: a.reason, chosen: 'keepwarm', by: 'user' })
545 await update($, advice, () => null)
546 }
547 s.userOff = false
548 // Like a reminder's button: it covers the absence, so it ends when you write in the session.
549 await startWindow($, s, choice.hours * 3_600_000, 'user', `手机通知:保温 ${choice.hours}h`)
550 await push($, s, `${name} · 已保温 ${choice.hours}h`, `约 ${fmtUsd(hourUsd(s))}/小时;你回来在这个会话里发消息,保温就结束。`)
551}
552
553// ---------- alerts: only what should not have happened, opt-in (/keepwarm alert on) ----------
554
555type AlarmKind = 'lapsed' | 'unexplained' | 'budget' | 'pinglimit'
556const ALARM_GAP_MS = 60 * 60 * 1000
557
558const dayStart = (now: number) => {
559 const d = new Date(now)
560 return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
561}
562
563// To the Mac (and the phone when you are away), at most once an hour per kind and session.
564async function alarm($: EngineInterface, s: State, kind: AlarmKind, title: string, message: string) {
565 if ((await $.store.get('alert')) !== true) return
566 const now = await $.clock.now()
567 const seen = ((await $.store.get('alarms')) as Record<string, number> | undefined) ?? {}
568 const k = `${kind}:${kind === 'budget' ? '' : s.sid}`
569 if (now - (seen[k] ?? 0) < ALARM_GAP_MS) return
570 const kept: Record<string, number> = {}
571 for (const [key, at] of Object.entries(seen)) if (now - at < 24 * 3_600_000) kept[key] = at
572 kept[k] = now
573 await $.store.set('alarms', kept)
574 await alert($, s, `⚠️ ${title}`, message)
575}
576
577// What keepwarm spent today, split into what it chose to spend and what the person asked for.
578// Only the first has a budget: pausing the second would undo what they asked for, and with many
579// sessions each kept warm on purpose the total grows past any fixed line. The person's own
580// windows are bounded per session by pingLimit instead.
581async function spendToday($: EngineInterface, now: number): Promise<{ auto: number; manual: number }> {
582 const from = dayStart(now)
583 const log = ((await $.store.get('audit')) as Array<{ at: number; kind: string; usd?: number; manual?: boolean }> | undefined) ?? []
584 const out = { auto: 0, manual: 0 }
585 for (const r of log) if (r.at >= from && r.kind === 'request') out[r.manual ? 'manual' : 'auto'] += r.usd ?? 0
586 return out
587}
588
589async function overBudget($: EngineInterface): Promise<boolean> {
590 return cfg.dailyBudgetUsd > 0 && (await $.store.get('budgetHit')) === dayStart(await $.clock.now())
591}
592
593async function checkBudget($: EngineInterface, s: State) {
594 if (!cfg.dailyBudgetUsd || (await overBudget($))) return
595 const now = await $.clock.now()
596 const spent = (await spendToday($, now)).auto
597 if (spent <= cfg.dailyBudgetUsd) return
598 await $.store.set('budgetHit', dayStart(now))
599 await audit($, s, 'change', '今日自动花费超过上限', { spent, budget: cfg.dailyBudgetUsd })
600 const msg = `keepwarm 今天自己花了 ${fmtUsd(spent)}(上限 $${cfg.dailyBudgetUsd}),午夜前空闲检查改为免费提醒;你开的保温照常。`
601 $.ui.toast(msg)
602 await alarm($, s, 'budget', '自动花费超过上限', msg)
603}
604
605async function alertLine($: EngineInterface, now: number): Promise<string> {
606 const on = (await $.store.get('alert')) === true
607 const spent = await spendToday($, now)
608 const over = await overBudget($)
609 return `alerts 报警${on ? '开' : '关'}(/keepwarm alert on|off|test)\n` +
610 `today keepwarm 今天共花 ${fmtUsd(spent.auto + spent.manual)}:自己主动的 ${fmtUsd(spent.auto)}${cfg.dailyBudgetUsd ? `(上限 $${cfg.dailyBudgetUsd}${over ? ',已超,空闲检查改为免费提醒' : ''})` : ''},` +
611 `你要求的 ${fmtUsd(spent.manual)}(你开的保温、check、离开判断;不设上限,每会话 ${cfg.pingLimitHours} 小时最多 ping ${cfg.pingLimit} 次)`
612}
613
614// The desktop app names its sessions local_<uuid>; its deep link wants that id, and
615// its record maps it to the CLI session id this mod knows.
616const DESKTOP_SESSIONS = '$HOME/Library/Application Support/Claude/claude-code-sessions'
617
618async function desktopSession($: EngineInterface, s: State): Promise<{ id: string; title: string } | null> {
619 try {
620 // The pattern goes in as an argument: quotes written inside this template literal would be
621 // eaten by JS and then by sh, and grep would search for cliSessionId:<id> and never match.
622 const r = await $.process.run(['sh', '-c', `f=$(grep -rl --include='local_*.json' -F -e "$1" "${DESKTOP_SESSIONS}" 2>/dev/null | head -1) && [ -n "$f" ] && cat "$f"`, 'kw', `"cliSessionId":"${s.sid}"`])
623 const d = JSON.parse(r.stdout) as { sessionId?: string; cliSessionId?: string; title?: string }
624 return d.cliSessionId === s.sid && d.sessionId?.startsWith('local_') ? { id: d.sessionId, title: d.title ?? '' } : null
625 } catch {
626 return null
627 }
628}
629
630async function notify($: EngineInterface, s: State, title: string, message: string) {
631 $.ui.toast(`${title}:${message}`)
632 try {
633 // The desktop app runs sessions headless, so surfaces() may not say 'desktop':
634 // the app's own record of this session is the test.
635 const ds = await desktopSession($, s)
636 $.ui.log(`keepwarm notify: surfaces=${(await $.session.surfaces()).join(',') || '-'} desktop=${ds?.id ?? 'none'}`, { to: 'debug' })
637 const argv = ['terminal-notifier', '-title', 'Claude Code', '-subtitle', `${ds?.title || project(s)} · ${title}`, '-message', message, '-group', `keepwarm-${s.sid}`]
638 // Clicking opens this very session in the desktop app.
639 // Bring the app forward first: a link to the session already on screen changes nothing by itself.
640 if (ds) argv.push('-execute', `open -b com.anthropic.claudefordesktop && open 'claude://code/continue?session=${ds.id}&source=desktop_action'`)
641 await auditedRun($, s, 'notify', 'Mac 通知', argv, { title, message: message.slice(0, 200), jump: ds?.id ?? null })
642 } catch {
643 // terminal-notifier missing or refused: the toast and band still show.
644 }
645}
646
647// ---------- cold-send guard (from cache-tax) ----------
648
649function guardText(s: State, now: number): string {
650 const p = priceOf(s.lastModel)
651 const warm = p ? s.ctx * p[0] / 1e6 : null
652 return `缓存已冷 ${fmtDuration(now - s.lastRequestAt - TTL_MS)}(最后请求在 ${fmtDuration(now - s.lastRequestAt)} 前)。` +
653 `这次发送要重写约 ${fmtTok(s.ctx)} tokens,约 ${fmtUsd(coldUsd(s))}(热的话只要 ${fmtUsd(warm)})。`
654}
655
656const MISS_ZH = { expired: '缓存过期', model: '换了模型', other: '提示前缀变了' } as const
657
658// Judged per request, not per turn: a long turn whose context grows step by step writes a lot
659// in total and is not a miss, while a rewrite in the middle of a turn (after a foreground
660// subagent that ran past the hour) is one.
661async function checkMiss($: EngineInterface, s: State, u: TurnUsage, prevAt: number) {
662 const prev = s.stepCtx || s.ctx
663 const read = u.cache_read_input_tokens
664 const write = u.cache_creation_input_tokens
665 const prevModel = s.stepModel ?? s.lastModel
666 s.stepCtx = read + write + u.input_tokens
667 s.stepModel = u.model
668 const quiet = s.quietMiss
669 s.quietMiss = false
670 // Nothing was cached yet (a new session's first message), or the summary of a compaction.
671 if (!prevAt || s.skipMiss) {
672 s.skipMiss = false
673 return
674 }
675 if (prev <= 20000 || read >= 0.5 * prev || write < 0.5 * prev) return
676 const now = await $.clock.now()
677 const gapMs = prevAt ? now - prevAt : 0
678 const cause: MissCause = prevModel && prevModel !== u.model ? 'model' : gapMs > TTL_MS ? 'expired' : 'other'
679 const p = priceOf(u.model)
680 const miss: Miss = { at: now, tokens: write, usd: p ? write * p[1] / 1e6 : null, cause, gapMs }
681 s.misses.push(miss)
682 await appendLog($, 'misses', { sid: s.sid, cwd: s.cwd, model: u.model, ...miss })
683 if (!quiet) $.ui.toast(`冷写入 ${fmtTok(write)} tokens,约 ${fmtUsd(miss.usd)}(${MISS_ZH[cause]})`)
684 if (cause === 'other') {
685 await alarm($, s, 'unexplained', '缓存被意外整段重写', `同一模型、距上次请求只有 ${fmtDuration(gapMs)},仍整段重写了 ${fmtTok(write)} tokens(约 ${fmtUsd(miss.usd)})。可能是系统提示或工具列表变了,或者回退、编辑了历史消息。`)
686 } else if (cause === 'expired' && s.deadline) {
687 await alarm($, s, 'lapsed', '保温期间缓存过期', `保温窗口开着,缓存还是过期了:重写 ${fmtTok(write)} tokens(约 ${fmtUsd(miss.usd)})。看 /keepwarm log 找原因。`)
688 }
689}
690
691const hhmm = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
692
693// A window whose pings could not go out (the Mac slept, or this session's process was not
694// running after an app restart): the cache lapsed an hour after the last request, inside the
695// window. Record it and end the window rather than let it run out silently.
696async function lapsedWindow($: EngineInterface, s: State, deadline: number, cause: string) {
697 const lostAt = s.lastRequestAt + TTL_MS
698 const why = `保温本该到 ${hhmm(deadline)},但${cause},缓存约 ${hhmm(lostAt)} 已过期`
699 await audit($, s, 'change', '保温中断', { why, deadline, lastRequestAt: s.lastRequestAt, lostAt })
700 await alarm($, s, 'lapsed', '保温失效', why)
701 if (s.deadline) await stop($, s, why)
702 else $.ui.toast(`keepwarm:${why}`)
703}
704
705async function missesTotal($: EngineInterface): Promise<{ n: number; usd: number }> {
706 const log = ((await $.store.get('misses')) as Array<{ usd: number | null }> | undefined) ?? []
707 return { n: log.length, usd: log.reduce((a, m) => a + (m.usd ?? 0), 0) }
708}
709
710// ---------- keep-warm window ----------
711
712function disarm(s: State) {
713 if (s.pending) s.pending.cancel()
714 s.pending = null
715}
716
717function disarmCheck(s: State) {
718 if (s.check) s.check.cancel()
719 s.check = null
720}
721
722async function save($: EngineInterface, s: State) {
723 if (s.deadline) {
724 await $.store.set(key(s, 'deadline'), s.deadline)
725 await $.store.set(key(s, 'owner'), s.owner ?? 'user')
726 await $.store.set(key(s, 'reason'), s.reason)
727 await $.store.set(key(s, 'wake'), s.wakePrompt)
728 await $.store.set(key(s, 'sticky'), s.sticky)
729 } else {
730 for (const k of ['deadline', 'owner', 'reason', 'wake', 'sticky']) await $.store.delete(key(s, k))
731 }
732 if (s.userOff) await $.store.set(key(s, 'userOff'), true)
733 else await $.store.delete(key(s, 'userOff'))
734}
735
736async function stop($: EngineInterface, s: State, why: string | null) {
737 if (s.deadline) await audit($, s, 'change', '停止保温', { why: why ?? '(无提示)', owner: s.owner, left: s.deadline - (await $.clock.now()) })
738 s.deadline = 0
739 s.owner = null
740 s.reason = ''
741 s.wakePrompt = ''
742 s.sticky = false
743 s.stopped = why
744 disarm(s)
745 stopCaffeinate($, s)
746 await save($, s)
747 $.ui.status(statusText(s, await $.clock.now()))
748 if (why) $.ui.toast(`keepwarm 停止:${why}`)
749 await arm($, s)
750}
751
752async function arm($: EngineInterface, s: State) {
753 disarm(s)
754 disarmCheck(s)
755 const now = await $.clock.now()
756 if (s.deadline && now >= s.deadline) {
757 await audit($, s, 'change', '保温到期结束', { owner: s.owner })
758 s.deadline = 0
759 s.owner = null
760 await save($, s)
761 }
762 const live = s.lastRequestAt > 0 && !s.compacted && !isCold(s, now)
763 if (s.deadline) {
764 if (live) {
765 const delay = Math.min(s.deadline - now, Math.max(1000, s.lastRequestAt + s.every - now))
766 s.pending = $.clock.after(delay, () => { void ping($, s) })
767 if (s.endWarnedFor !== s.deadline && s.deadline - now > 1000) {
768 if (s.endWarn) s.endWarn.cancel()
769 s.endWarn = $.clock.after(Math.max(1000, s.deadline - END_WARN_MS - now), () => { void endWarn($, s) })
770 }
771 } else {
772 // Cold or just compacted: never ping (it would pay the rewrite); only expire the window.
773 s.pending = $.clock.after(s.deadline - now, () => { void arm($, s) })
774 }
775 } else if (live && s.policy !== 'off' && s.idle !== 'off' && s.ctx >= cfg.remindMinCtx && s.checkedFor !== s.turnAt) {
776 // One reminder per idle stretch, before the cache lapses.
777 s.check = $.clock.after(Math.max(1000, s.lastRequestAt + CHECK_AFTER_MS - now), () => { void idleCheck($, s) })
778 }
779 $.ui.status(statusText(s, now))
780 await saveRuntime($, s)
781}
782
783// Module variables start over on a hot reload; the store does not.
784async function saveRuntime($: EngineInterface, s: State) {
785 if (!s.sid) return
786 const rt: Runtime = { lastRequestAt: s.lastRequestAt, turnAt: s.turnAt, ctx: s.ctx, window: s.window, model: s.lastModel, compacted: s.compacted }
787 await $.store.set(`rt:${s.sid}`, rt)
788}
789
790async function loadRuntime($: EngineInterface, s: State, now: number) {
791 for (const k of await $.store.keys()) {
792 if (!k.startsWith('rt:') || k === `rt:${s.sid}`) continue
793 const old = (await $.store.get(k)) as Runtime | undefined
794 if (!old || now - old.lastRequestAt > RT_KEEP_MS) await $.store.delete(k)
795 }
796 const rt = (await $.store.get(`rt:${s.sid}`)) as Runtime | undefined
797 if (!rt || s.lastRequestAt) return
798 s.lastRequestAt = rt.lastRequestAt
799 s.turnAt = rt.turnAt
800 s.ctx = rt.ctx
801 s.window = rt.window
802 s.lastModel = rt.model
803 s.compacted = rt.compacted
804}
805
806// ---------- leave: every live session at once ----------
807
808// The sessions are separate processes; they talk through small JSON files, each
809// written whole and renamed into place, so a reader never sees half a file.
810const BUS = '$HOME/.claude/keepwarm-bus'
811
812async function busGet<T>($: EngineInterface, name: string): Promise<T | undefined> {
813 try {
814 const r = await $.process.run(['sh', '-c', `cat "${BUS}/$1" 2>/dev/null`, 'kw', name])
815 return r.stdout.trim() ? JSON.parse(r.stdout) as T : undefined
816 } catch {
817 return undefined
818 }
819}
820
821async function busSet($: EngineInterface, s: State, name: string, value: unknown) {
822 await auditedRun($, s, 'change', '写离开广播文件', ['sh', '-c', `mkdir -p "${BUS}" && printf '%s\\n' "$2" > "${BUS}/$1.$$" && mv "${BUS}/$1.$$" "${BUS}/$1"`, 'kw', name, JSON.stringify(value)], { file: name })
823}
824
825async function busAll<T>($: EngineInterface, prefix: string): Promise<T[]> {
826 const r = await $.process.run(['sh', '-c', `cat "${BUS}/$1"* 2>/dev/null; true`, 'kw', prefix])
827 const out: T[] = []
828 for (const line of r.stdout.split('\n')) { try { if (line.trim()) out.push(JSON.parse(line) as T) } catch { /* partial */ } }
829 return out
830}
831
832function leaveSkip(s: State, now: number): string | null {
833 if (!s.lastRequestAt) return '还没有请求'
834 if (s.compacted) return '刚 compact'
835 if (isCold(s, now)) return '缓存已冷'
836 if (s.ctx < MIN_CTX) return '上下文很小'
837 return null
838}
839
840async function broadcastLeave($: EngineInterface, s: State, hours: number, machineOn: boolean, id?: string) {
841 const now = await $.clock.now()
842 const req: LeaveReq = { id: id ?? `${now}-${s.sid.slice(0, 8)}`, at: now, from: s.sid, hours, machineOn }
843 const cur = await busGet<LeaveReq>($, 'leave-req')
844 if (cur?.id === req.id) return
845 await auditedRun($, s, 'change', '清理一天前的离开广播回复', ['sh', '-c', `find "${BUS}" -name 'leave-res-*' -mtime +1 -delete 2>/dev/null; true`])
846 await busSet($, s, 'leave-req', req)
847 await update($, leaveAll, () => ({ id: req.id, hours, machineOn, startedAt: now, items: [], collecting: true }))
848 if (s.collect) s.collect.cancel()
849 s.collect = $.clock.every(3000, () => { void collect($, s, req.id) })
850 void poll($, s)
851}
852
853async function collect($: EngineInterface, s: State, id: string) {
854 const la = await read($, leaveAll)
855 if (!la || la.id !== id) { if (s.collect) s.collect.cancel(); s.collect = null; return }
856 const items: KeepwarmLeaveItem[] = []
857 for (const r of await busAll<{ id: string } & KeepwarmLeaveItem>($, 'leave-res-')) {
858 if (r.id === id) items.push({ sid: r.sid, project: r.project, topic: r.topic ?? '', ctx: r.ctx, action: r.action, reason: r.reason, keepUsd: r.keepUsd, compactUsd: r.compactUsd, coldUsd: r.coldUsd })
859 }
860 const now = await $.clock.now()
861 const done = now - la.startedAt >= LEAVE_COLLECT_MS
862 await update($, leaveAll, v => v && v.id === id ? { ...v, items, collecting: !done } : v)
863 if (done) {
864 if (s.collect) s.collect.cancel()
865 s.collect = null
866 await pushLeaveSummary($, s, { ...la, items, collecting: false })
867 }
868}
869
870async function poll($: EngineInterface, s: State) {
871 const now = await $.clock.now()
872 const req = await busGet<LeaveReq>($, 'leave-req')
873 if (!req || req.id === s.leaveSeen || now - req.at >= LEAVE_REQ_TTL_MS) return
874 s.leaveSeen = req.id
875 if (leaveSkip(s, now) !== null) return
876 const a = await leaveCheck($, s, req.hours, req.machineOn)
877 const L = a.leave
878 await busSet($, s, `leave-res-${s.sid}`, { id: req.id, sid: s.sid, project: project(s), topic: a.topic ?? '', ctx: a.ctx, action: a.action, reason: a.reason, keepUsd: L?.keepUsd ?? null, compactUsd: L?.compactUsd ?? null, coldUsd: a.coldUsd })
879 // The advice waits in this session's own band; acting on it is done here, by hand.
880 if (a.action !== 'ignore') {
881 await offerAdvice($, s, a)
882 }
883}
884
885// ---------- phone (ntfy): notifications, with buttons when a reply topic is set ----------
886
887// reply_topic: where the buttons on a push post their one-time token; without it pushes carry none.
888type Ntfy = { server: string; topic: string; reply: string | null }
889
890async function ntfyConfig($: EngineInterface): Promise<Ntfy | null> {
891 try {
892 if (!cfg.ntfyConfigFile) return null
893 const r = await $.process.run(['sh', '-c', 'f=$1; case $f in "~/"*) f="$HOME/${f#??}";; esac; cat "$f"', 'kw', cfg.ntfyConfigFile])
894 const c = JSON.parse(r.stdout) as { server?: string; topic?: string; reply_topic?: string }
895 return c.server && c.topic ? { server: c.server.replace(/\/$/, ''), topic: c.topic, reply: c.reply_topic || null } : null
896 } catch {
897 return null
898 }
899}
900
901async function push($: EngineInterface, s: State, title: string, markdown: string, actions: NtfyAction[] = []) {
902 const c = await ntfyConfig($)
903 if (!c) return
904 const body = { topic: c.topic, title, message: markdown.slice(0, 4000), markdown: true, priority: 3, tags: ['hourglass'], ...(actions.length ? { actions } : {}) }
905 try {
906 await auditedRun($, s, 'notify', '手机推送', ['curl', '-s', '-m', '15', '-H', 'Content-Type: application/json', '-d', JSON.stringify(body), c.server + '/'], { title })
907 } catch {
908 // offline: the desktop notification and band still show
909 }
910}
911
912const ACTION_ZH = { compact: 'compact', keepwarm: '保温', ignore: '不管' } as const
913
914async function pushLeaveSummary($: EngineInterface, s: State, la: KeepwarmLeaveAll) {
915 if (!la.items.length) return
916 const lines = la.items.map(i => `- **${i.project}**${i.topic ? `(${i.topic})` : ''} ${fmtTok(i.ctx)} → **${ACTION_ZH[i.action]}**:${i.reason}`)
917 await push($, s, `离开 ${fmtDuration(la.hours * 3_600_000)}:${la.items.length} 个会话`, lines.join('\n') + '\n\n建议已显示在各会话的输入框上方,到会话里自己处理。')
918}
919
920// Ten minutes before a keep-warm window ends: offer to extend it, once per window.
921async function endWarn($: EngineInterface, s: State) {
922 s.endWarn = null
923 const now = await $.clock.now()
924 if (!s.deadline || s.endWarnedFor === s.deadline || s.deadline - now > END_WARN_MS + 1000 || isCold(s, now)) return
925 s.endWarnedFor = s.deadline
926 const a: KeepwarmAdvice = {
927 action: 'keepwarm', topic: '', reason: `保温约 ${fmtDuration(s.deadline - now)} 后结束,之后缓存会在 1 小时内过期。要续的话选保温`,
928 instructions: '', hours: DEFAULT_WINDOW_MS / 3_600_000, ctx: s.ctx, coldUsd: coldUsd(s), hourUsd: hourUsd(s), judgeUsd: 0, at: now, auto: false,
929 }
930 await offerAdvice($, s, a)
931 await alert($, s, '保温快结束了', `${a.reason}(上下文 ${fmtTok(s.ctx)},过期重写 ${fmtUsd(coldUsd(s))})`, [warmButton(a.hours, '续'), IGNORE_BUTTON])
932}
933
934async function ping($: EngineInterface, s: State) {
935 s.pending = null
936 if (!s.deadline) return
937 const now = await $.clock.now()
938 if (now >= s.deadline) return arm($, s)
939 if (now - s.lastRequestAt < s.every - 1000) return arm($, s) // a turn re-armed meanwhile
940 if (isCold(s, now)) return lapsedWindow($, s, s.deadline, '电脑睡眠或进程暂停超过 1 小时,ping 没能按时发出')
941 if (s.last && now - s.last.at < PING_MIN_GAP_MS) {
942 s.pending = $.clock.after(s.last.at + PING_MIN_GAP_MS - now, () => { void ping($, s) })
943 return
944 }
945 const recent = (((await $.store.get('pings')) as Array<{ sid: string; at: number }> | undefined) ?? [])
946 .filter(p => p.sid === s.sid && now - p.at < cfg.pingLimitHours * 3_600_000).length
947 if (recent >= cfg.pingLimit) {
948 const why = `${cfg.pingLimitHours} 小时内已 ping ${recent} 次,达到上限 ${cfg.pingLimit}`
949 await alarm($, s, 'pinglimit', 'ping 次数到了上限', `${why},保温已停。正常每 55 分钟一次,到上限多半是哪里出了问题,看 /keepwarm log。`)
950 return stop($, s, why)
951 }
952 const reply = await auditedFork($, s, s.wakePrompt ? '保温 ping(带唤醒判断)' : '保温 ping', s.wakePrompt ? wakeJudgePrompt(s, now) : PING_PROMPT, s.owner === 'user')
953 if (s.owner !== 'user') await checkBudget($, s)
954 if (!reply.isAnswered && reply.reason === 'nothing-to-fork') return stop($, s, '没有可以 fork 的对话')
955 if (!reply.isAnswered && reply.reason !== 'empty-reply') return stop($, s, `ping 失败(${reply.reason})`)
956 const u = reply.usage
957 const usd = forkUsd(u, priceOf(s.lastModel))
958 const warm = forkWasWarm(u)
959 s.last = { at: now, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, usd, warm }
960 s.pings += 1
961 s.pingUsd += usd ?? 0
962 await appendLog($, 'pings', { sid: s.sid, cwd: s.cwd, model: s.lastModel, ctx: s.ctx, owner: s.owner, ...s.last })
963 if (warm && s.wakePrompt && reply.isAnswered) await wakeDecision($, s, reply.text, now)
964 if (!warm) return stop($, s, `ping 读 ${fmtTok(u.cache_read_input_tokens)}、写 ${fmtTok(u.cache_creation_input_tokens)},缓存已经丢了`)
965 s.lastRequestAt = now
966 await arm($, s)
967}
968
969// With a wake task, the fork (which sees the whole conversation and the time) decides first,
970// so a wake that needs nothing leaves the conversation untouched and only tool work becomes a turn.
971function wakeJudgePrompt(s: State, now: number): string {
972 return [
973 '[keepwarm 定时唤醒,不是用户消息;不要调用工具,只输出一行 JSON]',
974 `现在是 ${new Date(now).toString()},用户已离开 ${fmtDuration(now - s.lastRequestAt)},保温还剩 ${fmtDuration(s.deadline - now)}。`,
975 `唤醒任务:${s.wakePrompt}`,
976 '决定这次唤醒怎么处理:',
977 '- skip:现在不需要做任何事(时间未到、无新情况),这次唤醒不留痕迹。',
978 '- note:不需要工具,但有一句值得记入对话、供之后参考的话。',
979 '- act:需要用工具真正检查或处理(看日志、查服务、跑命令),将运行一整轮对话并保留在对话里。',
980 '- stop:任务已无必要,结束保温。',
981 '输出格式:{"decision":"skip|note|act|stop","note":"<note 时的一句话,否则空>","task":"<act 时要做的具体事情,否则空>"}',
982 ].join('\n')
983}
984
985async function wakeDecision($: EngineInterface, s: State, text: string, now: number) {
986 let v: { decision?: string; note?: string; task?: string } = {}
987 try {
988 v = JSON.parse(/\{[\s\S]*\}/.exec(text)?.[0] ?? '{}')
989 } catch {
990 v = {}
991 }
992 const decision = v.decision === 'note' || v.decision === 'act' || v.decision === 'stop' ? v.decision : 'skip'
993 await appendLog($, 'wakes', { sid: s.sid, cwd: s.cwd, at: now, decision, note: v.note ?? '', task: v.task ?? '' })
994 if (decision === 'note' && v.note) {
995 const text = `[keepwarm 唤醒记录 ${new Date(now).toLocaleString()}] ${String(v.note).slice(0, 500)}`
996 try {
997 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
998 await audit($, s, 'change', '往对话里插入唤醒记录', { text })
999 } catch (err) {
1000 await audit($, s, 'change', '往对话里插入唤醒记录', { text, ok: false, error: errText(err) })
1001 }
1002 $.ui.toast(`keepwarm 记录:${String(v.note).slice(0, 80)}`)
1003 } else if (decision === 'act') {
1004 await auditedSubmit($, s, '唤醒后发起一轮真实对话',
1005 `[keepwarm 定时唤醒,保温剩 ${fmtDuration(s.deadline - now)}] ${v.task || s.wakePrompt}\n` +
1006 '做完后简短汇报;一切正常且无需用户处理就只回一行。保温会自动继续,任务已无必要时调用 keepwarm stop。')
1007 } else if (decision === 'stop') {
1008 await stop($, s, null)
1009 }
1010}
1011
1012async function startWindow($: EngineInterface, s: State, windowMs: number, owner: Owner, reason: string, wakePrompt = '', sticky = false) {
1013 const now = await $.clock.now()
1014 const deadline = now + Math.min(windowMs, MAX_WINDOW_MS)
1015 // The model may extend the person's window, never shorten it.
1016 if (owner === 'model' && s.deadline && s.owner === 'user') {
1017 s.deadline = Math.max(s.deadline, deadline)
1018 } else {
1019 s.deadline = deadline
1020 s.owner = owner
1021 s.sticky = sticky
1022 }
1023 s.reason = reason
1024 s.wakePrompt = wakePrompt
1025 s.stopped = null
1026 await audit($, s, 'change', '开启保温', { by: owner, owner: s.owner, until: s.deadline, reason, wake: wakePrompt || undefined, sticky: s.sticky })
1027 await save($, s)
1028 await arm($, s)
1029}
1030
1031// Keeps the Mac from idle sleep while a leave window runs; lid-close on battery still sleeps.
1032// The child dies with the module too (a reload), which only ends the stay-awake, not the window.
1033function caffeinate($: EngineInterface, s: State, ms: number) {
1034 stopCaffeinate($, s)
1035 const it = $.process.spawn({ argv: ['caffeinate', '-i', '-t', String(Math.ceil(ms / 1000))] })
1036 void audit($, s, 'change', '启动 caffeinate 防闲置睡眠', { seconds: Math.ceil(ms / 1000) })
1037 s.caff = it
1038 void (async () => {
1039 try { for await (const _ of it) { /* no output */ } } catch { /* not on macOS, or killed */ }
1040 if (s.caff === it) s.caff = null
1041 })()
1042}
1043
1044function stopCaffeinate($: EngineInterface, s: State) {
1045 const it = s.caff
1046 s.caff = null
1047 if (it) {
1048 void it.return(undefined).catch(() => {})
1049 void audit($, s, 'change', '停止 caffeinate')
1050 }
1051}
1052
1053// ---------- context reminder ----------
1054
1055function judgePrompt(s: State, idleMs: number): string {
1056 return [
1057 '[keepwarm 自动检查,不是用户消息;不要调用工具,只输出一行 JSON]',
1058 `用户已离开约 ${fmtDuration(idleMs)},prompt 缓存约 ${fmtDuration(TTL_MS - idleMs)} 后过期。`,
1059 `当前上下文 ${fmtTok(s.ctx)} tokens(窗口 ${fmtTok(s.window)}),项目目录 ${s.cwd}。`,
1060 `过期后用户回来时,重写缓存约 ${fmtUsd(coldUsd(s))};保温约 ${fmtUsd(hourUsd(s))}/小时;compact 后约 15K tokens,但会丢失细节。`,
1061 '判断对这个会话最合适的做法:',
1062 '- keepwarm:任务还在进行或在等用户回复/审阅,细节仍然有用,用户很可能几小时内回来。',
1063 '- compact:一个阶段已经完成,之后只需要结论与要点,或上下文已接近上限。',
1064 '- ignore:对话已基本结束、用户短期内不会回来,或继续保留没有价值。',
1065 '输出格式:{"action":"compact|keepwarm|ignore","topic":"<这个会话在做什么、做到哪一步了,中文,100 字以内>","reason":"<为什么这样建议,中文,100 字以内>","instructions":"<若 compact:给 /compact 的保留要点,写全;否则空>","hours":<若 keepwarm:建议保温小时数,1-12;否则 0>}',
1066 ].join('\n')
1067}
1068
1069// What a compact costs now while warm, plus re-reading its result cold on return
1070// (system + tools + summary, roughly).
1071const COMPACT_OUT_TOK = 8000
1072const POST_COMPACT_TOK = 50000
1073const compactNowUsd = (s: State) => {
1074 const p = priceOf(s.lastModel)
1075 return p ? (s.ctx * p[0] + COMPACT_OUT_TOK * p[2] + POST_COMPACT_TOK * p[1]) / 1e6 : null
1076}
1077
1078function leavePrompt(s: State, hours: number, machineOn: boolean, keepUsd: number | null, compactUsd: number | null): string {
1079 return [
1080 '[keepwarm 离开检查,不是用户消息;不要调用工具,只输出一行 JSON]',
1081 `用户现在要离开约 ${hours} 小时,${machineOn ? '电脑保持开机不睡眠,可以保温' : '电脑会合盖或关机,无法保温,缓存一定会过期'}。`,
1082 `当前上下文 ${fmtTok(s.ctx)} tokens(窗口 ${fmtTok(s.window)}),项目目录 ${s.cwd},项目策略 ${s.policy}。`,
1083 '三个选项的估算花费:',
1084 machineOn ? `- keepwarm:保温 ${hours} 小时约 ${fmtUsd(keepUsd)},回来时缓存是热的,细节全在。` : '- keepwarm:不可用。',
1085 `- compact:现在趁缓存还热压缩约 ${fmtUsd(compactUsd)}(含回来时冷读压缩结果),但会丢失细节。`,
1086 `- ignore:什么都不做,回来时重写缓存约 ${fmtUsd(coldUsd(s))},细节全在。`,
1087 '综合花费和"回来后还需要哪些细节"判断。存放个人资料、长期记录、或正在进行且依赖大量细节的会话,不要建议 compact。',
1088 '如果建议 compact,instructions 写清回来后继续工作必须保留的要点。',
1089 `输出格式:{"action":"${machineOn ? 'compact|keepwarm|ignore' : 'compact|ignore'}","topic":"<这个会话在做什么、做到哪一步了,中文,100 字以内>","reason":"<为什么这样建议,中文,100 字以内>","instructions":"<若 compact:给 /compact 的保留要点,写全;否则空>","hours":${machineOn ? hours : 0}}`,
1090 ].join('\n')
1091}
1092
1093async function leaveCheck($: EngineInterface, s: State, hours: number, machineOn: boolean): Promise<KeepwarmAdvice> {
1094 const now = await $.clock.now()
1095 s.checkedFor = s.turnAt // this idle stretch is handled; no 50-minute check on top
1096 disarmCheck(s)
1097 const keepUsd = machineOn ? (hourUsd(s) ?? 0) * hours || null : null
1098 const compactUsd = compactNowUsd(s)
1099 const reply = await auditedFork($, s, `离开判断(${hours}h,${machineOn ? '开机' : '关机'})`, leavePrompt(s, hours, machineOn, keepUsd, compactUsd), true)
1100 let judgeUsd: number | null = null
1101 let v = null as ReturnType<typeof parseVerdict>
1102 if (reply.isAnswered) {
1103 judgeUsd = forkUsd(reply.usage, priceOf(s.lastModel))
1104 if (forkWasWarm(reply.usage)) s.lastRequestAt = now
1105 v = parseVerdict(reply.text)
1106 }
1107 if (!v) {
1108 // No usable answer: pick the cheapest option by the numbers alone.
1109 const opts: [KeepwarmAdvice['action'], number | null][] = [['keepwarm', keepUsd], ['compact', compactUsd], ['ignore', coldUsd(s)]]
1110 const best = opts.filter(o => o[1] != null).sort((a, b) => (a[1] as number) - (b[1] as number))[0]
1111 v = { action: best ? best[0] : 'ignore', topic: '', reason: '判断没有给出结果,按估算花费选了最便宜的', instructions: '', hours }
1112 }
1113 if (v.action === 'keepwarm' && !machineOn) v = { ...v, action: 'ignore' }
1114 const a: KeepwarmAdvice = {
1115 ...v, hours: Math.min(hours, MAX_WINDOW_MS / 3_600_000), ctx: s.ctx, coldUsd: coldUsd(s), hourUsd: hourUsd(s), judgeUsd, at: now, auto: false,
1116 leave: { hours, machineOn, keepUsd, compactUsd },
1117 }
1118 await appendLog($, 'judgements', { sid: s.sid, cwd: s.cwd, model: s.lastModel, ...a, read: reply.isAnswered ? reply.usage.cache_read_input_tokens : 0, write: reply.isAnswered ? reply.usage.cache_creation_input_tokens : 0 })
1119 await arm($, s)
1120 return a
1121}
1122
1123function parseVerdict(text: string): { action: KeepwarmAdvice['action']; topic: string; reason: string; instructions: string; hours: number } | null {
1124 const m = /\{[\s\S]*\}/.exec(text)
1125 if (!m) return null
1126 try {
1127 const v = JSON.parse(m[0]) as Record<string, unknown>
1128 const action = v.action === 'compact' || v.action === 'keepwarm' || v.action === 'ignore' ? v.action : null
1129 if (!action) return null
1130 const hours = typeof v.hours === 'number' && v.hours > 0 ? Math.min(v.hours, 12) : 3
1131 return { action, topic: String(v.topic ?? '').slice(0, 400), reason: String(v.reason ?? '').slice(0, 400), instructions: String(v.instructions ?? '').slice(0, 3000), hours }
1132 } catch {
1133 return null
1134 }
1135}
1136
1137async function apply($: EngineInterface, s: State, a: KeepwarmAdvice, choice: KeepwarmAdvice['action'], by: 'user' | 'auto') {
1138 if (s.expire) s.expire.cancel()
1139 s.expire = null
1140 await appendLog($, 'decisions', { sid: s.sid, cwd: s.cwd, at: await $.clock.now(), ctx: a.ctx, advised: a.action, reason: a.reason, chosen: choice, by })
1141 await update($, advice, () => null)
1142 if (choice === 'compact') {
1143 const command = a.instructions ? `/compact ${a.instructions.replace(/\s+/g, ' ').trim()}` : '/compact'
1144 try {
1145 await auditedCompact($, s, a.instructions)
1146 } catch {
1147 // Headless hosts (the desktop app, the SDK) refuse a direct compaction and refuse a prompt
1148 // that starts with "/": run the /compact command itself. The clipboard stays as the last
1149 // resort, also when the command returned but no compaction followed.
1150 let failed = ''
1151 const n = s.compactions
1152 try {
1153 await auditedCommand($, s, 'compact(以 /compact 命令执行)', 'compact', a.instructions)
1154 if (s.compactions === n) failed = '命令已执行,但没有看到压缩发生'
1155 } catch (err) {
1156 failed = errText(err)
1157 }
1158 if (failed) {
1159 const copied = await $.ui.copy({ text: command }).catch(() => ({ isCopied: false }))
1160 await audit($, s, 'change', '复制 /compact 命令到剪贴板', { copied: copied.isCopied })
1161 $.ui.toast(`compact 没能自动执行(${failed})。${copied.isCopied ? '命令已复制,' : ''}请在输入框发送:${command}`)
1162 }
1163 }
1164 } else if (choice === 'keepwarm') {
1165 await startWindow($, s, a.hours * 3_600_000, by === 'user' ? 'user' : 'model', a.reason || '空闲检查建议')
1166 if (a.leave?.machineOn) caffeinate($, s, s.deadline - (await $.clock.now()))
1167 }
1168}
1169
1170// A reminder only means something while the cache it is about is still warm.
1171// The judge fork wrote or read the whole prefix at a.at, so that is when the hour starts.
1172const adviceStale = (a: KeepwarmAdvice, now: number) => now - a.at >= TTL_MS - 1000
1173
1174// A new reminder goes to the band above the prompt until the cache it is about lapses.
1175async function offerAdvice($: EngineInterface, s: State, a: KeepwarmAdvice) {
1176 await update($, advice, () => a)
1177 await expireAdvice($, s)
1178}
1179
1180async function expireAdvice($: EngineInterface, s: State) {
1181 if (s.expire) s.expire.cancel()
1182 s.expire = null
1183 const a = await read($, advice)
1184 if (!a) return
1185 const now = await $.clock.now()
1186 if (!adviceStale(a, now)) {
1187 s.expire = $.clock.after(a.at + TTL_MS - now, () => { void expireAdvice($, s) })
1188 return
1189 }
1190 await appendLog($, 'decisions', { sid: s.sid, cwd: s.cwd, at: now, ctx: a.ctx, advised: a.action, reason: a.reason, chosen: 'expired', by: 'auto' })
1191 await update($, advice, () => null)
1192}
1193
1194async function idleCheck($: EngineInterface, s: State, forced = false) {
1195 s.check = null
1196 const now = await $.clock.now()
1197 if (!forced && (s.deadline || isCold(s, now) || s.compacted || now - s.lastRequestAt < CHECK_AFTER_MS - 1000)) return arm($, s)
1198 s.checkedFor = s.turnAt
1199 if (s.policy === 'warm') {
1200 await startWindow($, s, DEFAULT_WINDOW_MS, 'user', '项目策略:总是保温')types/index.d.ts 45 lines1export type KeepwarmAdvice = {
2 action: 'compact' | 'keepwarm' | 'ignore'
3 reason: string
4 // What the session is about, in a few words (for notifications).
5 topic?: string
6 instructions: string
7 hours: number
8 ctx: number
9 coldUsd: number | null
10 hourUsd: number | null
11 judgeUsd: number | null
12 at: number
13 auto: boolean
14 // Set when the person said they are leaving (/keepwarm leave).
15 leave?: { hours: number; machineOn: boolean; keepUsd: number | null; compactUsd: number | null }
16}
17
18export type KeepwarmLeaveItem = {
19 sid: string
20 project: string
21 topic: string
22 ctx: number
23 action: 'compact' | 'keepwarm' | 'ignore'
24 reason: string
25 keepUsd: number | null
26 compactUsd: number | null
27 coldUsd: number | null
28}
29
30export type KeepwarmLeaveAll = {
31 id: string
32 hours: number
33 machineOn: boolean
34 startedAt: number
35 items: KeepwarmLeaveItem[]
36 collecting: boolean
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 // panel: a counter bumped to redraw the panel (its countdown, a window started or stopped).
42 keepwarm: { advice: KeepwarmAdvice | null; leaveAll: KeepwarmLeaveAll | null; panel: number }
43 }
44}
45