SLOPSHOPPER

cache-tax

A Claude Code mod that keeps the one-hour prompt cache warm during breaks and holds a cold send, after a break or a model change, on a card with its estimated…

newbandcommandstatuspromptmodel
★ 2v?MITupdated 2026-10-13partalemre/MODZ/mods/cache-tax
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-tax
› 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 › /keepwarm ⎿ cache-tax: keepwarm on for 6h: 50m into every idle stretch, a ping reads the cache without rewriting it ⟨Claude Code's own drawing⟩ ▣ [>] Cache · warm · keepwarm 6h left · ping in 50m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▣ [>] Cache · warm · keepwarm 6h left · ping in 50m
README

cache-tax

Keeps Claude Code's one-hour prompt cache warm while you take a break, and holds a cold send on a card that shows what rewriting the cache would cost.

Install

/plugin marketplace add partalemre/MODZ
/plugin install cache-tax@modz

Or in one step: /plugin install cache-tax --marketplace partalemre/MODZ.

What you see

A held send. If the cache has gone cold and the context is at least 50,000 tokens, your next ordinary message is held once. A card above the prompt shows when the cache went cold, what sending now would cost (the rewrite, as an upper bound) against what a warm turn would have cost, and the held message. Three buttons:

  • Pay and send (p) sends your held message as written and turns keepwarm on for 3 hours.
  • Start fresh (n) runs /clear and puts the held message back in the prompt box.
  • Cache status (s) runs /cache-tax.

Sending the same message again also pays. Slash commands are never held. Without a band to draw in, the hold is a one-line refusal instead.

A model change. A prompt cache belongs to the model that wrote it, so switching models (Opus 5 to Sonnet 5, say) makes the next send rewrite the whole context even when the clock says warm. When the session's model differs from the one the last request used, the same card holds the send with the reason "Model changed (Opus 5 → Sonnet 5): the cache will be rewritten" and the rewrite priced at the new model. The same rules apply: 50,000 tokens or more, held once, /cache-tax guard warn only shows the price. Switching back before sending takes the card down. The first prompt of a session has no earlier request to compare with, so only the clock guards it. A model name the mod cannot read as a family and version (an alias such as default) never counts as a change.

A status row at the bottom of the band, shown only when it means something: keepwarm is on or has stopped, the cache is cold, or a large cache goes cold within 10 minutes. It says warm, cold, unknown or stopped in words, so colour is never the only signal.

Warming. /keepwarm opens a bounded window. After 50 idle minutes inside it, one tool-less request over the session's transcript refreshes the cache. After each ping the mod reads the usage back and stops if the ping read nothing or wrote at least a tenth of what it read. A window belongs to one session and ends at its deadline.

Commands

/keepwarm               keep warm for six hours
/keepwarm 90m           a window of your own (also 2h30m, 6h)
/keepwarm always        a six-hour window at every session start, remembered
/keepwarm 6h every 2m   ping every two minutes (testing knob, minimum 1m, this window only)
/keepwarm status        the window, next ping and last receipt
/keepwarm off           stop, and turn always off
/cache-tax              model, cache state, cold price, keepwarm, guard and this session's cold writes
/cache-tax guard refuse hold a cold send once (default)
/cache-tax guard warn   show the price and send

Settings

There are no plugin options. The guard mode and the always switch are set with the commands above and remembered across sessions.

Cost

This mod calls the model. Each keepwarm ping is a real request: it bills a cache read, a few uncached tokens and whatever the model answers, with no output cap. The prices shown are estimates from list prices, not a measurement from the server, and on a subscription they are a yardstick rather than a bill. A detected cold write arms at least three hours of keepwarm. The held-send card itself costs nothing.

Limits

  • The 50-minute schedule needs a one-hour main cache. Included subscription usage has it by default; for API-key, usage-credit and cloud-provider sessions set promptCacheTtl to "1h", because a 50-minute timer cannot protect a five-minute cache.
  • The cold or warm state is an estimate from a one-hour clock, not a live check of the server's cache.
  • The refusal's token count is an upper bound: the engine's context figure includes the last response's output.
  • A ping that reads warm only proves the cache was warm then. Changes to the prompt prefix can invalidate the cache regardless of time.
  • The cold-write tally is per session, held in memory, and /clear empties it.
  • Claude Code must stay running. Mods need Claude Code 2.1.287 or later.
  • If the separate hook version (cache-tax@claude-code-hooks) is installed too, cold sends are guarded twice. The mod says so once at session start; keep one.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish. Command names and arguments stay English.

Credits

Adapted from karanb192/cache-tax (v2.2.1), licensed MIT. The license file is in this folder.

Source 22 files
hooks/register.ts 278 lines
1import type { EngineInterface, On, Register, TurnCompleteInput } from 'claude-code'
2
3import { composeBand } from './band'
4import { pinned } from './pin'
5import type { Prefs } from './band'
6import { AUTO_WARM_MS, DEFAULT_WINDOW_MS, MIN_MS, MIN_PING_MS, PING_AFTER_MS, freshState, isFullMiss, parseDuration, priceOf, resetForClear } from './core'
7import type { State } from './core'
8import { NAME, onPrompt } from './guard'
9import type { GuardIo } from './guard'
10import type { HoldActions } from './hold'
11import { bandKey } from './row'
12import { seedFromResume } from './session'
13import { isLightTheme } from './theme'
14import { KEY_ALWAYS, KEY_GUARD, arm, deadlineKey, everyKey, prune, startWindow, stop, stopForGood, updateStatus } from './warm'
15import type { Io } from './warm'
16import { setLang } from './lang'
17import { alwaysText, armedText, cardText, commands, paidText, say, statusText } from './words'
18
19export { fmtDuration } from './fmt'
20export { freshState, parseDuration, resetForClear } from './core'
21export type { State } from './core'
22export { seedFromResume } from './session'
23export type { ResumeFields } from './session'
24
25/** keepwarm's and the guard's reach, bound here: only this file holds `$`. */
26function io($: EngineInterface): Io {
27  return {
28    now: () => $.clock.now(),
29    after: (ms, fn) => $.clock.after(ms, fn),
30    fork: prompt => $.model.fork({ prompt }),
31    get: key => $.store.get(key),
32    set: (key, value) => $.store.set(key, value),
33    del: key => $.store.delete(key),
34    redraw: () => $.ui.invalidate('ui.render'),
35    status: text => $.ui.status(text),
36  }
37}
38
39function guardIo($: EngineInterface): GuardIo {
40  return {
41    now: () => $.clock.now(),
42    model: () => $.session.model(),
43    log: text => $.ui.log(text),
44    redraw: () => $.ui.invalidate('ui.render'),
45  }
46}
47
48type Ctx = { s: State; prefs: Prefs; drawn: string }
49
50/** What the band shows changes minute by minute; it is redrawn only when the drawing would differ. */
51function tick($: EngineInterface, c: Ctx, now: number) {
52  const key = bandKey(c.s, now)
53  if (key === c.drawn) return
54  c.drawn = key
55  $.ui.invalidate('ui.render')
56}
57
58function actions($: EngineInterface, s: State): HoldActions {
59  const release = () => {
60    const held = s.held
61    s.held = null
62    $.ui.invalidate('ui.render')
63    return held
64  }
65  return {
66    pay: () => void (async () => {
67      const held = release()
68      if (!held) return
69      s.resend = held.text
70      await $.prompt.submit({ text: held.text, asUser: true })
71    })(),
72    fresh: () => void (async () => {
73      const held = release()
74      await $.command.run({ command: 'clear' })
75      if (held) await $.prompt.fill({ text: held.text })
76    })(),
77    status: () => void $.command.run({ command: NAME }),
78  }
79}
80
81async function readPrefs($: EngineInterface, prefs: Prefs) {
82  const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
83  prefs.isLight = isLightTheme(theme)
84  prefs.noColor = Boolean(await $.env.get('NO_COLOR'))
85}
86
87async function restore($: EngineInterface, s: State, now: number) {
88  await prune(io($), s, now)
89  const saved = await $.store.get(deadlineKey(s))
90  const savedEvery = await $.store.get(everyKey(s))
91  s.deadline = typeof saved === 'number' && saved > now ? saved : 0
92  s.every = typeof savedEvery === 'number' && savedEvery >= MIN_PING_MS ? savedEvery : PING_AFTER_MS
93  s.guard = (await $.store.get(KEY_GUARD)) === 'warn' ? 'warn' : 'refuse'
94  s.always = (await $.store.get(KEY_ALWAYS)) === true
95  // Always means a fresh default window every session, whatever the last one left behind.
96  if (s.always) await startWindow(io($), s, { windowMs: DEFAULT_WINDOW_MS, every: PING_AFTER_MS })
97  const usage = await $.session.usage()
98  if (usage.context.tokens) s.ctx = usage.context.tokens
99}
100
101async function registerCommands($: EngineInterface) {
102  const specs = commands()
103  await $.command.register(specs.keepwarm)
104  await $.command.register(specs.cacheTax)
105  // The hook form of cache-tax ships a /cache-tax:status skill; both installed means two guards.
106  if ((await $.command.list()).some(cmd => cmd.name === 'cache-tax:status')) $.ui.log(say().hookTwice)
107}
108
109async function onKeepwarm($: EngineInterface, s: State, args: string): Promise<string> {
110  const words = args.trim().split(/\s+/).filter(Boolean)
111  const now = await $.clock.now()
112  if (words[0] === 'off') {
113    const wasAlways = s.always
114    await stopForGood(io($), s)
115    return wasAlways ? say().keepwarm.offAlways : say().keepwarm.off
116  }
117  if (words[0] === 'always') {
118    s.always = true
119    await $.store.set(KEY_ALWAYS, true)
120    await startWindow(io($), s, { windowMs: DEFAULT_WINDOW_MS, every: PING_AFTER_MS })
121    return alwaysText(s, now)
122  }
123  if (words[0] === 'status') return statusText(s, now) ?? say().keepwarm.off
124  const window = words.length ? parseDuration(words[0] ?? '') : DEFAULT_WINDOW_MS
125  if (window == null) return say().keepwarm.usage
126  // "every 2m" is a testing knob and lasts only for the window it was given with.
127  let every = PING_AFTER_MS
128  if (words[1] === 'every') {
129    const period = parseDuration(words[2] ?? '')
130    if (period == null || period < MIN_PING_MS) return say().keepwarm.every
131    every = period
132  }
133  await startWindow(io($), s, { windowMs: window, every })
134  return armedText(s, now, window)
135}
136
137async function onCacheTax($: EngineInterface, s: State, args: string): Promise<string> {
138  const words = args.trim().split(/\s+/).filter(Boolean)
139  if (words[0] !== 'guard') return cardText(s, await $.clock.now())
140  if (words[1] !== 'warn' && words[1] !== 'refuse') return say().guardMode.usage
141  s.guard = words[1]
142  await $.store.set(KEY_GUARD, s.guard)
143  return s.guard === 'refuse' ? say().guardMode.refuse : say().guardMode.warn
144}
145
146async function onTurnComplete($: EngineInterface, s: State, e: TurnCompleteInput) {
147  const now = await $.clock.now()
148  // A sleeping host may deliver this turn before the expired window's timer.
149  if (s.deadline && now >= s.deadline) await stop(io($), s, null)
150  // turn.step stamps the exact request time; when no step of this turn did, the turn's end is the floor.
151  if (now - s.lastRequestAt > e.durationMs) s.lastRequestAt = now
152  s.compacted = false
153  s.ackedAt = 0
154  s.held = null
155  const u = e.usage
156  if (u) {
157    if (u.model) s.lastModel = u.model
158    const prev = s.ctx
159    // A turn's usage is its responses summed, so a ten-step turn reports ten reads of the context. The live window
160    // is the engine's figure; the sum is only the fallback for a host that reports no tokens.
161    const write = u.cache_creation_input_tokens
162    const live = (await $.session.usage()).context.tokens
163    s.ctx = live && live > 0 ? live : u.input_tokens + u.cache_read_input_tokens + write
164    if (isFullMiss(prev, write) || s.coldWritePending) await scoreMiss($, s, { now, write })
165  }
166  s.coldWritePending = false
167  await arm(io($), s)
168}
169
170async function scoreMiss($: EngineInterface, s: State, { now, write }: { now: number; write: number }) {
171  const price = priceOf(s.lastModel)
172  const usd = price ? write * price[1] / 1e6 : null
173  s.misses.push({ at: now, tokens: write, usd })
174  if (s.deadline >= now + AUTO_WARM_MS) return
175  await startWindow(io($), s, { windowMs: AUTO_WARM_MS, every: s.every })
176  $.ui.log(paidText(write, usd))
177}
178
179function wireBand(on: On, c: Ctx) {
180  const { s } = c
181  on('config.set', { key: 'theme' }, async ($, e, next) => {
182    const result = await next(e)
183    if (result.deny === undefined) {
184      c.prefs.isLight = isLightTheme(result.value)
185      if (s.hasBand) $.ui.invalidate('ui.render')
186    }
187    return result
188  })
189
190  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
191    const theirs = await next(e)
192    if (!s.hasBand || e.props.hasSurvey) return theirs
193    const band = { s, prefs: c.prefs, actions: actions($, s), columns: e.props.bodyColumns, now: await $.clock.now() }
194    if (e.surface === 'desktop') return pinned(e, composeBand({ surface: 'desktop', ui: $.ui.resolve(e) }, theirs, band))
195    if (e.surface === 'terminal') return pinned(e, composeBand({ surface: 'terminal', ui: $.ui.resolve(e) }, theirs, band))
196    return theirs
197  })
198}
199
200function wireSession(on: On, c: Ctx) {
201  const { s } = c
202  on('session.start', async ($, e, next) => {
203    // The interface language (kit/lang.ts). Spelled out: the engine admits an env read by a literal name only.
204    setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
205    const r = await next(e)
206    s.hasBand = e.surface === 'terminal' || e.surface === 'desktop'
207    if (s.hasBand) {
208      await readPrefs($, c.prefs)
209      $.clock.every(MIN_MS, () => void $.clock.now().then(now => tick($, c, now)))
210    }
211    s.sid = await $.session.id()
212    const now = await $.clock.now()
213    await restore($, s, now)
214    await registerCommands($)
215    updateStatus(io($), s, now)
216    return r
217  })
218
219  // Resume fields seed the guard before any turn of the resumed session has run.
220  on('classic.SessionStart', async ($, e, next) => {
221    const r = await next(e)
222    if (e.source === 'clear') {
223      await stop(io($), s, null)
224      resetForClear(s)
225      s.sid = await $.session.id()
226      updateStatus(io($), s, await $.clock.now())
227      return r
228    }
229    const line = seedFromResume(s, e, await $.clock.now())
230    // The resume payload may omit the model; without it the guard cannot price the cold write.
231    if (!s.lastModel) s.lastModel = await $.session.model()
232    if (line) $.ui.log(line)
233    // The seeded clock decides whether a restored or always window pings before the first turn: never when it is cold.
234    await arm(io($), s)
235    return r
236  })
237}
238
239function wireTurns(on: On, s: State) {
240  on('command.run', { command: 'keepwarm' }, async ($, e) => ({ text: await onKeepwarm($, s, String(e.args ?? '')) }))
241  on('command.run', { command: NAME }, async ($, e) => ({ text: await onCacheTax($, s, String(e.args ?? '')) }))
242  on('prompt.submit', ($, e, next) => onPrompt(guardIo($), s, { e, next }))
243
244  on('turn.step', async function* ($, e, next) {
245    if (!e.agentId) {
246      s.lastRequestAt = await $.clock.now()
247      // The request writes the cache under its model; a send on another one finds it empty.
248      s.cacheModel = e.model
249    }
250    yield* next(e)
251  })
252
253  on('turn.complete', async ($, e, next) => {
254    const r = await next(e)
255    if (!e.agentId) await onTurnComplete($, s, e)
256    return r
257  })
258
259  on('session.compact', async ($, e, next) => {
260    const r = await next(e)
261    if (!e.agentId) {
262      s.compacted = true
263      s.ctx = 0
264      s.ackedAt = 0
265      s.held = null
266      await arm(io($), s)
267    }
268    return r
269  })
270}
271
272export const register: Register = on => {
273  const c: Ctx = { s: freshState(), prefs: { isLight: false, noColor: false }, drawn: '' }
274  wireBand(on, c)
275  wireSession(on, c)
276  wireTurns(on, c.s)
277}
278
hooks/band.ts 63 lines
1// The band above the prompt: the hold card above the other plugins' rows (a decision), the
2// status row below them (state, next to the prompt). A survey has the band to itself.
3
4import type { Elements, RenderElement } from 'claude-code'
5
6import { coldUsd, heldModel, isHoldShown, warmUsd } from './core'
7import type { State } from './core'
8import { fmtDuration, fmtInt } from './fmt'
9import { holdDesktop, holdTerminal } from './hold'
10import type { HoldActions } from './hold'
11import type { HoldView } from './hold-svg'
12import { modelName } from './model-switch'
13import { rowDesktop, rowOf, rowTerminal } from './row'
14import { bandWords } from './ui-words'
15import { autoWarm, coldFor, rateText } from './words'
16
17export type Prefs = { isLight: boolean; noColor: boolean }
18
19/** Why the card is up, longest first: the model switch it was held for, or how long ago the clock ran out. */
20function reasonOf(s: State, now: number): string[] {
21  const switched = s.held?.switch
22  if (switched) return bandWords().switched(modelName(switched.from), modelName(switched.to))
23  return [bandWords().wentCold(fmtDuration(coldFor(s, now)))]
24}
25
26export function holdView(s: State, now: number): HoldView {
27  const model = heldModel(s)
28  return {
29    held: s.held?.text ?? '',
30    reason: reasonOf(s, now),
31    tokens: fmtInt(s.ctx),
32    coldUsd: coldUsd(s, model),
33    warmUsd: warmUsd(s, model),
34    rate: rateText(s, model),
35    autoWarm: autoWarm(),
36  }
37}
38
39/** The element table of a surface that has the band, tagged so its own builders are picked. */
40export type Drawing = { surface: 'desktop'; ui: Elements['desktop'] } | { surface: 'terminal'; ui: Elements['terminal'] }
41export type Band = { s: State; prefs: Prefs; actions: HoldActions; columns: number; now: number }
42
43function holdCard(d: Drawing, b: Band): RenderElement | null {
44  if (!isHoldShown(b.s, b.now)) return null
45  const options = { view: holdView(b.s, b.now), actions: b.actions, ...b.prefs, columns: b.columns }
46  return d.surface === 'desktop' ? holdDesktop(d.ui, options) : holdTerminal(d.ui, options)
47}
48
49function statusRow(d: Drawing, b: Band): RenderElement | null {
50  const row = rowOf(b.s, b.now)
51  if (row === null) return null
52  const options = { row, ...b.prefs, columns: b.columns }
53  return d.surface === 'desktop' ? rowDesktop(d.ui, options) : rowTerminal(d.ui, options)
54}
55
56/** Mine around theirs: a hold card above, the status row below; theirs alone when there is neither. */
57export function composeBand(d: Drawing, theirs: RenderElement, b: Band): RenderElement {
58  const above = holdCard(d, b)
59  const below = statusRow(d, b)
60  if (above === null && below === null) return theirs
61  return d.ui.Box({ flexDirection: 'column', gap: above === null ? 0 : 1, children: [above, theirs, below] })
62}
63
hooks/pin.ts 48 lines
1// MODZ kit — pinned press handles. Every element the JSX runtime builds gets a fresh handle, and the
2// host releases a drawing's old handles the moment it redraws. A desktop holds the tree it last
3// painted, so a click that lands just after any redraw (a state write, a focus flip, a poll) names a
4// released handle and the engine drops it ("ui_press not handled"). The host keeps a handle alive
5// across redraws when the new tree carries the same one, so each element here keeps the first handle
6// its site and key ever got: every tree a surface still holds stays pressable, with the latest closure.
7import type { RenderElement } from 'claude-code'
8
9export type PinSite = { surface: string; component?: string; requestId?: string }
10
11type Node = { type?: string; props?: { key?: unknown }; press?: { plugin: string; handle: number }; children?: unknown[] }
12
13// Far above the runtime's own counter, which starts at 1 and climbs by one per element drawn.
14const FIRST_PINNED = 1_000_000_000
15const pins = new Map<string, number>()
16
17function handleFor(id: string): number {
18  let handle = pins.get(id)
19  if (handle === undefined) {
20    handle = FIRST_PINNED + pins.size
21    pins.set(id, handle)
22  }
23
24  return handle
25}
26
27function pinNode(node: unknown, ctx: { site: string; seen: Map<string, number> }): unknown {
28  if (typeof node !== 'object' || node === null) return node
29  const el = node as Node
30  const children = el.children?.map(child => pinNode(child, ctx))
31  const withKids = children === undefined ? el : { ...el, children }
32  // Only this plugin's own elements: the runtime names the plugin of every tree `next` returned.
33  if (el.press?.plugin !== '' || typeof el.props?.key !== 'string') return withKids
34  const base = `${ctx.site}\u0000${el.type}\u0000${el.props.key}`
35  const nth = ctx.seen.get(base) ?? 0
36  ctx.seen.set(base, nth + 1)
37
38  return { ...withKids, press: { plugin: '', handle: handleFor(`${base}\u0000${nth}`) } }
39}
40
41// Return a render hook's tree through this: `return pinned(e, tree)`.
42export function pinned<T extends RenderElement | null | undefined>(site: PinSite, tree: T): T {
43  if (tree === null || tree === undefined) return tree
44  const ctx = { site: `${site.surface}\u0000${site.component ?? ''}\u0000${site.requestId ?? ''}`, seen: new Map<string, number>() }
45
46  return pinNode(tree, ctx) as T
47}
48
hooks/core.ts 144 lines
1// The cache model: prices, the one-hour clock, the session's state and the number formats.
2
3import type { ModelSwitch } from './model-switch'
4
5export const MIN_MS = 60 * 1000
6export const HOUR_MS = 60 * MIN_MS
7export const TTL_MS = HOUR_MS
8export const PING_AFTER_MS = 50 * MIN_MS
9export const MIN_PING_MS = MIN_MS
10export const AUTO_WARM_MS = 3 * HOUR_MS
11export const DEFAULT_WINDOW_MS = 6 * HOUR_MS
12/** The status row also shows a warm cache this close to going cold. */
13export const SOON_MS = 10 * MIN_MS
14export const BIG_TOKENS = 50000
15export const PING_PROMPT = 'Reply with the single word: warm'
16
17// $ per million tokens, [cache read, 1h cache write, output], list prices September 2026.
18// Longer family names first: a model id matches the first row it contains.
19const PRICES: Array<[string, number, number, number]> = [
20  ['fable-5-1', 0.25, 20, 50],
21  ['fable-5', 1, 20, 50],
22  ['opus-5', 0.5, 10, 25],
23  ['opus-4', 0.5, 10, 25],
24  ['sonnet-5', 0.2, 4, 10],
25  ['sonnet', 0.3, 6, 15],
26  ['haiku', 0.1, 2, 5],
27]
28
29export type Price = [number, number, number]
30export type PingRecord = { at: number; read: number; write: number; usd: number | null; warm: boolean }
31export type Miss = { at: number; tokens: number; usd: number | null }
32export type GuardMode = 'refuse' | 'warn'
33/** A prompt the guard dropped, waiting on the hold card; `switch` when a model change, not the clock, made it cold. */
34export type Held = { text: string; at: number; switch: ModelSwitch | null }
35
36export type State = {
37  hasBand: boolean
38  hasStatus: boolean
39  sid: string
40  deadline: number
41  every: number
42  always: boolean
43  lastRequestAt: number
44  lastModel: string | null
45  /** The model of the main loop's last request: the one the cache now belongs to. */
46  cacheModel: string | null
47  ctx: number
48  compacted: boolean
49  guard: GuardMode
50  ackedAt: number
51  coldWritePending: boolean
52  misses: Miss[]
53  pending: { cancel: () => void } | null
54  last: PingRecord | null
55  stopped: string | null
56  held: Held | null
57  resend: string | null
58}
59
60export function freshState(): State {
61  return {
62    hasBand: false, hasStatus: false, sid: '', deadline: 0, every: PING_AFTER_MS, always: false, lastRequestAt: 0,
63    lastModel: null, cacheModel: null, ctx: 0, compacted: false, guard: 'refuse', ackedAt: 0, coldWritePending: false, misses: [],
64    pending: null, last: null, stopped: null, held: null, resend: null,
65  }
66}
67
68export function priceOf(model: string | null): Price | null {
69  const m = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
70  for (const [family, read, write, output] of PRICES) if (m.includes(family)) return [read, write, output]
71  return null
72}
73
74export function coldUsd(s: State, model = s.lastModel): number | null {
75  const price = priceOf(model)
76  return price ? s.ctx * price[1] / 1e6 : null
77}
78
79export function warmUsd(s: State, model = s.lastModel): number | null {
80  const price = priceOf(model)
81  return price ? s.ctx * price[0] / 1e6 : null
82}
83
84type ForkUsage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
85
86/** Everything a fork bills: the cache read, any cache write, uncached input at the base rate (half the 1h write rate), and the output. */
87export function pingUsd(u: ForkUsage, price: Price): number {
88  return (u.cache_read_input_tokens * price[0] + u.cache_creation_input_tokens * price[1] + u.input_tokens * price[1] / 2 + u.output_tokens * price[2]) / 1e6
89}
90
91/** The read-only upper bound: pings at the cache-read rate that cost as much as one cold write of the same context. */
92export function breakEvenPings(s: State): number | null {
93  const price = priceOf(s.lastModel)
94  if (!price || s.ctx <= 0) return null
95  return Math.floor(price[1] / price[0])
96}
97
98export function isCold(s: State, now: number): boolean {
99  return s.lastRequestAt > 0 && !s.compacted && now - s.lastRequestAt >= TTL_MS
100}
101
102/** A held send's card is up while its cause lasts: the clock's cold, or the model switch it was held for. */
103export const isHoldShown = (s: State, now: number): boolean => s.held !== null && (s.held.switch !== null || isCold(s, now))
104
105/** The model a held send is priced at: the one it would be sent to. */
106export const heldModel = (s: State): string | null => s.held?.switch?.to ?? s.lastModel
107
108/** Time left before a warm cache goes cold; null while nothing is known or it already is. */
109export function untilCold(s: State, now: number): number | null {
110  if (!s.lastRequestAt || s.compacted || isCold(s, now)) return null
111  return s.lastRequestAt + TTL_MS - now
112}
113
114export function parseDuration(text: string): number | null {
115  const m = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(text.trim())
116  if (!m || (m[1] === undefined && m[2] === undefined)) return null
117  return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * MIN_MS
118}
119
120/** A turn whose cache write is at least this share of a sizeable previous context re-wrote it all. */
121const FULL_SHARE = 0.5
122const FULL_FLOOR = 20000
123
124export const isFullMiss = (prev: number, write: number): boolean => prev > FULL_FLOOR && write >= FULL_SHARE * prev
125
126export function disarm(s: State) {
127  if (s.pending) s.pending.cancel()
128  s.pending = null
129}
130
131/** /clear starts a new conversation in the same process; nothing priced before it still exists. */
132export function resetForClear(s: State) {
133  s.ctx = 0
134  s.lastRequestAt = 0
135  s.cacheModel = null
136  s.compacted = false
137  s.ackedAt = 0
138  s.coldWritePending = false
139  s.misses = []
140  s.held = null
141  s.resend = null
142  disarm(s)
143}
144
hooks/guard.ts 67 lines
1// The guard on a typed prompt: a cold send of a big context waits once. Cold is the one-hour clock running out,
2// or a model change, since the cache belongs to the model that wrote it. Of a prompt only its first character is
3// read, and its text kept while it waits.
4
5import type { PromptSubmitInput, PromptSubmitResult } from 'claude-code'
6
7import { BIG_TOKENS, isCold } from './core'
8import type { State } from './core'
9import { switchOf } from './model-switch'
10import type { ModelSwitch } from './model-switch'
11import { dropText, warnText } from './words'
12
13export const NAME = 'cache-tax'
14
15export type Submit = { e: PromptSubmitInput; next: (e: PromptSubmitInput) => Promise<PromptSubmitResult> }
16
17/** The guard's reach, bound in register.ts, the one file that holds `$`. */
18export type GuardIo = {
19  now: () => Promise<number>
20  model: () => Promise<string>
21  log: (text: string) => void
22  redraw: () => void
23}
24
25/** The switch the next send would make, read only once a turn has named the cache's model. */
26async function switchNow(io: GuardIo, s: State): Promise<ModelSwitch | null> {
27  if (!s.cacheModel) return null
28  return switchOf(s.cacheModel, await io.model())
29}
30
31function release(io: GuardIo, s: State) {
32  if (s.held && s.hasBand) io.redraw()
33  s.held = null
34}
35
36// The card's own resend: the person pressed pay, so its cold write is scored.
37function ownResend(s: State, { e, next }: Submit): Promise<PromptSubmitResult> {
38  if (e.origin.kind === 'plugin' && e.origin.name === NAME && e.text === s.resend) {
39    s.resend = null
40    s.coldWritePending = true
41  }
42  return next(e)
43}
44
45export async function onPrompt(io: GuardIo, s: State, { e, next }: Submit): Promise<PromptSubmitResult> {
46  if (e.origin.kind === 'plugin') return ownResend(s, { e, next })
47  if (typeof e.text !== 'string' || e.text.trimStart().startsWith('/') || s.ctx < BIG_TOKENS) return next(e)
48  const now = await io.now()
49  const switched = await switchNow(io, s)
50  if (!switched && !isCold(s, now)) {
51    // Switched back while a switch held the send: that card no longer says anything true.
52    if (s.held?.switch) release(io, s)
53    return next(e)
54  }
55  if (s.guard === 'warn' || s.ackedAt === s.lastRequestAt) {
56    if (s.guard === 'warn') io.log(warnText(s, { now, switched }))
57    s.ackedAt = 0
58    s.coldWritePending = true
59    release(io, s)
60    return next(e)
61  }
62  s.ackedAt = s.lastRequestAt
63  s.held = { text: e.text, at: now, switch: switched }
64  if (s.hasBand) io.redraw()
65  return { drop: dropText(s, now) }
66}
67
hooks/hold.ts 118 lines
1// The hold card: a cold send waits above the prompt with its price beside a warm turn's,
2// the held text quoted back, and three ways on. Desktop draws Svg; the terminal says it in text.
3
4import type { Elements, RenderElement } from 'claude-code'
5
6import { fmtUsd } from './fmt'
7import { COMPARE_H, HEADER_H, compareSvg, headerSvg, warmCaptions } from './hold-svg'
8import type { HoldView } from './hold-svg'
9import { ICONS } from './icons'
10import { iconSvg } from './svg'
11import { TERMINAL, palette } from './theme'
12import type { Palette } from './theme'
13import { bandWords as words } from './ui-words'
14
15export type HoldActions = { pay: () => void; fresh: () => void; status: () => void }
16export type HoldOptions = { view: HoldView; actions: HoldActions; isLight: boolean; noColor: boolean; columns: number }
17
18// About 8 px a reported column; the card's border and padding take 6 of them (as in every MODZ card).
19const PX_PER_COLUMN = 8
20const CARD_CHROME = 6
21const MIN_WIDTH = 300
22const MAX_WIDTH = 720
23const ROW_ICON = 16
24
25/** The card's inner width from the band's columns. */
26export const contentWidth = (columns: number): number =>
27  Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, (columns - CARD_CHROME) * PX_PER_COLUMN))
28
29const heldQuote = (view: HoldView): string => `“${view.held}”`
30const keepText = (view: HoldView): string => words().keep(view.autoWarm)
31
32function buttons(ui: Elements['desktop'] | Elements['terminal'], actions: HoldActions): RenderElement {
33  const { Box, Button } = ui
34  const { pay, fresh, status } = words()
35  return Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, alignItems: 'center', children: [
36    Button({ key: 'cache-tax:pay', ...pay, variant: 'secondary', onPress: actions.pay }),
37    Button({ key: 'cache-tax:fresh', ...fresh, variant: 'secondary', onPress: actions.fresh }),
38    Button({ key: 'cache-tax:status', ...status, plain: true, onPress: actions.status }),
39  ] })
40}
41
42function infoRow(ui: Elements['desktop'], glyph: { paths: string; color: string }, children: RenderElement[]): RenderElement {
43  const { Box, Svg } = ui
44  const { paths, color } = glyph
45  return Box({ flexDirection: 'row', gap: 1, alignItems: 'center', children: [
46    Box({ flexShrink: 0, children: [Svg({ source: iconSvg(paths, ROW_ICON, color), alt: '', width: ROW_ICON, height: ROW_ICON })] }),
47    ...children,
48  ] })
49}
50
51function desktopInfo(ui: Elements['desktop'], view: HoldView, p: Palette): RenderElement {
52  const { Box, Text } = ui
53  return Box({ flexDirection: 'column', children: [
54    infoRow(ui, { paths: ICONS.pauseCircle, color: p.lo }, [
55      Text({ color: p.mid, wrap: 'truncate-end', children: [words().held, Text({ color: p.hi, children: [heldQuote(view)] })] }),
56    ]),
57    infoRow(ui, { paths: ICONS.shieldCheck, color: p.lo }, [Text({ color: p.mid, children: [keepText(view)] })]),
58  ] })
59}
60
61export function holdDesktop(ui: Elements['desktop'], o: HoldOptions): RenderElement {
62  const { Box, Svg } = ui
63  const p = palette(o.isLight)
64  const width = contentWidth(o.columns)
65  const alt = words().alt(fmtUsd(o.view.coldUsd), fmtUsd(o.view.warmUsd))
66  return Box({ key: 'cache-tax:hold', flexDirection: 'column', alignSelf: 'flex-start', gap: 1, paddingX: 2, paddingY: 1, borderStyle: 'round',
67    borderColor: p.line, backgroundColor: p.card, children: [
68      Svg({ source: headerSvg(width, o.view, p), alt, width, height: HEADER_H }),
69      Svg({ source: compareSvg(width, o.view, p), alt: `${words().tokens(o.view.tokens)}, ${o.view.rate}`, width, height: COMPARE_H }),
70      desktopInfo(ui, o.view, p),
71      buttons(ui, o.actions),
72    ] })
73}
74
75const BAR_FULL = 24
76const BAR_MIN = 1
77/** Cells the label and price columns take, and what the rest of a price line needs beside the bar. */
78const LABEL_CELLS = 13
79const PRICE_CELLS = 6
80const LINE_CHROME = 30
81
82/** `━━━━━━╸` scaled to the share, at least one cell. */
83export function textBar(share: number, cells: number): string {
84  const filled = Math.max(BAR_MIN, Math.round(share * cells))
85  return '━'.repeat(Math.max(0, filled - 1)) + '╸'
86}
87
88type PriceLine = { label: string; usd: string; color: string | undefined; bar: string }
89
90function priceLine(ui: Elements['terminal'], { label, usd, color, bar }: PriceLine): RenderElement {
91  const { Box, Text } = ui
92  return Box({ flexDirection: 'row', gap: 1, children: [
93    Text({ dimColor: true, children: [label.padEnd(LABEL_CELLS)] }),
94    Text({ bold: true, color, children: [usd.padStart(PRICE_CELLS)] }),
95    Text({ color, children: [bar] }),
96  ] })
97}
98
99export function holdTerminal(ui: Elements['terminal'], o: HoldOptions): RenderElement {
100  const { Box, Text } = ui
101  const kiln = o.noColor ? undefined : TERMINAL.edit
102  const v = o.view
103  const share = v.coldUsd && v.warmUsd !== null ? Math.min(1, v.warmUsd / v.coldUsd) : 0
104  const cells = Math.max(BAR_MIN, Math.min(BAR_FULL, o.columns - LINE_CHROME))
105  const said = words()
106  return Box({ key: 'cache-tax:hold', flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderDimColor: true, children: [
107    Text({ bold: true, color: kiln, children: [said.terminalEyebrow] }),
108    Text({ bold: true, children: [said.headline] }),
109    Text({ dimColor: true, children: [v.reason[0] ?? ''] }),
110    priceLine(ui, { label: said.sendNow, usd: fmtUsd(v.coldUsd), color: kiln, bar: textBar(1, cells) }),
111    priceLine(ui, { label: said.warmTurn, usd: fmtUsd(v.warmUsd), color: undefined, bar: `${textBar(share, cells)} ${warmCaptions(v).at(-1) ?? ''}` }),
112    Text({ dimColor: true, children: [`${said.tokens(v.tokens)} ${said.rewritten(v.rate)[0]}`] }),
113    Text({ wrap: 'truncate-end', children: [Text({ dimColor: true, children: [`⏸ ${said.held}`] }), heldQuote(v)] }),
114    Text({ dimColor: true, children: [`✓ ${keepText(v)}`] }),
115    buttons(ui, o.actions),
116  ] })
117}
118
hooks/row.ts 110 lines
1// The status row under the band: only while it means something (DESIGN.md §6): keepwarm on or
2// stopped, the cache cold, or warm with ten minutes or less left. Cube, state word, then detail.
3
4import type { Elements, RenderElement } from 'claude-code'
5
6import { BIG_TOKENS, SOON_MS, coldUsd, isCold, isHoldShown, untilCold } from './core'
7import type { State } from './core'
8import { cube } from './cube'
9import { fmtDuration, fmtUsd } from './fmt'
10import { statusIcons } from './status-icons'
11import { doc } from './svg'
12import { TERMINAL, palette } from './theme'
13import { bandWords } from './ui-words'
14import { coldFor, statusText } from './words'
15
16export type RowState = 'warm' | 'cold' | 'unknown' | 'stopped'
17export type Row = { state: RowState; detail: string[] }
18
19function temperature(s: State, now: number): RowState {
20  if (!s.lastRequestAt || s.compacted) return 'unknown'
21  return isCold(s, now) ? 'cold' : 'warm'
22}
23
24function keepwarmRow(s: State, now: number): Row | null {
25  const line = statusText(s, now)
26  if (line === undefined || (s.deadline && now >= s.deadline)) return null
27  if (s.stopped) return { state: 'stopped', detail: [line] }
28  return { state: temperature(s, now), detail: line.split(' · ') }
29}
30
31/** What the row says now, or null when it has nothing worth a line. */
32export function rowOf(s: State, now: number): Row | null {
33  if (isHoldShown(s, now)) return null
34  if (s.deadline || s.stopped) return keepwarmRow(s, now)
35  if (s.ctx < BIG_TOKENS) return null
36  if (isCold(s, now)) {
37    return { state: 'cold', detail: [bandWords().coldAgo(fmtDuration(coldFor(s, now))), bandWords().nextUpTo(fmtUsd(coldUsd(s)))] }
38  }
39  const left = untilCold(s, now)
40  if (left === null || left > SOON_MS) return null
41  return { state: 'warm', detail: [bandWords().goesCold(fmtDuration(left)), bandWords().keepWarm] }
42}
43
44/** A key that changes whenever the drawn card or row would: the band is redrawn only then. */
45export function bandKey(s: State, now: number): string {
46  const hold = isHoldShown(s, now) ? `${s.held?.switch?.to ?? ''}:${fmtDuration(coldFor(s, now))}` : ''
47  return `${hold}|${JSON.stringify(rowOf(s, now))}`
48}
49
50const SEP = ' · '
51const ROW_CHROME = 18
52
53/** Detail segments kept from the left while they fit, so a narrow band drops the least important. */
54export function fitDetail(row: Row, columns: number): string {
55  const room = columns - ROW_CHROME - bandWords().states[row.state].length
56  const kept: string[] = []
57  for (const part of row.detail) {
58    if ([...kept, part].join(SEP).length > room && kept.length > 0) break
59    kept.push(part)
60  }
61  return kept.join(SEP)
62}
63
64const CUBE_PX = 16
65
66function lidOf(state: RowState, isLight: boolean): string {
67  const p = palette(isLight)
68  if (state === 'warm') return p.role.ok.fg
69  return state === 'cold' ? p.role.edit.fg : p.lo
70}
71
72export type RowOptions = { row: Row; isLight: boolean; noColor: boolean; columns: number }
73
74export function rowDesktop(ui: Elements['desktop'], o: RowOptions): RenderElement {
75  const { Box, Svg, Text } = ui
76  const p = palette(o.isLight)
77  const color = o.noColor || o.row.state === 'unknown' || o.row.state === 'stopped' ? undefined : lidOf(o.row.state, o.isLight)
78  const icon = o.noColor ? Text({ bold: true, children: ['[>]'] })
79    : Svg({ source: doc({ width: CUBE_PX, height: CUBE_PX }, cube({ x: 0, y: 0, size: CUBE_PX }, { lid: lidOf(o.row.state, o.isLight), outline: p.hi })), alt: 'Cache Tax', width: CUBE_PX, height: CUBE_PX })
80  return Box({ key: 'cache-tax:row', flexDirection: 'row', alignItems: 'center', gap: 1, children: [
81    Box({ flexShrink: 0, children: [icon] }),
82    Text({ color: o.noColor ? undefined : p.mid, wrap: 'truncate-end', children: [
83      bandWords().cache, Text({ color, bold: true, children: [bandWords().states[o.row.state]] }), `${SEP}${fitDetail(o.row, o.columns)}`,
84    ] }),
85  ] })
86}
87
88const IMAGE_CELLS = 3
89
90function terminalColor(state: RowState, noColor: boolean): string | undefined {
91  if (noColor) return undefined
92  if (state === 'warm') return TERMINAL.ok
93  return state === 'cold' ? TERMINAL.edit : undefined
94}
95
96export function rowTerminal(ui: Elements['terminal'], o: RowOptions): RenderElement {
97  const { Box, Image, Text } = ui
98  const variant = statusIcons[o.isLight ? 'light' : 'dark']
99  const asset = o.row.state === 'warm' || o.row.state === 'cold' ? variant[o.row.state] : variant.neutral
100  const icon = o.noColor ? Text({ bold: true, children: ['[>]'] })
101    : Image({ source: { png: asset.png }, columns: IMAGE_CELLS, rows: 1, alt: '[>]' })
102  return Box({ key: 'cache-tax:row', flexDirection: 'row', alignItems: 'center', children: [
103    Box({ flexShrink: 0, children: [icon] }),
104    Text({ wrap: 'truncate-end', children: [
105      ` ${bandWords().cache}`, Text({ color: terminalColor(o.row.state, o.noColor), bold: true, children: [bandWords().states[o.row.state]] }),
106      `${SEP}${fitDetail(o.row, o.columns)}`,
107    ] }),
108  ] })
109}
110
hooks/session.ts 30 lines
1// A resumed or forked session's fields seed the clock before any turn of it has run.
2
3import { BIG_TOKENS, coldUsd } from './core'
4import type { State } from './core'
5import { fmtUsd } from './fmt'
6import { resumeText } from './words'
7
8export type ResumeFields = {
9  source: string
10  model?: string
11  context_tokens?: number
12  seconds_since_last_response?: number
13  prompt_cache_likely_expired?: boolean
14  estimated_cache_write_usd?: number
15}
16
17const SECOND_MS = 1000
18
19/** Applies a resumed session's fields to the state; returns the line to log, if any. */
20export function seedFromResume(s: State, e: ResumeFields, now: number): string | null {
21  if (e.source !== 'resume' && e.source !== 'fork') return null
22  if (typeof e.context_tokens === 'number' && e.context_tokens > 0) s.ctx = e.context_tokens
23  if (typeof e.seconds_since_last_response === 'number') s.lastRequestAt = now - e.seconds_since_last_response * SECOND_MS
24  if (typeof e.model === 'string') s.lastModel = e.model
25  s.compacted = false
26  if (e.prompt_cache_likely_expired !== true || s.ctx < BIG_TOKENS) return null
27  const usd = typeof e.estimated_cache_write_usd === 'number' ? fmtUsd(e.estimated_cache_write_usd) : fmtUsd(coldUsd(s))
28  return resumeText(s.ctx, usd)
29}
30
hooks/theme.ts 59 lines
1// Tidemark tokens (DESIGN.md §2-3), both themes. Every colour the mod draws comes from here,
2// so moving to the shared kit later is one import.
3
4export type Role = 'read' | 'edit' | 'run' | 'ok' | 'warn' | 'danger' | 'plan'
5export type Tone = { fg: string; tint: string; line: string }
6export type Palette = {
7  sheet: string
8  card: string
9  inset: string
10  line: string
11  lineStrong: string
12  track: string
13  hi: string
14  mid: string
15  lo: string
16  role: Record<Role, Tone>
17}
18
19const DARK: Palette = {
20  sheet: '#191917', card: '#1F1F1C', inset: '#171715', line: '#2E2E2A', lineStrong: '#45443F', track: '#34332F',
21  hi: '#F0EEE8', mid: '#C2BEB4', lo: '#8E8A80',
22  role: {
23    read: { fg: '#7FB6E8', tint: '#1E2B37', line: '#2F4A63' },
24    edit: { fg: '#E58A5F', tint: '#38251D', line: '#6A3E2D' },
25    run: { fg: '#B19CF3', tint: '#29243D', line: '#4A3F75' },
26    ok: { fg: '#6FC593', tint: '#1B2F25', line: '#2F5A42' },
27    warn: { fg: '#E9B949', tint: '#332D19', line: '#66552A' },
28    danger: { fg: '#F2727F', tint: '#3B2023', line: '#74343C' },
29    plan: { fg: '#E07BC0', tint: '#351F2E', line: '#6B3A5C' },
30  },
31}
32
33const LIGHT: Palette = {
34  sheet: '#F4F2EC', card: '#FFFFFF', inset: '#F6F4EF', line: '#E4E0D6', lineStrong: '#CFC9BC', track: '#ECE8DF',
35  hi: '#1C1B19', mid: '#4F4B44', lo: '#77726A',
36  role: {
37    read: { fg: '#2464A0', tint: '#E7F0F9', line: '#B9D3EC' },
38    edit: { fg: '#A94A26', tint: '#FBEDE6', line: '#EFC6B4' },
39    run: { fg: '#6243C4', tint: '#F0ECFC', line: '#CFC3F3' },
40    ok: { fg: '#1F6E43', tint: '#E4F4EA', line: '#B5DEC4' },
41    warn: { fg: '#8A6000', tint: '#FBF2D9', line: '#EBD48F' },
42    danger: { fg: '#C0263A', tint: '#FCE8EA', line: '#F1B9C0' },
43    plan: { fg: '#A8337F', tint: '#FBE9F4', line: '#EDBFDC' },
44  },
45}
46
47export const palette = (isLight: boolean): Palette => (isLight ? LIGHT : DARK)
48
49/** The terminal's theme keys for each role (DESIGN.md §2). */
50export const TERMINAL: Record<Role, string> = {
51  read: 'suggestion', edit: 'claude', run: 'permission', ok: 'success', warn: 'warning', danger: 'error', plan: 'merged',
52}
53
54/** Claude Code's theme names say light in them: `light`, `light-daltonized`, ... */
55export const isLightTheme = (value: unknown): boolean => typeof value === 'string' && value.startsWith('light')
56
57export const FONT = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, system-ui, sans-serif`
58export const MONO = `ui-monospace, 'SF Mono', Menlo, monospace`
59
hooks/warm.ts 140 lines
1// keepwarm: a bounded window of cheap fork pings that keep the one-hour cache read, not re-written.
2
3import type { ModelForkResult } from 'claude-code'
4
5import { PING_AFTER_MS, PING_PROMPT, TTL_MS, disarm, isCold, pingUsd, priceOf } from './core'
6import type { State } from './core'
7import { fmtTok, fmtUsd } from './fmt'
8import { bandWords } from './ui-words'
9import { statusText } from './words'
10
11const KEY_DEADLINE = 'deadline'
12const KEY_EVERY = 'every'
13export const KEY_GUARD = 'guard'
14export const KEY_ALWAYS = 'always'
15const MIN_DELAY_MS = 1000
16/** A warm ping writes only its own message; a write of this share of the read or more means the prefix broke. */
17const BROKEN_SHARE = 0.1
18
19/** What keepwarm needs from the engine, bound in register.ts (the one file that holds `$`). */
20export type Io = {
21  now: () => Promise<number>
22  after: (ms: number, fn: () => void) => { cancel: () => void }
23  fork: (prompt: string) => Promise<ModelForkResult | null>
24  get: (key: string) => Promise<unknown>
25  set: (key: string, value: unknown) => Promise<unknown>
26  del: (key: string) => Promise<unknown>
27  redraw: () => void
28  status: (text: string | undefined) => void
29}
30
31// The window and its ping period belong to the session that armed them, so a
32// second session, or one resumed from another transcript, never inherits them
33// and cannot turn them off. The always switch and the guard mode stay global.
34export const deadlineKey = (s: State): string => `${KEY_DEADLINE}:${s.sid}`
35export const everyKey = (s: State): string => `${KEY_EVERY}:${s.sid}`
36
37/** Redraws the band, or on a surface without one pins the keepwarm line; never clears a line it did not set. */
38export function updateStatus(io: Io, s: State, now: number) {
39  if (s.hasBand) return io.redraw()
40  const text = statusText(s, now)
41  if (text === undefined && !s.hasStatus) return
42  s.hasStatus = text !== undefined
43  io.status(text)
44}
45
46/** Clears this session's own dead window and the bare keys a store written before 2.1.1 still holds. Other sessions' keys are never touched: a read followed by a delete cannot be made atomic against their renewal. */
47export async function prune(io: Io, s: State, now: number) {
48  for (const key of [KEY_DEADLINE, deadlineKey(s)]) {
49    const deadline = await io.get(key)
50    if (deadline === undefined) continue
51    if (typeof deadline === 'number' && deadline > now) continue
52    await io.del(key)
53    await io.del(KEY_EVERY + key.slice(KEY_DEADLINE.length))
54  }
55}
56
57async function halt(io: Io, s: State, end: { why: string | null; isForgettingAlways: boolean }) {
58  s.deadline = 0
59  s.every = PING_AFTER_MS
60  s.stopped = end.why
61  disarm(s)
62  await io.del(deadlineKey(s))
63  await io.del(everyKey(s))
64  if (end.isForgettingAlways) {
65    s.always = false
66    await io.del(KEY_ALWAYS)
67  }
68  updateStatus(io, s, await io.now())
69}
70
71export const stop = (io: Io, s: State, why: string | null): Promise<void> => halt(io, s, { why, isForgettingAlways: false })
72/** keepwarm off: the window ends and so does always, for every later session too. */
73export const stopForGood = (io: Io, s: State): Promise<void> => halt(io, s, { why: null, isForgettingAlways: true })
74
75export async function arm(io: Io, s: State) {
76  disarm(s)
77  if (!s.deadline) return
78  const now = await io.now()
79  if (now >= s.deadline) return stop(io, s, null)
80  // A cold window still needs expiry cleanup, but must not send a model request.
81  if (s.lastRequestAt && !s.compacted && !isCold(s, now)) {
82    const untilCold = s.lastRequestAt + TTL_MS - now
83    const delay = Math.min(s.deadline - now, untilCold, Math.max(MIN_DELAY_MS, s.lastRequestAt + s.every - now))
84    s.pending = io.after(delay, () => { void ping(io, s) })
85  } else {
86    s.pending = io.after(s.deadline - now, () => { void arm(io, s) })
87  }
88  updateStatus(io, s, now)
89}
90
91type ForkReply = ModelForkResult | null
92
93function failureText(reply: ForkReply): string | null {
94  if (reply === null) return bandWords().noPing
95  if (reply.isAnswered !== false) return null
96  if (reply.reason === 'nothing-to-fork') return bandWords().nothingToFork
97  if (reply.reason === 'api-error') return bandWords().apiError(reply.status === null ? '' : ` (${reply.status})`)
98  return reply.reason === 'aborted' ? bandWords().aborted : bandWords().noText
99}
100
101async function ping(io: Io, s: State) {
102  s.pending = null
103  if (!s.deadline) return
104  const now = await io.now()
105  if (now >= s.deadline) return arm(io, s)
106  // A turn in the meantime re-armed the timer; this callback is stale.
107  if (isCold(s, now)) return arm(io, s)
108  if (now - s.lastRequestAt < s.every - MIN_DELAY_MS) return
109  let reply: ForkReply
110  try {
111    reply = await io.fork(PING_PROMPT)
112  } catch (err) {
113    return stop(io, s, bandWords().pingFailed(err instanceof Error ? err.message : String(err)))
114  }
115  const failure = failureText(reply)
116  if (failure !== null || reply === null || reply.isAnswered === false) return stop(io, s, failure)
117  const u = reply.usage
118  const price = priceOf(s.lastModel)
119  const warm = u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < BROKEN_SHARE * u.cache_read_input_tokens
120  const usd = price ? pingUsd(u, price) : null
121  s.last = { at: now, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, usd, warm }
122  if (!warm) {
123    return stop(io, s, bandWords().gone({ read: fmtTok(u.cache_read_input_tokens), wrote: fmtTok(u.cache_creation_input_tokens), usd: fmtUsd(usd) }))
124  }
125  s.lastRequestAt = now
126  await arm(io, s)
127}
128
129export async function startWindow(io: Io, s: State, span: { windowMs: number; every: number }) {
130  const { windowMs, every } = span
131  const now = await io.now()
132  s.every = every
133  if (every === PING_AFTER_MS) await io.del(everyKey(s))
134  else await io.set(everyKey(s), every)
135  s.deadline = now + windowMs
136  s.stopped = null
137  await io.set(deadlineKey(s), s.deadline)
138  await arm(io, s)
139}
140
hooks/lang.ts 24 lines
1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12  current = langOf(value)
13
14  return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24
hooks/words.ts 130 lines
1// What the mod says and when: the lines are in phrases-en.ts and phrases-tr.ts, picked at the moment they are
2// said (the language is set at session.start). Command names and their arguments stay English.
3
4import type { CommandSpec } from 'claude-code'
5
6import { AUTO_WARM_MS, DEFAULT_WINDOW_MS, TTL_MS, breakEvenPings, coldUsd, heldModel, isCold, priceOf, warmUsd } from './core'
7import type { State } from './core'
8import { modelName } from './model-switch'
9import type { ModelSwitch } from './model-switch'
10import { decimal, fmtDuration, fmtInt, fmtTok, fmtUsd } from './fmt'
11import { pick } from './lang'
12import { EN } from './phrases-en'
13import type { Phrases } from './phrases-en'
14import { TR } from './phrases-tr'
15
16export const say = (): Phrases => pick({ en: EN, tr: TR })
17
18export const autoWarm = (): string => fmtDuration(AUTO_WARM_MS)
19
20/** How long ago the cache went cold. */
21export const coldFor = (s: State, now: number): number => now - s.lastRequestAt - TTL_MS
22
23export function rateText(s: State, model = s.lastModel): string {
24  const price = priceOf(model)
25  return price ? `$${decimal(price[1])}/MTok` : say().rateUnknown
26}
27
28/** Why the send is cold, and the model it is priced at: the one a switch sends it to. */
29export type Cause = { now: number; switched: ModelSwitch | null }
30
31export function guardText(s: State, { now, switched }: Cause): string {
32  const model = switched?.to ?? s.lastModel
33  const warm = warmUsd(s, model)
34  const lead = switched ? say().switchLead(modelName(switched.from), modelName(switched.to)) : say().coldLead(fmtDuration(coldFor(s, now)))
35  return say().guard({ lead, tokens: fmtInt(s.ctx), rate: rateText(s, model), usd: fmtUsd(coldUsd(s, model)), warm: warm == null ? null : fmtUsd(warm) })
36}
37
38/** The drop reason for the held send. With the band the card says the rest, so the host's line stays short. */
39export function dropText(s: State, now: number): string {
40  const switched = s.held?.switch ?? null
41  const usd = fmtUsd(coldUsd(s, heldModel(s)))
42  if (s.hasBand) return switched ? say().dropSwitched(usd) : say().dropHeld(usd)
43  return say().drop(guardText(s, { now, switched }), autoWarm())
44}
45
46export const warnText = (s: State, cause: Cause): string => say().warn(guardText(s, cause), autoWarm())
47
48export const paidText = (tokens: number, usd: number | null): string =>
49  say().paid({ tokens: fmtTok(tokens), usd: fmtUsd(usd), auto: autoWarm() })
50
51export const resumeText = (tokens: number, usd: string): string => say().resume(fmtInt(tokens), usd)
52
53/** What comes after the time left in the keepwarm line: the next ping, or why there is none yet. */
54function nextPingText(s: State, now: number): string {
55  if (!s.lastRequestAt) return say().firstTurn
56  if (s.compacted) return say().afterCompact
57  if (isCold(s, now)) return say().coldPing(fmtDuration(s.every))
58  return say().pingIn(fmtDuration(s.lastRequestAt + s.every - now))
59}
60
61export const lastPingText = (s: State): string => (s.last ? say().lastPing(fmtTok(s.last.read), fmtUsd(s.last.usd)) : '')
62
63/** The keepwarm line: the status fallback, `/keepwarm status` and the card's keepwarm row. */
64export function statusText(s: State, now: number): string | undefined {
65  if (s.stopped) return say().stopped(s.stopped)
66  if (!s.deadline) return undefined
67  return `${say().left(fmtDuration(s.deadline - now))}${nextPingText(s, now)}${lastPingText(s)}`
68}
69
70const coldNowText = (s: State): string => say().coldNow(fmtDuration(s.every))
71
72/** The reply to an arming command; on a cold cache it says when the first ping can come. */
73export function armedText(s: State, now: number, windowMs: number): string {
74  const span = fmtDuration(windowMs)
75  if (isCold(s, now)) return `${say().armedCold(span)}${coldNowText(s)}`
76  return say().armed({ span, every: fmtDuration(s.every) })
77}
78
79export const alwaysText = (s: State, now: number): string =>
80  say().always(fmtDuration(DEFAULT_WINDOW_MS)) + (isCold(s, now) ? coldNowText(s) : '')
81
82/** The two commands as the command list shows them. */
83export const commands = (): Record<'keepwarm' | 'cacheTax', CommandSpec> => ({
84  keepwarm: {
85    name: 'keepwarm',
86    description: say().keepwarmCommand,
87    argumentHint: '[6h | always | off | status]',
88    immediate: true,
89  },
90  cacheTax: {
91    name: 'cache-tax',
92    description: say().cacheTaxCommand,
93    argumentHint: '[status | guard warn | guard refuse]',
94    immediate: true,
95  },
96})
97
98const row = (label: string, value: string): string => `${label.padEnd(12)}${value}`
99
100function stateRow(s: State, now: number): string {
101  if (s.compacted) return say().compacted
102  if (!s.lastRequestAt) return say().noRequest
103  if (isCold(s, now)) return say().coldState(fmtDuration(now - s.lastRequestAt))
104  return say().warmState(fmtDuration(s.lastRequestAt + TTL_MS - now))
105}
106
107function keepwarmRow(s: State, now: number): string {
108  const always = s.always ? ' (always)' : ''
109  if (s.deadline) return `${say().on((statusText(s, now) ?? '').replace(/^keepwarm /, ''))}${always}`
110  if (s.stopped) return `${say().stoppedRow(s.stopped)}${always}`
111  const window = fmtDuration(DEFAULT_WINDOW_MS)
112  return s.always ? say().offUntilNext(window) : say().off(window)
113}
114
115/** `/cache-tax`: the model, the clock, the price and the session's tally. */
116export function cardText(s: State, now: number): string {
117  const { labels } = say()
118  const lines = [s.lastModel ?? say().noModel, row(labels.state, stateRow(s, now)), row(labels.context, say().context(fmtInt(s.ctx)))]
119  lines.push(row(labels.cost, say().cost(fmtUsd(coldUsd(s)), fmtUsd(warmUsd(s)))))
120  lines.push(row(labels.keepwarm, keepwarmRow(s, now)))
121  const pings = breakEvenPings(s)
122  if (pings != null) {
123    lines.push(row(labels.even, say().even(pings, { every: fmtDuration(s.every), span: fmtDuration(pings * s.every) })))
124  }
125  lines.push(row(labels.guard, s.guard === 'refuse' ? say().guardRefuse : say().guardWarn))
126  const paid = s.misses.reduce((a, m) => a + (m.usd ?? 0), 0)
127  lines.push(row(labels.session, say().session(s.misses.length, fmtUsd(paid))))
128  return lines.join('\n')
129}
130