SLOPSHOPPER

keepwarm

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…

newpanebandguardcommandtoast
v0.6.3MITupdated 2026-10-10luwux/keepwarm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keepwarm
│ ┃ keepwarm ✕ › fix the failing auth test and add an audit log call │ ┃ 缓存还剩 1h00m │ ┃ 没在保温 ⏺ Read(src/auth.ts) │ ┃ 上下文 97K · 过期重写 $0.78 · 保温 $0.02/h ⎿ Read 6 lines │ ┃ [ compact ] [ 保温 8h ] [ 收起 ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /keepwarm │ ⎿ keepwarm: app(项目策略 ask) │ ⎿ keepwarm: │ ⎿ keepwarm: 缓存 热,还剩 1h00m · 上下文 97K / 200K · claude-opus-5-5 │ ⎿ keepwarm: 保温 关 │ ⎿ keepwarm: 提醒 不提醒(上下文不到 250K,或缓存已冷) │ ⎿ keepwarm: 价格 过期后重写一次约 $0.78,保温约 $0.02/小时 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ keepwarm: 缓存 1h00m

Draws

Pane · keepwarm
缓存还剩 1h00m 没在保温 上下文 97K · 过期重写 $0.78 · 保温 $0.02/h [ compact ] [ 保温 8h ] [ 收起 ]
README

keepwarm

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.

Install

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.

Why

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.

What it does

  • Status line countdown: 缓存 42m ("cache 42m") until this session's cache lapses, refreshed on every model request, tool-result requests included.
  • Idle reminder (~50 minutes after the last turn, only for sessions above 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.
  • Keep-warm windows: /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.
  • Buttons on the phone (optional, via ntfy): when you are away from the Mac, the reminder is pushed to your phone with buttons: compact, keep warm N hours, leave it. A tap posts a one-time token to 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.
  • Alerts (/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.
  • Leaving: /keepwarm leave 2h asks every live, warm session for its own verdict and shows one summary.
  • Never blocks a prompt unless you turn guard refuse on.

Every request is accounted for

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.

Commands

CommandWhat it does
/keepwarm or statuscurrent state
on 8h / off / autokeep warm for 8 hours / stop and forbid the model to start one / allow it again
checkjudge 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, ledgeraudit log, cold-write ledger
push, paneltest push with buttons, button pane on the Mac

Settings

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.

Cost and risk

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.

Development

claude --plugin-dir .
claude plugin validate .
claude plugin test .
python3 tests/check-audit.py

Credits

Keep-warm pings are derived from cache-tax (MIT, Karan Bansal); see LICENSE.cache-tax. keepwarm itself is MIT; see LICENSE.

Source 2 files
hooks/register.tsx 1693 lines
1// 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 lines
1export 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