Keeps the prompt cache warm while you are away and stops an accidental cold send, draws a line above the prompt with the folder, git status, 5h and 7d usage…

English | 简体中文
A Claude Code mod that keeps long sessions cheap. It keeps your prompt cache warm while you're away, stops the one message that would rebuild a cold cache, and draws one line above the prompt with where you are and how much of your plan is left.
<a href="#install">Install</a> · <a href="#features">Features</a> · <a href="#getting-started">Getting started</a> · <a href="#configure">Configure</a> · <a href="#faq">FAQ</a> · <a href="#development">Development</a>
/clear to start fresh.Model, effort, context and the cache's real expiry belong to the line below the prompt, your own status line. anvil writes its keepalive state to a small file the status line can read, and /anvil:statusline sets up a matching status line for you.
Inside Claude Code:
/plugin install anvil --marketplace Mistakey/anvil
/reload-plugins
Add the marketplace, then install from it:
/plugin marketplace add Mistakey/anvil
/plugin install anvil@anvil
From the terminal:
claude plugin marketplace add Mistakey/anvil
claude plugin install anvil@anvil
Or load it straight from a clone, for one session:
git clone https://github.com/Mistakey/anvil
claude --plugin-dir ./anvil
The installer may say config options aren't set; the defaults are fine, and /config changes them.
| Part | Shows |
|---|---|
| directory | the repository's name (bold), a linked worktree's name, the subdirectory you're in; outside git, the session's directory |
| git | branch, ↑ ahead and ↓ behind, no upstream, a paused merge or rebase 2/5, = conflicts, + staged, ! modified, ? untracked, stashes, uncommitted +lines −lines |
| 5h, 7d | a 10-cell heat bar, the percentage used, and the reset time (22:00, or 09:00 10-15 on another day); — until the window starts |
| cost | the session cost Claude Code reports (/cost) |
Git is re-read after each reply, after a tool that changes files or runs commands, and when you send a message. At session start the usage readings take a few seconds to arrive; until then anvil shows the last readings any of your local sessions saw. When the terminal is narrow, names shorten first, then the bars go, then git keeps only the branch, then the cost and git go; the reset times stay. To hide parts, reorder them or turn the line off, see band under Configure.
| Command | Does |
|---|---|
/anvil | list the subcommands and their current state |
/anvil keepalive | turn the keepalive off or on for this session, regardless of the threshold |
A new session starts from the defaults you set in /config.
$.model.fork) that asks for a one-word reply. It doesn't enter your transcript./anvil shows why.claude --resume, /resume), anvil starts from the expiry it recorded, renewals included; the resume info Claude Code provides counts only from the last reply and would call a renewed cache expired./clear if you don't need the old context./clear.Run /anvil:statusline to get the line shown in the screenshots: model and effort · context · cache ( 94% · warm · keepalive). Claude checks for node and a Nerd Font, shows you a preview of anvil's reference script (skills/statusline/statusline.mjs), changes whatever you ask (parts, order, colors, icons), backs up your current status line, writes the script to ~/.claude/statusline.mjs and sets statusLine for you. Run it again whenever you want to restyle the line.
The status line is your own script (statusLine in your settings); Claude Code hands it the model, effort, context and the cache's real state (prompt_cache: hit_ratio, warm, expires_at). What it can't know is whether anvil will renew the cache, so anvil writes that for every session to:
~/.claude/anvil/sessions/<session_id>.json # under CLAUDE_CONFIG_DIR when it is set
{ "renews": true, "error": null, "expiresAt": 1791998610193, "at": 1791995010193 }
session_id is the one in the status line's input. renews is true while the keepalive is on and hasn't stopped; error is the reason it stopped on a failed renewal (12 hours idle isn't an error); expiresAt is anvil's estimate of the expiry (Unix milliseconds, renewals included), which anvil reads back itself when the session is resumed. anvil only writes these files; clean up the ones older than a week from your script if you like. With it, a cache segment can read 94% · warm · keepalive, 94% · expires 21:40 or 94% · expired 21:40, colored by state: green when the keepalive will renew it, yellow when it lasts only until a set time, red once expired.
Run /config and open anvil's options:
| Option | Default | Meaning |
|---|---|---|
threshold | 50000 | context size in tokens at which the keepalive turns on by itself and the guard starts guarding |
autoKeepalive | true | turn the keepalive on by itself for sessions at or above the threshold; /anvil keepalive still toggles it per session |
guard | true | hold back the first message after the cache has expired |
band | dir, git, 5h, 7d, cost | the parts of the line above the prompt, in display order, separated by commas; leave a part out to hide it, leave it empty or write off to turn the line off |
| It does | Through |
|---|---|
| reads session events: requests and their usage, subagents starting and ending, tool calls, messages you send | the mod hooks |
| sends the keepalive request | $.model.fork, only while a session is idle |
runs git in the session's directory, read-only (--no-optional-locks) | $.process.run |
| keeps recent 5h and 7d readings, shared by your local sessions | $.store |
| writes the keepalive state for your status line | ~/.claude/anvil/sessions/ |
Nothing leaves your machine except the keepalive request. claude plugin validate . prints every hook and call the mod makes.
The line above the prompt doesn't appear. It gives way while Claude Code shows a survey there, and ctrl+x ctrl+a collapses it. If it still doesn't show, look in the transcript for a line from Claude Code that names a failed anvil hook, and open an issue with it.
The icons show as boxes or question marks. Your terminal font isn't a Nerd Font. Install one and select it in the terminal's settings.
Does the cache state match what Claude Code itself tracks? The keepalive and the guard work from anvil's own estimate: a mod can't read Claude Code's cache tracker, so anvil works from request times and usage. Requests anvil doesn't see (another plugin's fork, Claude Code's built-in keepalive) can make it conservative: at worst the guard warns once too often or the keepalive sends one extra request. Your status line gets Claude Code's real figures.
Does it work on an API key, or with a 5-minute cache? The line above the prompt does (5h and 7d stay at — without a subscription). The keepalive and the guard assume the 1-hour TTL of a subscription.
Why are the dollar amounts in the guard API prices if I'm on a subscription? How cache reads and writes count against subscription limits isn't documented, so API prices are the only comparable measure. The cost above the prompt is the figure Claude Code reports (/cost).
My organization manages Claude Code. Under managed settings, Claude Code doesn't deliver classic.SessionStart and session.compact to plugins a person installs. There, the cache estimate doesn't pick up a resumed session or a compaction, unless your organization lists anvil in managed settings.
The repository is the plugin: .claude-plugin/plugin.json is the manifest and hooks/register.ts is the entry. Claude Code loads the TypeScript directly; there's no build step.
claude --plugin-dir . # load it; saving a file hot-reloads
claude plugin validate . # hooks, calls, and anything Claude Code would reject
claude plugin test . # run tests/*.test.ts
npx -p typescript tsc -p . # type-check
Type declarations for the current Claude Code build are generated into .claude-plugin/types/ the first time the plugin loads in an interactive session. Without one, generate them with CLAUDE_CODE_PLUGIN_DIR_WATCH=1 claude --plugin-dir . -p "ok" --model haiku --max-turns 1.
CONTEXT.md defines the terms, AGENTS.md collects the conventions and the known pitfalls of the mods API, and docs/adr/ records the design decisions. These are in Chinese.
Both are MIT-licensed. THIRD_PARTY_NOTICES.md lists what anvil adapted from each and reproduces their licenses.
hooks/register.ts 642 lines1// anvil 的 hooks 入口:所有 on(...) 和用到 `$` 的辅助函数都在这里,结构说明见下面「模块结构」。
2// 模块结构照 flightdeck(https://github.com/scasella/claude-flightdeck)的 register.tsx / core.ts:
3// MIT License, Copyright (c) 2026 Stephen Casella。缓存状态的估算和保活循环的接线照 cache-tax
4// (https://github.com/karanb192/cache-tax,HEAD 06cac47)改写:MIT License, Copyright (c) 2026 Karan Bansal。
5// 两份许可原文见 THIRD_PARTY_NOTICES.md。
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register, RenderInput, TurnCompleteInput, TurnStepInput, TurnUsage } from 'claude-code'
9
10import { EMPTY_PLACE, GIT, NUMSTAT, REV_PARSE, STATUS, bandLine, baseName, drawBand, gitView, operationOf, parseNumstat, parseRepo, parseStatus } from './band'
11import type { RebaseFiles } from './band'
12import * as cacheState from './cache'
13import type { Cache, ResumeFields, Submission } from './cache'
14import { answer, describe } from './command'
15import type { Listed, Subcommand } from './command'
16import {
17 KEEPALIVE_SUBCOMMAND,
18 LIMIT_STOP,
19 PING_PROMPT,
20 disarm,
21 dueAction,
22 freshLoop,
23 isEnabled,
24 keepaliveStatus,
25 loopMemory,
26 loopView,
27 nextDelay,
28 renew,
29 restart,
30 restoreLoop,
31 savedExpiry,
32 startStretch,
33 statuslineFile,
34 stopFor,
35 thrownStop,
36} from './keepalive'
37import type { Loop } from './keepalive'
38import { readSettings } from './settings'
39import { normalized, normalizing } from './shape'
40import { addTally, requestSpeed, turnDurationRow } from './speed'
41import { EMPTY_USAGE, HISTORY_KEYS, applyReading, parseHistory, parseResetsAt, recordSample, storedLimits } from './usage'
42import type { Histories, Reading } from './usage'
43import type { BandPart, GitView, KeepaliveStop, Settings, SpeedTally, TurnSpeeds } from '../types'
44
45// 模块结构(照 flightdeck 的 register.tsx / core.ts),引擎扫描源码的规则见 AGENTS.md Pitfalls「模块结构」:
46// - 所有 on(...) 都写在下面的 register 里;用到 `$` 的辅助函数写在本文件顶层(`$` 不能跨文件传)。
47// 功能文件(band.tsx、keepalive.ts 等)只放不碰 `$` 的纯逻辑和界面组件,由这里调用。
48// - 几个功能都要的事件(session.start 等)只注册一次、不带匹配器:处理函数里按写下的顺序依次调用各功能,
49// 先后顺序就是处理函数里的行序。新功能往这类事件里加一行调用,放在它依赖的功能之后、`/anvil` 命令之前。
50// - 只属于一个功能的事件用真匹配器注册(带 args 的 command.run、按 component 的 ui.render 等),
51// 不用 `{}` 空匹配器。新功能的子命令说明加进 SUBCOMMANDS(补全菜单的参数提示也由它列出)、现在的状态加进 subcommandStates,
52// 执行写成带 args 匹配器的 command.run(只认子命令本身,敲一次切换一次),注册在兜底的 command.run 之前。
53// - 热重载不清 `$.state`,旧代码按旧形状写下的值还在:对象、列表形状的 atom 读写时合并到现在的形状上
54// (`normalized(x, await read($, x))`、`update($, x, normalizing(x, …))`,形状写在 shape.ts);新加或改了形状的 atom 同样处理。
55// - 显示分两处(用户 2026-10-14/15 定):主输入框上方的 band(AbovePrompt)画目录、git、5h、7d、花费(band.tsx);
56// 主输入框下方是用户自己的 statusline 脚本(不归本仓库),anvil 只往 `~/.claude/anvil/sessions/` 写保活状态给它读。
57
58// ---------------------------------------------------------------- 设置
59
60const SETTINGS = { plugin: 'anvil', key: 'settings' } as const
61
62/** 把生效的设置写进 `$.state`,供拿不到 options 的读者(测试)核对 */
63async function publishSettings($: EngineInterface, settings: Settings) {
64 await $.state.set(SETTINGS, settings)
65}
66
67// ---------------------------------------------------------------- 缓存状态(D1、D2)与冷发送守卫(D4)
68
69const CACHE = { plugin: 'anvil', key: 'cache' } as const
70const cacheView = atom(CACHE, cacheState.view(cacheState.freshCache()))
71const CACHE_MEMORY = { plugin: 'anvil', key: 'cacheMemory' } as const
72
73/** 把缓存状态的读数写进 `$.state`,保活状态和测试从这里读;原样另存一份,热重载后接上 */
74async function publishCache($: EngineInterface, cache: Cache) {
75 await $.state.set(CACHE_MEMORY, cache)
76 await $.state.set(CACHE, cacheState.view(cache))
77}
78
79/**
80 * 会话开始(含热重载):`$.state` 里有上一次加载存下的缓存状态(热重载换掉模块内存、`$.state` 还在)就接上;
81 * 没有(新会话)或认不出就冷启动:取计价用的模型和引擎的实时上下文,还没有请求,不判过期
82 */
83async function startCache($: EngineInterface, cache: Cache) {
84 const restored = cacheState.restoreCache((await $.state.get(CACHE_MEMORY)).value)
85 if (restored) {
86 Object.assign(cache, restored)
87 cache.model ??= await $.session.model()
88 } else {
89 cache.model = await $.session.model()
90 const live = (await $.session.usage()).context.tokens
91 if (live !== undefined && live > 0) cache.context = live
92 }
93 await publishCache($, cache)
94}
95
96/** `classic.SessionStart`:`/clear` 清零;恢复会话按引擎给的字段起步 */
97async function resumeCache($: EngineInterface, cache: Cache, e: ResumeFields) {
98 if (e.source === 'clear') cacheState.resetForClear(cache)
99 else cacheState.seedFromResume(cache, e, await $.clock.now(), await resumedExpiry($))
100 await publishCache($, cache)
101}
102
103/** 主会话一次请求开始:过期时刻顺延 */
104async function stepStarted($: EngineInterface, cache: Cache) {
105 cacheState.stampRequest(cache, await $.clock.now())
106 await publishCache($, cache)
107}
108
109/** 主会话一次请求的回复到了 */
110async function stepAnswered($: EngineInterface, cache: Cache, usage: TurnUsage) {
111 cacheState.recordResponse(cache, usage)
112 await publishCache($, cache)
113}
114
115/** 主会话一轮结束 */
116async function turnCompleted($: EngineInterface, cache: Cache, e: TurnCompleteInput) {
117 const now = await $.clock.now()
118 // turn.step 记下的是请求的确切时刻;这一轮没有一次 step 记过时,以轮末为下限(cache-tax 的做法)
119 if (now - cache.lastRequestAt > e.durationMs) cacheState.stampRequest(cache, now)
120 if (e.usage) {
121 const live = (await $.session.usage()).context.tokens
122 cacheState.recordTurn(cache, e.usage, cache.context, live, now)
123 }
124 await publishCache($, cache)
125}
126
127async function compacted($: EngineInterface, cache: Cache) {
128 cacheState.recordCompact(cache)
129 await publishCache($, cache)
130}
131
132/** 守卫:该拦就记下这段过期已拦过,返回提示行;不拦返回 null */
133async function guardSubmission($: EngineInterface, cache: Cache, submission: Submission, threshold: number): Promise<string | null> {
134 const now = await $.clock.now()
135 if (!cacheState.shouldGuard(cache, submission, threshold, now)) return null
136 cache.ackedFor = cache.expiresAt
137 await publishCache($, cache)
138 // drop 之后引擎不一定把原文留在主输入框(D11 待实测):等 drop 落定,主输入框空着就放回去
139 $.clock.after(0, () => { void restoreDraft($, submission.text) })
140 return cacheState.guardText(cache, now)
141}
142
143/** 主输入框空着时,把被拦的原文放回去;引擎已保留原文或用户已在写别的,就不动 */
144async function restoreDraft($: EngineInterface, text: string) {
145 try {
146 const box = await $.prompt.read()
147 if (box.text.trim() === '') await $.prompt.fill({ text, mode: 'replace' })
148 } catch {
149 // 没有主输入框(headless)或读写被拒:提示行仍在,用户可以重新输入
150 }
151}
152
153// ---------------------------------------------------------------- 缓存保活(D3),循环照 cache-tax 的 keepwarm
154
155const keepaliveOverride = atom({ plugin: 'anvil', key: 'keepaliveOverride' } as const, null as boolean | null)
156const keepaliveLoop = atom({ plugin: 'anvil', key: 'keepalive' } as const, loopView(freshLoop()))
157const LOOP_MEMORY = { plugin: 'anvil', key: 'keepaliveMemory' } as const
158
159async function publishLoop($: EngineInterface, loop: Loop, settings: Settings) {
160 await $.state.set(LOOP_MEMORY, loopMemory(loop))
161 await update($, keepaliveLoop, () => loopView(loop))
162 await writeStatusline($, settings).catch(() => undefined)
163}
164
165/** 会话开始(含热重载):接上上一次加载存下的续期次数、停下和重新开启的时刻;旧模块的定时器已被引擎撤掉,这次重新定时 */
166async function startLoop($: EngineInterface, loop: Loop) {
167 restoreLoop(loop, (await $.state.get(LOOP_MEMORY)).value)
168}
169
170/** 按缓存状态重新定时(cache-tax 的 arm):没开、已停下、冷了都不定时;这段空闲到上限就停下 */
171async function armKeepalive($: EngineInterface, cache: Cache, loop: Loop, settings: Settings) {
172 disarm(loop)
173 if (loop.stopped || !isEnabled(settings, await read($, keepaliveOverride), cache.context)) return publishLoop($, loop, settings)
174 const delay = nextDelay(cache, loop, await $.clock.now())
175 if (delay === 'limit') return stopKeepalive($, loop, settings, LIMIT_STOP)
176 if (delay !== null) loop.pending = $.clock.after(delay, () => void pingKeepalive($, cache, loop, settings).catch(() => undefined))
177 await publishLoop($, loop, settings)
178}
179
180async function stopKeepalive($: EngineInterface, loop: Loop, settings: Settings, stop: KeepaliveStop) {
181 disarm(loop)
182 loop.stopped = stop
183 await publishLoop($, loop, settings)
184}
185
186/** 定时器到点:发一次 fork,读数达标就续期、再定时,否则停下(cache-tax 的 ping) */
187async function pingKeepalive($: EngineInterface, cache: Cache, loop: Loop, settings: Settings) {
188 loop.pending = null
189 if (loop.stopped || loop.inFlight) return
190 if (!isEnabled(settings, await read($, keepaliveOverride), cache.context)) return publishLoop($, loop, settings)
191 const now = await $.clock.now()
192 const action = dueAction(cache, loop, now)
193 if (action === 'limit') return stopKeepalive($, loop, settings, LIMIT_STOP)
194 if (action === 'rearm') return armKeepalive($, cache, loop, settings)
195
196 const stretch = cache.lastRequestAt
197 loop.inFlight = true
198 await publishLoop($, loop, settings)
199 let stop: KeepaliveStop | null
200 try {
201 stop = stopFor(await $.model.fork({ prompt: PING_PROMPT }))
202 } catch (error) {
203 stop = thrownStop(error)
204 }
205 loop.inFlight = false
206 // 请求路上用户回来发了消息:这一段空闲已经结束,结果不再算到新的一段上(cache-tax 没有这层)
207 if (cache.lastRequestAt !== stretch) return publishLoop($, loop, settings)
208 if (stop) return stopKeepalive($, loop, settings, stop)
209 renew(cache, now)
210 loop.renewals += 1
211 await publishCache($, cache)
212 await armKeepalive($, cache, loop, settings)
213}
214
215/**
216 * `/anvil keepalive`:运行中(已启用、没停下)就关掉,没开或已停下就开启,之后不再看自动开启和阈值(D3)。
217 * 开启时这一段空闲的停下也清掉、12 小时上限从现在重新算,按缓存状态重新定时;关闭时 armKeepalive 只撤掉定时器。
218 */
219async function toggleKeepalive($: EngineInterface, cache: Cache, loop: Loop, settings: Settings) {
220 const isRunning = isEnabled(settings, await read($, keepaliveOverride), cache.context) && loop.stopped === null
221 const isOn = !isRunning
222 await update($, keepaliveOverride, () => isOn)
223 if (isOn) restart(loop, await $.clock.now())
224 await armKeepalive($, cache, loop, settings)
225 return isOn ? 'Cache keepalive on (this session)' : 'Cache keepalive off (this session)'
226}
227
228/** 保活状态的输入:设置、本会话的切换、上下文、循环的读数 */
229async function keepaliveInput($: EngineInterface, settings: Settings) {
230 const [cached, override, loop] = await Promise.all([read($, cacheView).then(value => normalized(cacheView, value)), read($, keepaliveOverride), read($, keepaliveLoop).then(value => normalized(keepaliveLoop, value))])
231 return { settings, override, context: cached.context, loop }
232}
233
234/** `/anvil` 列表里的保活状态 */
235async function keepaliveNow($: EngineInterface, settings: Settings) {
236 return keepaliveStatus(await keepaliveInput($, settings))
237}
238
239/** 缓存状态变了:保活按新的状态重新定时(同时把会不会续写给 statusline) */
240async function cacheChanged($: EngineInterface, cache: Cache, loop: Loop, settings: Settings) {
241 await armKeepalive($, cache, loop, settings)
242}
243
244// ---------------------------------------------------------------- 写给 statusline 的保活状态
245
246/** 上一次写下的文件和内容(不含时刻):没变就不写 */
247let statuslineWritten = ''
248
249/** 会话开始时、写状态文件之前读到的那份:新进程里恢复会话时 session.start 会先把文件写掉,恢复时用这份 */
250let savedAtStart: { path: string; expiresAt: number | null } | null = null
251
252/** 这个会话的状态文件;拿不到配置目录或会话 id 时为 null */
253async function statuslinePath($: EngineInterface): Promise<string | null> {
254 const [dir, id] = await Promise.all([configDir($), $.session.id()])
255 return dir === null || id === '' ? null : `${dir}/anvil/sessions/${id}.json`
256}
257
258async function readSavedExpiry($: EngineInterface, path: string): Promise<number | null> {
259 const text = await $.fs.read(path).catch(() => undefined)
260 return typeof text === 'string' ? savedExpiry(text) : null
261}
262
263/** session.start 一开始:先记下上一次写下的过期时刻 */
264async function rememberSaved($: EngineInterface) {
265 const path = await statuslinePath($)
266 savedAtStart = path === null ? null : { path, expiresAt: await readSavedExpiry($, path) }
267}
268
269/** 恢复会话:这个会话上一次写下的过期时刻(会话开始时记下的那份,或会话内 `/resume` 到的别的会话的文件) */
270async function resumedExpiry($: EngineInterface): Promise<number | null> {
271 const path = await statuslinePath($)
272 if (path === null) return null
273 return savedAtStart?.path === path ? savedAtStart.expiresAt : readSavedExpiry($, path)
274}
275
276/** `~/.claude`;设了 CLAUDE_CONFIG_DIR 时按它 */
277async function configDir($: EngineInterface): Promise<string | null> {
278 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
279 if (dir) return dir
280 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
281 return home ? `${home}/.claude` : null
282}
283
284/**
285 * 把这个会话会不会自动续期、续期出没出错写进 `~/.claude/anvil/sessions/<session_id>.json`:
286 * statusline 按 stdin 的 session_id 读(用户 2026-10-15 定)。内容没变就不写
287 */
288async function writeStatusline($: EngineInterface, settings: Settings) {
289 const [path, input, cached, now] = await Promise.all([statuslinePath($), keepaliveInput($, settings), read($, cacheView), $.clock.now()])
290 if (path === null) return
291 const file = statuslineFile(input, normalized(cacheView, cached).expiresAt, now)
292 const key = `${path}\n${file.renews}\n${file.error}\n${file.expiresAt}`
293 if (key === statuslineWritten) return
294 await $.fs.write(path, JSON.stringify(file))
295 statuslineWritten = key
296}
297
298// ---------------------------------------------------------------- 输入框上方那一行:用量
299
300const usage = atom({ plugin: 'anvil', key: 'usage' } as const, EMPTY_USAGE)
301
302async function noteReading($: EngineInterface, reading: Reading) {
303 // 先记进存档再更新读数:读数一变那一行就重画,重画时存档已经是新的
304 await recordHistories($, reading).catch(() => undefined)
305 await update($, usage, normalizing(usage, current => applyReading(current, reading)))
306}
307
308/** 5h、7d 的读数记进 `$.store` 的存档(本机会话共用);先 get 再 set 不是原子的,丢一两个读数无妨,不加锁 */
309async function recordHistories($: EngineInterface, reading: Reading) {
310 const at = await $.clock.now()
311 for (const kind of ['five_hour', 'seven_day'] as const) {
312 const limit = reading.rateLimits.find(one => one.kind === kind)
313 const resetsAt = parseResetsAt(limit?.resetsAt)
314 if (limit === undefined || resetsAt === null) continue
315 const history = parseHistory(await $.store.get(HISTORY_KEYS[kind]))
316 await $.store.set(HISTORY_KEYS[kind], recordSample(history, kind, resetsAt, { at, percent: limit.percentUsed }))
317 }
318}
319
320/** 本会话还没有 5h、7d 的读数时,先画存档里最近的 */
321async function seedFromHistory($: EngineInterface) {
322 if (normalized(usage, await read($, usage)).rateLimits.length > 0) return
323 const histories: Histories = {}
324 for (const kind of ['five_hour', 'seven_day'] as const) {
325 const history = parseHistory(await $.store.get(HISTORY_KEYS[kind]))
326 if (history) histories[kind] = history
327 }
328 const stored = storedLimits(histories, await $.clock.now())
329 if (stored.length > 0) await update($, usage, normalizing(usage, current => (current.rateLimits.length > 0 ? current : { ...current, rateLimits: stored })))
330}
331
332/** 读一次;读数里有 5h、7d 时返回 true */
333async function readUsage($: EngineInterface): Promise<boolean> {
334 const reading = await $.session.usage().catch(() => null)
335 if (reading) await noteReading($, reading)
336 return reading !== null && reading.rateLimits.length > 0
337}
338
339/** 开局时引擎的 5h、7d 还空着,约 3 秒后才有(伪终端实测):先画存档,再隔一阵读一次,最多读 USAGE_RETRIES 次 */
340const USAGE_RETRY_MS = 2000
341const USAGE_RETRIES = 10
342
343async function startUsage($: EngineInterface) {
344 await seedFromHistory($).catch(() => undefined)
345 if (await readUsage($)) return
346 retryUsage($, USAGE_RETRIES)
347}
348
349function retryUsage($: EngineInterface, left: number) {
350 if (left <= 0) return
351 $.clock.after(USAGE_RETRY_MS, () => {
352 void readUsage($).then(
353 found => (found ? undefined : retryUsage($, left - 1)),
354 () => undefined,
355 )
356 })
357}
358
359// ---------------------------------------------------------------- 输入框上方那一行:目录与 git
360
361const place = atom({ plugin: 'anvil', key: 'place' } as const, EMPTY_PLACE)
362
363/** 正在读 git 时又要读:读完再读一次,不并发 */
364type GitRefresh = { isRunning: boolean; isWanted: boolean }
365
366/** 运行一条 git 命令;不在 git 里、git 不在、超时都当没有输出 */
367async function git($: EngineInterface, args: readonly string[]): Promise<string | null> {
368 const ran = await $.process.run([...GIT, ...args], { timeoutMs: 5000 }).catch(() => null)
369 return ran !== null && ran.exitCode === 0 ? ran.stdout : null
370}
371
372async function readText($: EngineInterface, path: string): Promise<string | undefined> {
373 const text = await $.fs.read(path).catch(() => undefined)
374 return typeof text === 'string' ? text.trim() : undefined
375}
376
377/** rebase 停下时的进度和原分支 */
378async function readRebase($: EngineInterface, gitDir: string, names: ReadonlySet<string>): Promise<RebaseFiles | null> {
379 if (names.has('rebase-merge')) {
380 const dir = `${gitDir}/rebase-merge`
381 const [step, total, headName] = await Promise.all([readText($, `${dir}/msgnum`), readText($, `${dir}/end`), readText($, `${dir}/head-name`)])
382 return { dir: 'rebase-merge', step, total, headName, isAm: false }
383 }
384 if (names.has('rebase-apply')) {
385 const dir = `${gitDir}/rebase-apply`
386 const [step, total, headName, isAm] = await Promise.all([
387 readText($, `${dir}/next`),
388 readText($, `${dir}/last`),
389 readText($, `${dir}/head-name`),
390 $.fs.exists(`${dir}/applying`).catch(() => false),
391 ])
392 return { dir: 'rebase-apply', step, total, headName, isAm }
393 }
394 return null
395}
396
397/** 不在 git 里时为 null */
398async function readGit($: EngineInterface): Promise<GitView | null> {
399 const repo = await git($, REV_PARSE)
400 const paths = repo === null ? null : parseRepo(repo)
401 if (!paths) return null
402 const [status, numstat, entries] = await Promise.all([git($, STATUS), git($, NUMSTAT), $.fs.list(paths.gitDir).catch(() => [])])
403 const names = new Set(entries.map(entry => entry.name))
404 const rebase = await readRebase($, paths.gitDir, names)
405 return gitView(paths, parseStatus(status ?? ''), numstat === null ? null : parseNumstat(numstat), operationOf(names, rebase), rebase)
406}
407
408async function refreshPlace($: EngineInterface, refresh: GitRefresh) {
409 if (refresh.isRunning) {
410 refresh.isWanted = true
411 return
412 }
413 refresh.isRunning = true
414 try {
415 do {
416 refresh.isWanted = false
417 const [cwd, view] = await Promise.all([$.session.cwd(), readGit($)])
418 await update($, place, () => ({ dir: baseName(cwd), git: view }))
419 } while (refresh.isWanted)
420 } finally {
421 refresh.isRunning = false
422 }
423}
424
425/** 会改文件或运行命令的工具:调用完 git 状态可能变了 */
426const CHANGES_FILES = new Set(['Bash', 'PowerShell', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
427
428/** 画那一行;有问卷占着那一栏、手机上不画、设置里一段都没选时,交给引擎 */
429async function drawAbovePrompt($: EngineInterface, e: RenderInput<'AbovePrompt'>, parts: readonly BandPart[]) {
430 if (e.surface === 'mobile' || e.props.hasSurvey || parts.length === 0) return null
431 const [where, used, now] = await Promise.all([read($, place).then(value => normalized(place, value)), read($, usage).then(value => normalized(usage, value)), $.clock.now()])
432 const columns = e.props.bodyColumns
433 return drawBand($.ui.resolve(e), bandLine(where, used, now, columns, parts), columns)
434}
435
436// ---------------------------------------------------------------- 每段回复末尾的速度
437
438const NO_TURN_SPEEDS: TurnSpeeds = { current: null, lines: {} }
439const turnSpeeds = atom({ plugin: 'anvil', key: 'turnSpeeds' } as const, NO_TURN_SPEEDS)
440
441/** 还在跑的子 agent:原生收尾那行这时换成"Waiting for N background agents to finish",读数按它算起点 */
442const runningAgents = atom({ plugin: 'anvil', key: 'runningAgents' } as const, [] as string[])
443
444/** 记着的收尾行最多这么多段,多了丢最早的 */
445const TURN_LINES = 500
446
447/** 主会话一次请求的速度:算得出就计入这一段的读数 */
448async function noteSpeed($: EngineInterface, e: TurnStepInput, speed: SpeedTally | null) {
449 if (!speed) return
450 await update($, turnSpeeds, normalizing(turnSpeeds, current => {
451 const turn = current.current?.turnId === e.turnId ? current.current : null
452 return {
453 ...current,
454 current: turn ? { ...turn, tally: addTally(turn.tally, speed) } : { turnId: e.turnId, tally: speed, firstTokenMs: speed.firstTokenMs },
455 }
456 }))
457}
458
459/**
460 * 主会话的收尾那行(对话记录里的 `turn_duration`)记进对话:这一段的读数记到它的 uuid 下,
461 * TurnDuration 的 requestId 就是这个 uuid(anvil-k0t.52 伪终端实测),画的时候按它取
462 */
463async function closeTurnSpeed($: EngineInterface, uuid: string) {
464 const [now, running] = await Promise.all([$.clock.now(), read($, runningAgents).then(value => normalized(runningAgents, value))])
465 await update($, turnSpeeds, normalizing(turnSpeeds, current => {
466 if (!current.current) return current
467 const { tally, firstTokenMs } = current.current
468 const kept = Object.entries(current.lines).slice(-(TURN_LINES - 1))
469 return { current: null, lines: { ...Object.fromEntries(kept), [uuid]: { tally, firstTokenMs, doneAt: now, waitingAgents: running.length } } }
470 }))
471}
472
473/** 换了对话(`/clear`、`/resume`、`/branch`):旧的收尾行不再画在屏幕上 */
474async function resetSpeeds($: EngineInterface) {
475 await update($, turnSpeeds, () => NO_TURN_SPEEDS)
476}
477
478/** 这一段回复的读数;恢复前的、打断的没有 */
479async function turnSpeedOf($: EngineInterface, requestId: string) {
480 return normalized(turnSpeeds, await read($, turnSpeeds)).lines[requestId]
481}
482
483async function noteAgent($: EngineInterface, agentId: string, isRunning: boolean) {
484 await update($, runningAgents, normalizing(runningAgents, list => (isRunning ? (list.includes(agentId) ? list : [...list, agentId]) : list.filter(id => id !== agentId))))
485}
486
487// ---------------------------------------------------------------- /anvil 命令
488
489// 各功能交出的 /anvil 子命令说明,按列出的顺序排
490const SUBCOMMANDS: readonly Subcommand[] = [KEEPALIVE_SUBCOMMAND]
491
492/** `/anvil` 不带参数时列出的各子命令在本会话现在的状态,和 SUBCOMMANDS 同序 */
493async function subcommandStates($: EngineInterface, settings: Settings): Promise<Listed[]> {
494 const states = [(await keepaliveNow($, settings)).text]
495 return SUBCOMMANDS.map((subcommand, index) => ({ ...subcommand, state: states[index] ?? '' }))
496}
497
498async function registerAnvil($: EngineInterface) {
499 await $.command.register({ name: 'anvil', ...describe(SUBCOMMANDS) })
500}
501
502// ---------------------------------------------------------------- hooks
503
504export const register: Register = (on, options) => {
505 const settings = readSettings(options)
506 const cache = cacheState.freshCache()
507 const loop = freshLoop()
508 const gitRefresh: GitRefresh = { isRunning: false, isWanted: false }
509
510 on('session.start', async ($, e, next) => {
511 await rememberSaved($).catch(() => undefined)
512 await publishSettings($, settings)
513 // 功能在这里按顺序接入
514 // 热重载换掉模块内存、撤掉定时器:缓存状态和保活循环从 `$.state` 接上,按接上的状态重新定时
515 await startCache($, cache)
516 await startLoop($, loop)
517 await cacheChanged($, cache, loop, settings)
518 await startUsage($)
519 await refreshPlace($, gitRefresh).catch(() => undefined)
520 await registerAnvil($)
521 return next(e)
522 })
523
524 // `/clear`、恢复会话不触发 session.start,只走这里
525 on('classic.SessionStart', async ($, e, next) => {
526 const result = await next(e)
527 await resumeCache($, cache, e)
528 // `/clear`、会话内恢复别的会话(`/resume`、`/branch`)都换了对话:上一段空闲的停下不再算,
529 // 按起步的缓存状态重新定时(冷了不发)
530 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
531 startStretch(loop)
532 await resetSpeeds($).catch(() => undefined)
533 }
534 await cacheChanged($, cache, loop, settings)
535 // `$.state` 换了对话就清空:用量和 git 重新读,不等第一条消息
536 await startUsage($)
537 await refreshPlace($, gitRefresh).catch(() => undefined)
538 return result
539 })
540
541 on('turn.step', async function* ($, e, next) {
542 if (e.agentId) return yield* next(e)
543 await stepStarted($, cache)
544 // 用户回来了:这一段空闲结束,一轮结束时再定时(cache-tax 也是一轮结束才定时)
545 startStretch(loop)
546 await publishLoop($, loop, settings)
547 // token/s 从首字算起(D8):首字是响应的第一块(含 engine 块,.51 实测),结束是 stop 那块;
548 // 首字等待从请求开始算;整段一起到时生成用时退回整次用时
549 const startAt = await $.clock.now()
550 let firstAt: number | null = null
551 let stopAt: number | null = null
552 let output: number | undefined
553 const stream = next(e)
554 for await (const chunk of stream) {
555 if (chunk.kind === 'stop') {
556 stopAt = await $.clock.now()
557 output = chunk.usage?.output_tokens
558 } else if (firstAt === null) {
559 firstAt = await $.clock.now()
560 }
561 yield chunk
562 }
563 const result = await stream.result
564 if (result.usage) await stepAnswered($, cache, result.usage)
565 await noteSpeed($, e, requestSpeed(output ?? result.usage?.output_tokens, startAt, firstAt, stopAt)).catch(() => undefined)
566 return result
567 })
568
569 on('turn.complete', async ($, e, next) => {
570 const result = await next(e)
571 if (!e.agentId) {
572 await turnCompleted($, cache, e)
573 await cacheChanged($, cache, loop, settings)
574 // 本会话花费在一轮结束时重新读
575 await readUsage($).catch(() => undefined)
576 await refreshPlace($, gitRefresh).catch(() => undefined)
577 } else {
578 await noteAgent($, e.agentId, false).catch(() => undefined)
579 }
580 return result
581 })
582
583 on('agent.spawn', async ($, e, next) => {
584 const started = await next(e)
585 if (started.deny === undefined && started.agentId) await noteAgent($, started.agentId, true).catch(() => undefined)
586 return started
587 })
588
589 on('tool.call', async ($, e, next) => {
590 const ran = await next(e)
591 if (CHANGES_FILES.has(String(e.tool))) void refreshPlace($, gitRefresh).catch(() => undefined)
592 return ran
593 })
594
595 // 主会话一段回复的收尾那行:先记下这一段的读数再交下去,画它的时候已经有了
596 on('session.append', { door: 'notice' }, async ($, e, next) => {
597 if (!e.agentId && e.message.type === 'system' && e.message.name === 'turn_duration') await closeTurnSpeed($, e.uuid).catch(() => undefined)
598 return next(e)
599 })
600
601 on('session.measure', async ($, e, next) => {
602 await noteReading($, e)
603 return next(e)
604 })
605
606 on('session.compact', async ($, e, next) => {
607 const result = await next(e)
608 // precompute 只预先算好摘要、不换掉对话;被否决的压缩也没换
609 if (!e.agentId && e.trigger !== 'precompute' && result.skip === undefined) {
610 await compacted($, cache)
611 await cacheChanged($, cache, loop, settings)
612 }
613 return result
614 })
615
616 on('prompt.submit', async ($, e, next) => {
617 // 用户可能在别处运行过 git;不等它读完
618 void refreshPlace($, gitRefresh).catch(() => undefined)
619 if (settings.guard) {
620 const drop = await guardSubmission($, cache, e, settings.threshold)
621 if (drop !== null) return { drop }
622 }
623 return next(e)
624 })
625
626 // 原生收尾那行后面接这一段的读数;没有读数时照原样
627 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
628 const turn = e.surface === 'terminal' ? await turnSpeedOf($, e.requestId) : undefined
629 if (!turn) return next(e)
630 return turnDurationRow($.ui.resolve(e), await next(e), e.props, turn)
631 })
632
633 // 主输入框上方:目录名 · git · 5h · 7d · 花费
634 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => (await drawAbovePrompt($, e, settings.band)) ?? next(e))
635
636 // 功能自己的子命令在这里接入(带 args 匹配器),兜底的放最后
637 on('command.run', { command: 'anvil', args: /^\s*keepalive\s*$/ }, async $ => ({ text: await toggleKeepalive($, cache, loop, settings) }))
638
639 // 兜底回答功能没接走的 /anvil
640 on('command.run', { command: 'anvil' }, async ($, e) => ({ text: answer(await subcommandStates($, settings), e.args) }))
641}
642hooks/band.tsx 292 lines1// 输入框上方那一行(AbovePrompt band):目录名 · git · 5h · 7d · 花费(用户 2026-10-14/15 定)。
2// 设置 `band` 可隐藏其中几段、调顺序、整行关闭(anvil-hy6)。
3// 模型、effort、上下文、缓存交给输入框下方的 statusline,这一行不重复。
4// git 照原来的身份栏(0.2.0 之前的 identity.tsx):取法照用户原来的 ~/.claude/statusline.mjs(`--no-optional-locks`、
5// 分离 HEAD 写 `@提交号`、`@{u}` 领先落后、储藏数),改成一条 `status --porcelain=v2 --branch --show-stash` 取齐分支、上游、
6// 领先落后、各项计数和储藏数;另加目录名、未提交行数、停在一半的命令。组内单空格、组间两空格。
7// 只放不碰 `$` 的纯逻辑和界面组件;运行 git、读 git 目录和读用量在 register.ts。
8
9import type { Elements, RenderElement } from 'claude-code'
10
11import type { BandPart, GitOperation, GitView, Place, Usage } from '../types'
12import { COLOR, ICON, SEPARATOR, fitSegments, widthOf } from './style'
13import type { Segment } from './style'
14import { fitTail } from './text'
15import { costSegments, limitSegments } from './usage'
16
17export const EMPTY_PLACE: Place = { dir: '', git: null }
18
19// ---------------------------------------------------------------- git 的读法
20
21/** 每条 git 命令都带:status 刷新索引时会拿 index.lock,超时被杀就留下残锁(statusline 的注释) */
22export const GIT = ['git', '--no-optional-locks'] as const
23
24/** 仓库根、git 目录、共用 git 目录(判断 linked worktree)、会话目录在仓库里的相对路径;不在 git 里时失败 */
25export const REV_PARSE = ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir', '--show-prefix'] as const
26
27/** 分支、上游、领先落后、储藏数和逐个文件的状态 */
28export const STATUS = ['status', '--porcelain=v2', '--branch', '--show-stash'] as const
29
30/** 未提交改动的行数:工作区和暂存区相对 HEAD */
31export const NUMSTAT = ['diff', 'HEAD', '--numstat'] as const
32
33export type RepoPaths = { top: string; gitDir: string; commonDir: string; prefix: string }
34
35export function parseRepo(out: string): RepoPaths | null {
36 const [top, gitDir, commonDir, prefix = ''] = out.split(/\r?\n/)
37 if (!top || !gitDir || !commonDir) return null
38 return { top, gitDir, commonDir, prefix: prefix.replace(/\/+$/, '') }
39}
40
41/** 路径的最后一段;`/` 和 `\` 都算分隔符 */
42export function baseName(path: string): string {
43 const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
44 return parts[parts.length - 1] || path
45}
46
47const samePath = (a: string, b: string) => a.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase() === b.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
48
49/**
50 * 目录名:主仓库取仓库目录名;linked worktree 另接 worktree 目录名(主仓库名取共用 git 目录的上一级);
51 * 子目录接相对路径。不在 git 里的情况由调用方取会话目录名。
52 */
53export function repoNames(paths: RepoPaths): Pick<GitView, 'repo' | 'worktree' | 'sub'> {
54 if (samePath(paths.gitDir, paths.commonDir)) return { repo: baseName(paths.top), worktree: null, sub: paths.prefix }
55 const common = paths.commonDir.replace(/[\\/]+$/, '')
56 const repo = /[\\/]\.git$/.test(common) ? baseName(common.replace(/[\\/]\.git$/, '')) : baseName(common).replace(/\.git$/, '')
57 return { repo, worktree: baseName(paths.top), sub: paths.prefix }
58}
59
60export type Status = Omit<GitView, 'repo' | 'worktree' | 'sub' | 'lines' | 'operation'>
61
62/** `git status --porcelain=v2 --branch --show-stash` */
63export function parseStatus(out: string): Status {
64 const status: Status = { branch: null, commit: null, noUpstream: false, ahead: 0, behind: 0, conflicts: 0, staged: 0, modified: 0, untracked: 0, stashes: 0 }
65 let oid: string | null = null
66 let head: string | null = null
67 let hasUpstream = false
68 for (const line of out.split(/\r?\n/)) {
69 const words = line.split(' ')
70 if (line.startsWith('# branch.oid ')) oid = words[2] ?? null
71 else if (line.startsWith('# branch.head ')) head = words.slice(2).join(' ')
72 else if (line.startsWith('# branch.upstream ')) hasUpstream = true
73 else if (line.startsWith('# branch.ab ')) {
74 status.ahead = Math.abs(Number.parseInt(words[2] ?? '0', 10)) || 0
75 status.behind = Math.abs(Number.parseInt(words[3] ?? '0', 10)) || 0
76 } else if (line.startsWith('# stash ')) status.stashes = Number.parseInt(words[2] ?? '0', 10) || 0
77 else if (line.startsWith('1 ') || line.startsWith('2 ')) {
78 const xy = words[1] ?? '..'
79 if (xy[0] !== '.') status.staged += 1
80 if (xy[1] !== '.') status.modified += 1
81 } else if (line.startsWith('u ')) status.conflicts += 1
82 else if (line.startsWith('? ')) status.untracked += 1
83 }
84 if (head === null || head === '(detached)') {
85 status.commit = oid && oid !== '(initial)' ? oid.slice(0, 7) : null
86 } else {
87 status.branch = head
88 status.noUpstream = !hasUpstream
89 }
90 return status
91}
92
93/** `git diff HEAD --numstat`;二进制文件(`-`)不计 */
94export function parseNumstat(out: string): { added: number; deleted: number } {
95 let added = 0
96 let deleted = 0
97 for (const line of out.split(/\r?\n/)) {
98 const [plus, minus] = line.split('\t')
99 if (plus === undefined || minus === undefined) continue
100 added += Number.parseInt(plus, 10) || 0
101 deleted += Number.parseInt(minus, 10) || 0
102 }
103 return { added, deleted }
104}
105
106/** rebase 停下时 git 目录里的进度文件:rebase-merge 是 msgnum / end,rebase-apply(含 am)是 next / last */
107export type RebaseFiles = { dir: 'rebase-merge' | 'rebase-apply'; step?: string; total?: string; headName?: string; isAm: boolean }
108
109/**
110 * git 命令停在一半:看 git 目录里留下的标记。rebase 优先(它停下时也可能留着别的标记),
111 * 其次 merge、cherry-pick、revert、bisect。
112 */
113export function operationOf(names: ReadonlySet<string>, rebase: RebaseFiles | null): GitOperation | null {
114 if (rebase) {
115 const step = Number.parseInt(rebase.step ?? '', 10)
116 const total = Number.parseInt(rebase.total ?? '', 10)
117 const name = rebase.isAm ? 'am' : 'rebase'
118 return Number.isFinite(step) && Number.isFinite(total) && total > 0 ? { name, step, total } : { name }
119 }
120 if (names.has('MERGE_HEAD')) return { name: 'merge' }
121 if (names.has('CHERRY_PICK_HEAD')) return { name: 'cherry-pick' }
122 if (names.has('REVERT_HEAD')) return { name: 'revert' }
123 if (names.has('BISECT_LOG')) return { name: 'bisect' }
124 return null
125}
126
127/** 拼出 git 读数;rebase 中 HEAD 是分离的,分支名取 rebase 的 head-name */
128export function gitView(paths: RepoPaths, status: Status, lines: GitView['lines'], operation: GitOperation | null, rebase: RebaseFiles | null): GitView {
129 const view: GitView = { ...repoNames(paths), ...status, lines, operation }
130 const headName = rebase?.headName?.trim().replace(/^refs\/heads\//, '')
131 if (headName && headName !== 'detached HEAD') return { ...view, branch: headName, commit: null, noUpstream: false }
132 return view
133}
134
135// ---------------------------------------------------------------- 各段
136
137/** 分支、worktree 名、子目录各自最多占几格;窄屏时先把名字截短(从前面截,留下结尾),最短 NAME_MIN 格 */
138const NAME_CELLS = 20
139const NAME_MIN = 8
140
141/** git 里几组之间空两格 */
142const GROUP_GAP = 2
143
144function spaced(groups: readonly Segment[][]): Segment[] {
145 return groups.filter(group => group.length > 0).flatMap((group, index) => (index === 0 ? group : [{ text: ' '.repeat(GROUP_GAP) }, ...group]))
146}
147
148/** 目录名:仓库名加粗,linked worktree 接 worktree 名,子目录接相对路径;不在 git 里时是会话目录名。bare 时只写仓库名(窄屏) */
149export function dirSegments(place: Place, names: number = NAME_CELLS, bare = false): Segment[] {
150 const git = place.git
151 const out: Segment[] = [{ text: `${ICON.dir} `, color: COLOR.dir }, { text: git ? git.repo : place.dir || '—', bold: true }]
152 if (!git || bare) return out
153 if (git.worktree) out.push({ text: ` ${ICON.worktree} `, color: COLOR.icon }, { text: fitTail(git.worktree, names) })
154 if (git.sub) out.push({ text: fitTail(`/${git.sub}`, names), color: COLOR.secondary })
155 return out
156}
157
158function opSegments(operation: GitOperation): Segment[] {
159 const progress = operation.step !== undefined && operation.total !== undefined ? ` ${operation.step}/${operation.total}` : ''
160 return [
161 { text: `${ICON.pause} `, color: COLOR.high },
162 { text: operation.name, color: COLOR.high, bold: true },
163 ...(progress ? [{ text: progress, color: COLOR.high }] : []),
164 ]
165}
166
167/**
168 * git:分支紧跟 ↑↓ 和"无上游"、停在一半的命令、= + ! ? 计数和储藏、未提交行数;不在 git 里为空。
169 * short 时只留分支一组和停在一半的命令(窄屏)
170 */
171export function gitSegments(git: GitView | null, names: number = NAME_CELLS, short = false): Segment[] {
172 if (!git) return []
173 const branch: Segment[] = []
174 if (git.branch !== null) branch.push({ text: `${ICON.branch} ${fitTail(git.branch, names)}`, color: COLOR.branch })
175 else if (git.commit !== null) branch.push({ text: `${ICON.detached} @${git.commit}`, color: COLOR.branch })
176 if (branch.length > 0) {
177 if (git.ahead > 0) branch.push({ text: ` ↑${git.ahead}`, color: COLOR.warn })
178 if (git.behind > 0) branch.push({ text: ` ↓${git.behind}`, color: COLOR.warn })
179 if (git.noUpstream) branch.push({ text: ' no upstream', color: COLOR.secondary })
180 }
181 const operation = git.operation ? opSegments(git.operation) : []
182 if (short) return spaced([branch, operation])
183
184 const marks: (Segment | null)[] = [
185 git.conflicts > 0 ? { text: `=${git.conflicts}`, color: COLOR.danger, bold: true } : null,
186 git.staged > 0 ? { text: `+${git.staged}`, color: COLOR.ok } : null,
187 git.modified > 0 ? { text: `!${git.modified}`, color: COLOR.warn } : null,
188 git.untracked > 0 ? { text: `?${git.untracked}`, color: COLOR.secondary } : null,
189 git.stashes > 0 ? { text: `${ICON.stash} ${git.stashes}`, color: COLOR.secondary } : null,
190 ]
191 const counts: Segment[] = marks
192 .filter((one): one is Segment => one !== null)
193 .flatMap((one, index) => (index === 0 ? [one] : [{ text: ' ' }, one]))
194
195 const lines = git.lines
196 const changed: Segment[] =
197 lines && lines.added + lines.deleted > 0
198 ? [
199 { text: `${ICON.lines} `, color: COLOR.icon },
200 { text: `+${lines.added}`, color: COLOR.ok },
201 { text: ' ' },
202 { text: `−${lines.deleted}`, color: COLOR.danger },
203 ]
204 : []
205
206 return spaced([branch, operation, counts, changed])
207}
208
209// ---------------------------------------------------------------- 一行
210
211const joined = (parts: readonly Segment[][]) => parts.filter(part => part.length > 0).flatMap((part, index) => (index === 0 ? part : [SEPARATOR, ...part]))
212
213/** 窄屏时依次让出的东西;重置时刻一直留着(用户定) */
214type Level = { names: number; bars: boolean; git: 'full' | 'short' | 'none'; cost: boolean; bareDir: boolean }
215
216const LEVELS: readonly Level[] = [
217 { names: NAME_CELLS, bars: true, git: 'full', cost: true, bareDir: false },
218 { names: NAME_MIN, bars: true, git: 'full', cost: true, bareDir: false },
219 { names: NAME_MIN, bars: false, git: 'full', cost: true, bareDir: false },
220 { names: NAME_MIN, bars: false, git: 'short', cost: true, bareDir: false },
221 { names: NAME_MIN, bars: false, git: 'short', cost: false, bareDir: false },
222 { names: NAME_MIN, bars: false, git: 'none', cost: false, bareDir: false },
223 { names: NAME_MIN, bars: false, git: 'none', cost: false, bareDir: true },
224]
225
226/** 左边空 1 格,和主输入框的边线错开 */
227const PAD_LEFT = 1
228
229/** 这一行能画的几段,默认全画、按这个顺序 */
230export const BAND_PARTS: readonly BandPart[] = ['dir', 'git', '5h', '7d', 'cost']
231
232/**
233 * 设置里的 `band`:逗号或空格隔开的段名,按写的顺序画;不认识的、重复的跳过,大小写不论。
234 * 一段都没有(空串、`off`)时整行不画
235 */
236export function parseBand(value: string): BandPart[] {
237 const parts: BandPart[] = []
238 for (const word of value.toLowerCase().split(/[\s,]+/)) {
239 const part = BAND_PARTS.find(one => one === word)
240 if (part && !parts.includes(part)) parts.push(part)
241 }
242 return parts
243}
244
245function partAt(part: BandPart, place: Place, usage: Usage, now: number, level: Level): Segment[] {
246 switch (part) {
247 case 'dir':
248 return dirSegments(place, level.names, level.bareDir)
249 case 'git':
250 return level.git === 'none' ? [] : gitSegments(place.git, level.names, level.git === 'short')
251 case '5h':
252 return limitSegments('five_hour', usage.rateLimits, now, level.bars)
253 case '7d':
254 return limitSegments('seven_day', usage.rateLimits, now, level.bars)
255 case 'cost':
256 return level.cost ? costSegments(usage) : []
257 }
258}
259
260function lineAt(place: Place, usage: Usage, now: number, level: Level, parts: readonly BandPart[]): Segment[] {
261 return joined(parts.map(part => partAt(part, place, usage, now, level)))
262}
263
264/**
265 * 整行:放得下就全画;放不下先把名字截短,再去掉进度条、git 只留分支、去掉花费、去掉 git、目录只留仓库名,
266 * 还放不下就从行尾截(窄屏退化先这样,用户再调)。parts 是设置里选的段和顺序,隐藏的段不占地方
267 */
268export function bandLine(place: Place, usage: Usage, now: number, columns: number, parts: readonly BandPart[] = BAND_PARTS): Segment[] {
269 const room = Math.max(0, columns - PAD_LEFT)
270 for (const level of LEVELS) {
271 const line = lineAt(place, usage, now, level, parts)
272 if (widthOf(line) <= room) return line
273 }
274 return fitSegments(lineAt(place, usage, now, LEVELS[LEVELS.length - 1] as Level, parts), room)
275}
276
277/** 画成一行;嵌套的 Text 沿用外层的 color,所以外层不设颜色(AGENTS.md Pitfalls) */
278export function drawBand(els: Pick<Elements['terminal'], 'Box' | 'Text'>, segments: readonly Segment[], columns: number): RenderElement {
279 const { Box, Text } = els
280 return (
281 <Box key="band" width={columns} flexShrink={0} paddingLeft={PAD_LEFT} marginTop={1}>
282 <Text wrap="truncate">
283 {segments.map(segment => (
284 <Text color={segment.color} bold={segment.bold}>
285 {segment.text}
286 </Text>
287 ))}
288 </Text>
289 </Box>
290 )
291}
292hooks/cache.ts 223 lines1// 缓存状态(spec D1、D2)和冷发送守卫(D4)的纯逻辑:估算、计价、守卫的判定和提示行。
2// 钩子和 `$` 调用都在 register.ts。
3//
4// 估算、牌价表、守卫和会话生命周期照 cache-tax(https://github.com/karanb192/cache-tax,HEAD 06cac47)改写:
5// MIT License, Copyright (c) 2026 Karan Bansal。偏离之处在各函数的注释里写明。
6
7import type { ModelUsage, TurnUsage } from 'claude-code'
8
9import type { CacheMemory, CacheView } from '../types'
10
11/** 一律按 1 小时 TTL(ADR 0001) */
12export const TTL_MS = 60 * 60 * 1000
13
14/** 一轮写入不少于上一轮上下文的一半、且上一轮上下文超过这个量,就算整段重建(cache-tax 的判据) */
15const FULL_WRITE_FLOOR = 20000
16
17// 每百万 token 的美元牌价:[缓存读取, 1 小时缓存写入],取自 cache-tax(2026 年 9 月牌价)。
18// 长的系列名在前:模型 id 取第一个包含的行。
19const PRICES: ReadonlyArray<readonly [string, number, number]> = [
20 ['fable-5-1', 0.25, 20],
21 ['fable-5', 1, 20],
22 ['opus-5', 0.5, 10],
23 ['opus-4', 0.5, 10],
24 ['sonnet-5', 0.2, 4],
25 ['sonnet', 0.3, 6],
26 ['haiku', 0.1, 2],
27]
28
29/**
30 * 主会话的缓存状态;在 register.ts 的闭包里,`/clear` 和会话内恢复别的会话时清零。
31 * 每次变化原样写进 `$.state` 的 anvil.cacheMemory,热重载后从那里接上(restoreCache);形状在 types 里
32 */
33export type Cache = CacheMemory
34
35export function freshCache(): Cache {
36 return {
37 lastRequestAt: 0, expiresAt: 0, compacted: false, context: 0, output: 0, model: null,
38 last: null, session: { read: 0, input: 0 }, coldWrites: [], steppedThisTurn: false, ackedFor: 0,
39 }
40}
41
42/** `/clear` 开始新的对话:之前的一切都不再计价。就地清零,闭包里的引用不变 */
43export function resetForClear(c: Cache) {
44 Object.assign(c, freshCache(), { model: c.model })
45}
46
47const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
48const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
49const isPart = (v: unknown): v is { read: number; input: number } => isRecord(v) && isNumber(v.read) && isNumber(v.input)
50const isColdWrite = (v: unknown) => isRecord(v) && isNumber(v.at) && isNumber(v.tokens) && (v.usd === null || isNumber(v.usd))
51
52/**
53 * 热重载后读回上一次加载写下的缓存状态(anvil.cacheMemory)。没有,或是别的形状(旧代码写的),返回 null:按冷启动。
54 */
55export function restoreCache(v: unknown): Cache | null {
56 if (!isRecord(v)) return null
57 const ok = isNumber(v.lastRequestAt) && isNumber(v.expiresAt) && typeof v.compacted === 'boolean' &&
58 isNumber(v.context) && isNumber(v.output) && (v.model === null || typeof v.model === 'string') &&
59 (v.last === null || isPart(v.last)) && isPart(v.session) && Array.isArray(v.coldWrites) && v.coldWrites.every(isColdWrite) &&
60 typeof v.steppedThisTurn === 'boolean' && isNumber(v.ackedFor)
61 return ok ? JSON.parse(JSON.stringify(v)) as Cache : null
62}
63
64function priceOf(model: string | null): readonly [number, number] | null {
65 const m = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
66 for (const [family, read, write] of PRICES) if (m.includes(family)) return [read, write]
67 return null
68}
69
70/** 按 1 小时写入价折算重建 tokens 的金额(D2);模型不认识时为 null */
71export function writeUsd(tokens: number, model: string | null): number | null {
72 const price = priceOf(model)
73 return price ? tokens * price[1] / 1e6 : null
74}
75
76const inputOf = (u: ModelUsage) => u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
77
78/** 重建量 = 上下文输入侧 + 上一条回复的输出(D1;cache-tax 只算输入侧) */
79export const rebuildTokens = (c: Cache) => c.context + c.output
80
81export function isExpired(c: Cache, now: number): boolean {
82 return c.expiresAt > 0 && !c.compacted && now >= c.expiresAt
83}
84
85/** 主会话发出一次请求(turn.step 开始,或 turn.complete 兜底):过期时刻顺延到这次请求 + 1 小时 */
86export function stampRequest(c: Cache, now: number) {
87 c.lastRequestAt = now
88 c.expiresAt = now + TTL_MS
89}
90
91/** 主会话一次请求的回复到了:上下文以这一条为准,压缩后的"不判过期"到此结束 */
92export function recordResponse(c: Cache, usage: TurnUsage) {
93 c.context = inputOf(usage)
94 c.output = usage.output_tokens
95 c.model = usage.model
96 c.compacted = false
97 c.steppedThisTurn = true
98}
99
100/**
101 * 主会话一轮结束:记命中率,判断这一轮是否整段重建了缓存。
102 *
103 * `prev` 是这一轮之前的上下文输入侧。一轮的 usage 是各次请求之和,整段重建发生在第一次请求,
104 * 和 cache-tax 一样拿整轮写入量和上一轮上下文比。cache-tax 还把"守卫放行过的那条"一律算成冷写,
105 * 这里不算:只按实际写入量记,估算误报时不多记一次。
106 */
107export function recordTurn(c: Cache, usage: TurnUsage, prev: number, live: number | undefined, now: number) {
108 c.model = usage.model
109 const input = inputOf(usage)
110 c.last = { read: usage.cache_read_input_tokens, input }
111 c.session = { read: c.session.read + usage.cache_read_input_tokens, input: c.session.input + input }
112 // turn.step 没给出读数时(引擎没有逐次的 usage),退到引擎的实时上下文
113 if (!c.steppedThisTurn && live !== undefined && live > 0) c.context = live
114 const write = usage.cache_creation_input_tokens
115 if (prev > FULL_WRITE_FLOOR && write >= 0.5 * prev) c.coldWrites.push({ at: now, tokens: write, usd: writeUsd(write, c.model) })
116 c.compacted = false
117 c.steppedThisTurn = false
118 c.ackedFor = 0
119}
120
121/** 主会话压缩:到下一次响应前不判过期,上下文未知 */
122export function recordCompact(c: Cache) {
123 c.compacted = true
124 c.context = 0
125 c.output = 0
126 c.ackedFor = 0
127}
128
129/** `classic.SessionStart`(resume、fork)给出的字段 */
130export type ResumeFields = {
131 source: string
132 model?: string
133 context_tokens?: number
134 seconds_since_last_response?: number
135 prompt_cache_likely_expired?: boolean
136}
137
138/**
139 * 恢复会话时按引擎给的字段起步(D1)。
140 *
141 * 会话内 `/resume`、`/branch` 换的是另一个会话:上一个会话的读数、命中率、冷写都不再算,先清零(同 `/clear`,
142 * 计价用的模型保留),再只按引擎给的字段起步。
143 * `context_tokens` 是上一条回复的输入加输出,正是重建量;输出部分不单列,记在 context 里。
144 * 引擎判定 `prompt_cache_likely_expired` 时,过期时刻最晚取现在:哪怕按 1 小时还没到(引擎知道真实 TTL)。
145 *
146 * saved 是 anvil 上次写进状态文件的过期时刻,算上了续期;引擎的两个字段只看最后一次主会话回复、不知道续期,
147 * 退出前续过期的会话恢复后会被误判过期(用户 2026-10-15 实测被守卫拦下;同一分钟里跨进程 resume 实测缓存全部读到)。
148 * saved 比引擎给的晚就照它;但引擎判定已过期、离最后一次回复又不到 1 小时(或没给),说明引擎知道 TTL 更短
149 * (超额后 5 分钟),不照它。cache-tax 的 seedFromResume 只看引擎的字段。
150 */
151export function seedFromResume(c: Cache, e: ResumeFields, now: number, saved: number | null = null) {
152 if (e.source !== 'resume' && e.source !== 'fork') return
153 resetForClear(c)
154 if (typeof e.context_tokens === 'number' && e.context_tokens > 0) c.context = e.context_tokens
155 if (typeof e.model === 'string') c.model = e.model
156 if (typeof e.seconds_since_last_response === 'number') stampRequest(c, now - e.seconds_since_last_response * 1000)
157 if (e.prompt_cache_likely_expired === true) c.expiresAt = Math.min(c.expiresAt || now, now)
158 const since = e.seconds_since_last_response
159 const shortTtl = e.prompt_cache_likely_expired === true && (typeof since !== 'number' || since * 1000 < TTL_MS)
160 if (saved !== null && !shortTtl && saved > c.expiresAt) c.expiresAt = saved
161}
162
163const ratio = (part: { read: number; input: number } | null) => part && part.input > 0 ? part.read / part.input : null
164
165/** 交给显示(缓存框)的读数,写进 `$.state` 的 anvil.cache */
166export function view(c: Cache): CacheView {
167 const rebuild = rebuildTokens(c)
168 return {
169 expiresAt: c.expiresAt > 0 ? c.expiresAt : null,
170 compacted: c.compacted,
171 context: c.context,
172 rebuild: { tokens: rebuild, usd: writeUsd(rebuild, c.model) },
173 hitRate: { last: ratio(c.last), session: ratio(c.session) },
174 coldWrites: { count: c.coldWrites.length, usd: c.coldWrites.reduce((sum, w) => sum + (w.usd ?? 0), 0) },
175 }
176}
177
178// ---------------------------------------------------------------- 格式(D10 用词)
179
180/** 时长写作 47s / 2m41s / 1h10m */
181export function formatDuration(ms: number): string {
182 const seconds = Math.max(0, Math.floor(ms / 1000))
183 if (seconds < 60) return `${seconds}s`
184 const minutes = Math.floor(seconds / 60)
185 if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
186 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
187}
188
189export function formatTokens(n: number): string {
190 return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
191}
192
193export const formatUsd = (usd: number) => `$${usd.toFixed(2)}`
194
195// ---------------------------------------------------------------- 冷发送守卫(D4)
196
197/** 守卫看的那次提交 */
198export type Submission = { text: string; origin: { kind: string } }
199
200/**
201 * 这次提交该不该拦。
202 *
203 * 只拦用户在主输入框里亲手按 Enter 发给模型的消息(origin composer):插件、远程、通知、
204 * 定时任务等来源都放行(cache-tax 只放行插件;spec 说只拦用户自己的,放回主输入框也只对 composer 有意义)。
205 * 斜杠命令放行;`!` 开头的 shell 命令不发给模型,也放行。
206 */
207export function shouldGuard(c: Cache, submission: Submission, threshold: number, now: number): boolean {
208 if (submission.origin.kind !== 'composer') return false
209 const head = submission.text.trimStart()
210 if (head.startsWith('/') || head.startsWith('!')) return false
211 if (!isExpired(c, now) || c.context < threshold) return false
212 return c.ackedFor !== c.expiresAt
213}
214
215/** drop 原因就是提示行(D4 文案);模型不认识时省掉金额 */
216export function guardText(c: Cache, now: number): string {
217 const rebuild = rebuildTokens(c)
218 const usd = writeUsd(rebuild, c.model)
219 const cost = usd === null ? '' : ` (~${formatUsd(usd)})`
220 return `anvil: the cache expired ${formatDuration(now - c.expiresAt)} ago; sending this message rebuilds ${formatTokens(rebuild)} tokens${cost}. ` +
221 'The message is back in the prompt: press Enter again to send it, or /clear to start a new session if you no longer need this context.'
222}
223hooks/command.ts 46 lines1// `/anvil` 命令的纯逻辑:说明和兜底回答。注册和执行都在 register.ts。
2
3/**
4 * `/anvil` 注册时的说明,即补全菜单里的灰字;name 留在 register.ts 写字面量,validate 才认得。
5 * 照内置命令(`/tui [default|fullscreen]`、`/voice [hold|tap|off]`)和 flightdeck 的写法:
6 * description 一句简短的命令说明,argumentHint 在方括号里用 `|` 列出全部子命令(不带参数也能用,所以是方括号)。
7 */
8export function describe(subcommands: readonly Subcommand[]): { description: string; argumentHint: string } {
9 return {
10 description: 'Toggle prompt-cache keepalive for this session; with no argument, list the current state',
11 argumentHint: `[${subcommands.map(({ name }) => name).join('|')}]`,
12 }
13}
14
15/**
16 * 功能交给 `/anvil` 的一个子命令的说明,如 `/anvil keepalive`。子命令都是切换:敲一次切一次,只作用于当前会话。
17 *
18 * 执行在 register.ts 里注册,匹配器写字面量、和这里的 name 对上,不带别的词:
19 * `on('command.run', { command: 'anvil', args: /^\s*keepalive\s*$/ }, async ($, e) => ...)`。
20 * 没被接走的 `/anvil`(不带子命令、子命令未知、后面多了词)由兜底的 command.run 用 answer 回答。
21 */
22export type Subcommand = {
23 name: string
24 /** 列表里的一句说明 */
25 summary: string
26}
27
28/** 列表里的一行:子命令和它在本会话现在的状态 */
29export type Listed = Subcommand & { state: string }
30
31const USAGE = 'Usage: /anvil <subcommand>. Each run toggles it, for this session only'
32
33function list(subcommands: readonly Listed[]): string {
34 if (subcommands.length === 0) return `${USAGE}\n No subcommands available`
35 const width = Math.max(...subcommands.map(subcommand => subcommand.name.length))
36 const rows = subcommands.map(({ name, summary, state }) => ` /anvil ${name.padEnd(width)} ${summary} (now: ${state})`)
37 return [USAGE, ...rows].join('\n')
38}
39
40/** 兜底的回答:不带子命令时列出子命令和状态;子命令未知或后面多了词(含已去掉的 `panel`、`translate`)时先说明再列出 */
41export function answer(subcommands: readonly Listed[], args: string): string {
42 const asked = args.trim().split(/\s+/).join(' ')
43 if (asked === '') return list(subcommands)
44 return `Unknown subcommand: ${asked}\n${list(subcommands)}`
45}
46hooks/keepalive.ts 208 lines1// 缓存保活(spec D3)的纯逻辑:开没开、什么时候发请求、读数判据、停下的原因、`/anvil` 列表里的保活状态和写给 statusline 的状态。
2// 循环本身(定时器、`$.model.fork`)在 register.ts。
3//
4// 循环照 cache-tax 的 keepwarm 改写(https://github.com/karanb192/cache-tax,HEAD 06cac47):
5// MIT License, Copyright (c) 2026 Karan Bansal。和它不同的只有 spec 定下的几处:按上下文阈值自动开、
6// 不开有界窗口而是每段空闲最多 12 小时、停下只结束这一段空闲;各处注释里写明。
7
8import type { ModelForkResult } from 'claude-code'
9
10import type { KeepaliveMemory, KeepaliveStop, KeepaliveView, Settings, StatuslineFile } from '../types'
11import { TTL_MS, isExpired } from './cache'
12import type { Cache } from './cache'
13import type { Subcommand } from './command'
14import { COLOR } from './style'
15import type { Segment } from './style'
16import { formatTokens } from './usage'
17
18/** 上一次读到缓存之后空闲多久发请求(cache-tax 的 PING_AFTER_MS):离 1 小时过期还有 10 分钟 */
19export const PING_AFTER_MS = 50 * 60 * 1000
20
21/** 一段空闲最多续多久(D3);cache-tax 是用户开的窗口,默认 6 小时 */
22export const IDLE_LIMIT_MS = 12 * 60 * 60 * 1000
23
24/** 只读一遍缓存,回复越短越好:fork 不能设输出上限(cache-tax 的提示词) */
25export const PING_PROMPT = 'Reply with the single word: warm'
26
27export const KEEPALIVE_SUBCOMMAND: Subcommand = { name: 'keepalive', summary: 'Turn prompt-cache keepalive on or off' }
28
29/** 保活循环,在 register.ts 的闭包里;定时器和路上的请求随模块丢掉,其余写进 anvil.keepaliveMemory,热重载后接上 */
30export type Loop = {
31 /** 等着的定时器 */
32 pending: { cancel: () => void } | null
33 /** 续期请求正在路上 */
34 inFlight: boolean
35 /** 这一段空闲里续期成功的次数 */
36 renewals: number
37 stopped: KeepaliveStop | null
38 /** 这一段空闲里用 `/anvil keepalive` 重新开启的时刻,12 小时上限从它算起;0 表示没有 */
39 restartedAt: number
40}
41
42export function freshLoop(): Loop {
43 return { pending: null, inFlight: false, renewals: 0, stopped: null, restartedAt: 0 }
44}
45
46/** 热重载后要接上的那部分循环(写进 `$.state` 的 anvil.keepaliveMemory) */
47export type LoopMemory = KeepaliveMemory
48
49export const loopMemory = (loop: Loop): LoopMemory => ({ renewals: loop.renewals, stopped: loop.stopped, restartedAt: loop.restartedAt })
50
51const isStop = (v: unknown): v is KeepaliveStop => {
52 const stop = v as KeepaliveStop | null
53 return typeof stop === 'object' && stop !== null && ['limit', 'reading', 'error'].includes(stop.kind) && typeof stop.reason === 'string'
54}
55
56/**
57 * 热重载后读回上一次加载写下的循环(anvil.keepaliveMemory):续期次数、停下、重新开启的时刻。
58 * 定时器和路上的请求随旧模块丢了,不接(重新定时由 register.ts 做)。没有或是别的形状就不动,按冷启动。
59 */
60export function restoreLoop(loop: Loop, value: unknown): boolean {
61 const v = value as Partial<Record<keyof LoopMemory, unknown>> | null
62 if (typeof v !== 'object' || v === null || Array.isArray(v)) return false
63 if (typeof v.renewals !== 'number' || typeof v.restartedAt !== 'number' || !(v.stopped === null || isStop(v.stopped))) return false
64 disarm(loop)
65 loop.inFlight = false
66 loop.renewals = v.renewals
67 loop.stopped = v.stopped === null ? null : { kind: v.stopped.kind, reason: v.stopped.reason }
68 loop.restartedAt = v.restartedAt
69 return true
70}
71
72export function disarm(loop: Loop) {
73 loop.pending?.cancel()
74 loop.pending = null
75}
76
77/** 主会话发出请求:用户回来了,上一段空闲的停下和续期次数都清掉(D3:停下只结束这一段空闲) */
78export function startStretch(loop: Loop) {
79 disarm(loop)
80 loop.renewals = 0
81 loop.stopped = null
82 loop.restartedAt = 0
83}
84
85/** 停下后手动重新开启(D3):清掉停下,这一段空闲的 12 小时上限从现在重新算 */
86export function restart(loop: Loop, now: number) {
87 loop.stopped = null
88 loop.restartedAt = now
89}
90
91export const loopView = (loop: Loop): KeepaliveView => ({ stopped: loop.stopped, renewing: loop.inFlight || loop.renewals > 0, inFlight: loop.inFlight })
92
93/** 默认:`/config` 开着自动开启、上下文达到阈值(D3,阈值与守卫共用)。cache-tax 要用户 `/keepwarm` 手动开,这里按阈值自动开 */
94const isAutomatic = (settings: Settings, context: number) => settings.autoKeepalive && context >= settings.threshold
95
96/**
97 * 这个会话现在该不该保活:本会话用 `/anvil keepalive` 切换过(override 有值)就照它,不看自动开启和阈值;
98 * 没切换过按默认。
99 */
100export function isEnabled(settings: Settings, override: boolean | null, context: number): boolean {
101 return override ?? isAutomatic(settings, context)
102}
103
104/**
105 * 这一段空闲的截止时刻:最后一次主会话请求 + 12 小时(续期不算请求,不推后它);
106 * 停下后手动重新开启过,就从开启的时刻算起
107 */
108const deadlineOf = (cache: Cache, loop: Loop) => Math.max(cache.lastRequestAt, loop.restartedAt) + IDLE_LIMIT_MS
109
110/** 上一次读到缓存(主会话请求或续期)之后 50 分钟 */
111const pingAtOf = (cache: Cache) => cache.expiresAt - TTL_MS + PING_AFTER_MS
112
113/** 缓存还没有、已过期、刚压缩完:不发请求,等下一次主会话请求(cache-tax:冷了不发) */
114const isCold = (cache: Cache, now: number) => cache.lastRequestAt === 0 || cache.expiresAt === 0 || cache.compacted || isExpired(cache, now)
115
116export const LIMIT_STOP: KeepaliveStop = { kind: 'limit', reason: 'idle for over 12 hours' }
117
118/**
119 * 定时器该等多久(cache-tax 的 arm):到发请求的时刻,但不晚于过期和这段空闲的截止。
120 * 返回 null 表示不用定时;'limit' 表示这段空闲已到上限,该停下。
121 */
122export function nextDelay(cache: Cache, loop: Loop, now: number): number | 'limit' | null {
123 if (isCold(cache, now)) return null
124 const deadline = deadlineOf(cache, loop)
125 if (now >= deadline) return 'limit'
126 return Math.min(deadline - now, cache.expiresAt - now, Math.max(1000, pingAtOf(cache) - now))
127}
128
129/**
130 * 定时器到点时做什么(cache-tax 的 ping 开头):到上限就停;冷了或还没到发请求的时刻
131 * (定时器停在过期或截止上,或已被新的请求顶替)就重新定时;否则发请求。
132 */
133export function dueAction(cache: Cache, loop: Loop, now: number): 'limit' | 'rearm' | 'ping' {
134 if (cache.lastRequestAt > 0 && now >= deadlineOf(cache, loop)) return 'limit'
135 if (isCold(cache, now)) return 'rearm'
136 if (now < pingAtOf(cache) - 1000) return 'rearm'
137 return 'ping'
138}
139
140/** 读数达标:读到了缓存,写入不到读数的 10%(cache-tax 的判据,spec 沿用) */
141export function isWarm(reply: Extract<ModelForkResult, { isAnswered: true }>): boolean {
142 const { cache_read_input_tokens: read, cache_creation_input_tokens: write } = reply.usage
143 return read > 0 && write < 0.1 * read
144}
145
146/** fork 的结果为什么不算续上;null 表示续上了 */
147export function stopFor(reply: ModelForkResult): KeepaliveStop | null {
148 if (reply.isAnswered) return isWarm(reply) ? null : { kind: 'reading', reason: 'cache had expired by renewal' }
149 switch (reply.reason) {
150 case 'api-error':
151 return { kind: 'error', reason: `renewal failed: ${reply.status === null ? reply.error : `HTTP ${reply.status}`}` }
152 case 'empty-reply':
153 return { kind: 'error', reason: 'renewal got no reply' }
154 case 'aborted':
155 return { kind: 'error', reason: 'renewal was aborted' }
156 case 'nothing-to-fork':
157 return { kind: 'error', reason: 'no conversation to renew yet' }
158 }
159}
160
161export const thrownStop = (error: unknown): KeepaliveStop => ({
162 kind: 'error',
163 reason: `renewal failed: ${error instanceof Error ? error.message : String(error)}`,
164})
165
166/** 续上了:过期时刻以这次请求的时刻顺延(D1);用户在请求路上回来过,就不往回拨 */
167export function renew(cache: Cache, requestedAt: number) {
168 cache.expiresAt = Math.max(cache.expiresAt, requestedAt + TTL_MS)
169}
170
171// ---------------------------------------------------------------- 保活状态
172
173export type StatusInput = { settings: Settings; override: boolean | null; context: number; loop: KeepaliveView }
174
175/**
176 * 按默认的五种:已启用、进行中(这段空闲里续期过或正在续期)、未启用(自动开启已关闭 / 上下文低于阈值)、已停止(原因)。
177 * 本会话切换过的:已关闭(本会话);开着时已启用、进行中带"(本会话)",停下照样写已停止(原因)。
178 * 已停止:空闲到上限为灰,读数不达标和出错为红;其余开着为正文色,没开为灰(颜色现在没人画,留着给以后的显示)。
179 */
180export function keepaliveStatus({ settings, override, context, loop }: StatusInput): Segment {
181 if (override === false) return { text: 'off (this session)', color: COLOR.secondary }
182 if (loop.stopped) return { text: `stopped (${loop.stopped.reason})`, color: loop.stopped.kind === 'limit' ? COLOR.secondary : COLOR.danger }
183 if (override === null && !settings.autoKeepalive) return { text: 'not on (auto-enable is off)', color: COLOR.secondary }
184 if (override === null && context < settings.threshold) return { text: `not on (context below ${formatTokens(settings.threshold)})`, color: COLOR.secondary }
185 const mark = override ? ' (this session)' : ''
186 // 正常在保活是好状态:正文色,不上色(好状态安静,anvil-k0t.46)
187 return { text: `${loop.renewing ? 'renewing' : 'on'}${mark}` }
188}
189
190/**
191 * 写给 statusline 的:会不会自动续期(开着、这一段空闲没停下),续期出错的原因(空闲到上限不算出错)。
192 * statusline 的缓存段据此写"有效 · 自动续期""续期出错"(用户 2026-10-15 定)
193 */
194export function statuslineFile({ settings, override, context, loop }: StatusInput, expiresAt: number | null, at: number): StatuslineFile {
195 const error = loop.stopped && loop.stopped.kind !== 'limit' ? loop.stopped.reason : null
196 return { renews: isEnabled(settings, override, context) && loop.stopped === null, error, expiresAt, at }
197}
198
199/** 读回状态文件里的过期时刻;没有、不是这个形状为 null */
200export function savedExpiry(text: string): number | null {
201 try {
202 const file = JSON.parse(text) as Partial<StatuslineFile> | null
203 return typeof file?.expiresAt === 'number' && file.expiresAt > 0 ? file.expiresAt : null
204 } catch {
205 return null
206 }
207}
208hooks/settings.ts 35 lines1import type { PluginOptions } from 'claude-code'
2
3import type { Settings } from '../types'
4import { parseBand } from './band'
5
6// 默认值只写在插件清单的 userConfig 里:引擎按字段类型校验、填好默认值再交给 register,
7// 不在 options 里的值都按清单默认。这里只把 options 收窄成 Settings,碰到引擎没兑现的值就报错。
8
9function number(options: PluginOptions, key: keyof Settings): number {
10 const value = options[key]
11 if (typeof value !== 'number') throw new Error(`anvil: setting ${key} should be a number, got ${JSON.stringify(value)}`)
12 return value
13}
14
15function boolean(options: PluginOptions, key: keyof Settings): boolean {
16 const value = options[key]
17 if (typeof value !== 'boolean') throw new Error(`anvil: setting ${key} should be a boolean, got ${JSON.stringify(value)}`)
18 return value
19}
20
21function string(options: PluginOptions, key: keyof Settings): string {
22 const value = options[key]
23 if (typeof value !== 'string') throw new Error(`anvil: setting ${key} should be a string, got ${JSON.stringify(value)}`)
24 return value
25}
26
27export function readSettings(options: PluginOptions): Settings {
28 return {
29 threshold: number(options, 'threshold'),
30 autoKeepalive: boolean(options, 'autoKeepalive'),
31 guard: boolean(options, 'guard'),
32 band: parseBand(string(options, 'band')),
33 }
34}
35hooks/shape.ts 114 lines1// 读 `$.state` 时合并到默认值上(照 flightdeck 的 normalize,https://github.com/scasella/claude-flightdeck,
2// MIT License, Copyright (c) 2026 Stephen Casella):热重载不清 `$.state`,上一次加载按旧形状写下的值、
3// 缺字段的值,读出来都补成现在的形状,钩子不因此抛错(anvil-k0t.53)。register.ts 读写 atom 时经 normalized / normalizing。
4//
5// 形状照默认值写:对象按字段合并(多出来的旧字段留着),数字、字符串、布尔类型不对就取默认,
6// 默认值是 null 的字段什么都收;数组默认什么项都收。要细到每一项的,用 maybe、listOf、recordOf、fit。
7
8import { EMPTY_TALLY } from './speed'
9import type { Atom } from 'claude-code'
10
11const MARK = Symbol('shape')
12
13type Marked =
14 | { [MARK]: 'maybe'; shape: unknown }
15 | { [MARK]: 'list'; shape: unknown }
16 | { [MARK]: 'record'; shape: unknown }
17 | { [MARK]: 'fit'; fit: (stored: unknown) => unknown }
18
19/** null,或按里面的形状合并的对象 */
20export const maybe = (shape: object): Marked => ({ [MARK]: 'maybe', shape })
21/** 每一项按形状合并;类型不对的项丢掉 */
22export const listOf = (shape: unknown): Marked => ({ [MARK]: 'list', shape })
23/** 键随意、每个值按形状合并的对象;不是对象的值丢掉 */
24export const recordOf = (shape: object): Marked => ({ [MARK]: 'record', shape })
25/** 自己认:给存着的值,返回现在的形状 */
26export const fit = (fn: (stored: unknown) => unknown): Marked => ({ [MARK]: 'fit', fit: fn })
27
28/** 几个取值之一,认不出就取第一个 */
29export const oneOf = (values: readonly string[]): Marked => fit(stored => (typeof stored === 'string' && values.includes(stored) ? stored : values[0]))
30
31const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
32const isMarked = (v: unknown): v is Marked => isObject(v) && MARK in v
33
34/** 存着的值和形状是不是同一类:对象对对象,其余比 typeof */
35function isKind(shape: unknown, stored: unknown): boolean {
36 const plain = isMarked(shape) && shape[MARK] !== 'fit' ? shape.shape : shape
37 if (isObject(plain)) return isObject(stored)
38 return typeof stored === typeof plain && stored !== null
39}
40
41/** 把存着的值合并到形状给的默认值上;读不到(undefined)时就是默认值 */
42export function conform(shape: unknown, stored: unknown): unknown {
43 if (isMarked(shape)) {
44 switch (shape[MARK]) {
45 case 'maybe':
46 return isObject(stored) ? conform(shape.shape, stored) : null
47 case 'list':
48 return Array.isArray(stored) ? stored.filter(item => isKind(shape.shape, item)).map(item => conform(shape.shape, item)) : []
49 case 'record':
50 return isObject(stored)
51 ? Object.fromEntries(Object.entries(stored).filter(([, value]) => isObject(value)).map(([key, value]) => [key, conform(shape.shape, value)]))
52 : {}
53 case 'fit':
54 return shape.fit(stored)
55 }
56 }
57 if (shape === null) return stored === undefined ? null : stored
58 if (Array.isArray(shape)) return Array.isArray(stored) ? stored : shape
59 if (isObject(shape)) {
60 const from = isObject(stored) ? stored : {}
61 const merged: Record<string, unknown> = { ...from }
62 for (const [key, value] of Object.entries(shape)) merged[key] = conform(value, from[key])
63 return merged
64 }
65 return typeof stored === typeof shape ? stored : shape
66}
67
68// ---------------------------------------------------------------- 各 atom 的形状(types/index.d.ts)
69
70const GIT = {
71 repo: '',
72 worktree: null,
73 sub: '',
74 branch: null,
75 commit: null,
76 noUpstream: false,
77 ahead: 0,
78 behind: 0,
79 conflicts: 0,
80 staged: 0,
81 modified: 0,
82 untracked: 0,
83 stashes: 0,
84 lines: maybe({ added: 0, deleted: 0 }),
85 operation: maybe({ name: '' }),
86}
87
88/** 形状会变、要细到每一项的 atom(按 `$.state` 里的键);其余按 atom 的初始值合并 */
89const SHAPES: Record<string, unknown> = {
90 usage: { rateLimits: listOf({ kind: '', percentUsed: 0 }), costUsd: null },
91 place: { dir: '', git: maybe(GIT) },
92 keepalive: { stopped: maybe({ kind: oneOf(['error', 'limit', 'reading']), reason: '' }), renewing: false, inFlight: false },
93 runningAgents: listOf(''),
94 turnSpeeds: {
95 current: maybe({ turnId: '', tally: EMPTY_TALLY, firstTokenMs: 0 }),
96 lines: recordOf({ tally: EMPTY_TALLY, firstTokenMs: 0, doneAt: 0, waitingAgents: 0 }),
97 },
98}
99
100/** `$.state` 里 anvil 的一个键读到的值,合并成现在的形状;initial 是 atom 的初始值 */
101export function conformState(key: string, initial: unknown, stored: unknown): unknown {
102 return conform(Object.hasOwn(SHAPES, key) ? SHAPES[key] : initial, stored)
103}
104
105/** 读到的值合并成现在的形状:`normalized(place, await read($, place))`(`read` 要在调用处直接写 atom,扫描才认) */
106export function normalized<T>(target: Atom<T>, value: T): T {
107 return conformState(target.ref.key, target.initial, value) as T
108}
109
110/** update 的改法先拿到合并后的值:`update($, place, normalizing(place, current => …))` */
111export function normalizing<T>(target: Atom<T>, change: (value: T) => T): (value: T) => T {
112 return value => change(normalized(target, value))
113}
114hooks/speed.tsx 133 lines1// 每段回复的速度和首字:接在原生收尾那行(TurnDuration)后面。本会话的平均已去掉(用户 2026-10-14 定,面板随之去掉)。
2// token/s 照 .51 探针:首字是响应的第一块(引擎的信封也算),结束是 stop 那块,只算主会话;
3// 思考不出正文,只隔一阵来一个空的 thinking 块,有时整段思考只在结尾来一个,跳过 engine 块会把思考的时间漏掉。
4// 首字等待从请求开始算到首字。一段里几次请求合起来时 token/s 按 token 加权(总输出 ÷ 总生成用时)。
5// 只放不碰 `$` 的纯逻辑和界面组件;记请求时刻在 register.ts。
6
7import type { Elements, RenderElement } from 'claude-code'
8
9import type { SpeedTally, TurnSpeed } from '../types'
10import { formatDuration } from './cache'
11import { cells } from './text'
12
13export const EMPTY_TALLY: SpeedTally = { requests: 0, output: 0, generationMs: 0, firstTokenMs: 0, isLowerBound: false }
14
15// ---------------------------------------------------------------- 速度
16
17/**
18 * 比这还快的不是流出来的,是整段一起到的:.51 探针里真流出来的 Opus 5.5 是 55–107、Haiku 5.5 是 189–247 token/s,
19 * 整段一起到的算出来是 1980–9229
20 */
21const STREAM_CEILING = 1000
22
23/**
24 * 一次请求的读数:生成用时是首字到结束;整段一起到(快过 STREAM_CEILING)或只来了 stop 那块时退回整次用时,
25 * 只会偏低不会偏高,标 isLowerBound。没有结束、没有输出或跨度不为正时算不出
26 */
27export function requestSpeed(output: number | null | undefined, startAt: number, firstAt: number | null, stopAt: number | null): SpeedTally | null {
28 if (output == null || output <= 0 || stopAt === null) return null
29 const first = firstAt ?? stopAt
30 const isStreamed = stopAt > first && output / ((stopAt - first) / 1000) <= STREAM_CEILING
31 const generationMs = isStreamed ? stopAt - first : stopAt - startAt
32 if (generationMs <= 0) return null
33 return { requests: 1, output, generationMs, firstTokenMs: first - startAt, isLowerBound: !isStreamed }
34}
35
36/**
37 * 合起来:次数和首字都加。token/s 只用量得出流速的请求:一边是下限、一边量得出时只留量得出的那边,
38 * 平均不会因为一次整段一起到的请求就一直标 ≥(用户 2026-10-14 反馈);都是下限时才按下限合计
39 */
40export function addTally(a: SpeedTally, b: SpeedTally): SpeedTally {
41 const counted = (one: SpeedTally) => one.requests > 0 && one.generationMs > 0
42 const rate = !counted(a) ? b : !counted(b) ? a : a.isLowerBound === b.isLowerBound ? null : a.isLowerBound ? b : a
43 return {
44 requests: a.requests + b.requests,
45 output: rate ? rate.output : a.output + b.output,
46 generationMs: rate ? rate.generationMs : a.generationMs + b.generationMs,
47 firstTokenMs: a.firstTokenMs + b.firstTokenMs,
48 isLowerBound: rate ? rate.isLowerBound : a.isLowerBound,
49 }
50}
51
52/** 按 token 加权:总输出 ÷ 总生成用时,不是把每次的速度再平均;还没有请求时为 null */
53export function tokensPerSecond(tally: SpeedTally): number | null {
54 return tally.requests > 0 && tally.generationMs > 0 ? Math.round(tally.output / (tally.generationMs / 1000)) : null
55}
56
57/** 速度的数字;计入的请求都用了退路时前面标 ≥(照 coralline) */
58function speedNumber(tally: SpeedTally): string | null {
59 const speed = tokensPerSecond(tally)
60 return speed === null ? null : `${tally.isLowerBound ? '≥' : ''}${speed}`
61}
62
63/** 秒数:不到 60 秒一位小数(12.7s),60 秒起写 1m35s */
64export function formatSeconds(ms: number): string {
65 const tenths = Math.round(Math.max(0, ms) / 100)
66 return tenths < 600 ? `${(tenths / 10).toFixed(1)}s` : formatDuration(ms)
67}
68
69// ---------------------------------------------------------------- 每段回复末尾
70
71/**
72 * 原生收尾那行的时长写法(Claude Code 2.1.299 的 formatDuration,不带 hideTrailingZeros):
73 * 不到 1 分钟按整秒往下取(4s),往上 1m 4s、1h 2m 3s、1d 2h 3m,秒数四舍五入
74 */
75export function engineDuration(ms: number): string {
76 if (ms < 60_000) return ms === 0 ? '0s' : ms < 1 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 1000)}s`
77 let days = Math.floor(ms / 86_400_000)
78 let hours = Math.floor((ms % 86_400_000) / 3_600_000)
79 let minutes = Math.floor((ms % 3_600_000) / 60_000)
80 let seconds = Math.round((ms % 60_000) / 1000)
81 if (seconds === 60) (seconds = 0), (minutes += 1)
82 if (minutes === 60) (minutes = 0), (hours += 1)
83 if (hours === 24) (hours = 0), (days += 1)
84 if (days > 0) return `${days}d ${hours}h ${minutes}m`
85 if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
86 return `${minutes}m ${seconds}s`
87}
88
89const two = (n: number) => String(n).padStart(2, '0')
90
91/** 本地时刻 HH:MM:原生那行的"done 15:43" */
92export function clockTime(at: number): string {
93 const date = new Date(at)
94 return `${two(date.getHours())}:${two(date.getMinutes())}`
95}
96
97/**
98 * 原生收尾那行画出的字(图标之后):`Baked for 1m 4s · done 15:43`;
99 * 收尾时还有子 agent 在跑,写成 `Waiting for 1 background agent to finish`
100 */
101export function engineTurnText(props: { word: string; durationMs: number }, turn: Pick<TurnSpeed, 'doneAt' | 'waitingAgents'>): string {
102 const count = turn.waitingAgents
103 if (count > 0) return `Waiting for ${count} background ${count === 1 ? 'agent' : 'agents'} to finish`
104 return `${props.word} for ${engineDuration(props.durationMs)} · done ${clockTime(turn.doneAt)}`
105}
106
107/** 接在原生那行后面的字:` · N token/s · TTFT Ns`,这一段的请求都用了退路时 token/s 前标 ≥ */
108export function turnSuffix(turn: TurnSpeed): string {
109 const speed = speedNumber(turn.tally)
110 return `${speed === null ? '' : ` · ${speed} token/s`} · TTFT ${formatSeconds(turn.firstTokenMs)}`
111}
112
113/** 原生那行的图标占 2 格(minWidth 2),字从第 3 格起 */
114const ENGINE_ICON_CELLS = 2
115
116/**
117 * 原生收尾那行接上这一段的读数。原生那行宽 100%、拿不到它画了多宽,引擎也不许把它放进带 width 的 Box:
118 * 读数用绝对定位压在它的最后一行上,起点按原生的写法算出来(anvil-k0t.52 伪终端实测)
119 */
120export function turnDurationRow(els: Pick<Elements['terminal'], 'Box' | 'Text'>, native: RenderElement, props: { word: string; durationMs: number }, turn: TurnSpeed): RenderElement {
121 const { Box, Text } = els
122 return (
123 <Box>
124 {native}
125 <Box position="absolute" bottom={0} left={ENGINE_ICON_CELLS + cells(engineTurnText(props, turn))}>
126 <Text dimColor wrap="truncate">
127 {turnSuffix(turn)}
128 </Text>
129 </Box>
130 </Box>
131 )
132}
133hooks/usage.ts 187 lines1// 输入框上方那一行的 5h、7d 和花费:读数、格式和 5h/7d 的读数存档。读数的收集在 register.ts,画在 band.tsx。
2
3import type { RateLimit, Usage } from '../types'
4import { COLOR, ICON, bar, levelColor } from './style'
5import type { Segment } from './style'
6
7export const EMPTY_USAGE: Usage = { rateLimits: [], costUsd: null }
8
9/** 引擎的一次读数(`$.session.usage()` 或 `session.measure`)里要的部分 */
10export type Reading = {
11 rateLimits: readonly RateLimit[]
12 cost?: { usd: number }
13}
14
15/** 读数里没有 5h、7d(开局还没有响应)时留着原来的:可能是存档里最近的(storedLimits) */
16export function applyReading(usage: Usage, reading: Reading): Usage {
17 return {
18 ...usage,
19 rateLimits: reading.rateLimits.length === 0 ? usage.rateLimits : reading.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
20 costUsd: reading.cost?.usd ?? usage.costUsd,
21 }
22}
23
24// ---------------------------------------------------------------- 格式
25
26/** 152k、1.2M;不到一千原样。快到一百万时写 1M,不写 1000k */
27export function formatTokens(tokens: number): string {
28 if (tokens >= 999_500) return `${Number((tokens / 1_000_000).toFixed(1))}M`
29 if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`
30 return String(Math.round(tokens))
31}
32
33/** $0.31、$123 */
34export function formatUsd(usd: number): string {
35 return usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`
36}
37
38const two = (n: number) => String(n).padStart(2, '0')
39const DAY = 86_400_000
40
41/** 本地时刻,一律绝对时刻:当天写 HH:MM,不是当天写 HH:MM MM-DD(时间在前);不写星期、"明天" */
42export function formatWhen(at: number, now: number): string {
43 const date = new Date(at)
44 const hhmm = `${two(date.getHours())}:${two(date.getMinutes())}`
45 return date.toDateString() === new Date(now).toDateString() ? hhmm : `${hhmm} ${two(date.getMonth() + 1)}-${two(date.getDate())}`
46}
47
48/** 重置时刻:ISO 8601;也认 Unix 秒、毫秒(statusline 见过这几种) */
49export function parseResetsAt(resetsAt: string | undefined): number | null {
50 if (resetsAt === undefined || resetsAt === '') return null
51 if (/^\d+(\.\d+)?$/.test(resetsAt)) {
52 const n = Number(resetsAt)
53 return n > 1e12 ? n : n * 1000
54 }
55 const ms = Date.parse(resetsAt)
56 return Number.isNaN(ms) ? null : ms
57}
58
59// ---------------------------------------------------------------- 读数存档
60
61// 5h、7d 的读数记进 `$.store`(本机会话共用):本会话还没有读数时先拿存档里最近的画(storedLimits)。
62// 7d 记 total 是给以后的用完预测留的(预测先不做,用户 2026-10-14 定;算法见 git 历史里的 emptyAt)。
63
64/**
65 * 一个读数:时刻(`$.clock.now()` 的毫秒)和当时的百分比;7d 再记 total,跨重置连续的累计用量
66 * (窗口起点的 base + 百分比)
67 */
68export type Sample = { at: number; percent: number; total?: number }
69
70/**
71 * 读数存档(`$.store`,本机会话共用):当前窗口(重置时刻),读数按时刻排。5h 只存当前窗口的;
72 * 7d 跨重置留着,base 是当前窗口起点的累计用量
73 */
74export type History = { resetsAt: number; base?: number; samples: Sample[] }
75
76/** 各窗口的存档 */
77export type Histories = Partial<Record<'five_hour' | 'seven_day', History>>
78
79/** 存档按窗口分键 */
80export const HISTORY_KEYS = { five_hour: 'usageHistory.five_hour', seven_day: 'usageHistory.seven_day' } as const
81
82/**
83 * 存档留多久(keepMs,再加那之前最近的一个);分桶(bucketMs),一个桶只留最后一个读数。
84 * 7d 留 8 天:照上周同一时段重演要 7 天前的读数
85 */
86export const TREND = {
87 five_hour: { keepMs: 3_600_000, bucketMs: 60_000 },
88 seven_day: { keepMs: 8 * DAY, bucketMs: 5 * 60_000 },
89} as const
90
91/** 重置时刻晚了这么多才算新窗口:resetsAt 有小抖动时不反复清空存档 */
92export const NEW_WINDOW_MS = 10 * 60_000
93
94/** 存档是不是这个窗口的:重置时刻比存档里记的晚 NEW_WINDOW_MS 以上才算新窗口 */
95export function sameWindow(history: History, resetsAt: number): boolean {
96 return resetsAt - history.resetsAt < NEW_WINDOW_MS
97}
98
99/** 7d 的读数补上 total:旧存档没记的按它自己的 base 补 */
100const withTotals = (history: History): Sample[] =>
101 history.samples.map(one => (one.total === undefined ? { ...one, total: (history.base ?? 0) + one.percent } : one))
102
103/** 窗口 resetsAt 起点的累计用量:同一窗口沿用存档的 base;新窗口在 base 上加旧窗口最后一个读数的百分比 */
104function windowBase(history: History | null, resetsAt: number): number {
105 if (history === null) return 0
106 const base = history.base ?? 0
107 return sameWindow(history, resetsAt) ? base : base + (history.samples.at(-1)?.percent ?? 0)
108}
109
110/**
111 * 记下一个读数:同一窗口沿用存档里记的重置时刻;同一桶只留最后一个。5h 新窗口从空存档起;7d 不清空,
112 * 读数记上 total(base + 百分比),新窗口的 base 接上旧窗口。只留时刻晚于 at − keep 的读数,加上 at − keep 之前
113 * 最近的一个,和本窗口第一个 ≥ 100% 的读数(用满的时刻)
114 */
115export function recordSample(history: History | null, kind: keyof typeof TREND, resetsAt: number, sample: Sample): History {
116 const { keepMs, bucketMs } = TREND[kind]
117 const bucket = (at: number) => Math.floor(at / bucketMs)
118 const same = history !== null && sameWindow(history, resetsAt)
119 const seven = kind === 'seven_day'
120 const base = seven ? windowBase(history, resetsAt) : 0
121 const kept = history === null ? [] : seven ? withTotals(history) : same ? history.samples : []
122 const entry = seven ? { ...sample, total: base + sample.percent } : sample
123 const samples = [...kept.filter(one => bucket(one.at) !== bucket(entry.at)), entry].sort((a, b) => a.at - b.at)
124 const since = entry.at - keepMs
125 const before = samples.filter(one => one.at <= since).at(-1)
126 const pruned = [...(before ? [before] : []), ...samples.filter(one => one.at > since)]
127 const full = firstFull([...kept, entry].sort((a, b) => a.at - b.at), base)
128 const rest = full === undefined || pruned.includes(full) ? pruned : [full, ...pruned].sort((a, b) => a.at - b.at)
129 return { resetsAt: same && history !== null ? history.resetsAt : resetsAt, ...(seven ? { base } : {}), samples: rest }
130}
131
132/** 本窗口第一个 ≥ 100% 的读数:7d 按 total − base 算本窗口的百分比,上个窗口的读数不算 */
133const firstFull = (samples: readonly Sample[], base: number) => samples.find(one => (one.total ?? one.percent) - base >= 100)
134
135/** `$.store` 里读回来的存档;不是存档的样子就当没有 */
136export function parseHistory(value: unknown): History | null {
137 if (value === null || typeof value !== 'object') return null
138 const { resetsAt, base, samples } = value as { resetsAt?: unknown; base?: unknown; samples?: unknown }
139 if (typeof resetsAt !== 'number' || !Array.isArray(samples) || (base !== undefined && typeof base !== 'number')) return null
140 const valid = samples.every(one => typeof one?.at === 'number' && typeof one?.percent === 'number' && (one.total === undefined || typeof one.total === 'number'))
141 if (!valid) return null
142 return base === undefined ? { resetsAt, samples: samples as Sample[] } : { resetsAt, base, samples: samples as Sample[] }
143}
144
145/**
146 * 本会话还没有读数时(引擎的读数来自本进程最近一次 API 响应,开局、`/clear` 后要等一会儿)
147 * 用存档里最近的读数:存档本机会话共用,窗口没过重置时它就是已知最新的用量
148 */
149export function storedLimits(histories: Histories, now: number): RateLimit[] {
150 return (['five_hour', 'seven_day'] as const).flatMap(kind => {
151 const history = histories[kind]
152 const last = history?.samples.at(-1)
153 if (history === undefined || last === undefined || history.resetsAt <= now) return []
154 return [{ kind, percentUsed: last.percent, resetsAt: new Date(history.resetsAt).toISOString() }]
155 })
156}
157
158// ---------------------------------------------------------------- 画在那一行里的段
159
160const LABELS = { five_hour: '5h', seven_day: '7d' } as const
161
162/** 进度条 10 格(用户 2026-10-14 定,和 statusline 的一样长) */
163export const BAR_CELLS = 10
164
165/**
166 * 5h 或 7d:`5h ━━━━━───── 41% 21:40`。重置时刻一直显示(用户定);没有读数、或窗口已过重置时刻
167 * (下一条消息才开始新窗口)写 0% 和 —。withBar 为 false 时不画进度条(窄屏)
168 */
169export function limitSegments(kind: 'five_hour' | 'seven_day', limits: readonly RateLimit[], now: number, withBar = true): Segment[] {
170 const limit = limits.find(one => one.kind === kind)
171 const resetsAt = parseResetsAt(limit?.resetsAt)
172 const isCurrent = limit !== undefined && resetsAt !== null && resetsAt > now
173 const percent = isCurrent ? Math.min(100, Math.max(0, limit.percentUsed)) : 0
174 return [
175 { text: `${LABELS[kind]} `, color: COLOR.secondary },
176 ...(withBar ? [...bar(percent, BAR_CELLS), { text: ' ' }] : []),
177 { text: `${Math.round(percent)}%`, color: levelColor(percent) },
178 { text: ` ${ICON.reset} `, color: COLOR.icon },
179 { text: isCurrent ? formatWhen(resetsAt, now) : '—', color: COLOR.secondary },
180 ]
181}
182
183/** 花费:本会话总额(引擎报的会话花费,`/cost` 同源);还没有读数时不画 */
184export function costSegments(usage: Usage): Segment[] {
185 return usage.costUsd === null ? [] : [{ text: formatUsd(usage.costUsd), color: COLOR.cost }]
186}
187hooks/style.ts 103 lines1// 输入框上方那一行(band.tsx)和每段回复末尾的读数共用的样式:颜色、图标、热度进度条、按格截断。
2
3import { gradient } from './color'
4import { cells, fit } from './text'
5
6/** 一段同色文字;一行由几段拼成。color 不给时是终端的前景色(正文) */
7export type Segment = { text: string; color?: string; bold?: boolean }
8
9/**
10 * 色板。正文不设颜色,用终端的前景色,和对话里的字同一种白;中性色三级:次要文字、图标与分隔符、进度条的底。
11 * band 画在终端自己的底色上(用户的 Dracula #282a36):次要 5.6:1,图标 3.7:1。
12 * 和用户的 statusline(`~/.claude/statusline.mjs`)同一套状态色、分支色,上下两行看着是一家。
13 */
14export const COLOR = {
15 /** 次要文字:标签、重置时刻、计数里不要紧的(?、储藏、无上游) */
16 secondary: '#9da3b0',
17 /** 图标和分隔符 `·`:不是文字,比次要文字再暗一级 */
18 icon: '#7a8194',
19 /** 进度条的底:比图标再暗,填上的那段热度渐变才显得出来 */
20 track: '#4a4d54',
21 // 状态色:进度条热度、百分比、git 计数;一种颜色一个意思
22 ok: '#8fbf7f',
23 warn: '#d7bd6f',
24 high: '#d79a6f',
25 danger: '#e07a7a',
26 /** 目录图标:一行的开头,给个识别色,不和图标的灰混在一起 */
27 dir: '#82aadf',
28 /** 分支(git 的识别色,statusline 同色) */
29 branch: '#b18bd0',
30 /** 花费(statusline 同色) */
31 cost: '#7fbfae',
32} as const
33
34/** Nerd Font 图标(statusline 同一套字形);图标后一律留一个空格 */
35export const ICON = {
36 dir: '\u{F07C}',
37 branch: '\u{F418}',
38 detached: '\u{F417}',
39 worktree: '\u{F402}',
40 stash: '\u{F51E}',
41 lines: '\u{F440}',
42 pause: '\u{F04C}',
43 /** 重置时刻 */
44 reset: '\u{F0450}',
45} as const
46
47export const SEPARATOR: Segment = { text: ' · ', color: COLOR.icon }
48
49/** 百分比的四档:低于 60% 绿,60–80% 黄,80–90% 橙,90% 以上红 */
50export function levelColor(percent: number): string {
51 if (percent >= 90) return COLOR.danger
52 if (percent >= 80) return COLOR.high
53 if (percent >= 60) return COLOR.warn
54 return COLOR.ok
55}
56
57/**
58 * 进度条的热度(学 btop):每一格按它在条上的位置取色,45% 以前绿,往后依次过渡到黄、橙、红,
59 * 到 60%、80%、90% 正好是四档的颜色;填到哪,最后一格的颜色就和百分比的颜色一致
60 */
61export const HEAT = [
62 [0, COLOR.ok],
63 [45, COLOR.ok],
64 [60, COLOR.warn],
65 [80, COLOR.high],
66 [90, COLOR.danger],
67 [100, COLOR.danger],
68] as const
69
70/** 定长的进度条:已用部分 `━` 按位置取热度色,其余 `─` 是暗底;相邻同色的格子并成一段 */
71export function bar(percent: number, width: number): Segment[] {
72 const clamped = Math.min(100, Math.max(0, percent))
73 const filled = Math.round((clamped / 100) * width)
74 const out: Segment[] = []
75 for (let i = 0; i < filled; i += 1) {
76 const color = gradient(HEAT, ((i + 0.5) / width) * 100)
77 const last = out[out.length - 1]
78 if (last && last.color === color) last.text += '━'
79 else out.push({ text: '━', color })
80 }
81 if (width > filled) out.push({ text: '─'.repeat(width - filled), color: COLOR.track })
82 return out
83}
84
85/** 几段字的格数 */
86export const widthOf = (segments: readonly Segment[]) => cells(segments.map(segment => segment.text).join(''))
87
88/** 一行几段文字截到 room 格以内:放不下的那段截短、末尾换成 `…`,后面的丢掉 */
89export function fitSegments(list: readonly Segment[], room: number): Segment[] {
90 const kept: Segment[] = []
91 let left = room
92 for (const segment of list) {
93 const size = cells(segment.text)
94 if (size > left) {
95 if (left > 1) kept.push({ ...segment, text: fit(segment.text, left) })
96 break
97 }
98 kept.push(segment)
99 left -= size
100 }
101 return kept
102}
103hooks/text.ts 62 lines1// 终端格子宽度:中日韩文字和全角符号占 2 格,其余(含 Nerd Font 图标)占 1 格。
2// 输入框上方那一行按格子排版、截断,收尾那行的读数按它算起点,不交给引擎去裁。
3
4const WIDE: readonly (readonly [number, number])[] = [
5 [0x1100, 0x115f], // 韩文字母
6 [0x2e80, 0x303e], // 中日韩部首、标点
7 [0x3041, 0x33ff], // 假名、中日韩符号
8 [0x3400, 0x4dbf], // 扩展 A
9 [0x4e00, 0x9fff], // 基本汉字
10 [0xa000, 0xa4cf], // 彝文
11 [0xac00, 0xd7a3], // 韩文音节
12 [0xf900, 0xfaff], // 兼容汉字
13 [0xfe30, 0xfe4f], // 兼容形式
14 [0xff00, 0xff60], // 全角 ASCII
15 [0xffe0, 0xffe6], // 全角符号
16 [0x20000, 0x3fffd], // 扩展 B 及以后
17]
18
19function cellsOf(codePoint: number): number {
20 return WIDE.some(([from, to]) => codePoint >= from && codePoint <= to) ? 2 : 1
21}
22
23/** 字符串在终端里占的格数 */
24export function cells(text: string): number {
25 let total = 0
26 for (const char of text) total += cellsOf(char.codePointAt(0) ?? 0)
27 return total
28}
29
30/** 超出 width 格时截到 width 格以内,末尾换成 `…`;不超出原样返回 */
31export function fit(text: string, width: number): string {
32 if (cells(text) <= width) return text
33 if (width <= 0) return ''
34 let kept = ''
35 let used = 0
36 for (const char of text) {
37 const next = cellsOf(char.codePointAt(0) ?? 0)
38 if (used + next > width - 1) break
39 kept += char
40 used += next
41 }
42 return `${kept}…`
43}
44
45/**
46 * 超出 width 格时从前面截,留下结尾、开头换成 `…`;不超出原样返回。
47 * 分支、worktree 名区分彼此的部分多在结尾(task/anvil-k0t.46 → …k0t.46)
48 */
49export function fitTail(text: string, width: number): string {
50 if (cells(text) <= width) return text
51 if (width <= 1) return '…'
52 let kept = ''
53 let used = 0
54 for (const char of [...text].reverse()) {
55 const next = cellsOf(char.codePointAt(0) ?? 0)
56 if (used + next > width - 1) break
57 kept = char + kept
58 used += next
59 }
60 return `…${kept}`
61}
62hooks/color.ts 78 lines1// 真彩颜色的计算:在 OKLab 里插值,渐变不发灰、亮度均匀。进度条的热度用它;statusline 脚本里有同样的一份。
2
3type Lab = readonly [number, number, number]
4
5const parse = (hex: string): [number, number, number] => {
6 const h = hex.replace('#', '')
7 return [Number.parseInt(h.slice(0, 2), 16), Number.parseInt(h.slice(2, 4), 16), Number.parseInt(h.slice(4, 6), 16)]
8}
9
10const toLinear = (v: number) => {
11 const c = v / 255
12 return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
13}
14
15const toGamma = (v: number) => {
16 const c = Math.min(1, Math.max(0, v))
17 return 255 * (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)
18}
19
20const cache = new Map<string, Lab>()
21
22function lab(hex: string): Lab {
23 const hit = cache.get(hex)
24 if (hit) return hit
25 const [r, g, b] = parse(hex).map(toLinear) as [number, number, number]
26 const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
27 const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
28 const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
29 const out: Lab = [
30 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
31 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
32 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
33 ]
34 cache.set(hex, out)
35 return out
36}
37
38function hex(value: Lab): string {
39 const [L, a, b] = value
40 const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
41 const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
42 const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3
43 const rgb = [
44 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
45 -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
46 -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
47 ]
48 return `#${rgb.map(v => Math.round(toGamma(v)).toString(16).padStart(2, '0')).join('')}`
49}
50
51/** 渐变的档数:插值的比例取到 1/16,相邻同色的格子并成一段,一条进度条只有几个节点 */
52export const LEVELS = 16
53
54const quantize = (t: number) => Math.round(Math.min(1, Math.max(0, t)) * LEVELS) / LEVELS
55
56/** a 到 b 的 t 处(0 到 1,取到 1/16 档) */
57export function mix(a: string, b: string, t: number): string {
58 const k = quantize(t)
59 if (k === 0) return a
60 if (k === 1) return b
61 const A = lab(a)
62 const B = lab(b)
63 return hex([A[0] + (B[0] - A[0]) * k, A[1] + (B[1] - A[1]) * k, A[2] + (B[2] - A[2]) * k])
64}
65
66/** 多段渐变:stops 的位置从小到大 */
67export function gradient(stops: readonly (readonly [number, string])[], at: number): string {
68 const first = stops[0]
69 if (!first) return '#000000'
70 if (at <= first[0]) return first[1]
71 for (let i = 1; i < stops.length; i += 1) {
72 const [p0, c0] = stops[i - 1] as readonly [number, string]
73 const [p1, c1] = stops[i] as readonly [number, string]
74 if (at <= p1) return mix(c0, c1, p1 > p0 ? (at - p0) / (p1 - p0) : 1)
75 }
76 return (stops[stops.length - 1] as readonly [number, string])[1]
77}
78