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

<img src=".claude-plugin/icon.png" width="88" alt="">
A Claude Code mod that helps Pro and Max subscribers make the weekly usage limit last.
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>
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
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.
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.
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.
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.
<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.">
/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.">
| Option | Default | Values |
|---|---|---|
guard_mode | auto | auto, ask, off |
classifier_mode | auto | auto, ask, off |
budget_mode | auto | auto, off |
health_mode | auto | auto, off |
soft_limit | 150000 | 100000 to 1000000 tokens |
hard_limit | 300000 | 100000 to 1000000 tokens, higher than soft_limit |
rules_file | empty | absolute path to a JSON file of classifier rules |
/longhaul prints the report in the terminal./longhaul health shows what every session loads before any work.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.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.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."ui.render hook draws the meter and its notes above the prompt. With nothing to show, Claude Code draws its own.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.rules_file.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.
<CLAUDE_CONFIG_DIR or ~/.claude>/longhaul/; 0.2 no longer reads that folder, so you can delete it.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.context-mode and headroom shrink what goes into the conversation. longhaul manages how long it runs and who does the work.
MIT.
hooks/register.tsx 747 lines1import 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}
747hooks/config.ts 35 lines1export 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}
35hooks/prices.ts 49 lines1import 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}
49hooks/compact.ts 41 lines1import 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)
41hooks/session.ts 28 lines1import 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)
28hooks/guard.ts 116 lines1import 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}
116hooks/ledger.ts 123 lines1import 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)
123hooks/budget.ts 47 lines1const 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}
47hooks/band.tsx 57 lines1import 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}
57hooks/report.ts 260 lines1import 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}
260hooks/health.ts 153 lines1import 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}
153hooks/rules.ts 74 lines1export 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