SLOPSHOPPER

longhaul

Makes your Claude Code week last: compacts between tasks, steers plan steps to Sonnet, shows the pace you can afford.

bandguardcommandprompttooltimer
v0.2.1MITupdated 2026-10-20sssamuelll/longhaul
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · longhaul
› fix the failing auth test and add an audit log call ● longhaul: longhaul: rate limit kinds: five_hour ⏺ 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 › /longhaul ⎿ longhaul: longhaul report ⎿ longhaul: ⎿ longhaul: This week ⎿ longhaul: Weekly figures need a Pro or Max plan. ⎿ longhaul: About $0.00 at API prices across 1 session (an equivalent, not a bill) (est.) ⎿ longhaul: ▎ context ────────────── 0k ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▎ context ────────────── 0k
README

<img src=".claude-plugin/icon.png" width="88" alt="">

longhaul

A Claude Code mod that helps Pro and Max subscribers make the weekly usage limit last.

version licence telemetry

Install · What it does · Settings · Privacy

<img src=".github/readme/hero-light.gif" width="100%" alt="A Claude Code session works through a plan, one commit per step. The meter above the prompt shows context, weekly use and today's use. After a commit past the soft limit, the conversation compacts from 184k to 66k tokens, and the plan, decisions and open items are kept.">

<sub>An example session, drawn with the band's own code.</sub>

Install

At the prompt of a terminal session:

/plugin install longhaul --marketplace sssamuelll/longhaul

Answer y to add the marketplace, then pick a scope. From a shell:

claude plugin install longhaul --marketplace sssamuelll/longhaul

The problem

These are one developer's own measurements, taken from their sessions over a day and a half and weighted by public API prices. Your numbers will differ.

  • 36% of the weekly limit was used in about 1.5 days.
  • 56% of the usage was Claude re-reading the conversation.
  • A typical step carried about 390k tokens of context.
  • Auto-compact fired only near 967k, the default for a 1M-token model.
  • Compacting at 200k would have saved about 25%.
  • Every session started at about 93k tokens before the first prompt.
  • Sonnet 5.5 costs half of Opus 5.5 on every token type.

A long conversation is paid for again on every step. Keeping it short, and giving cheap work to the cheaper model, is where the savings are.

What it does

Five jobs. The guard and classifier have three modes each: auto acts, ask asks first, off does nothing. The meter and context health take auto or off, and the report is read on demand.

  1. Context guard. Above the soft limit, it compacts the conversation at a safe moment between tasks: after a commit, a passing test run, finished todos, or when your next prompt starts something new. Above the hard limit it compacts at the end of the turn, even mid-task. The summary is told to keep the plan, the decisions, the files touched and your corrections. Your own compaction instructions are never replaced. Every compaction writes one line to the transcript (sizes and why it happened) and is logged, so the report can show them. It never compacts again until the conversation has grown 50k tokens past where the last compaction left it, so a conversation that cannot shrink below the soft limit is not compacted over and over.
  2. Classifier. Local rules read each prompt, in English and Spanish. Execution work, such as plan steps, mechanical edits and searches, gets a short instruction to hand it to a Sonnet subagent and review the result. Design, architecture, security and decisions stay on the main model. The handoff instruction is added only when the main session runs on Opus or Fable; on Sonnet it would only add a step. On Opus or Fable the system prompt also asks Claude to start the built-in general-purpose and Explore subagents on Sonnet for execution work, and to keep review, design, planning and architecture on its own model. Claude makes that call: the mod never changes a subagent as it starts.

The built-in rules are in hooks/rules.ts. To use your own, write them to a JSON file and set rules_file to its absolute path. The file is an array of rules, each with an id, a verdict (opus or sonnet), a pattern and optionally flags. It is read once per session. If it is missing or broken, the built-in rules are used and a warning says so.

  1. Meter. One row right above the prompt, three small gauges in their own colours: context size against the hard limit, weekly use, and today's use against the daily allowance, then how far the week lasts at the current pace. Once a day, if today runs well ahead of the allowance, a calm line above it says how long the week lasts at this pace and where to look.

<img src=".github/readme/over-pace-light.png" width="100%" alt="Above the prompt: today 18% of 11%, at this pace the week lasts to Thursday, /longhaul shows where it went. Under it, the meter: context 142k, week 63%, today 18% of 11%, lasts to Thursday.">

  1. Report. Where the week's tokens went, and why. Biggest sessions, handoff outcomes, a Compactions section (how many, why, typical size before and after), and suggestions. It never edits your configuration.
  2. Context health. Once per session, after the first reply, it takes a free local estimate of what every session loads before any work: MCP tools per server, memory files and skills. It also finds long lines repeated across memory files. /longhaul health lists the items biggest first and says how to lighten them (/mcp to turn a server off for a project, shorten or split a memory file, keep repeated text in one file). At most once a week, a line above the prompt points to it when MCP tools you did not use this week or repeated text make up a large part of the start. Only paths, line counts and token estimates are kept, never file contents.

<img src=".github/readme/health-light.png" width="100%" alt="Above the prompt: sessions start at 68k, 24k of it is MCP tools unused this week, /longhaul health.">

Settings

OptionDefaultValues
guard_modeautoauto, ask, off
classifier_modeautoauto, ask, off
budget_modeautoauto, off
health_modeautoauto, off
soft_limit150000100000 to 1000000 tokens
hard_limit300000100000 to 1000000 tokens, higher than soft_limit
rules_fileemptyabsolute path to a JSON file of classifier rules

Commands

  • /longhaul prints the report in the terminal.
  • /longhaul health shows what every session loads before any work.

What it changes, sends, runs and writes

  • Prompts. When a classifier rule matches and the main session runs on Opus or Fable, the mod adds one note to the prompt's context; your text is not changed. In auto the note reads: "longhaul: this looks like execution work. Delegate it to a Sonnet subagent with a short brief (goal, files, how to check it). Review its changes and test results before accepting, then call mcp__longhaul__review with accepted, fixed or redone." In ask it tells Claude to ask you first. When a new task starts past the soft limit in an interactive session, the prompt is held, the conversation compacts, and the mod submits the same text again, unchanged, then clears the copy Claude Code put back in the prompt box, unless you have typed something else there. If the resend fails, it puts the text back in the prompt box.
  • System prompt. When the main session runs on Opus or Fable, the Agent tool is available and the classifier is on, the prompt.compose hook adds one section at the end: "longhaul: when you start a built-in general-purpose or Explore subagent for execution work, set its model to "sonnet". Keep your own model for review, design, planning and architecture." Subagents start exactly as Claude asks for them; the mod never changes them or their permission mode.
  • Compaction. The session.compact hook adds the keep-list after your own compaction instructions: "Keep, in this order: the current plan and its next step; the decisions made and why; the files touched; open items and failing tests; anything the user corrected or asked for in this session."
  • The band. The ui.render hook draws the meter and its notes above the prompt. With nothing to show, Claude Code draws its own.
  • Everything else only observes. The tool.call, turn.step, turn.complete, session.start and session.end hooks leave every event as it came. They count tokens, compactions and MCP calls, note which files were edited, and check shell commands and todo lists for a commit, a test run or finished todos. Commands are never kept. command.run answers only /longhaul, and the review tool records how a handoff went.
  • Sends. Nothing leaves your machine: no network calls, no telemetry. The resubmitted prompt, the note and the keep-list reach Claude through Claude Code, like your own prompts.
  • Runs. No programs.
  • Writes. No files. Its session records and a few dates (so a heads-up shows at most once a day or once a week) live in the storage Claude Code gives each plugin, and records older than 21 days are deleted. It never edits build, start-up, settings or instructions files. The only file it reads besides your memory files is the one you name in rules_file.

Privacy

Everything stays on your machine. The mod makes no network calls and sends no telemetry. The decision log never stores prompt text. Its records live in the storage Claude Code gives each plugin. PRIVACY.md lists everything it reads and writes.

What it can't do

  • With on-demand tool loading, only loaded schemas count. Context health counts the tool schemas that are already loaded for a server, so a server whose tools load on demand can look smaller than it is. A server is called unused only after the mod's records go back 3 days; before that the list says when it started recording and gives no turn-off advice.
  • No history from before installation. The report reads only the mod's own records, not Claude Code's transcripts, so it starts from the day you install. For older history, use ccusage. Records older than 21 days are deleted. Version 0.1 kept its records in <CLAUDE_CONFIG_DIR or ~/.claude>/longhaul/; 0.2 no longer reads that folder, so you can delete it.
  • Token splits and dollar figures are estimates. Anthropic does not publish how the limit weighs each token type, so these use API prices. The measured percent of the week is the only measured number.
  • Compaction waits for the hook. Claude Code won't compact while a hook holds a prompt. In interactive sessions, a new-task prompt past the soft limit is held, the conversation compacts, and the same text is sent again. In non-interactive runs it never holds a prompt and compacts only at the end of a turn.
  • Organisation-managed Claude Code. There, session.compact hooks don't run in installed plugins. The keep-list is then not added to Claude Code's own compactions, and those aren't counted. The mod's own compactions still pass the keep-list directly.
  • It can't switch models itself. It tells Claude to hand execution work to a Sonnet subagent and to start built-in subagents on Sonnet. Claude decides.

Works well with

context-mode and headroom shrink what goes into the conversation. longhaul manages how long it runs and who does the work.

Licence

MIT.

Source 15 files
hooks/register.tsx 747 lines
1import type { EngineInterface, Register } from 'claude-code'
2import type { BandEvent, Meter } from '../types/index'
3import { readConfig, type Config } from './config'
4import { contextOf } from './prices'
5import { KEEP_LIST, REASON_LABEL, withKeepList, setSoftLimit, getSoftLimit, declineAsk, resetDeclines, clearHardDecline, declines, isSelfCompacting, setSelfCompacting, type Compacted } from './compact'
6import { lastMainCtx, setMainCtx, setCompactPending, isCompactPending, setFloor, takeAboveSoftLog, newEpoch, epoch, peekRecord, setRecord, getFloor } from './session'
7import { emptyFacts, observeTool, isTaskDone, isNewTask, guardDecision, type TurnFacts } from './guard'
8import { addStep, capRecord, lastActivity, newRecord, parse, projectOf, totalCost, MAX_RATE, SESSION_PREFIX, type CompactReason, type SessionRecord } from './ledger'
9import { SEP, NOTICE_MCP, localDate, parseReset, pace } from './budget'
10import { eventText, meterSegs } from './band'
11import { buildReport, renderText, unusedServers, windowOf, type ReportData } from './report'
12import { NOTICE_TOTAL, NOTICE_EVERY, DUP_TOKENS, snapshotOf, unusedOf, addCalls, historyOf, healthAdvice, renderHealth, type BaseSnapshot } from './health'
13import { DEFAULT_RULES, parseRules, takeRulesWarn, type Rule } from './rules'
14import { classify } from './classifier'
15import { HANDOFF, ASK_HANDOFF, SUBAGENT_NOTE, REVIEW_TOOL, OUTCOMES, isExpensiveMain, noteEdit, applyReview, ageDecisions, getRules, setRules, clearMainEdited, resetHandoff } from './handoff'
16
17const DAY = 86_400_000
18const KEEP_DAYS = 21 // a session record this long without activity leaves the store
19
20// Guard state, memory only: nothing about prompts is ever written down.
21let facts: TurnFacts = emptyFacts()
22let recent: string[] = []
23let isInteractive = false // from session.start; only an interactive session may have a prompt dropped
24let loggedKinds = false
25let tries = { id: '', n: 0 } // a failing or empty breakdown is tried at most twice per session
26let judged = '' // the session whose heads-up has been decided (shown, or not due)
27let pending: Promise<SessionRecord> | null = null
28let pendingId = ''
29
30const k = (n: number): string => `${Math.floor(n / 1000)}k`
31
32// Everything that takes $ is a top-level declaration here: the directory reads $ only in this form.
33
34function fail($: EngineInterface, what: string, err: unknown): void {
35  try {
36    $.ui.log(`longhaul: ${what} failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
37  } catch {
38    // logging must not break the session either
39  }
40}
41
42// ---- the session record, kept in the plugin store ----
43
44async function oldestOther($: EngineInterface, key: string): Promise<string | undefined> {
45  const names = await $.store.keys()
46  let victim: string | undefined
47  let oldest = Infinity
48  for (const name of names) {
49    if (!name.startsWith(SESSION_PREFIX) || name === key) continue
50    const stored = await $.store.get(name)
51    const rec = parse(stored, newRecord('', '', 0))
52    const t = rec ? lastActivity(rec) : 0 // an unreadable record goes first
53    if (t < oldest) {
54      oldest = t
55      victim = name
56    }
57  }
58  return victim
59}
60
61// The store holds 4 MiB in all: when it refuses, the oldest other session record makes room.
62async function saveRecord($: EngineInterface, rec: SessionRecord): Promise<void> {
63  capRecord(rec)
64  const key = SESSION_PREFIX + rec.id
65  const dropped = new Set<string>()
66  for (;;) {
67    try {
68      await $.store.set(key, rec)
69      return
70    } catch (err) {
71      const victim = await oldestOther($, key)
72      if (victim === undefined || dropped.has(victim)) {
73        fail($, 'save session record', err)
74        return
75      }
76      dropped.add(victim)
77      await $.store.delete(victim)
78    }
79  }
80}
81
82async function loadRecord($: EngineInterface, id: string, project: string, now: number): Promise<SessionRecord> {
83  const fresh = newRecord(id, project, now)
84  try {
85    const stored = await $.store.get(SESSION_PREFIX + id)
86    return (stored !== undefined && parse(stored, fresh)) || fresh
87  } catch {
88    return fresh
89  }
90}
91
92// Records with activity at or after sinceMs; any older than KEEP_DAYS are deleted on the way.
93async function listRecords($: EngineInterface, sinceMs: number): Promise<SessionRecord[]> {
94  const now = await $.clock.now()
95  let names: string[]
96  try {
97    names = await $.store.keys()
98  } catch {
99    return []
100  }
101  const out: SessionRecord[] = []
102  for (const name of names) {
103    if (!name.startsWith(SESSION_PREFIX)) continue
104    try {
105      const stored = await $.store.get(name)
106      const rec = parse(stored, newRecord('', '', 0))
107      if (!rec) continue
108      const t = lastActivity(rec)
109      if (t < now - KEEP_DAYS * DAY) await $.store.delete(name)
110      else if (t >= sinceMs) out.push(rec)
111    } catch {
112      // unreadable record: skip it
113    }
114  }
115  return out
116}
117
118async function switchTo($: EngineInterface, old: SessionRecord | null, id: string): Promise<SessionRecord> {
119  if (old) await saveRecord($, old) // nothing in memory is discarded
120  const cwd = await $.session.cwd()
121  const next = await loadRecord($, id, projectOf(cwd), Date.now())
122  setRecord(next)
123  return next
124}
125
126// Concurrent callers (parallel subagents) share one in-flight load, so they share one record.
127async function current($: EngineInterface): Promise<SessionRecord> {
128  const id = await $.session.id()
129  const have = peekRecord()
130  if (have && have.id === id) return have
131  if (!pending || pendingId !== id) {
132    pendingId = id
133    pending = switchTo($, have, id)
134  }
135  const p = pending
136  try {
137    return await p
138  } finally {
139    if (pending === p) pending = null
140  }
141}
142
143// ---- the band's state ----
144
145async function readEvent($: EngineInterface): Promise<BandEvent | null> {
146  const s = await $.state.get({ plugin: 'longhaul', key: 'event' })
147  return s.value ?? null
148}
149
150async function setEvent($: EngineInterface, ev: BandEvent | null): Promise<void> {
151  await $.state.set({ plugin: 'longhaul', key: 'event' }, ev)
152}
153
154async function setMeter($: EngineInterface, m: Meter | null): Promise<void> {
155  await $.state.set({ plugin: 'longhaul', key: 'meter' }, m)
156}
157
158// A compaction changes the context size: the gauge shows it now, not at the next turn end.
159async function refreshMeter($: EngineInterface): Promise<void> {
160  const s = await $.state.get({ plugin: 'longhaul', key: 'meter' })
161  const m = s.value ?? null
162  if (m) await $.state.set({ plugin: 'longhaul', key: 'meter' }, { ...m, ctx: lastMainCtx })
163}
164
165// A new main prompt ends whatever the band was showing. A prompt typed over an ask means "Not now".
166async function clearBandOnPrompt($: EngineInterface): Promise<void> {
167  const ev = await readEvent($)
168  if (!ev) return
169  if (ev.kind === 'ask-compact') declineAsk(ev.hard)
170  await setEvent($, null)
171}
172
173// ---- compaction ----
174
175// The one place a finished compaction is counted, drawn on the band and written back to the guard's
176// view of the context. The session.compact hook calls it for manual/auto; compactNow for the plugin's own.
177async function afterCompact($: EngineInterface, trigger: string, r: Compacted, reason?: CompactReason): Promise<void> {
178  if (r.skip !== undefined || !(trigger === 'manual' || trigger === 'auto' || trigger === 'plugin')) return
179  const rec = await current($)
180  rec.compactions[trigger]++
181  const why: CompactReason = reason ?? (trigger === 'plugin' ? 'button' : trigger)
182  const before = typeof r.tokensBefore === 'number' ? r.tokensBefore : undefined
183  const sized = before !== undefined && typeof r.tokensAfter === 'number'
184  let at = Date.now()
185  try {
186    at = await $.clock.now()
187  } catch {
188    // the wall clock will do
189  }
190  rec.compactionLog.push({
191    at,
192    trigger,
193    reason: why,
194    ...(before !== undefined ? { before } : {}),
195    ...(typeof r.tokensAfter === 'number' ? { after: r.tokensAfter } : {}),
196  })
197  try {
198    $.ui.log(`compacted${sized ? ` ${k(before)} \u2192 ${k(r.tokensAfter as number)}` : ''}${SEP}${REASON_LABEL[why]}`, { to: 'transcript' })
199  } catch {
200    // a note must not break the count
201  }
202  setCompactPending(false)
203  const after = typeof r.tokensAfter === 'number' ? r.tokensAfter : 0
204  setMainCtx(after) // the next step re-measures
205  setFloor(after)
206  if (typeof r.tokensAfter === 'number')
207    try {
208      await refreshMeter($) // the gauge shows the new size now, not at the next turn end
209    } catch (err) {
210      fail($, 'meter after compact', err)
211    }
212  if (after > getSoftLimit() && takeAboveSoftLog()) {
213    try {
214      $.ui.log('the conversation is still above the soft limit after compacting; raise soft_limit or trim CLAUDE.md and MCP servers', { to: 'transcript' })
215    } catch {
216      // a note must not break the count
217    }
218  }
219  if (typeof r.tokensBefore === 'number' && typeof r.tokensAfter === 'number')
220    await setEvent($, { kind: 'compacted', before: r.tokensBefore, after: r.tokensAfter })
221}
222
223async function compactNow($: EngineInterface, reason: CompactReason): Promise<void> {
224  setSelfCompacting(true)
225  let r: Compacted
226  try {
227    r = await $.session.compact({ instructions: KEEP_LIST })
228  } finally {
229    setSelfCompacting(false)
230  }
231  await afterCompact($, 'plugin', r, reason)
232}
233
234// ---- the weekly meter ----
235
236// After each main turn: the meter, the rate point, and the once-a-day over-pace heads-up.
237async function updateBudget($: EngineInterface, cfg: Config): Promise<void> {
238  const off = cfg.budgetMode === 'off' // off hides the meter and the heads-up; the weekly reading is still recorded
239  const usage = await $.session.usage()
240  if (!loggedKinds) {
241    loggedKinds = true
242    $.ui.log(`longhaul: rate limit kinds: ${usage.rateLimits.map((r) => r.kind).join(', ') || 'none'}`, { to: 'debug' })
243  }
244  const seven = usage.rateLimits.find((r) => r.kind === 'seven_day') ?? usage.rateLimits.find((r) => /seven|7|week/i.test(r.kind))
245  const resetsAt = parseReset(seven?.resetsAt)
246  if (!seven || resetsAt === null) {
247    await setMeter($, off ? null : { ctx: lastMainCtx, hard: cfg.hardLimit, pace: null })
248    return
249  }
250  const now = await $.clock.now()
251  const date = localDate(now)
252  const percent = seven.percentUsed
253  const key = `day:${date}`
254  let midnight = await $.store.get(key)
255  if (typeof midnight !== 'number') {
256    midnight = percent
257    await $.store.set(key, percent)
258  } else if (percent < midnight) {
259    midnight = 0 // the week reset after the snapshot
260    await $.store.set(key, 0)
261  }
262  const rec = await current($)
263  if (rec.rate.at(-1)?.[1] !== percent) {
264    rec.rate.push([now, percent, totalCost(rec.cost)])
265    rec.rate = rec.rate.slice(-MAX_RATE)
266  }
267  rec.weekResetsAt = seven.resetsAt
268  const p = pace({ percent, resetsAt, now, midnightPercent: midnight as number })
269  await setMeter($, off ? null : { ctx: lastMainCtx, hard: cfg.hardLimit, pace: { ...p, stale: resetsAt <= now } })
270  if (!off && resetsAt > now && p.runsOutAt !== null && p.today > p.allowance * 1.5) {
271    const warned = await $.store.get(`warned:${date}`)
272    if (!warned) {
273      await $.store.set(`warned:${date}`, true)
274      await setEvent($, { kind: 'over-pace', today: Math.floor(p.today), allowance: Math.floor(p.allowance), runsOutAt: p.runsOutAt })
275    }
276  }
277}
278
279async function budgetAfterTurn($: EngineInterface, cfg: Config): Promise<void> {
280  try {
281    await updateBudget($, cfg)
282  } catch (err) {
283    fail($, 'budget', err)
284  }
285}
286
287// ---- context health ----
288
289// The free local estimate of what a session loads before any work. Null when there is none.
290async function takeSnapshot($: EngineInterface): Promise<BaseSnapshot | null> {
291  const usage = await $.session.usage({ breakdown: 'summary' })
292  const b = usage?.context?.breakdown
293  if (!b) return null
294  const texts = new Map<string, string>()
295  for (const m of b.memoryFiles ?? []) {
296    if (texts.has(m.path)) continue
297    try {
298      const text = await $.fs.read(m.path)
299      if (typeof text === 'string') texts.set(m.path, text)
300    } catch {
301      // unreadable: counted by the breakdown, not scanned
302    }
303  }
304  const at = await $.clock.now()
305  return snapshotOf(b, texts, at)
306}
307
308async function weekRecords($: EngineInterface, now: number): Promise<SessionRecord[]> {
309  const all = await listRecords($, now - KEEP_DAYS * DAY)
310  return windowOf(all, now).week
311}
312
313type TurnCtx = { interactive: boolean; compacting: boolean }
314
315// Heads-up: decided once per session, but kept for a later turn while a more urgent event is on the band
316// or a compaction is starting. The cheap gates come first; the week's records are read last.
317async function maybeNotice($: EngineInterface, cfg: Config, rec: SessionRecord, snap: BaseSnapshot, ctx: TurnCtx): Promise<void> {
318  if (cfg.healthMode === 'off' || !ctx.interactive || snap.total < NOTICE_TOTAL) return void (judged = rec.id)
319  if (ctx.compacting) return
320  const now = await $.clock.now()
321  const last = await $.store.get('health:notice')
322  if (typeof last === 'number' && now - last < NOTICE_EVERY) return void (judged = rec.id)
323  const shown = await readEvent($)
324  if (shown !== null) return // keep the cooldown unspent; try again at a later turn
325  const stored = await weekRecords($, now)
326  const week = [...stored.filter((r) => r.id !== rec.id), rec]
327  const unusedMcp = unusedOf(snap, addCalls(week), historyOf(week, now).mature).reduce((s, [, v]) => s + v.tokens, 0)
328  if (unusedMcp < NOTICE_MCP && snap.duplicate.tokens < DUP_TOKENS) return void (judged = rec.id)
329  await $.store.set('health:notice', now)
330  await setEvent($, { kind: 'health', total: snap.total, unusedMcp, duplicate: snap.duplicate.tokens })
331  judged = rec.id
332}
333
334// Call last in the turn end. First main turn: take the snapshot once and save it; then the heads-up.
335async function healthAfterFirstTurn($: EngineInterface, cfg: Config, ctx: TurnCtx): Promise<void> {
336  try {
337    const rec = await current($)
338    if (!rec.base) {
339      if (tries.id !== rec.id) tries = { id: rec.id, n: 0 }
340      if (tries.n++ >= 2) return
341      const snap = await takeSnapshot($)
342      if (!snap) return
343      rec.base = snap
344      await saveRecord($, rec)
345    }
346    if (judged !== rec.id) await maybeNotice($, cfg, rec, rec.base, ctx)
347  } catch (err) {
348    fail($, 'context health', err)
349  }
350}
351
352// This session's snapshot, else the latest of the same project, else the latest overall.
353async function runHealth($: EngineInterface): Promise<{ text: string }> {
354  const now = await $.clock.now()
355  const rec = await current($)
356  const stored = await weekRecords($, now)
357  const week = [...stored.filter((r) => r.id !== rec.id), rec]
358  const byAt = week.filter((r) => r.base).sort((a, b) => b.base!.at - a.base!.at)
359  const pick = rec.base ? rec : (byAt.find((r) => r.project === rec.project) ?? byAt[0])
360  if (!pick?.base) return { text: "No snapshot yet: it's taken after the first reply of a session." }
361  const calls = addCalls(week)
362  const history = historyOf(week, now)
363  return { text: `Snapshot from project ${pick.project}.\n\n${renderHealth(pick.base, healthAdvice(pick.base, calls, history), calls, history)}` }
364}
365
366// ---- the report ----
367
368async function reportFor($: EngineInterface, cfg: Config): Promise<ReportData> {
369  const now = await $.clock.now()
370  const records = await listRecords($, now - KEEP_DAYS * DAY)
371  let unused: string[] = []
372  try {
373    const tools = await $.tool.list()
374    unused = unusedServers(tools.map((t) => t.name), windowOf(records, now).week)
375  } catch (err) {
376    fail($, 'list tools for report', err)
377  }
378  return buildReport(records, now, unused, cfg)
379}
380
381async function runReport($: EngineInterface, cfg: Config): Promise<{ text: string }> {
382  const d = await reportFor($, cfg)
383  return { text: renderText(d) }
384}
385
386async function registerReport($: EngineInterface): Promise<void> {
387  try {
388    await $.command.register({ name: 'longhaul', description: "Where this week's tokens went and why", argumentHint: '[health]' })
389  } catch (err) {
390    fail($, 'register longhaul command', err)
391  }
392}
393
394// ---- Sonnet handoffs ----
395
396// The rules file named in the options replaces the built-in rules; no file is ever written.
397async function loadRules($: EngineInterface, cfg: Config): Promise<Rule[]> {
398  const file = cfg.rulesFile
399  if (!file) return DEFAULT_RULES
400  let text: string
401  try {
402    text = await $.fs.read(file)
403  } catch {
404    if (takeRulesWarn()) $.ui.log(`longhaul: could not read the rules file ${file}; using the built-in rules`, { to: 'debug' })
405    return DEFAULT_RULES
406  }
407  const rules = parseRules(text)
408  if (!rules) {
409    if (takeRulesWarn()) $.ui.log(`longhaul: the rules file ${file} is not a valid rules array; using the built-in rules`, { to: 'debug' })
410    return DEFAULT_RULES
411  }
412  return rules
413}
414
415// New main prompt: age pending decisions, then classify. Returns the context to attach, if any.
416async function handoffContext($: EngineInterface, cfg: Config, text: string): Promise<string | undefined> {
417  try {
418    const rec = await current($)
419    ageDecisions(rec)
420    if (cfg.classifierMode === 'off') return undefined
421    let rules = getRules()
422    if (!rules) {
423      rules = await loadRules($, cfg)
424      setRules(rules)
425    }
426    const r = classify(text, rules)
427    if (r.verdict !== 'sonnet') return undefined
428    const model = await $.session.model()
429    if (!isExpensiveMain(model)) return undefined
430    const at = await $.clock.now()
431    rec.decisions.push({ at, verdict: 'sonnet', ruleIds: r.ruleIds, mode: cfg.classifierMode, outcome: 'pending', subFiles: [], turnsSince: 0 })
432    clearMainEdited()
433    return cfg.classifierMode === 'ask' ? ASK_HANDOFF : HANDOFF
434  } catch (err) {
435    fail($, 'handoff', err)
436    return undefined
437  }
438}
439
440async function registerReview($: EngineInterface): Promise<void> {
441  try {
442    await $.tool.register({
443      name: 'review',
444      description: 'Record how a Sonnet handoff went after you reviewed it.',
445      inputSchema: { type: 'object', properties: { outcome: { type: 'string', enum: [...OUTCOMES] } }, required: ['outcome'] },
446      isDeferred: true,
447    })
448  } catch (err) {
449    fail($, 'register review tool', err)
450  }
451}
452
453// ---- the guard ----
454
455// New session or /clear: forget everything the guard knew; timers of the old one become no-ops.
456function resetGuard(): void {
457  newEpoch()
458  facts = emptyFacts()
459  recent = []
460  resetDeclines()
461  clearHardDecline()
462  resetHandoff()
463}
464
465// Turn end, main loop: compact or ask once the task is done, or at the hard limit.
466// Compacting runs from a timer so the turn is never held; a rejection there leaves a pending flag.
467async function atTurnEnd($: EngineInterface, cfg: Config): Promise<string> {
468  if (lastMainCtx < cfg.hardLimit) clearHardDecline()
469  const act = guardDecision({
470    mode: cfg.guardMode,
471    ctx: lastMainCtx,
472    floor: getFloor(),
473    soft: cfg.softLimit,
474    hard: cfg.hardLimit,
475    at: 'turn-end',
476    taskDone: isTaskDone(facts),
477    newTask: false,
478    ...declines(),
479  })
480  if (act === 'ask') await setEvent($, { kind: 'ask-compact', hard: lastMainCtx >= cfg.hardLimit })
481  else if (act === 'compact-at-turn-end') {
482    const ep = epoch()
483    const reason: CompactReason = lastMainCtx >= cfg.hardLimit ? 'hard-limit' : 'task-done' // decided now, not when the timer fires
484    $.clock.after(0, () => void compactTimer($, ep, reason))
485  }
486  return act
487}
488
489async function compactTimer($: EngineInterface, ep: number, reason: CompactReason): Promise<void> {
490  if (ep !== epoch()) return
491  try {
492    await compactNow($, reason)
493  } catch (err) {
494    setCompactPending(true) // the next prompt compacts first
495    fail($, 'compact at turn end', err)
496  }
497}
498
499// Interactive new-task prompt: the hook has already returned { drop }; compact, then send the text again.
500async function compactThenSubmit($: EngineInterface, text: string, ep: number, reason: CompactReason): Promise<void> {
501  if (ep !== epoch()) return
502  try {
503    await compactNow($, reason)
504  } catch (err) {
505    fail($, 'compact before prompt', err)
506  }
507  setCompactPending(false) // whatever happened: a failing summariser must not drop every prompt
508  if (ep !== epoch()) return
509  try {
510    await $.prompt.submit({ text, asUser: true }) // skips this plugin's own prompt.submit hook
511    // the drop put the text back in the box; clear it unless the person has typed since
512    if ((await $.prompt.read()).text.trim() === text.trim()) await $.prompt.fill({ text: '' })
513  } catch (err) {
514    fail($, 'resubmit after compact', err)
515    try {
516      await $.prompt.fill({ text })
517      $.ui.log("couldn't resend your prompt; it's back in the box", { to: 'transcript' })
518    } catch (err2) {
519      fail($, 'refill prompt', err2)
520    }
521  }
522}
523
524// New main prompt: maybe compact first. Returns a drop reason when the prompt was held back.
525// Never drops in a non-interactive session, or when `canDrop` is false; the turn-end path handles those.
526async function atPrompt($: EngineInterface, cfg: Config, text: string, canDrop: boolean): Promise<string | undefined> {
527  const waiting = isCompactPending() && cfg.guardMode !== 'off'
528  const act = waiting
529    ? 'compact-before-prompt'
530    : guardDecision({
531        mode: cfg.guardMode,
532        ctx: lastMainCtx,
533        floor: getFloor(),
534        soft: cfg.softLimit,
535        hard: cfg.hardLimit,
536        at: 'prompt',
537        taskDone: false,
538        newTask: isNewTask(text, recent),
539        ...declines(),
540      })
541  recent = [...recent, text].slice(-3)
542  facts = emptyFacts()
543  resetDeclines()
544  if (act !== 'compact-before-prompt' || !isInteractive || !canDrop) return undefined
545  const ep = epoch()
546  const reason: CompactReason = waiting ? 'retry' : lastMainCtx >= cfg.hardLimit ? 'hard-limit' : 'new-task'
547  $.clock.after(0, () => void compactThenSubmit($, text, ep, reason))
548  return 'longhaul: compacting before the new task'
549}
550
551export const register: Register = (on, options) => {
552  const cfg = readConfig(options)
553  setSoftLimit(cfg.softLimit)
554
555  // The band: the latest event in calm grey, and under it, right above the prompt, the meter row.
556  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
557    if (e.props.hasSurvey) return next(e)
558    const shown = await $.state.get({ plugin: 'longhaul', key: 'event' })
559    const meter = await $.state.get({ plugin: 'longhaul', key: 'meter' })
560    const ev = shown.value ?? null
561    const m = meter.value ?? null
562    const { Box, Button, Text } = $.ui.resolve(e)
563    const rows = []
564    if (ev && ev.kind !== 'ask-compact') rows.push(<Text dimColor>{eventText(ev)}</Text>)
565    else if (ev?.kind === 'ask-compact' && !e.props.isWorking)
566      // the ask waits for an idle prompt
567      rows.push(
568        <Box>
569          <Text dimColor>{`a good moment to compact${SEP}keeps the plan, decisions and open items `}</Text>
570          <Button
571            key="compact"
572            label="Compact now"
573            onPress={async () => {
574              try {
575                await compactNow($, 'button')
576                const now = await readEvent($)
577                if (now?.kind === 'ask-compact') await setEvent($, null)
578              } catch (err) {
579                fail($, 'compact from band', err)
580              }
581            }}
582          />
583          <Button
584            key="later"
585            label="Not now"
586            onPress={async () => {
587              declineAsk(ev.hard)
588              await setEvent($, null)
589            }}
590          />
591        </Box>,
592      )
593    if (m)
594      rows.push(
595        <Text wrap="truncate-end">
596          {meterSegs(m, e.props.bodyColumns ?? 120).map((s) => (
597            <Text {...(s.color ? { color: s.color } : {})} {...(s.dim ? { dimColor: true } : {})}>
598              {s.text}
599            </Text>
600          ))}
601        </Text>,
602      )
603    if (!rows.length) return next(e)
604    return <Box flexDirection="column">{rows}</Box>
605  })
606
607  on('turn.step', async function* ($, e, next) {
608    const r = yield* next(e)
609    try {
610      if (r.usage) {
611        const sub = Boolean(e.agentId)
612        const rec = await current($)
613        addStep(rec, r.usage.model, r.usage, sub)
614        if (!sub) setMainCtx(contextOf(r.usage))
615      }
616    } catch (err) {
617      fail($, 'turn.step', err)
618    }
619    return r
620  })
621
622  on('tool.call', async ($, e, next) => {
623    const r = await next(e)
624    try {
625      if (!e.agentId && !('deny' in r && r.deny)) facts = observeTool(facts, { tool: e.tool, input: e.input as Record<string, unknown>, ...('isError' in r && r.isError ? { isError: true as const } : {}) })
626      if (!('deny' in r && r.deny)) {
627        const rec = await current($)
628        noteEdit(rec, { tool: e.tool, input: e.input, ...(e.agentId ? { agentId: e.agentId } : {}) })
629      }
630      const m = /^mcp__(.+?)__/.exec(e.tool)
631      if (m) {
632        const rec = await current($)
633        rec.mcpCalls[m[1]] = (rec.mcpCalls[m[1]] ?? 0) + 1
634      }
635    } catch (err) {
636      fail($, 'tool.call', err)
637    }
638    return r
639  })
640
641  on('tool.call', { tool: REVIEW_TOOL }, async ($, e) => {
642    try {
643      const rec = await current($)
644      return applyReview(rec, e.input)
645    } catch (err) {
646      fail($, 'review', err)
647      return { deny: 'longhaul: could not record the outcome.' }
648    }
649  })
650
651  // On Opus or Fable, the system prompt asks for Sonnet subagents on execution work.
652  on('prompt.compose', async ($, e, next) => {
653    if (cfg.classifierMode === 'off' || !isExpensiveMain(e.model) || !e.tools.includes('Agent')) return next(e)
654    const r = await next(e)
655    return { sections: [...r.sections, { id: 'longhaul:subagents', text: SUBAGENT_NOTE, scope: 'session' as const }] }
656  })
657
658  on('turn.complete', async ($, e, next) => {
659    const r = await next(e)
660    if (!e.agentId) {
661      await budgetAfterTurn($, cfg) // before the save, so this turn's rate point is in the store
662      try {
663        const rec = await current($)
664        await saveRecord($, rec)
665      } catch (err) {
666        fail($, 'turn.complete', err)
667      }
668      if (e.isAborted) {
669        await healthAfterFirstTurn($, cfg, { interactive: isInteractive, compacting: false }) // an interrupted turn is no safe moment for the guard
670        return r
671      }
672      let act = ''
673      try {
674        act = await atTurnEnd($, cfg)
675      } catch (err) {
676        fail($, 'guard at turn end', err)
677      }
678      // last: its heads-up never replaces a more urgent event, nor lands as a compaction starts
679      await healthAfterFirstTurn($, cfg, { interactive: isInteractive, compacting: act === 'compact-at-turn-end' })
680    }
681    return r
682  })
683
684  on('prompt.submit', async ($, e, next) => {
685    // other plugins' prompts, commands and meta prompts pass untouched
686    if (e.command || e.isMeta || e.turnId || e.origin?.kind === 'plugin') return next(e)
687    // classify first: a handoff prompt is never held back, its compaction waits for the turn end
688    const note = await handoffContext($, cfg, e.text)
689    let drop: string | undefined
690    try {
691      drop = await atPrompt($, cfg, e.text, !note && !e.attachments?.length)
692    } catch (err) {
693      fail($, 'prompt.submit', err)
694    }
695    try {
696      await clearBandOnPrompt($)
697    } catch (err) {
698      fail($, 'clear band', err)
699    }
700    if (drop) return { drop }
701    return next(note ? { ...e, context: [...(e.context ?? []), note] } : e)
702  })
703
704  on('session.start', async ($, e, next) => {
705    isInteractive = e.isInteractive
706    resetGuard()
707    $.ui.status(undefined) // clears the plain status line an earlier build pinned; the meter is in the band now
708    await registerReview($)
709    await registerReport($)
710    return next(e)
711  })
712
713  on('command.run', { command: 'longhaul' }, async ($, e) => {
714    try {
715      const args = (e.args ?? '').trim().toLowerCase()
716      if (args === 'health') return await runHealth($)
717      return await runReport($, cfg)
718    } catch (err) {
719      fail($, 'longhaul report', err)
720      return { text: 'longhaul: could not build the report.' }
721    }
722  })
723
724  on('session.compact', async ($, e, next) => {
725    if (e.agentId || isSelfCompacting()) return next(e) // a subagent's, or the mod's own: no keep-list, not counted here
726    const r = await next({ ...e, instructions: withKeepList(e.instructions) })
727    try {
728      await afterCompact($, e.trigger, r, e.trigger === 'manual' || e.trigger === 'auto' ? e.trigger : undefined)
729    } catch (err) {
730      fail($, 'session.compact', err)
731    }
732    return r
733  })
734
735  on('session.end', async ($, e, next) => {
736    const r = await next(e)
737    try {
738      const rec = peekRecord()
739      if (rec) await saveRecord($, rec)
740    } catch (err) {
741      fail($, 'session.end', err)
742    }
743    if (e.reason === 'clear') resetGuard()
744    return r
745  })
746}
747
hooks/config.ts 35 lines
1export type Mode = 'auto' | 'ask' | 'off'
2export type Config = {
3  guardMode: Mode
4  classifierMode: Mode
5  budgetMode: 'auto' | 'off'
6  healthMode: 'auto' | 'off'
7  softLimit: number
8  hardLimit: number
9  rulesFile: string
10}
11
12const MIN = 100000
13const MAX = 1000000
14
15const pick = <T extends string>(v: unknown, allowed: readonly T[], dflt: T): T =>
16  allowed.includes(v as T) ? (v as T) : dflt
17
18const clamp = (v: unknown, dflt: number): number =>
19  typeof v === 'number' && Number.isFinite(v) ? Math.min(MAX, Math.max(MIN, v)) : dflt
20
21export function readConfig(options: Record<string, unknown>): Config {
22  const softLimit = clamp(options.soft_limit, 150000)
23  let hardLimit = clamp(options.hard_limit, 300000)
24  if (hardLimit <= softLimit) hardLimit = Math.min(MAX, Math.max(300000, softLimit + 50000))
25  return {
26    guardMode: pick(options.guard_mode, ['auto', 'ask', 'off'], 'auto'),
27    classifierMode: pick(options.classifier_mode, ['auto', 'ask', 'off'], 'auto'),
28    budgetMode: pick(options.budget_mode, ['auto', 'off'], 'auto'),
29    healthMode: pick(options.health_mode, ['auto', 'off'], 'auto'),
30    softLimit,
31    hardLimit,
32    rulesFile: typeof options.rules_file === 'string' ? options.rules_file.trim() : '',
33  }
34}
35
hooks/prices.ts 49 lines
1import type { ModelUsage } from 'claude-code'
2
3export type Tier = 'opus' | 'sonnet' | 'haiku' | 'fable'
4export type Price = { input: number; write: number; read: number; output: number }
5
6// $ per million tokens. Source: https://platform.claude.com/docs/en/about-claude/pricing
7// `write` is the 5-minute cache-write rate; 1-hour writes cost more and are counted at the 5-minute rate.
8// PRICES.haiku is the "prompts up to 100,000 tokens" row; HAIKU_LONG is the over-100k row (see stepCost).
9export const PRICES_AS_OF = '2026-10-18'
10export const PRICES: Record<Tier, Price> = {
11  opus: { input: 4, write: 5, read: 0.2, output: 20 },
12  sonnet: { input: 2, write: 2.5, read: 0.1, output: 10 },
13  haiku: { input: 0.1, write: 0.125, read: 0.01, output: 0.5 },
14  fable: { input: 10, write: 12.5, read: 0.25, output: 50 },
15}
16
17const HAIKU_LONG: Price = { input: 0.5, write: 0.625, read: 0.05, output: 2.5 }
18
19export function tierOf(model: string): Tier | null {
20  const m = model.toLowerCase()
21  if (m.includes('fable') || m.includes('mythos')) return 'fable'
22  for (const t of ['opus', 'sonnet', 'haiku'] as const) if (m.includes(t)) return t
23  return null
24}
25
26export type StepCost = { input: number; write: number; read: number; output: number }
27
28export function stepCost(model: string, u: ModelUsage): StepCost | null {
29  const t = tierOf(model)
30  if (!t) return null
31  const p = t === 'haiku' && contextOf(u) > 100_000 ? HAIKU_LONG : PRICES[t]
32  return {
33    input: (u.input_tokens * p.input) / 1e6,
34    write: (u.cache_creation_input_tokens * p.write) / 1e6,
35    read: (u.cache_read_input_tokens * p.read) / 1e6,
36    output: (u.output_tokens * p.output) / 1e6,
37  }
38}
39
40export function contextOf(u: ModelUsage): number {
41  return u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
42}
43
44export type Bucket = 'lt100k' | '100to200k' | '200to400k' | 'gt400k'
45
46export function bucketOf(ctx: number): Bucket {
47  return ctx < 100_000 ? 'lt100k' : ctx < 200_000 ? '100to200k' : ctx < 400_000 ? '200to400k' : 'gt400k'
48}
49
hooks/compact.ts 41 lines
1import type { CompactReason } from './ledger'
2
3export const KEEP_LIST =
4  'Keep, in this order: the current plan and its next step; the decisions made and why; the files touched; open items and failing tests; anything the user corrected or asked for in this session.'
5
6export const withKeepList = (instructions: string | undefined): string =>
7  instructions ? `${instructions}\n${KEEP_LIST}` : KEEP_LIST
8
9// Decline flags: the band sets them, register.tsx reads them.
10let declinedThisTask = false
11let declinedHard = false
12export const declineAsk = (hard: boolean): void => void (hard ? (declinedHard = true) : (declinedThisTask = true))
13export const resetDeclines = (): void => void (declinedThisTask = false)
14// Back under the hard limit: a later crossing asks again.
15export const clearHardDecline = (): void => void (declinedHard = false)
16export const declines = () => ({ declinedThisTask, declinedHard })
17
18// The soft limit, set once by register.tsx; only used to word the one "still above" note.
19let softLimit = Infinity
20export const setSoftLimit = (n: number): void => void (softLimit = n)
21export const getSoftLimit = (): number => softLimit
22
23export const REASON_LABEL: Record<CompactReason, string> = {
24  'task-done': 'task finished',
25  'new-task': 'new task',
26  'hard-limit': 'hard limit',
27  retry: 'retry',
28  button: 'you asked',
29  manual: '/compact',
30  auto: 'Claude Code auto-compact',
31}
32
33export type Compacted = { tokensBefore?: number; tokensAfter?: number; skip?: string }
34
35// Claude Code 2.1.302 also runs the mod's own compaction through the mod's session.compact hook, as
36// 'manual'. While one is in flight that hook passes it through, and it is counted here, once, for
37// every caller (timers, the band button).
38let selfCompacting = false
39export const isSelfCompacting = (): boolean => selfCompacting
40export const setSelfCompacting = (v: boolean): void => void (selfCompacting = v)
41
hooks/session.ts 28 lines
1import type { SessionRecord } from './ledger'
2
3// Main-loop context of the latest step; the guard reads it. Read it through a live binding.
4export let lastMainCtx = 0
5export const setMainCtx = (n: number): void => void (lastMainCtx = n)
6
7// Bumped when a session starts or is cleared; timers scheduled earlier see it changed and do nothing.
8let epochN = 0
9export const epoch = (): number => epochN
10export const newEpoch = (): void => void (epochN++, (lastMainCtx = 0), (compactPending = false), (floorCtx = 0), (aboveSoftLogged = false))
11
12// Context size right after the last compaction: the least the conversation can shrink to. The guard
13// does not compact again until the context has grown MIN_GAIN past it.
14let floorCtx = 0
15export const getFloor = (): number => floorCtx
16export const setFloor = (n: number): void => void (floorCtx = n)
17let aboveSoftLogged = false
18export const takeAboveSoftLog = (): boolean => (aboveSoftLogged ? false : (aboveSoftLogged = true))
19
20// A turn-end compaction was refused; retry before the next prompt. Any successful compaction clears it.
21let compactPending = false
22export const isCompactPending = (): boolean => compactPending
23export const setCompactPending = (v: boolean): void => void (compactPending = v)
24
25let rec: SessionRecord | null = null
26export const peekRecord = (): SessionRecord | null => rec
27export const setRecord = (r: SessionRecord): void => void (rec = r)
28
hooks/guard.ts 116 lines
1import type { Mode } from './config'
2
3export type TurnFacts = {
4  committed: boolean
5  testsPassed: boolean
6  editedAfterTests: boolean
7  todosDone: boolean
8  editedFiles: string[]
9}
10
11export const emptyFacts = (): TurnFacts => ({
12  committed: false,
13  testsPassed: false,
14  editedAfterTests: false,
15  todosDone: false,
16  editedFiles: [],
17})
18
19const COMMIT = /\bgit\s+commit\b|\bgh\s+pr\s+create\b/
20const TEST_RUN =
21  /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bpytest\b|\bcargo\s+test\b|\bgo\s+test\b|\bvitest\b|\bjest\b|\bclaude\s+plugin\s+test\b/
22const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit']
23
24export function observeTool(
25  f: TurnFacts,
26  use: { tool: string; input: Record<string, unknown>; isError?: true },
27): TurnFacts {
28  const input = use?.input && typeof use.input === 'object' ? use.input : {}
29  const next = { ...f, editedFiles: [...f.editedFiles] }
30  if (use?.tool === 'Bash' && typeof input.command === 'string') {
31    if (use.isError) {
32      if (TEST_RUN.test(input.command)) next.testsPassed = false
33    } else {
34      if (COMMIT.test(input.command)) next.committed = true
35      if (TEST_RUN.test(input.command)) {
36        next.testsPassed = true
37        next.editedAfterTests = false
38      }
39    }
40  } else if (EDIT_TOOLS.includes(use?.tool) && typeof input.file_path === 'string' && input.file_path) {
41    if (next.testsPassed) next.editedAfterTests = true
42    if (!next.editedFiles.includes(input.file_path)) next.editedFiles.push(input.file_path)
43  } else if (use?.tool === 'TodoWrite' && Array.isArray(input.todos)) {
44    const todos = input.todos as { status?: unknown }[]
45    next.todosDone = todos.length > 0 && todos.every((t) => t?.status === 'completed')
46  }
47  return next
48}
49
50export const isTaskDone = (f: TurnFacts): boolean =>
51  f.committed || (f.testsPassed && !f.editedAfterTests) || f.todosDone
52
53// Phrases are matched on lowercased text with accents stripped and curly apostrophes
54// straightened. Anchored so they do not fire mid-sentence in an ongoing task.
55const NEW_TASK_PHRASES = [
56  /\bwhat('?s| is) next\b/,
57  /\bnow let's\b/,
58  /\banother thing\b/,
59  /\bnew task\b/,
60  /^(the )?next task\b/,
61  /^(y ahora )?que sigue\b/,
62  /\botra cosa\b/,
63  /\bnueva tarea\b/,
64  /^(la )?siguiente tarea\b/,
65]
66// Issue/PR references: "#12" (2+ digits), "PR 12", "issue 12", ".../pull/12", ".../issues/12".
67const REF = /#(\d{2,})\b|\b(?:pr|issue)\s*#?\s*(\d+)|\/(?:pull|issues)\/(\d+)/gi
68const CSS_CONTEXT = /\b(colou?r|background|border|fill|stroke|shadow|hex|css|font|padding|margin)\b/i
69
70const norm = (s: string) => s.replace(/[‘’]/g, "'").toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
71const refs = (s: string) =>
72  new Set(
73    [...s.matchAll(REF)]
74      .filter((m) => !(m[1] && /^(\d{3}|\d{6})$/.test(m[1]) && CSS_CONTEXT.test(s)))
75      .map((m) => m[1] ?? m[2] ?? m[3]),
76  )
77const words = (s: string) => new Set(norm(s).match(/[a-zñ]{3,}/g) ?? [])
78
79export function isNewTask(text: string, recent: string[]): boolean {
80  const t = norm(text)
81  if (NEW_TASK_PHRASES.some((r) => r.test(t))) return true
82  if (!recent.length) return false
83  const seen = new Set(recent.flatMap((p) => [...refs(p)]))
84  if (seen.size && [...refs(text)].some((r) => !seen.has(r))) return true
85  const w = words(text)
86  if (w.size < 5) return false
87  const comparable = recent.map(words).filter((r) => r.size >= 5)
88  if (!comparable.length) return false
89  // ponytail: Jaccard on the whole word set; good enough for a heuristic signal.
90  return comparable.every((r) => [...w].filter((x) => r.has(x)).length / new Set([...w, ...r]).size < 0.1)
91}
92
93// A compaction must be able to free at least this much, or it is not worth its cost.
94export const MIN_GAIN = 50_000
95
96export type GuardAction = 'none' | 'compact-before-prompt' | 'compact-at-turn-end' | 'ask'
97
98export function guardDecision(i: {
99  mode: Mode
100  ctx: number
101  soft: number
102  hard: number
103  at: 'turn-end' | 'prompt'
104  taskDone: boolean
105  newTask: boolean
106  declinedThisTask: boolean
107  declinedHard: boolean
108  floor?: number // context right after the last compaction
109}): GuardAction {
110  if (i.mode === 'off' || i.ctx < i.soft || i.ctx - (i.floor ?? 0) < MIN_GAIN) return 'none'
111  if (i.at === 'prompt') return i.mode === 'auto' && i.newTask ? 'compact-before-prompt' : 'none'
112  if (i.ctx >= i.hard) return i.mode === 'auto' ? 'compact-at-turn-end' : i.declinedHard ? 'none' : 'ask'
113  if (!i.taskDone) return 'none'
114  return i.mode === 'auto' ? 'compact-at-turn-end' : i.declinedThisTask ? 'none' : 'ask'
115}
116
hooks/ledger.ts 123 lines
1import type { ModelUsage } from 'claude-code'
2import type { BaseSnapshot } from './health'
3import { stepCost, contextOf, bucketOf, type StepCost, type Bucket } from './prices'
4
5export type Decision = {
6  at: number
7  verdict: 'sonnet'
8  ruleIds: string[]
9  mode: 'auto' | 'ask'
10  outcome: 'pending' | 'accepted' | 'fixed' | 'redone' | 'unreported'
11  guessed?: true
12  subFiles: string[]
13  turnsSince: number
14}
15
16export type CompactReason = 'task-done' | 'new-task' | 'hard-limit' | 'retry' | 'button' | 'manual' | 'auto'
17export type CompactionEntry = { at: number; trigger: 'plugin' | 'manual' | 'auto'; reason: CompactReason; before?: number; after?: number }
18
19export type SessionRecord = {
20  v: 1
21  id: string
22  project: string
23  startedAt: number
24  steps: number
25  subSteps: number
26  peakCtx: number
27  cost: StepCost
28  byBucket: Record<Bucket, number>
29  compactions: { auto: number; manual: number; plugin: number }
30  rate: [at: number, weeklyPercent: number, cumulativeCost: number][]
31  weekResetsAt?: string
32  mcpCalls: Record<string, number>
33  decisions: Decision[]
34  compactionLog: CompactionEntry[]
35  base?: BaseSnapshot
36}
37
38const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
39
40export const totalCost = (c: StepCost): number => c.input + c.write + c.read + c.output
41
42export function newRecord(id: string, project: string, now: number): SessionRecord {
43  return {
44    v: 1,
45    id,
46    project,
47    startedAt: now,
48    steps: 0,
49    subSteps: 0,
50    peakCtx: 0,
51    cost: { input: 0, write: 0, read: 0, output: 0 },
52    byBucket: { lt100k: 0, '100to200k': 0, '200to400k': 0, gt400k: 0 },
53    compactions: { auto: 0, manual: 0, plugin: 0 },
54    rate: [],
55    mcpCalls: {},
56    decisions: [],
57    compactionLog: [],
58  }
59}
60
61// A main step's cost lands in the bucket of its context size; a subagent step has its own
62// context, so it adds to cost and subSteps only.
63export function addStep(rec: SessionRecord, model: string, u: ModelUsage, isSub: boolean): void {
64  const n = {
65    input_tokens: num(u?.input_tokens),
66    output_tokens: num(u?.output_tokens),
67    cache_read_input_tokens: num(u?.cache_read_input_tokens),
68    cache_creation_input_tokens: num(u?.cache_creation_input_tokens),
69  }
70  const c = stepCost(model, n)
71  if (c) {
72    rec.cost.input += c.input
73    rec.cost.write += c.write
74    rec.cost.read += c.read
75    rec.cost.output += c.output
76  }
77  if (isSub) {
78    rec.subSteps++
79    return
80  }
81  rec.steps++
82  const ctx = contextOf(n)
83  rec.peakCtx = Math.max(rec.peakCtx, ctx)
84  if (c) rec.byBucket[bucketOf(ctx)] += totalCost(c)
85}
86
87export const projectOf = (cwd: string): string => cwd.split(/[\\/]/).filter(Boolean).pop() ?? ''
88
89export function parse(stored: unknown, fresh: SessionRecord): SessionRecord | null {
90  try {
91    const p = typeof stored === 'string' ? JSON.parse(stored) : stored
92    if (!p || p.v !== 1 || typeof p.id !== 'string') return null
93    return {
94      ...fresh,
95      ...p,
96      cost: { ...fresh.cost, ...p.cost },
97      byBucket: { ...fresh.byBucket, ...p.byBucket },
98      compactions: { ...fresh.compactions, ...p.compactions },
99      mcpCalls: { ...p.mcpCalls },
100      rate: Array.isArray(p.rate) ? p.rate : [],
101      decisions: Array.isArray(p.decisions) ? p.decisions : [],
102      compactionLog: Array.isArray(p.compactionLog) ? p.compactionLog : [],
103    }
104  } catch {
105    return null
106  }
107}
108
109export const MAX_RATE = 2000
110export const MAX_DECISIONS = 500
111export const MAX_COMPACTION_LOG = 200
112
113export const SESSION_PREFIX = 'session:'
114
115// The store is capped at 4 MiB: keep the newest points of the unbounded lists.
116export function capRecord(rec: SessionRecord): void {
117  rec.rate = rec.rate.slice(-MAX_RATE)
118  rec.decisions = rec.decisions.slice(-MAX_DECISIONS)
119  rec.compactionLog = rec.compactionLog.slice(-MAX_COMPACTION_LOG)
120}
121
122export const lastActivity = (rec: SessionRecord): number => Math.max(rec.startedAt, rec.rate.at(-1)?.[0] ?? 0)
123
hooks/budget.ts 47 lines
1const DAY = 86_400_000
2const SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
3const LONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
4
5// U+00B7, written as an escape so no editor or shell encoding can corrupt it.
6export const SEP = ' \u00b7 '
7
8// Unused MCP tokens at which the context-health heads-up is worth a line.
9export const NOTICE_MCP = 10_000
10
11// Too early in the week the rate says nothing: no projection before this much of it has passed.
12const MIN_PROJECT = 6 * 3_600_000
13
14export type Pace = { weekly: number; today: number; allowance: number; resetsAt: number; runsOutAt: number | null }
15
16export const shortDay = (t: number): string => SHORT[new Date(t).getDay()]
17export const longDay = (t: number): string => LONG[new Date(t).getDay()]
18
19// Local YYYY-MM-DD.
20export function localDate(t: number): string {
21  const d = new Date(t)
22  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
23}
24
25export function pace(i: { percent: number; resetsAt: number; now: number; midnightPercent: number }): Pace {
26  const start = i.resetsAt - 7 * DAY
27  const elapsed = i.now - start
28  const left = (i.resetsAt - i.now) / DAY
29  const room = Math.max(0, 100 - i.percent)
30  const allowance = left > 0 ? Math.min(room, room / left) : 0
31  const out = i.percent > 0 && elapsed >= MIN_PROJECT ? start + (100 / i.percent) * elapsed : null
32  return {
33    weekly: i.percent,
34    today: Math.max(0, i.percent - i.midnightPercent),
35    allowance,
36    resetsAt: i.resetsAt,
37    runsOutAt: out !== null && out <= i.resetsAt ? out : null,
38  }
39}
40
41// resetsAt is an ISO string; an epoch-seconds string is accepted too.
42export function parseReset(s: string | undefined): number | null {
43  if (!s) return null
44  const n = /^\d+(\.\d+)?$/.test(s) ? Number(s) * 1000 : Date.parse(s)
45  return Number.isFinite(n) ? n : null
46}
47
hooks/band.tsx 57 lines
1import type { BandEvent, Meter } from '../types/index'
2import { longDay, shortDay, NOTICE_MCP, SEP } from './budget'
3
4const k = (n: number): string => `${Math.floor(n / 1000)}k`
5
6// The meter's palette: one colour per gauge, mid-tones that hold on a dark or a light theme.
7// Red is only the mark at the start of the row, never a value.
8export const TEAL = '#4FBFAF'
9export const VIOLET = '#A58AE8'
10export const GOLD = '#D6B16E'
11const MARK = '#C8102E'
12const GAP = '   '
13const MAX_BAR = 14
14const MIN_BAR = 4
15
16export type Seg = { text: string; color?: string; dim?: boolean }
17
18// One row of gauges laid out for `cols` cells: the bars shrink first, then the pace words go, then the bars.
19export function meterSegs(m: Meter, cols: number): Seg[] {
20  const p = m.pace
21  const gauges = [{ label: 'context', fill: m.ctx / m.hard, value: k(m.ctx), color: TEAL }]
22  if (p) gauges.push({ label: 'week', fill: p.weekly / 100, value: `${Math.floor(p.weekly)}%`, color: VIOLET })
23  if (p && !p.stale)
24    gauges.push({ label: 'today', fill: p.allowance > 0 ? p.today / p.allowance : 1, value: `${Math.floor(p.today)}% of ${Math.floor(p.allowance)}%`, color: GOLD })
25  let tail = !p || p.stale ? '' : p.runsOutAt !== null ? `at this pace${SEP}lasts to ${shortDay(p.runsOutAt)}` : `on pace${SEP}lasts to ${shortDay(p.resetsAt)}`
26  const fit = (): number => {
27    const fixed = 2 + gauges.reduce((s, g) => s + g.label.length + g.value.length + 2, 0) + GAP.length * (gauges.length - 1) + (tail ? GAP.length + tail.length : 0)
28    return Math.min(MAX_BAR, Math.floor((cols - fixed) / gauges.length))
29  }
30  let bar = fit()
31  if (bar < MIN_BAR && tail) {
32    tail = ''
33    bar = fit()
34  }
35  if (bar < MIN_BAR) bar = 0
36  const segs: Seg[] = [{ text: '\u258e ', color: MARK }]
37  gauges.forEach((g, i) => {
38    if (i) segs.push({ text: GAP })
39    segs.push({ text: `${g.label} `, dim: true })
40    if (bar) {
41      const full = Math.max(0, Math.min(bar, Math.round(g.fill * bar)))
42      segs.push({ text: '\u2501'.repeat(full), color: g.color }, { text: `${'\u2500'.repeat(bar - full)} `, dim: true })
43    }
44    segs.push({ text: g.value, color: g.color })
45  })
46  if (tail) segs.push({ text: GAP }, { text: tail, dim: true })
47  return segs.filter((s) => s.text)
48}
49
50export function eventText(ev: Exclude<BandEvent, { kind: 'ask-compact' }>): string {
51  if (ev.kind === 'compacted') return `compacted ${k(ev.before)} \u2192 ${k(ev.after)}${SEP}plan, decisions and open items kept`
52  if (ev.kind === 'over-pace') return `today ${ev.today}% of ${ev.allowance}%${SEP}at this pace the week lasts to ${longDay(ev.runsOutAt)}${SEP}/longhaul shows where it went`
53  return ev.unusedMcp >= NOTICE_MCP
54    ? `sessions start at ${k(ev.total)}${SEP}${k(ev.unusedMcp)} of it is MCP tools unused this week${SEP}/longhaul health`
55    : `sessions start at ${k(ev.total)}${SEP}${ev.duplicate} tokens of it repeat across memory files${SEP}/longhaul health`
56}
57
hooks/report.ts 260 lines
1import type { Config } from './config'
2import { totalCost, type SessionRecord } from './ledger'
3import { PRICES_AS_OF, type Bucket } from './prices'
4import { parseReset, SEP } from './budget'
5import { REASON_LABEL } from './compact'
6import type { CompactReason } from './ledger'
7
8const DAY = 86_400_000
9const WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
10const EST = ' (est.)'
11const GUESS_LIMIT = 200_000 // the context size the guard suggestion points at
12
13// Weekly rise that no local session's cost can account for: other devices, the web, or time between sessions.
14export const ELSEWHERE = '(elsewhere)'
15
16export type Share = { points: number; percent: number }
17type Line = { kind: 'text' | 'bullet' | 'sub'; text: string }
18type Section = { title: string; lines: Line[] }
19export type ReportData = {
20  where: { read: number; newContext: number; output: number } // whole percent of total cost
21  sections: Section[]
22  footer: string
23}
24
25// Weekly percent each session is credited with. Each rise between consecutive points of the merged
26// timeline is split by how much each session's cost grew in that interval (interpolated between the
27// session's own points); a rise with no growth anywhere goes to ELSEWHERE.
28export function measuredShares(records: SessionRecord[]): Record<string, Share> {
29  const out: Record<string, Share> = {}
30  const pts = records.flatMap((r) => r.rate.map(([at, pct, cum]) => ({ id: r.id, at, pct, cum })))
31  pts.sort((a, b) => a.at - b.at || a.pct - b.pct)
32  const own = new Map(records.map((r) => [r.id, [...r.rate].sort((a, b) => a[0] - b[0])]))
33  const cumAt = (id: string, t: number): number => {
34    const p = own.get(id) ?? []
35    if (!p.length) return 0
36    if (t <= p[0][0]) return p[0][2]
37    for (let i = 1; i < p.length; i++) {
38      if (t <= p[i][0]) {
39        const [a, , ca] = p[i - 1]
40        const [b, , cb] = p[i]
41        return b === a ? cb : ca + ((cb - ca) * (t - a)) / (b - a)
42      }
43    }
44    return p[p.length - 1][2]
45  }
46  const ids = [...own.keys()]
47  for (let i = 1; i < pts.length; i++) {
48    const rise = pts[i].pct - pts[i - 1].pct
49    if (rise <= 0) continue // a drop is a reset
50    const t0 = pts[i - 1].at
51    const t1 = pts[i].at
52    const grew = ids.map((id) => [id, Math.max(0, cumAt(id, t1) - cumAt(id, t0))] as const).filter(([, g]) => g > 0)
53    const sum = grew.reduce((s, [, g]) => s + g, 0)
54    const split = sum > 0 ? grew.map(([id, g]) => [id, (rise * g) / sum] as const) : [[ELSEWHERE, rise] as const]
55    for (const [id, part] of split) {
56      const s = (out[id] ??= { points: 0, percent: 0 })
57      s.percent += part
58      s.points += Math.floor(rise)
59    }
60  }
61  return out
62}
63
64const pct = (x: number, of: number): number => (of > 0 ? Math.round((x / of) * 100) : 0)
65const money = (n: number): string => `$${n.toFixed(2)}`
66const k = (n: number): string => `${Math.floor(n / 1000)}k`
67const plural = (n: number, w: string): string => `${n} ${w}${n === 1 ? '' : 's'}`
68const stamp = (t: number): string => {
69  const d = new Date(t)
70  return `${WEEKDAY[d.getDay()]} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
71}
72
73const BUCKETS: [Bucket, string][] = [
74  ['lt100k', 'under 100k tokens'],
75  ['100to200k', '100k to 200k'],
76  ['200to400k', '200k to 400k'],
77  ['gt400k', 'over 400k'],
78]
79
80// The week's records: window from the latest reset minus 7 days, else the last 7 days.
81// A reset already in the past is moved forward by whole weeks, so the window is [reset - 7d, reset).
82export function windowOf(records: SessionRecord[], now: number): { week: SessionRecord[]; resetsAt: number | null; start: number; end: number } {
83  const resets = records.map((r) => parseReset(r.weekResetsAt)).filter((n): n is number => n !== null)
84  let resetsAt: number | null = resets.length ? Math.max(...resets) : null
85  if (resetsAt !== null && resetsAt <= now) resetsAt += Math.ceil((now - resetsAt + 1) / (7 * DAY)) * 7 * DAY
86  const start = (resetsAt ?? now) - 7 * DAY
87  const end = resetsAt ?? Infinity
88  const week = records.filter((r) => {
89    const t = Math.max(r.startedAt, r.rate.at(-1)?.[0] ?? 0)
90    return t >= start && t < end
91  })
92  return { week, resetsAt, start, end }
93}
94
95// `unusedServers` are preformatted ("name (N tools)"); `cfg` is only needed for the guard suggestion.
96export function buildReport(
97  records: SessionRecord[],
98  now: number,
99  unusedServers: string[],
100  cfg?: Pick<Config, 'guardMode' | 'softLimit'>,
101): ReportData {
102  const { week, resetsAt, start, end } = windowOf(records, now)
103
104  const sum = { input: 0, write: 0, read: 0, output: 0 }
105  const buckets: Record<Bucket, number> = { lt100k: 0, '100to200k': 0, '200to400k': 0, gt400k: 0 }
106  for (const r of week) {
107    for (const key of Object.keys(sum) as (keyof typeof sum)[]) sum[key] += r.cost[key]
108    for (const b of Object.keys(buckets) as Bucket[]) buckets[b] += r.byBucket[b]
109  }
110  const total = totalCost(sum)
111  const main = Object.values(buckets).reduce((a, b) => a + b, 0)
112  const where = {
113    read: pct(sum.read, total),
114    newContext: pct(sum.write + sum.input, total),
115    output: pct(sum.output, total),
116  }
117
118  // only meter readings inside the week feed the measured figures, as for the headline
119  const inside = week.map((r) => ({ ...r, rate: r.rate.filter(([at]) => at >= start && at < end) }))
120  const shares = measuredShares(inside)
121  const inWindow = inside.flatMap((r) => r.rate.map(([, p]) => p))
122  const measuredTotal = Object.values(shares).reduce((s, x) => s + x.percent, 0)
123  const sections: Section[] = []
124
125  const head: Line[] = []
126  if (inWindow.length) {
127    const latest = Math.max(...inWindow)
128    head.push({ kind: 'text', text: `Used ${Math.floor(latest)}% of the weekly limit${resetsAt !== null ? `, resets ${stamp(resetsAt)}` : ''}.` })
129  } else head.push({ kind: 'text', text: 'Weekly figures need a Pro or Max plan.' })
130  head.push({ kind: 'text', text: `About ${money(total)} at API prices across ${plural(week.length, 'session')} (an equivalent, not a bill)${EST}` })
131  sections.push({ title: 'This week', lines: head })
132
133  sections.push({
134    title: 'Where it went',
135    lines: [
136      { kind: 'bullet', text: `${where.read}% re-reading earlier context (cache reads)${EST}` },
137      { kind: 'bullet', text: `${where.newContext}% new context (cache writes and fresh input)${EST}` },
138      { kind: 'bullet', text: `${where.output}% output${EST}` },
139    ],
140  })
141
142  sections.push({
143    title: 'Why',
144    lines: [
145      { kind: 'text', text: 'Cost by context size, main session:' },
146      ...BUCKETS.map(([b, name]) => ({ kind: 'bullet' as const, text: `${pct(buckets[b], main)}% at ${name}${EST}` })),
147    ],
148  })
149
150  const top = [...week].sort((a, b) => totalCost(b.cost) - totalCost(a.cost)).slice(0, 5)
151  const bigLines: Line[] = []
152  for (const r of top) {
153    const comp = r.compactions.auto + r.compactions.manual + r.compactions.plugin
154    bigLines.push({
155      kind: 'bullet',
156      text: `${r.project}${SEP}${stamp(r.startedAt)}${SEP}${pct(totalCost(r.cost), total)}% of cost${SEP}${plural(r.steps, 'step')}${SEP}peak ${k(r.peakCtx)}${SEP}${plural(comp, 'compaction')}${EST}`,
157    })
158    const m = shares[r.id]
159    if (m) bigLines.push({ kind: 'sub', text: `measured: ${m.percent.toFixed(1)}% of the week, from ${plural(m.points, 'point')}` })
160  }
161  const away = shares[ELSEWHERE]
162  if (away && away.percent > 0)
163    bigLines.push({ kind: 'bullet', text: `elsewhere or between sessions: ${away.percent.toFixed(1)}% of the week, from ${plural(away.points, 'point')}` })
164  if (!top.length) bigLines.push({ kind: 'text', text: 'No sessions recorded yet.' })
165  else if (measuredTotal > 0) bigLines.push({ kind: 'text', text: `Measured across all sessions: ${measuredTotal.toFixed(1)}% of the week.` })
166  sections.push({ title: 'Biggest sessions', lines: bigLines })
167
168  const decisions = week.flatMap((r) => r.decisions)
169  const count = (o: string): number => decisions.filter((d) => d.outcome === o).length
170  sections.push({
171    title: 'Sonnet handoffs',
172    lines: [
173      {
174        kind: 'text',
175        text: decisions.length
176          ? `${plural(decisions.length, 'handoff')}: ${count('accepted')} accepted, ${count('fixed')} fixed, ${count('redone')} redone, ${count('unreported')} not reported.`
177          : 'No handoffs this week.',
178      },
179    ],
180  })
181
182  const log = week.flatMap((r) => r.compactionLog)
183  const median = (xs: number[]): number => {
184    const s = [...xs].sort((a, b) => a - b)
185    return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2
186  }
187  const compLines: Line[] = []
188  if (!log.length) compLines.push({ kind: 'text', text: 'No compactions this week.' })
189  else {
190    const by = (Object.keys(REASON_LABEL) as CompactReason[])
191      .map((r) => [r, log.filter((e) => e.reason === r).length] as const)
192      .filter(([, n]) => n > 0)
193    compLines.push({ kind: 'text', text: `${plural(log.length, 'compaction')}: ${by.map(([r, n]) => `${n} ${REASON_LABEL[r]}`).join(', ')}.` })
194    const sized = log.filter((e) => typeof e.before === 'number' && typeof e.after === 'number')
195    if (sized.length)
196      compLines.push({ kind: 'text', text: `Typically ${k(median(sized.map((e) => e.before as number)))} down to ${k(median(sized.map((e) => e.after as number)))}.` })
197    const hard = week.filter((r) => r.compactionLog.some((e) => e.reason === 'hard-limit')).length
198    if (hard) compLines.push({ kind: 'text', text: `${plural(hard, 'session')} reached the hard limit.` })
199  }
200  sections.push({ title: 'Compactions', lines: compLines })
201
202  // Fix first: only what applies, biggest estimated saving first.
203  const fixes: string[] = []
204  if (cfg && (cfg.guardMode === 'off' || cfg.softLimit > 300_000)) {
205    const above = pct(buckets['200to400k'] + buckets.gt400k, main)
206    if (above > 0) {
207      const what = cfg.guardMode === 'off' ? 'Turn the context guard on' : `Lower soft_limit from ${k(cfg.softLimit)}`
208      fixes.push(`${what} so sessions compact near ${k(GUESS_LIMIT)}: ${above}% of main-session cost sat above that${EST}`)
209    }
210  }
211  const byRule = new Map<string, { decided: number; redone: number }>()
212  for (const d of decisions) {
213    if (d.outcome !== 'accepted' && d.outcome !== 'fixed' && d.outcome !== 'redone') continue
214    for (const id of d.ruleIds) {
215      const s = byRule.get(id) ?? { decided: 0, redone: 0 }
216      s.decided++
217      if (d.outcome === 'redone') s.redone++
218      byRule.set(id, s)
219    }
220  }
221  for (const [id, s] of byRule) {
222    if (s.decided >= 3 && s.redone / s.decided >= 0.3) {
223      fixes.push(`Handoff rule ${id} was redone in ${s.redone} of ${s.decided} handoffs: narrow it or keep that work on the main model${EST}`)
224    }
225  }
226  if (week.length) for (const s of unusedServers) fixes.push(`MCP server ${s} was not called this week: disable it if you do not need it.`)
227  sections.push({
228    title: 'Fix first',
229    lines: fixes.length ? fixes.map((text) => ({ kind: 'bullet' as const, text })) : [{ kind: 'text', text: 'Nothing to fix this week.' }],
230  })
231
232  return {
233    where,
234    sections,
235    footer: `Prices as of ${PRICES_AS_OF}, at public API prices. Shares are estimates from token counts; measured figures come from the plan's weekly meter.`,
236  }
237}
238
239export function renderText(d: ReportData): string {
240  const out: string[] = ['longhaul report', '']
241  for (const s of d.sections) {
242    out.push(s.title)
243    for (const l of s.lines) out.push(l.kind === 'bullet' ? `- ${l.text}` : l.kind === 'sub' ? `    ${l.text}` : `  ${l.text}`)
244    out.push('')
245  }
246  out.push(d.footer)
247  return out.join('\n')
248}
249
250// Servers whose tools exist but were never called this week, as "name (N tools)".
251export function unusedServers(toolNames: string[], week: SessionRecord[]): string[] {
252  const tools = new Map<string, number>()
253  for (const name of toolNames) {
254    const m = /^mcp__(.+?)__/.exec(name)
255    if (m && m[1] !== 'longhaul') tools.set(m[1], (tools.get(m[1]) ?? 0) + 1)
256  }
257  const called = new Set(week.flatMap((r) => Object.entries(r.mcpCalls).filter(([, n]) => n > 0).map(([s]) => s)))
258  return [...tools].filter(([s]) => !called.has(s)).map(([s, n]) => `${s} (${plural(n, 'tool')})`)
259}
260
hooks/health.ts 153 lines
1import type { SessionUsage } from 'claude-code'
2import type { SessionRecord } from './ledger'
3import { localDate } from './budget'
4
5const DAY = 86_400_000
6const MIN_LINE = 40 // characters, after trimming and collapsing whitespace
7export const MAX_FILE = 1_000_000 // characters; a bigger memory file is counted by the breakdown but not scanned
8const BIG_FILE_LINES = 200
9const BIG_FILE_TOKENS = 3000
10export const DUP_TOKENS = 500
11export const NOTICE_TOTAL = 60_000
12const MIN_HISTORY = 3 * DAY // the mod's records must reach back this far before a server counts as unused
13const WIRE = /^mcp__(.+?)__/ // same key as the call counts in register.tsx
14export const NOTICE_EVERY = 7 * DAY
15
16export type BaseSnapshot = {
17  at: number
18  total: number
19  afterFirstReply?: true // no Messages row to subtract: the total includes the first turn
20  mcp: Record<string, { tools: number; tokens: number; label?: string }> // keyed by wire name; label is what /mcp lists
21  memory: { path: string; tokens: number; lines?: number; type?: string }[]
22  skills: { count: number; tokens: number }
23  duplicate: { lines: number; tokens: number; files: string[] }
24}
25export type Advice = { text: string; tokens: number }
26export type History = { mature: boolean; since: number }
27
28const n = (x: number): string => Math.round(x).toLocaleString('en-US')
29const plural = (c: number, w: string): string => `${c} ${w}${c === 1 ? '' : 's'}`
30const EST = '(est.)'
31
32// Lines of 40+ characters that two or more files share. Only counts and paths leave this function.
33export function findDuplicates(files: { path: string; text: string }[]): BaseSnapshot['duplicate'] {
34  const seen = new Map<string, Set<string>>()
35  for (const f of files) {
36    for (const raw of f.text.split('\n')) {
37      const l = raw.trim().replace(/\s+/g, ' ')
38      if (l.length < MIN_LINE || !/[\p{L}\p{N}]/u.test(l)) continue // rules and table separators
39      let s = seen.get(l)
40      if (!s) seen.set(l, (s = new Set()))
41      s.add(f.path)
42    }
43  }
44  let chars = 0
45  let lines = 0
46  const paths = new Set<string>()
47  for (const [l, s] of seen) {
48    if (s.size < 2) continue
49    lines++
50    chars += (s.size - 1) * l.length
51    s.forEach((p) => paths.add(p))
52  }
53  return { lines, tokens: Math.round(chars / 4), files: files.map((f) => f.path).filter((p) => paths.has(p)) }
54}
55
56type Breakdown = NonNullable<NonNullable<SessionUsage['context']>['breakdown']>
57
58// The local estimate of what a session loads before any work, from the usage breakdown and the text of
59// each memory file (absent when it could not be read).
60export function snapshotOf(b: Breakdown, texts: Map<string, string>, at: number): BaseSnapshot {
61  const mcp: BaseSnapshot['mcp'] = {}
62  for (const t of b.mcpTools ?? []) {
63    const key = WIRE.exec(t.name)?.[1] ?? t.serverName
64    if (!t.isLoaded || key === 'longhaul') continue
65    const s = (mcp[key] ??= { tools: 0, tokens: 0, ...(t.serverName !== key ? { label: t.serverName } : {}) })
66    s.tools++
67    s.tokens += t.tokens
68  }
69  const memory: BaseSnapshot['memory'] = []
70  const scanned: { path: string; text: string }[] = []
71  for (const m of b.memoryFiles ?? []) {
72    if (memory.some((x) => x.path === m.path)) continue
73    let lines: number | undefined
74    const text = texts.get(m.path)
75    if (text !== undefined && text.length <= MAX_FILE) {
76      const rows = text.split('\n')
77      if (rows.at(-1) === '') rows.pop() // a trailing newline is not a line
78      lines = rows.length
79      scanned.push({ path: m.path, text })
80    }
81    memory.push({ path: m.path, tokens: m.tokens, ...(lines === undefined ? {} : { lines }), ...(m.type ? { type: m.type } : {}) })
82  }
83  const messages = (b.categories ?? []).find((c) => c.name === 'Messages')?.tokens
84  return {
85    at,
86    total: messages === undefined ? b.totalTokens : Math.max(0, b.totalTokens - messages),
87    ...(messages === undefined ? { afterFirstReply: true as const } : {}),
88    mcp,
89    memory,
90    skills: { count: b.skills?.totalSkills ?? 0, tokens: b.skills?.tokens ?? 0 },
91    duplicate: findDuplicates(scanned),
92  }
93}
94
95export const unusedOf = (snap: BaseSnapshot, calls: Record<string, number>, mature = true): [string, { tools: number; tokens: number; label?: string }][] =>
96  mature ? Object.entries(snap.mcp).filter(([s]) => !(calls[s] > 0)) : []
97
98const nameOf = (key: string, label?: string): string => (label ? `${key} (${label})` : key)
99
100export function healthAdvice(snap: BaseSnapshot, calls: Record<string, number>, history?: History): Advice[] {
101  const out: Advice[] = []
102  for (const [s, v] of unusedOf(snap, calls, history?.mature ?? true))
103    out.push({
104      tokens: v.tokens,
105      text: `${nameOf(s, v.label)} has ${plural(v.tools, 'tool')}, about ${n(v.tokens)} tokens ${EST} in every session, and was not used this week: turn it off for projects that don't use it (${s.startsWith('plugin_') ? '/plugin' : '/mcp'}).`,
106    })
107  for (const m of snap.memory)
108    if (m.type !== 'Managed' && ((m.lines ?? 0) > BIG_FILE_LINES || m.tokens > BIG_FILE_TOKENS))
109      out.push({
110        tokens: m.tokens,
111        text: `${m.path} is ${m.lines === undefined ? '' : `${plural(m.lines, 'line')} and `}about ${n(m.tokens)} tokens ${EST}: shorten it, or split it so each part loads only where it is needed.`,
112      })
113  const d = snap.duplicate
114  if (d.tokens >= DUP_TOKENS)
115    out.push({
116      tokens: d.tokens,
117      text: `About ${n(d.tokens)} tokens ${EST} are ${plural(d.lines, 'line')} repeated across ${d.files.join(', ')}: keep the shared text in one file.`,
118    })
119  if (!out.length) return [{ tokens: 0, text: 'Your sessions start light.' }]
120  return out.sort((a, b) => b.tokens - a.tokens)
121}
122
123export function renderHealth(snap: BaseSnapshot, advice: Advice[], calls: Record<string, number> = {}, history?: History): string {
124  const items: [number, string][] = []
125  for (const [s, v] of Object.entries(snap.mcp))
126    items.push([v.tokens, `${nameOf(s, v.label)} MCP: ${plural(v.tools, 'tool')}, ${n(v.tokens)} tokens ${EST}, ${calls[s] > 0 ? 'used this week' : history && !history.mature ? `no calls recorded since ${localDate(history.since)}` : 'not used this week'}`])
127  for (const m of snap.memory) items.push([m.tokens, `${m.path}: ${m.lines === undefined ? '' : `${plural(m.lines, 'line')}, `}${n(m.tokens)} tokens ${EST}`])
128  if (snap.skills.count) items.push([snap.skills.tokens, `skills: ${snap.skills.count} listed, ${n(snap.skills.tokens)} tokens ${EST}`])
129  const d = snap.duplicate
130  if (d.lines) items.push([d.tokens, `repeated text: ${plural(d.lines, 'line')} across ${plural(d.files.length, 'file')}, ${n(d.tokens)} tokens ${EST}`])
131  items.sort((a, b) => b[0] - a[0])
132  return [
133    'Context health',
134    '',
135    `Sessions start at ${n(snap.total)} tokens ${EST}${snap.afterFirstReply ? ', counted after the first reply' : ''}. What loads before any work:`,
136    ...items.map(([, t]) => `- ${t}`),
137    '',
138    'Advice',
139    ...advice.map((a) => `- ${a.text}`),
140  ].join('\n')
141}
142
143export const addCalls = (recs: SessionRecord[]): Record<string, number> => {
144  const out: Record<string, number> = {}
145  for (const r of recs) for (const [s, c] of Object.entries(r.mcpCalls)) out[s] = (out[s] ?? 0) + c
146  return out
147}
148
149export const historyOf = (recs: SessionRecord[], now: number): History => {
150  const since = Math.min(now, ...recs.map((r) => r.startedAt))
151  return { mature: now - since >= MIN_HISTORY, since }
152}
153
hooks/rules.ts 74 lines
1export type Rule = { id: string; verdict: 'opus' | 'sonnet'; pattern: string; flags?: string }
2
3// \b is ASCII-only, so Spanish rules use \p{L} lookarounds with the u flag.
4const L = String.raw`(?<!\p{L})`
5const R = String.raw`(?!\p{L})`
6const es = (id: string, verdict: Rule['verdict'], body: string): Rule => ({ id: `es.${id}`, verdict, pattern: `${L}(?:${body})${R}`, flags: 'iu' })
7const en = (id: string, verdict: Rule['verdict'], body: string): Rule => ({ id: `en.${id}`, verdict, pattern: String.raw`\b(?:${body})\b` })
8
9const DATA = 'data|database|db|tables?|migrations?|records?|rows?|users?|backups?|prod|production|branch(?:es)?|history'
10const STEP = String.raw`go with|start|do|run|execute|implement|continue with|proceed with`
11
12export const DEFAULT_RULES: Rule[] = [
13  // Opus: design and judgment
14  en('design', 'opus', String.raw`design|architect(?:ure)?|trade-?offs?|brainstorm\w*`),
15  en('decide', 'opus', String.raw`should (?:we|i)|which (?:is )?(?:better|best)|best way|most professional|pros and cons|most natural|best (?:approach|option|solution|practice)`),
16  en('why', 'opus', String.raw`why|root cause|intermittent|flaky`),
17  en('review', 'opus', String.raw`review|audit|critique`),
18  en('write-plan', 'opus', String.raw`(?:write|draft|create|make|outline)\b.{0,30}\b(?:plan|spec|rfc|design doc)`),
19  en('security', 'opus', String.raw`security|vulnerabilit\w+|exploit|xss|csrf|secrets?|credentials?`),
20  en('money', 'opus', String.raw`payments?|billing|invoices?|stripe|refunds?|checkout`),
21  en('auth', 'opus', String.raw`auth|authentication|authorization|oauth|passwords?|jwt`),
22  en('delete-data', 'opus', String.raw`(?:delete|drop|wipe|purge|truncate)\b.{0,40}\b(?:${DATA})`),
23  // Opus, Spanish
24  es('design', 'opus', String.raw`dise[ñn]o|dise[ñn]ar|arquitectura|brainstorm\w*|lluvia de ideas|pros y contras`),
25  es('decide', 'opus', String.raw`cu[aá]l (?:ser[ií]a|es (?:la|el) (?:mejor|forma|manera))|deber[ií]amos|(?:la )?mejor (?:forma|manera)|(?:lo )?m[aá]s profesional|qu[eé] opinas`),
26  es('why', 'opus', String.raw`por qu[eé]|causa ra[ií]z|intermitente`),
27  es('review', 'opus', String.raw`revisa|revisar|revisi[oó]n|audita|auditar|critica`),
28  es('write-plan', 'opus', String.raw`(?:escribe|redacta|crea|haz|arma)\b.{0,30}\b(?:plan|spec|especificaci[oó]n)`),
29  es('security', 'opus', String.raw`seguridad|vulnerabilidad\w*|credenciales|secretos?`),
30  es('money', 'opus', String.raw`pagos?|facturaci[oó]n|facturas?|reembolsos?`),
31  es('auth', 'opus', String.raw`autenticaci[oó]n|autorizaci[oó]n|contrase[ñn]as?`),
32  es('delete-data', 'opus', String.raw`(?:borra|borrar|elimina|eliminar|bota)\b.{0,40}\b(?:datos|base de datos|tablas?|migraciones|registros|usuarios|respaldos?|backups?|producci[oó]n|historial)`),
33  // Sonnet: execution Opus can review
34  en('plan-step', 'sonnet', String.raw`(?:${STEP})\s+(?:the\s+)?(?:next\s+)?(?:task|step|milestone)(?:\s*#?\d+)?|(?:${STEP})\s+[a-z]\d+`),
35  en('mechanical', 'sonnet', String.raw`rename\s+\S+\s+to\s+\S+|reformat|find and replace|search and replace|bulk (?:rename|replace|edit)`),
36  { id: 'en.search', verdict: 'sonnet', pattern: String.raw`^\s*(?:where is|where's|find|locate|search for|grep)\b` },
37  en('docs', 'sonnet', String.raw`add (?:jsdoc|docstrings?|comments?) to|document (?:this|the) (?:code|function|module)|generate (?:the )?(?:docs|readme)`),
38  en('lint-fix', 'sonnet', String.raw`fix\b.{0,25}\b(?:lint|type|typescript|eslint|tsc)\b.{0,10}`),
39  en('run-tests', 'sonnet', String.raw`run (?:the )?(?:tests?|test suite|linter|build)`),
40  es('plan-step', 'sonnet', String.raw`(?:ejecuta|arranca|empieza|sigue|implementa|vamos|dale)\s+(?:con\s+)?(?:(?:el|la|la siguiente|el siguiente)\s+)?(?:hito|tarea|paso|etapa)(?:\s*#?\d+)?|(?:arranca|empieza|vamos|dale)\s+con\s+[a-z]\d+`),
41  es('mechanical', 'sonnet', String.raw`renombra\s+\S+\s+(?:a|como)\s+\S+|reformatea|busca y reemplaza`),
42  { id: 'es.search', verdict: 'sonnet', pattern: String.raw`^\s*(?:d[oó]nde (?:est[aá]n?|queda|se define)|busca|encuentra|localiza)(?!\p{L})`, flags: 'iu' },
43  es('docs', 'sonnet', String.raw`documenta (?:esto|el c[oó]digo|la funci[oó]n)|genera (?:la )?(?:documentaci[oó]n|readme)`),
44  es('lint-fix', 'sonnet', String.raw`arregla\b.{0,25}\b(?:lint|tipos|eslint|tsc)`),
45  es('run-tests', 'sonnet', String.raw`corre (?:las )?pruebas|ejecuta (?:las )?(?:pruebas|tests)`),
46]
47
48let warned = false // one debug line per session
49export const resetRulesWarn = (): void => void (warned = false)
50export const takeRulesWarn = (): boolean => (warned ? false : (warned = true))
51
52function valid(r: unknown): r is Rule {
53  const x = r as Rule
54  if (!x || typeof x.id !== 'string' || typeof x.pattern !== 'string') return false
55  if (x.verdict !== 'opus' && x.verdict !== 'sonnet') return false
56  if (x.flags !== undefined && typeof x.flags !== 'string') return false
57  try {
58    new RegExp(x.pattern, x.flags ?? 'i')
59    return true
60  } catch {
61    return false
62  }
63}
64
65// The rules in a rules file's text, or null when it is not a JSON array.
66export function parseRules(text: string): Rule[] | null {
67  try {
68    const parsed: unknown = JSON.parse(text)
69    return Array.isArray(parsed) ? parsed.filter(valid) : null
70  } catch {
71    return null
72  }
73}
74