SLOPSHOPPER

anvil

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…

newbandspinnerguardcommandprompt
v0.4.1MITupdated 2026-10-15Mistakey/anvil
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · anvil
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /anvil ⎿ anvil: Usage: /anvil <subcommand>. Each run toggles it, for this session only ⎿ anvil: /anvil keepalive Turn prompt-cache keepalive on or off (now: on) app · 5h ━━━─────── 31% 󰑐 09:53 · 7d ────────── 0% 󰑐 — · $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app · 5h ━━━─────── 31% 󰑐 09:53 · 7d ────────── 0% 󰑐 — · $0.42
README

anvil

License: MIT Claude Code 2.1.296+

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>

Features

  • Cache keepalive. When a session with a large context sits idle, anvil sends one background request shortly before the prompt cache expires. The request reads the cache, stays out of the transcript, and keeps your next message from paying for a full rebuild. It runs for up to 12 hours per idle stretch.
  • Cold-send guard. If the cache has already expired and the context is large, anvil holds back your next message once and tells you what sending it will cost (tokens and an API-price estimate). Press Enter again to send it, or /clear to start fresh.
  • A line above the prompt. Directory · git · 5-hour usage · 7-day usage · session cost. Git shows the branch with ahead/behind, a paused merge or rebase, conflicts, staged, modified, untracked, stashes and uncommitted lines. Each usage window has a 10-cell heat bar, the percentage and its reset time.
  • Each reply's speed. Output tokens per second and time to first token (TTFT) are added to the end of Claude Code's "Worked for …" line.

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.

Requirements

  • Claude Code 2.1.296 or later (tested on 2.1.299). Mods are an early-access Claude Code feature and their API can change between releases.
  • A Nerd Font in your terminal, for the icons.
  • A Claude subscription (Pro or Max) is what the keepalive is designed for: anvil assumes the main session's 1-hour cache TTL, which only holds on a subscription.

Install

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.

Getting started

1. Read the line above the prompt

PartShows
directorythe repository's name (bold), a linked worktree's name, the subdirectory you're in; outside git, the session's directory
gitbranch, ↑ ahead and ↓ behind, no upstream, a paused merge or rebase 2/5, = conflicts, + staged, ! modified, ? untracked, stashes, uncommitted +lines −lines
5h, 7da 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
costthe 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.

2. Commands

CommandDoes
/anvillist the subcommands and their current state
/anvil keepaliveturn the keepalive off or on for this session, regardless of the threshold

A new session starts from the defaults you set in /config.

How the keepalive works

  • anvil estimates the cache's expiry itself: the last main-session request plus 1 hour.
  • When a session's context reaches the threshold (50k tokens by default) and it goes idle, anvil waits 50 minutes and sends one background request ($.model.fork) that asks for a one-word reply. It doesn't enter your transcript.
  • Each request checks its own usage: if it read close to the whole context and wrote little, the cache counts as renewed and the next request is scheduled. Otherwise the keepalive stops: no cache read, too much written, an error, or 12 hours idle. /anvil shows why.
  • Stopping only ends the current idle stretch. Once you send a message, the next idle stretch is kept warm again.
  • Each request costs about one read of the whole context plus a few output tokens, counted against your plan. Claude Code has to stay open.
  • Quitting Claude Code stops the keepalive. When you resume the session later (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.

How the guard works

  • When the cache has expired and the context is at or above the threshold, anvil drops your next message once, puts it back in the prompt, and shows a line like: anvil: the cache expired 1h10m ago; sending this message rebuilds 152k tokens (~$3.04). …
  • Press Enter again to send it, or use /clear if you don't need the old context.
  • Slash commands and messages sent by plugins pass straight through. Nothing is blocked right after a compaction or a /clear.

The line below the prompt

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.

Configure

Run /config and open anvil's options:

OptionDefaultMeaning
threshold50000context size in tokens at which the keepalive turns on by itself and the guard starts guarding
autoKeepalivetrueturn the keepalive on by itself for sessions at or above the threshold; /anvil keepalive still toggles it per session
guardtruehold back the first message after the cache has expired
banddir, git, 5h, 7d, costthe 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

What it touches

It doesThrough
reads session events: requests and their usage, subagents starting and ending, tool calls, messages you sendthe 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.

FAQ

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.

Development

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.

Acknowledgements

  • Flightdeck by Stephen Casella: the module layout and merging stored state onto its defaults.
  • cache-tax by Karan Bansal: the cache estimate, the keepalive loop and the cold-send guard.

Both are MIT-licensed. THIRD_PARTY_NOTICES.md lists what anvil adapted from each and reproduces their licenses.

License

MIT

Source 13 files
hooks/register.ts 642 lines
1// 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}
642
hooks/band.tsx 292 lines
1// 输入框上方那一行(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}
292
hooks/cache.ts 223 lines
1// 缓存状态(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}
223
hooks/command.ts 46 lines
1// `/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}
46
hooks/keepalive.ts 208 lines
1// 缓存保活(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}
208
hooks/settings.ts 35 lines
1import 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}
35
hooks/shape.ts 114 lines
1// 读 `$.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}
114
hooks/speed.tsx 133 lines
1// 每段回复的速度和首字:接在原生收尾那行(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}
133
hooks/usage.ts 187 lines
1// 输入框上方那一行的 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}
187
hooks/style.ts 103 lines
1// 输入框上方那一行(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}
103
hooks/text.ts 62 lines
1// 终端格子宽度:中日韩文字和全角符号占 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}
62
hooks/color.ts 78 lines
1// 真彩颜色的计算:在 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