Model and effort routing per kind of work, with prompt-cache and cost tracking, a cold-cache guard, and an optional learn mode that explains what each choice…

A Claude Code mod that picks the model and effort for each kind of work, and shows what those choices cost.
Plan mode, normal work and each subagent type can run on a different model: Opus for planning, Sonnet for edits, Haiku for searches. The mod tracks prompt-cache health and spend as you go, stops a send that would re-write a cold cache, and can explain where your money went with your own numbers.
The field guide walks through every part of the screen it adds, with annotated pictures.
main, plan, each subagent type (sub:Explore, sub:Plan, …), workflows and teammates, or pick a preset. The /route pane shows what a change costs per turn, the one-off cache rewrite it causes, and when it pays back, before you apply it.cache 96% 42m · context 31% · last $0.05 · session $1.23 · 5h 38% · 7d 12%. That's the cache hit rate and minutes until it goes cold, context used, the cost of your last prompt and of this session, and your 5-hour and 7-day limits. The band's last line has Context and Cost for a breakdown with tips on what to trim or reroute.next /clear, next /compact or next /handoff button:/handoff skill when you have one./route next off, or the Routes pane's Next-step advice row, hides it./route handoffs auto on, when the cache is in its last minutes and work is still open, it asks Claude for a handoff while reading the context is still cheap and saves it under ~/.claude/handoffs/<repo>/. Once per prompt; it never clears the session itself./route handoffs lists the handoffs in ~/.claude/handoffs/<repo>/, saved by auto-handoff or your own /handoff, each in its own box with its title, a few lines on what it is about (the first paragraph under its ## Goal, or after the title) and up to six of its open steps (the list under its ## Open steps). Each open one has Resume (fills the prompt with resume from <path>), Done and Not relevant. A handoff closes on its own when a newer one is saved in the same session, or when you send a prompt naming it; one left open for 14 days folds away as stale. The band shows Handoffs ●N while other sessions' handoffs are open in this repo. /route handoffs off, or the Routes pane's Handoffs row, turns the whole feature off: no count in the band and no auto-handoff. Files are never edited or deleted; to prune old ones: find ~/.claude/handoffs -name '*.md' -mtime +30 -delete.~/.claude/skills and ~/.claude/commands, downloaded ones included) are loaded and which get used. It also tracks the skills synced from your claude.ai account. A plugin's skills count toward the plugin; project skills and Claude Code's built-in ones aren't tracked. Once one has been loaded for two weeks and ten sessions without use in 30 days, the Context pane's Unused MCP servers, plugins and skills section and /route unused name it with the fix. Anything newer isn't judged yet. The mod changes nothing itself: a tip's button puts the command in your prompt, and it runs only when you press Enter./mcp. When none of your connected claude.ai connectors is used, it also offers "disableClaudeAiConnectors": true.claude plugin disable <id> --scope local, which writes false to the repo's .claude/settings.local.json; Disable everywhere fills --scope user. /route unused also prints the enabledPlugins block to paste. A plugin listed covers its own MCP server.~/.claude/skills-archive (move it back to restore). One only this repo uses gets Move here, which makes it a project skill so other repos stop loading it. A skill the skills CLI installed is named with its source, from ~/.agents/.skill-lock.json; archiving it moves only Claude Code's link."syncClaudeAiSkills": false, which stops syncing all of them.claude.ai Figma) or the desktop app (an id) loaded it. Connectors you never signed in to, and claude.ai's own Visualize, aren't counted. A plugin's hooks can't be seen, so plugins with only hooks are never listed.▤ today $12.30 opens History.This send re-writes ~200k tokens on Opus 5.5 = $1.60 (warm would be $0.04). Send it again to pay, or /clear./clear. The lesson shows as a card above the band right when it applies, and goes away when you send your next prompt. Mute topic on the card silences that kind of lesson. Three habits no hook can spot are worth keeping anyway: /clear between unrelated tasks, /btw for side questions, and Esc Esc to rewind. /route away, and the Context pane's Before a break box once the context is big, compare the cheapest ways to step away from a session.Needs Claude Code 2.1.287 or later (tested with 2.1.294). At the prompt of a terminal Claude Code session:
/plugin install model-router --marketplace TFoxik/claude-model-router
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). Or from a shell:
claude plugin marketplace add TFoxik/claude-model-router
claude plugin install model-router@claude-model-router
A fresh install changes no model: it starts on the inherit preset, which only tracks costs. Run /route preset mine, or pick a preset in /route, to start routing. New to prompt caching? Turn on learn mode with /route learn on.
Claude Code doesn't auto-update third-party marketplaces unless you turn it on. Once, in /plugin: Marketplaces → claude-model-router → Enable auto-update. New sessions then pick up each release at startup; open ones need /reload-plugins.
To update by hand instead:
claude plugin marketplace update claude-model-router
claude plugin update model-router@claude-model-router
Then restart or run /reload-plugins. Saved settings carry over. See CHANGELOG.md for what changed.
| Command | Effect | ||||
|---|---|---|---|---|---|
/route | Open the Routes pane: routing on top, every setting below it | ||||
| `/route preset inherit\ | mine\ | balanced\ | frugal\ | max` | Apply a preset (inherit changes no model, it only tracks) |
/route set <route> <model> [effort] | Set one route, for example /route set sub:Explore haiku low | ||||
/route on / off | Enable or disable routing | ||||
| `/route cost [last\ | session\ | history]` | Open the Cost pane, on a tab | ||
/route last / stats / context | Open Cost on Last prompt, Cost on History, or the Context pane | ||||
/route stats clear | Delete the saved prompt history | ||||
| `/route stats text on\ | off` | Keep the first 120 characters of each prompt in Cost › History (default on); off also removes text already kept | |||
/route learn / `learn on\ | off` | Show whether learn mode is on and which topics are muted / turn learn mode on or off (default off) | |||
| `/route learn mute\ | unmute <topic>` | Silence a topic: cold-rewrite, switch-rewrite, pricey-model, context-high, compacted, subagents, cache-expiring, plan-toggle, strategy, handoff, plan-ready, corrections, exploration, auto-handoff | |||
| `/route next on\ | off` | Show or hide the band's next /clear, /compact or /handoff and the Context pane's advice tip (default on) | |||
/route handoffs / `handoffs on\ | off` | Open the Handoffs pane: open handoffs to resume, mark done or not relevant / turn handoffs on or off: the band's count and auto-handoff (default on) | |||
| `/route handoffs auto on\ | off` | Save a handoff while the cache is still warm, before it goes cold with work open (default off) | |||
/route away | Before a break: the ways to step away, priced for this session; also in the Context pane | ||||
/route unused | MCP servers, plugins and skills this repo has long left unused, and the command that turns each off | ||||
| `/route guard refuse\ | warn\ | off` | What a send onto a cold cache does (default refuse: held once with its price); also the Routes pane's Cold-cache guard row | ||
| `/route ttl 5\ | 60` | Prompt-cache lifetime for the warm/cold timer (default 60); also the Routes pane's Cache lifetime row | |||
| `/route band on\ | off` | Show or hide the band; also the Routes pane's Band row. Routing works either way |
Routes: main, plan, sub:*, sub:<agent-type> (such as sub:Explore), workflow, teammate. Models: opus, sonnet, haiku, fable, inherit. Efforts: low through max, auto (main and plan only), inherit.
hooks/routes.ts (as of 2026-10-06). Model IDs are the Anthropic API's; on a provider that names models differently, turn routing off or set routes to inherit.auto effort (presets balanced and frugal) sends the first 4,000 characters of each prompt you type to Haiku to classify it, about $0.0001 and a few hundred milliseconds. Fixed efforts make no call./route handoffs auto on) sends one prompt asking for a handoff, at most once per prompt, when the cache is about to go cold with work open. It is a normal turn: it reads the context at the warm cache price and pays for the handoff it writes.~/.claude/handoffs/. It lists and reads the handoffs there and, with auto-handoff on, writes new ones. It also reads the .md.closed markers other tools may leave beside a handoff, to show it as done. It never writes, edits or deletes a file it didn't write.~/.claude/skills and ~/.claude/commands and reads ~/.agents/.skill-lock.json, to know your personal skills and where the skills CLI installed them from. It never reads your transcripts.scripts/try.sh [folder]
claude plugin validate .
claude plugin test .
scripts/try.sh starts Claude in folder (default: here) with this checkout in place of the installed mod, which stays off for that session only. /reload-plugins there picks up later edits. The local copy keeps its own saved state; files such as handoffs are shared. In this repo, asking Claude to "try it locally" opens that session in a new terminal tab.
To see the unused-items advice without two weeks of history, run MODEL_ROUTER_IDLE_DEV=1 scripts/try.sh. After its first prompt, /route unused and the Context pane list whatever was loaded but not used in the last 30 days.
MIT, see LICENSE.
hooks/register.tsx 1556 lines1import { atom, read, update } from 'claude-code'
2import type { Args, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ActiveRoute, Carried, ColdGuard, CostTab, Effort, Health, LastUsage, LearnCard, Lesson, LessonKind, PromptCost, PromptRecord, RouteSpend, RouterConfig, Scope } from '../types'
5import {
6 CONCEPTS,
7 TOPICS,
8 awayOptions,
9 awayText,
10 capLessons,
11 cardOf,
12 lessonFromAdvice,
13 lessonFromAutoHandoff,
14 lessonFromCompact,
15 lessonFromContext,
16 lessonFromExpiry,
17 lessonFromExploration,
18 lessonFromPlanToggle,
19 lessonFromRewrite,
20 lessonsFromPrompt,
21 pickLesson,
22 sanitizeLessons,
23} from './lessons'
24import type { AwayFacts, LessonDraft } from './lessons'
25import {
26 AUTO_LABELS,
27 CACHE_TTLS,
28 DEFAULT_CONFIG,
29 EMPTY_PROMPT,
30 EMPTY_SPEND,
31 ENGINE,
32 FORK,
33 GUARD_MIN_TOKENS,
34 HISTORY_LIMIT,
35 PRESETS,
36 ROUTES,
37 addSpend,
38 addLateStep,
39 addStep,
40 coldForMs,
41 commandLabel,
42 contextLevel,
43 costOf,
44 effortForLabel,
45 fmtAge,
46 isLessonKind,
47 kTokens,
48 labelOf,
49 modelsOf,
50 money,
51 parseSetting,
52 plural,
53 presetOf,
54 printable,
55 typedText,
56 resolve,
57 rewriteCause,
58 rewriteOf,
59 rewritePrice,
60 routeForSpawn,
61 routeText,
62 sanitizeConfig,
63 sanitizeHistory,
64 shouldSwitch,
65 startingConfig,
66 subagentCount,
67 sumUsd,
68 todayUsd,
69 versionOf,
70 warmMinutesLeft,
71 withoutText,
72 withPreset,
73 withRoute,
74} from './routes'
75import { HANDOFF_DIR, HANDOFF_LABEL, HANDOFF_PROMPT, handoffPath, isAgentBusy, pendingAfter, segment, shouldAutoHandoff, tildePath } from './handoff'
76import type { PendingHandoff } from './handoff'
77import { bandCount, closeMarked, handoffDir, handoffRows, handoffsRoot, mergeListing, recordWritten, reopen, repoOfHandoff, resumeIn, sanitizeHandoffs, setStatus, detailsOf, withDetails } from './handoffs'
78import type { HandoffStatus, Handoffs } from './handoffs'
79import { EXPLORE_MIN, NO_STEPS, exploredTokens, hasHandoffCommand, isCorrection, isExplorationHeavy, openSteps, planEventOf, stepsAfter } from './signals'
80import type { Steps } from './signals'
81import { adviseSession, boundaryOf } from './strategy'
82import type { Boundary } from './strategy'
83import { messagesOf, tokensByServer } from './tips'
84import type { TipAction } from './tips'
85import { DEV_PATIENCE, PATIENCE, applyUsage, claudeAiKey, claudeAiReport, claudeAiTips, connectedServers, connectorAliases, idleItems, idleReport, idleTips, itemOfCommand, itemOfToolCall, listingTokens, mcpKey, mergeAliases, onlyHere, patienceText, pluginKey, pluginName, pluginOfMcpTool, pluginServer, sanitizeAliases, sanitizeUsage, skillKey, skillReport, skillTips, sourcesOfLock, syncedSkillNames } from './usage'
86import type { ItemKey, OwnSkill, Patience, UsageStore } from './usage'
87import { bandView } from './ui/band'
88import { contextMobileView, contextPaneView } from './ui/context-pane'
89import { COST_TAB_LABEL, OLD_COST_IDS, OLD_LEARN_ID, PANES, isTarget, paneOf } from './ui/kit'
90import type { Open, Target } from './ui/kit'
91import { costPaneView } from './ui/cost-pane'
92import { handoffsPaneView } from './ui/handoffs-pane'
93import { learnCardView } from './ui/learn-card'
94import { routesMobileView, routesPaneView } from './ui/routes-pane'
95import { statsFooterView } from './ui/stats'
96
97// Everything that takes `$` or reads an atom lives in this file: the engine's
98// scan follows `$` only into functions declared here and accepts atoms only
99// declared here. The views in ui/ are plain functions of the data they're given.
100const config = atom({ plugin: 'model-router', key: 'config' } as const, DEFAULT_CONFIG)
101const spend = atom({ plugin: 'model-router', key: 'spend' } as const, {} as Record<string, RouteSpend>)
102const isPlan = atom({ plugin: 'model-router', key: 'isPlan' } as const, false)
103const active = atom({ plugin: 'model-router', key: 'active' } as const, null as ActiveRoute | null)
104const draft = atom({ plugin: 'model-router', key: 'draft' } as const, null as RouterConfig | null)
105const lastUsage = atom({ plugin: 'model-router', key: 'lastUsage' } as const, null as LastUsage | null)
106const health = atom({ plugin: 'model-router', key: 'health' } as const, null as Health | null)
107const history = atom({ plugin: 'model-router', key: 'history' } as const, [] as PromptRecord[])
108const statsScope = atom({ plugin: 'model-router', key: 'statsScope' } as const, 'repo' as 'repo' | 'all')
109const costTab = atom({ plugin: 'model-router', key: 'costTab' } as const, 'last' as CostTab)
110const promptCost = atom({ plugin: 'model-router', key: 'promptCost' } as const, null as PromptCost | null)
111const isBandShown = atom({ plugin: 'model-router', key: 'isBandShown' } as const, true)
112const lessons = atom({ plugin: 'model-router', key: 'lessons' } as const, [] as Lesson[])
113const learnCard = atom({ plugin: 'model-router', key: 'learnCard' } as const, null as LearnCard | null)
114const handoffs = atom({ plugin: 'model-router', key: 'handoffs' } as const, {} as Handoffs)
115const handoffScope = atom({ plugin: 'model-router', key: 'handoffScope' } as const, 'repo' as Scope)
116const handoffClosedShown = atom({ plugin: 'model-router', key: 'handoffClosedShown' } as const, false)
117// Atoms outlive a hot reload of this code and `mem` does not: this keeps what the session would lose with it.
118const carried = atom({ plugin: 'model-router', key: 'carried' } as const, null as Carried | null)
119
120type $ = EngineInterface
121
122// The store keys the atoms kept across sessions are saved under. The engine's
123// scan needs each update() to name its atom, so callers update, then save().
124const STORE = { config: 'config', history: 'history', statsScope: 'statsScope', bandShown: 'bandShown', modelCommands: 'modelCommands', lessons: 'lessons', toolUse: 'toolUse', connectorNames: 'connectorNames', handoffs: 'handoffs', handoffScope: 'handoffScope', version: 'version' } as const
125
126/** Saves a value kept across sessions; a failed save keeps the session going. */
127async function save<T>($: $, key: string, value: T): Promise<T> {
128 await $.store.set(key, value).catch(() => undefined)
129 return value
130}
131
132// A read that failed, as distinct from a key never saved: nothing is overwritten on the strength of it.
133const UNREADABLE = Symbol('unreadable')
134
135const load = ($: $, key: string): Promise<unknown> => $.store.get(key).catch(() => UNREADABLE)
136
137/**
138 * Edits start from what is stored, not this session's copy: other sessions
139 * share the store, and saving a stale copy would undo what they saved.
140 */
141async function setConfig(
142 $: $,
143 fn: (c: RouterConfig) => RouterConfig,
144): Promise<RouterConfig> {
145 const stored = await load($, STORE.config)
146 const next = await update($, config, c => fn(stored === UNREADABLE || stored === undefined ? c : sanitizeConfig(stored)))
147 if (stored !== UNREADABLE) await save($, STORE.config, next)
148 await refreshStatus($).catch(() => undefined)
149 return next
150}
151
152/**
153 * Remembers that a slash command reached the model, so the cold guard holds it
154 * next time; merged with what other sessions learned.
155 */
156async function learnModelCommand($: $, name: string) {
157 if (mem.modelCommands.has(name)) return
158 mem.modelCommands.add(name)
159 const stored = await load($, STORE.modelCommands)
160 if (stored === UNREADABLE) return
161 const known = Array.isArray(stored) ? stored.filter((one): one is string => typeof one === 'string') : []
162 for (const one of known) mem.modelCommands.add(one)
163 await save($, STORE.modelCommands, [...mem.modelCommands])
164}
165
166// Prompt-only sources: their commands always reach the model.
167const PROMPT_SOURCES: ReadonlySet<string> = new Set(['user', 'mcp'])
168
169/** Whether a slash command starts a model turn: learned from earlier runs, or known from where it comes from. */
170async function reachesModel($: $, name: string): Promise<boolean> {
171 if (mem.modelCommands.has(name)) return true
172 const info = (await listCommands($)).find(one => one.name === name)
173 return info !== undefined && PROMPT_SOURCES.has(info.source)
174}
175
176/** The same for history: another session's prompts since this one loaded are kept. Neither saves after a failed read. */
177async function setHistory($: $, fn: (records: PromptRecord[]) => PromptRecord[]): Promise<PromptRecord[]> {
178 const stored = await load($, STORE.history)
179 const next = await update($, history, all => fn(stored === UNREADABLE || stored === undefined ? all : sanitizeHistory(stored)))
180 if (stored !== UNREADABLE) await save($, STORE.history, next)
181 return next
182}
183
184async function setLessons($: $, fn: (all: Lesson[]) => Lesson[]): Promise<Lesson[]> {
185 const stored = await load($, STORE.lessons)
186 const next = await update($, lessons, all => fn(stored === UNREADABLE || stored === undefined ? all : sanitizeLessons(stored)))
187 if (stored !== UNREADABLE) await save($, STORE.lessons, next)
188 return next
189}
190
191/** One at a time: two overlapping edits would both start from the same stored value, and the later save would drop the earlier. */
192function setHandoffs($: $, fn: (all: Handoffs) => Handoffs): Promise<Handoffs> {
193 const run = mem.handoffsChain.then(() => applyHandoffs($, fn))
194 mem.handoffsChain = run.catch(() => undefined)
195 return run
196}
197
198/** An edit that returns the records it was given changes nothing, so nothing is saved. */
199async function applyHandoffs($: $, fn: (all: Handoffs) => Handoffs): Promise<Handoffs> {
200 const stored = await load($, STORE.handoffs)
201 let isChanged = false
202 const next = await update($, handoffs, all => {
203 const base = stored === UNREADABLE || stored === undefined ? all : sanitizeHandoffs(stored)
204 const edited = fn(base)
205 isChanged = edited !== base
206 return edited
207 })
208 if (isChanged && stored !== UNREADABLE) await save($, STORE.handoffs, next)
209 return next
210}
211
212/** Without the text, title and gist are left for the next listing to read from the file. */
213async function recordHandoff($: $, path: string, repo: string, text: string | undefined) {
214 const [at, sessionId] = [await nowOf($), mem.kept.sessionId]
215 const details = text === undefined ? {} : detailsOf(text)
216 await setHandoffs($, all => recordWritten(all, path, { repo, at, ...(sessionId === undefined ? {} : { sessionId }), ...details })).catch(() => undefined)
217}
218
219type Listed = { path: string; mtimeMs: number }[]
220
221/** A folder's handoff files, and the mtime of each `.md.closed` marker by the handoff it closes; empty when the folder doesn't exist, `undefined` when it couldn't be read. */
222async function listHandoffs($: $, dir: string): Promise<{ files: Listed; markers: Record<string, number> } | undefined> {
223 try {
224 if (!(await $.fs.exists(dir))) return { files: [], markers: {} }
225 const entries = (await $.fs.list(dir)).filter(one => one.kind === 'file')
226 const files = entries.filter(one => one.name.endsWith('.md')).map(one => ({ path: `${dir}/${one.name}`, mtimeMs: one.mtimeMs }))
227 const markers = Object.fromEntries(entries.filter(one => one.name.endsWith('.md.closed')).map(one => [`${dir}/${one.name.slice(0, -'.closed'.length)}`, one.mtimeMs]))
228 return { files, markers }
229 } catch {
230 return undefined
231 }
232}
233
234/** Brings the records in line with the folder on disk, and reads the details of files not read yet or changed since. */
235async function refreshHandoffs($: $, scope?: Scope) {
236 const home = mem.home
237 if (home === undefined) return
238 const inScope = scope ?? (await read($, handoffScope))
239 const repos = inScope === 'repo' ? [segment(mem.repo)] : ((await $.fs.list(handoffsRoot(home)).catch(() => undefined))?.filter(one => one.kind === 'dir').map(one => one.name) ?? [])
240 const listedAt = await nowOf($)
241 const lists = await Promise.all(repos.map(repo => listHandoffs($, handoffDir(home, repo))))
242 const listing: Record<string, Listed> = Object.fromEntries(repos.flatMap((repo, i) => (lists[i] === undefined ? [] : [[repo, lists[i]!.files]])))
243 const markers = Object.assign({}, ...lists.map(one => one?.markers))
244 const preview = mergeListing(await read($, handoffs), listing, listedAt)
245 // A file outside the listing is stamped 0, so it is read once more when a listing shows its real mtime.
246 const mtimes: Record<string, number> = Object.fromEntries(Object.values(listing).flat().map(one => [one.path, one.mtimeMs]))
247 const unread = Object.entries(preview).filter(([path, r]) => r.readMtimeMs === undefined || (mtimes[path] ?? 0) > r.readMtimeMs)
248 .sort((a, b) => Number(b[1].status === 'open') - Number(a[1].status === 'open') || b[1].at - a[1].at).slice(0, 20)
249 const found = await Promise.all(unread.map(([path]) => $.fs.read(path).then(text => detailsOf(typeof text === 'string' ? text : ''), () => detailsOf(''))))
250 const details = Object.fromEntries(unread.map(([path], i) => [path, { ...found[i]!, readMtimeMs: mtimes[path] ?? 0 }]))
251 await setHandoffs($, all => withDetails(closeMarked(mergeListing(all, listing, listedAt), markers), details))
252}
253
254async function renderHandoffsPane($: $, e: Args<'ui.render'>) {
255 const [all, scope, isClosedShown, now, cfg] = await Promise.all([read($, handoffs), read($, handoffScope), read($, handoffClosedShown), nowOf($), read($, config)])
256 const repo = segment(mem.repo)
257 const { open, closed } = handoffRows(all, scope === 'repo' ? repo : undefined, now)
258 const folder = mem.home === undefined ? `~/${HANDOFF_DIR}/` : tildePath(scope === 'repo' ? handoffDir(mem.home, repo) : handoffsRoot(mem.home), mem.home)
259 return handoffsPaneView($.ui.resolve(e), { open, closed, isClosedShown, scope, repo, folder, isOn: cfg.isHandoffs }, {
260 setOn: isOn => void setHandoffsOn($, isOn),
261 resume: path => void $.prompt.fill({ text: `resume from ${tildePath(path, mem.home)}`, mode: 'replace' }).catch(() => undefined),
262 setStatus: (path, status: HandoffStatus) => void setHandoffs($, records => setStatus(records, path, status)),
263 reopen: path => void nowOf($).then(at => setHandoffs($, records => reopen(records, path, at))),
264 toggleClosed: () => void update($, handoffClosedShown, was => !was),
265 setScope: async next => {
266 await save($, STORE.handoffScope, await update($, handoffScope, () => next))
267 await refreshHandoffs($)
268 },
269 })
270}
271
272/** Keeps what the finished main-loop turn taught, and puts the one most worth it on the card. */
273async function flushLessons($: $) {
274 const drafts = mem.pendingLessons
275 mem.pendingLessons = []
276 const [cfg, p] = await Promise.all([read($, config), read($, promptCost)])
277 if (!cfg.isLearning) return
278 await teach($, [...drafts, ...(p === null ? [] : lessonsFromPrompt(p, cfg.cacheTtlMinutes))], await nowOf($))
279}
280
281/** Writes the auto-handoff turn's reply to its file; an interrupted, failed or empty turn saves nothing. */
282async function finishHandoff($: $, e: Args<'turn.complete'>) {
283 const asked = mem.handoff
284 if (asked?.turnId === undefined || asked.turnId !== e.turnId) return
285 mem.handoff = undefined
286 if (e.reason !== 'answer' || e.answer.trim() === '' || mem.home === undefined) return
287 const [now, sessionId, cost, cfg] = await Promise.all([nowOf($), $.session.id().catch(() => mem.kept.sessionId), read($, promptCost), read($, config)])
288 const path = handoffPath(mem.home, mem.repo, now, sessionId ?? 'session')
289 try {
290 await $.fs.write(path, `${e.answer.trim()}\n`)
291 } catch (err) {
292 $.ui.log(`model-router: could not save the auto-handoff to ${path}: ${err instanceof Error ? err.message : String(err)}`)
293 return
294 }
295 mem.handoffPath = path
296 await recordHandoff($, path, segment(mem.repo), e.answer)
297 if (cfg.isLearning) {
298 const avoidedUsd = rewritePrice(asked.model, asked.tokens, cfg.cacheTtlMinutes).write
299 mem.pendingLessons.push(lessonFromAutoHandoff({ spentUsd: cost?.usd ?? 0, avoidedUsd, tokens: asked.tokens, path: tildePath(path, mem.home) }))
300 }
301 await refreshStatus($).catch(() => undefined)
302}
303
304/** Keeps the lessons of topics not muted and puts the one most worth it on the card, if any is due. */
305async function teach($: $, drafts: readonly LessonDraft[], now: number) {
306 const { learnMuted } = await read($, config)
307 const all = drafts.filter(d => !learnMuted.includes(d.kind))
308 if (all.length === 0) return
309 let card: LearnCard | undefined
310 await setLessons($, earlier => {
311 const { sessionId } = mem.kept
312 const fresh: Lesson[] = all.map(d => ({ ...d, at: now, repo: mem.repo, ...(sessionId === undefined ? {} : { sessionId }) }))
313 const pick = pickLesson(fresh, earlier, now)
314 card = pick && cardOf(pick, earlier, fresh.length)
315 return capLessons([...earlier, ...fresh.map(one => (one === pick ? { ...one, isToasted: true } : one))])
316 })
317 const shown = card
318 if (shown === undefined) return
319 const [, isDrawn] = await Promise.all([update($, learnCard, () => shown), isCardDrawn($)])
320 if (!isDrawn) $.ui.toast(`${CONCEPTS[shown.kind].title}: ${shown.fact}`)
321}
322
323const clearCard = ($: $) => update($, learnCard, () => null)
324
325/** Only the terminal and the desktop draw above the prompt; a session drawn elsewhere alone gets the lesson as a toast. */
326async function isCardDrawn($: $) {
327 const surfaces = await $.session.surfaces().catch(() => [])
328 return surfaces.length === 0 || surfaces.some(one => one === 'terminal' || one === 'desktop')
329}
330
331/** Learn mode on or off; off drops what the running turn had drafted and the card, so nothing is kept or shown from it. */
332async function setLearning($: $, isOn: boolean) {
333 if (!isOn) {
334 mem.pendingLessons = []
335 await clearCard($)
336 }
337 await setConfig($, c => ({ ...c, isLearning: isOn }))
338}
339
340async function setHandoffsOn($: $, isOn: boolean) {
341 await setConfig($, c => ({ ...c, isHandoffs: isOn }))
342}
343
344async function setAutoHandoff($: $, isOn: boolean) {
345 await setConfig($, c => ({ ...c, autoHandoff: isOn }))
346}
347
348async function setShowNext($: $, isOn: boolean) {
349 await setConfig($, c => ({ ...c, showNext: isOn }))
350}
351
352/** Turning it off also removes the text already kept, this prompt's included. */
353async function setKeepText($: $, keep: boolean) {
354 await setConfig($, c => ({ ...c, keepPromptText: keep }))
355 if (keep) return
356 if (mem.kept.current) mem.kept = { ...mem.kept, current: { ...mem.kept.current, text: '' } }
357 await carry($)
358 await setHistory($, withoutText)
359}
360
361async function setMuted($: $, kind: LessonKind, isMuted: boolean) {
362 await setConfig($, c => ({ ...c, learnMuted: isMuted ? [...new Set([...c.learnMuted, kind])] : c.learnMuted.filter(one => one !== kind) }))
363 if (isMuted && (await read($, learnCard))?.kind === kind) await clearCard($)
364}
365
366const muteText = (kind: LessonKind) => `Learn mode stays quiet about "${CONCEPTS[kind].title}" now. /route learn unmute ${kind} brings it back.`
367
368async function muteFromCard($: $, kind: LessonKind) {
369 await setMuted($, kind, true)
370 $.ui.toast(muteText(kind))
371}
372
373const nowOf = ($: $) => $.clock.now().catch(() => Date.now())
374
375/** Copies what a hot reload would drop into an atom; session.start puts it back. */
376async function carry($: $) {
377 const next: Carried = { ...mem.kept, used: { ...mem.kept.used } }
378 await update($, carried, () => next)
379}
380
381const mainRoute = async ($: $) => ((await read($, isPlan)) ? 'plan' : 'main')
382
383/** A main-loop route's model and effort, falling back to what the session runs. */
384function loopRoute(cfg: RouterConfig, route: string, fallback: { model: string; effort?: string }) {
385 const r = resolve(cfg, route)
386 return { model: r.model ?? fallback.model, effort: r.effort ?? fallback.effort }
387}
388
389async function routedMainModel($: $, cfg: RouterConfig, fallback: string): Promise<string> {
390 return loopRoute(cfg, await mainRoute($), { model: fallback }).model
391}
392
393/**
394 * Plan mode on or off. The engine raises no event on the toggle itself, so on
395 * entering, the cost of getting here is already being paid; the lesson warns of the way back.
396 */
397async function setPlan($: $, isOn: boolean) {
398 const was = await read($, isPlan)
399 await update($, isPlan, () => isOn)
400 const cfg = await read($, config)
401 if (was || !isOn || !cfg.isLearning) return
402 const session = mem.lastMain ?? { model: await $.session.model() }
403 const tokens = (await $.session.usage()).context.tokens ?? 0
404 const draft = lessonFromPlanToggle(loopRoute(cfg, 'plan', session), loopRoute(cfg, 'main', session), tokens, cfg.cacheTtlMinutes)
405 if (draft !== undefined) mem.pendingLessons.push(draft)
406}
407
408const promptTokens = (last: LastUsage) => last.input + last.cacheRead + last.cacheWrite
409
410/** The price of the next main-loop send when the cache has gone cold over a big context. */
411async function coldSend($: $, cfg: RouterConfig): Promise<{ at: number; text: string } | undefined> {
412 const last = await read($, lastUsage)
413 if (last === null) return undefined
414 const ttl = cfg.cacheTtlMinutes
415 const coldFor = coldForMs(last.at, await nowOf($), ttl)
416 if (coldFor < 0) return undefined
417 const context = (await $.session.usage()).context.tokens ?? promptTokens(last)
418 if (context < GUARD_MIN_TOKENS) return undefined
419 const model = await routedMainModel($, cfg, last.model)
420 const { write, warm } = rewritePrice(model, context, ttl)
421 return {
422 at: last.at,
423 text: `the prompt cache went cold ${fmtAge(coldFor)} ago. This send re-writes ~${kTokens(context)} tokens on ${labelOf(model)} = ${money(write)} (warm would be ${money(warm)}).`,
424 }
425}
426
427/** This session as it stands, for pricing the ways to step away; undefined before anything is cached. `tokens` saves a usage read. */
428async function awayFacts($: $, cfg: RouterConfig, tokens?: number): Promise<AwayFacts | undefined> {
429 const last = await read($, lastUsage)
430 if (last === null) return undefined
431 tokens ??= (await $.session.usage()).context.tokens ?? promptTokens(last)
432 const warmMinutes = warmMinutesLeft(last.at, await nowOf($), cfg.cacheTtlMinutes)
433 return { tokens, model: await routedMainModel($, cfg, last.model), ttlMinutes: cfg.cacheTtlMinutes, warmMinutes }
434}
435
436/**
437 * Says once which version runs after an update reaches this session. A fresh install only remembers it; an install
438 * with settings from a version too old to record itself says it loaded, with no previous version to name.
439 */
440async function noteVersion($: $, hadSettings: boolean) {
441 const [seen, manifest] = await Promise.all([load($, STORE.version), $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => undefined)])
442 const now = typeof manifest === 'string' ? versionOf(manifest) : undefined
443 if (seen === UNREADABLE || now === undefined || now === seen) return
444 await save($, STORE.version, now)
445 if (typeof seen === 'string') $.ui.toast(`model-router ${now} loaded (was ${seen})`)
446 else if (hadSettings) $.ui.toast(`model-router ${now} loaded`)
447}
448
449async function closeOldLearnPane($: $) {
450 if ((await $.ui.panes().catch(() => [])).some(one => one.id === OLD_LEARN_ID)) await $.ui.close({ id: OLD_LEARN_ID }).catch(() => undefined)
451}
452
453async function learnStatus($: $): Promise<string> {
454 const cfg = await read($, config)
455 const muted = cfg.learnMuted.length === 0 ? ['No topics muted.'] : cfg.learnMuted.map(kind => `Muted: ${kind} (/route learn unmute ${kind})`)
456 return [`Learn mode is ${cfg.isLearning ? 'on' : 'off'}.`, ...muted].join('\n')
457}
458
459/** `/route away`: the ways to step away, priced for this session as it stands. */
460async function awayReport($: $): Promise<string> {
461 const facts = await awayFacts($, await read($, config))
462 return facts === undefined ? 'Nothing is cached in this session yet.' : awayText(facts)
463}
464
465async function refreshStatus($: $) {
466 const last = await read($, lastUsage)
467 if (last === null) return
468 const { context, rateLimits } = await $.session.usage()
469 const tokens = context.tokens ?? promptTokens(last)
470 const prompt = promptTokens(last)
471 const five = rateLimits.find(one => one.kind === 'five_hour')
472 const seven = rateLimits.find(one => one.kind === 'seven_day')
473 const now = await nowOf($)
474 const cfg = await read($, config)
475 const ttl = cfg.cacheTtlMinutes
476 const warmMinutes = warmMinutesLeft(last.at, now, ttl)
477 if (mem.compactCheckedAt === undefined || now - mem.compactCheckedAt > 10 * 60_000) {
478 const { context: detailed } = await $.session.usage({ breakdown: 'summary' })
479 const b = detailed.breakdown
480 mem.compactAt = b?.isAutoCompactEnabled ? b.autoCompactThreshold : undefined
481 mem.messagesShare = b && b.totalTokens > 0 ? messagesOf(b) / b.totalTokens : undefined
482 mem.compactCheckedAt = now
483 }
484 const model = await routedMainModel($, cfg, last.model)
485 const advice = adviseSession({ tokens, window: context.window, compactAt: mem.compactAt, model, ttlMinutes: ttl, warmMinutes, messagesShare: mem.messagesShare, boundary: mem.boundary, ...signalFacts() })
486 const next: Health = {
487 cache: prompt > 0 ? Math.round((last.cacheRead / prompt) * 100) : 0,
488 warmMinutes,
489 ctx: context.percent ?? Math.round((tokens / context.window) * 100),
490 ctxLevel: contextLevel(tokens, context.window, mem.compactAt),
491 last: (await read($, promptCost))?.usd ?? last.usd,
492 ...(warmMinutes === 0 && tokens >= GUARD_MIN_TOKENS ? { coldUsd: rewritePrice(model, tokens, ttl).write } : {}),
493 ...(five ? { fiveHour: Math.round(five.percentUsed) } : {}),
494 ...(seven ? { sevenDay: Math.round(seven.percentUsed) } : {}),
495 ...(advice.verdict === 'continue' || !cfg.showNext ? {} : { advice: { verdict: advice.verdict, reason: advice.reason, text: advice.text, command: advice.command } }),
496 }
497 const prev = await read($, health)
498 await update($, health, was => (JSON.stringify(was) === JSON.stringify(next) ? was : next))
499 void maybeAutoHandoff($, cfg, { warmMinutes, tokens, model }).catch(() => undefined)
500 if (!cfg.isLearning) return
501 for (const draft of [lessonFromContext(prev?.ctxLevel, next.ctxLevel, tokens, model, ttl, mem.compactAt !== undefined), ...(cfg.showNext ? [lessonFromAdvice(prev?.advice, advice)] : [])]) {
502 if (draft !== undefined) mem.pendingLessons.push(draft)
503 }
504 // The one lesson that can't wait for a turn to end: by then the cache has gone cold.
505 const expiring = lessonFromExpiry(prev?.warmMinutes, warmMinutes, ttl, tokens, model)
506 if (expiring !== undefined) await teach($, [expiring], now).catch(() => undefined)
507}
508
509/** In the cache's last minutes with work open, asks Claude for a handoff while reading the context is still cheap. */
510async function maybeAutoHandoff($: $, cfg: RouterConfig, warm: { warmMinutes: number; tokens: number; model: string }) {
511 const facts = {
512 isOn: cfg.isHandoffs && cfg.autoHandoff,
513 isArmed: mem.handoffArmed && mem.handoff === undefined,
514 isIdle: !mem.isTurnRunning,
515 isPromptEmpty: true,
516 canSave: mem.home !== undefined,
517 warmMinutes: warm.warmMinutes,
518 ttlMinutes: cfg.cacheTtlMinutes,
519 tokens: warm.tokens,
520 openSteps: openSteps(mem.steps),
521 hasPlan: mem.planPath !== undefined,
522 }
523 if (!shouldAutoHandoff(facts)) return
524 // Claimed before the awaits, so a refresh running alongside cannot submit a second one.
525 mem.handoffArmed = false
526 const [agents, box] = await Promise.all([$.agent.list().catch(() => []), $.prompt.read()])
527 const isIdle = !mem.isTurnRunning && !agents.some(one => isAgentBusy(one.status))
528 if (!shouldAutoHandoff({ ...facts, isIdle, isPromptEmpty: box.text.trim() === '' })) {
529 mem.handoffArmed = true
530 return
531 }
532 mem.handoff = { tokens: warm.tokens, model: warm.model }
533 const sent = await $.prompt.submit({ text: HANDOFF_PROMPT }).catch(() => undefined)
534 mem.handoff = pendingAfter(mem.handoff, { kind: 'submitted', isDropped: sent === undefined || sent.drop !== undefined })
535}
536
537/** Every slash command, or none when the engine can't say. */
538const listCommands = ($: $) => $.command.list().catch(() => [])
539
540/**
541 * `names`: what to show for a key; `owners`: the plugin key behind a plugin-shipped server's key; `commands`: the plugin or own-skill key behind a slash command;
542 * `own`: your own skills and commands under ~/.claude by key, and `ownNames` the same by name;
543 * `aliases`: the `claude_ai_<Name>` key of each desktop-app connector id; `synced`: claude.ai skills by every spelling a call uses, and `syncedCount` how many are loaded.
544 */
545type Loaded = {
546 keys: ItemKey[]
547 names: Record<ItemKey, string>
548 owners: Record<ItemKey, ItemKey>
549 commands: Record<string, ItemKey>
550 plugins: string[]
551 own: Record<ItemKey, OwnSkill>
552 ownNames: Set<string>
553 aliases: Record<ItemKey, ItemKey>
554 synced: Map<string, string>
555 syncedCount: number
556}
557
558// What is loaded changes only on /mcp, /plugin or a reload, so a minute-old answer serves a pane that redraws often.
559const LOADED_TTL_MS = 60_000
560
561/** MCP servers and plugins loaded now whose use the mod can see: plugins that serve a command, a skill or an MCP server. */
562async function loadedItems($: $): Promise<Loaded> {
563 const now = await nowOf($)
564 if (mem.loaded !== undefined && now - mem.loaded.at < LOADED_TTL_MS) return mem.loaded.value
565 const value = fetchLoaded($)
566 mem.loaded = { at: now, value }
567 // A failed fetch is not kept: the next caller tries again.
568 value.catch(() => (mem.loaded?.value === value ? (mem.loaded = undefined) : undefined))
569 return value
570}
571
572/** What was last loaded, however old, for hot paths that only need the plugin list or command names. */
573const cachedLoaded = ($: $) => mem.loaded?.value ?? loadedItems($)
574
575/** Your skills (a folder or a link with a SKILL.md) and commands (`.md` files) under ~/.claude, with where the `skills` CLI installed each from. */
576async function ownSkills($: $): Promise<OwnSkill[]> {
577 const home = mem.home ?? (await $.env.get('HOME').catch(() => undefined))
578 if (home === undefined) return []
579 const [skills, commands, lock] = await Promise.all([
580 $.fs.list(`${home}/.claude/skills`).catch(() => []),
581 $.fs.list(`${home}/.claude/commands`).catch(() => []),
582 $.fs.read(`${home}/.agents/.skill-lock.json`).then(sourcesOfLock, () => ({}) as Record<string, string>),
583 ])
584 const found = await Promise.all(
585 skills
586 .filter(one => !one.name.startsWith('.') && (one.kind === 'dir' || one.isLink))
587 .map(async one =>
588 (await $.fs.exists(`${home}/.claude/skills/${one.name}/SKILL.md`).catch(() => false))
589 ? [{ name: one.name, kind: 'skill' as const, isLink: one.isLink, ...(lock[one.name] === undefined ? {} : { source: lock[one.name] }) }]
590 : [],
591 ),
592 )
593 const files = commands
594 .filter(one => !one.name.startsWith('.') && one.name.endsWith('.md') && (one.kind === 'file' || one.isLink))
595 .map(one => ({ name: one.name.slice(0, -'.md'.length), kind: 'command' as const, isLink: one.isLink }))
596 return [...found.flat(), ...files]
597}
598
599/**
600 * Connector names: what /context shows now, over what earlier sessions saved,
601 * so an id the app no longer lists still merges into its name. Saved when
602 * something new turns up.
603 */
604function connectorNames($: $, stored: unknown, mcpTools: ReadonlyArray<{ name: string; serverName: string }>): Record<ItemKey, ItemKey> {
605 const known = stored === UNREADABLE ? {} : sanitizeAliases(stored)
606 const fresh = connectorAliases(mcpTools)
607 const merged = { ...known, ...fresh }
608 if (stored !== UNREADABLE && Object.entries(fresh).some(([id, key]) => known[id] !== key)) void save($, STORE.connectorNames, merged)
609 return merged
610}
611
612async function fetchLoaded($: $): Promise<Loaded> {
613 const [tools, settings, list, ownList, breakdown, storedNames] = await Promise.all([
614 $.tool.list().catch(() => []),
615 $.settings.read().catch(() => ({}) as Record<string, unknown>),
616 listCommands($),
617 ownSkills($),
618 $.session.usage({ breakdown: 'summary' }).then(u => u.context.breakdown).catch(() => undefined),
619 load($, STORE.connectorNames),
620 ])
621 const aliases = connectorNames($, storedNames, breakdown?.mcpTools ?? [])
622 const enabled = settings.enabledPlugins
623 const ids = Object.entries(typeof enabled === 'object' && enabled !== null ? enabled : {})
624 .filter(([id, isOn]) => isOn === true && pluginName(id) !== $.plugin.name)
625 .map(([id]) => id)
626 const plugins = ids.map(pluginName)
627 const commands: Record<string, ItemKey> = {}
628 for (const info of list) {
629 const key = itemOfCommand(info)
630 if (key !== undefined) commands[info.name] = key
631 }
632 const seen = new Set(Object.values(commands))
633 const allMcp = tools.filter(one => one.mcp).map(one => one.name)
634 const keys = new Set(connectedServers(allMcp, aliases))
635 const names: Record<ItemKey, string> = {}
636 const owners: Record<ItemKey, ItemKey> = {}
637 for (const tool of allMcp) {
638 const key = itemOfToolCall(tool, {})
639 const hit = key !== undefined && keys.has(key) ? pluginServer(tool, plugins) : undefined
640 if (key === undefined || hit === undefined) continue
641 owners[key] = pluginKey(hit.plugin)
642 names[key] = `plugin:${hit.plugin}:${hit.server}`
643 seen.add(owners[key])
644 }
645 for (const id of ids) {
646 if (!seen.has(pluginKey(id))) continue
647 keys.add(pluginKey(id))
648 names[pluginKey(id)] = id
649 }
650 const own: Record<ItemKey, OwnSkill> = {}
651 for (const one of ownList) {
652 const key = skillKey(one.name)
653 own[key] = one
654 keys.add(key)
655 names[key] = one.name
656 commands[one.name] ??= key
657 }
658 const synced = syncedSkillNames(breakdown?.skills?.skillFrontmatter ?? [])
659 const listed = new Set(synced.values())
660 for (const name of listed) {
661 keys.add(claudeAiKey(name))
662 names[claudeAiKey(name)] = name
663 }
664 return { keys: [...keys], names, owners, commands, plugins, own, ownNames: new Set(ownList.map(one => one.name)), aliases, synced, syncedCount: listed.size }
665}
666
667function noteUse(key: ItemKey, at: number, calls: number) {
668 const was = mem.kept.used[key]
669 mem.kept.used[key] = { calls: (was?.calls ?? 0) + calls, lastUsed: Math.max(was?.lastUsed ?? at, at) }
670}
671
672/** The MCP server, plugin or own skill a tool use counts toward, if any. */
673async function itemsOfToolUse($: $, tool: string, input: Record<string, unknown>): Promise<ItemKey[]> {
674 // Only a Skill call needs the loaded names; a connector id is merged into its name when usage is saved.
675 const known = tool === 'Skill' ? await cachedLoaded($) : undefined
676 const direct = itemOfToolCall(tool, input, known?.ownNames, known?.synced)
677 if (direct === undefined) return []
678 const owner = direct.startsWith('mcp:plugin_') ? pluginOfMcpTool(tool, (await cachedLoaded($)).plugins) : undefined
679 return owner === undefined ? [direct] : [direct, owner]
680}
681
682/** Counts a finished tool call toward the MCP server or plugin behind it, and notes work being wrapped up, planned, tracked or explored. */
683async function noteToolCall($: $, e: Args<'tool.call'>, result: unknown) {
684 const input = e as unknown as Record<string, unknown>
685 if (e.agentId === undefined) {
686 if (e.tool === 'Bash' && typeof input.command === 'string') mem.boundary = boundaryOf(input.command) ?? mem.boundary
687 mem.steps = stepsAfter(mem.steps, e.tool, input, result)
688 const written = e.tool === 'Write' && typeof input.file_path === 'string' ? input.file_path : undefined
689 const handoffRepo = written === undefined ? undefined : repoOfHandoff(written, mem.home)
690 if (written !== undefined && handoffRepo !== undefined) await recordHandoff($, written, handoffRepo, typeof input.content === 'string' ? input.content : undefined)
691 const plan = planEventOf(e.tool, input, result)
692 if (plan?.kind === 'saved') mem.planPath = plan.path
693 if (plan?.kind === 'work') mem.planPath = undefined
694 const explored = exploredTokens(e.tool, result)
695 if (explored > 0) {
696 mem.explored += explored
697 void noteExploration($).catch(() => undefined)
698 }
699 }
700 const items = await itemsOfToolUse($, e.tool, input)
701 if (items.length === 0) return
702 const at = await nowOf($)
703 for (const key of items) noteUse(key, at, 1)
704 // Kept for a reload without holding up the tool's result.
705 void carry($).catch(() => undefined)
706}
707
708/** The first time reads and searches fill the main context this session, learn mode explains subagents. */
709async function noteExploration($: $) {
710 if (mem.isExploreTaught || mem.explored < EXPLORE_MIN || !(await read($, config)).isLearning) return
711 const total = (await $.session.usage()).context.tokens ?? 0
712 if (!isExplorationHeavy(mem.explored, total)) return
713 mem.isExploreTaught = true
714 mem.pendingLessons.push(lessonFromExploration(mem.explored, total))
715}
716
717/** What a fresh context forgets; open steps and a saved handoff outlive a compaction, as Claude Code's own todo list does. */
718function resetSignals(isClear: boolean) {
719 if (isClear) {
720 mem.steps = NO_STEPS
721 mem.handoffPath = undefined
722 mem.handoff = pendingAfter(mem.handoff, { kind: 'fresh-context' })
723 }
724 mem.planPath = undefined
725 mem.corrections = 0
726 mem.explored = 0
727 mem.isExploreTaught = false
728}
729
730const signalFacts = () => ({
731 openSteps: openSteps(mem.steps),
732 planPath: tildePath(mem.planPath, mem.home),
733 corrections: mem.corrections,
734 hasHandoffSkill: mem.hasHandoffSkill,
735 handoffPath: tildePath(mem.handoffPath, mem.home),
736})
737
738async function noteCommand($: $, name: string) {
739 const key = (await cachedLoaded($)).commands[name]
740 if (key === undefined) return
741 noteUse(key, await nowOf($), 1)
742 void carry($).catch(() => undefined)
743}
744
745/** Backfills a resumed transcript: its calls only move `lastUsed`, since the session that made them counted them. */
746async function backfillUsage($: $) {
747 const [now, messages] = await Promise.all([nowOf($), $.session.messages().catch(() => [])])
748 for (const message of messages) for (const use of message.toolUses) for (const key of await itemsOfToolUse($, use.tool, use.input)) noteUse(key, now, 0)
749 await carry($)
750}
751
752/**
753 * Saves what this session used, merged with what other sessions stored. A
754 * session's loaded items are taken at its first save after a prompt, when
755 * slow MCP servers have connected; a session with no prompt is not counted.
756 */
757async function flushUsage($: $, canCount: boolean) {
758 const isNewSession = canCount && mem.kept.isUncounted
759 if (!isNewSession && Object.keys(mem.kept.used).length === 0) return
760 // Taken fresh: a list cached at session start may predate the MCP servers that connect late.
761 if (isNewSession) mem.loaded = undefined
762 const [stored, now, loaded] = await Promise.all([load($, STORE.toolUse), nowOf($), isNewSession ? loadedItems($) : mem.loaded?.value.catch(() => undefined)])
763 if (stored === UNREADABLE) return
764 const used = mem.kept.used
765 mem.kept = { ...mem.kept, used: {}, isUncounted: mem.kept.isUncounted && !isNewSession }
766 const next = applyUsage(mergeAliases(sanitizeUsage(stored), loaded?.aliases ?? {}), mem.repo, { at: now, isNewSession, seen: isNewSession ? (loaded?.keys ?? []) : [], used })
767 const isSaved = await $.store.set(STORE.toolUse, next).then(() => true, () => false)
768 if (isSaved) {
769 mem.savedUsage = { at: now, value: next }
770 } else {
771 // Not saved, so not lost: the calls and the uncounted session wait for the next save.
772 for (const [key, use] of Object.entries(used)) noteUse(key, use.lastUsed, use.calls)
773 if (isNewSession) mem.kept = { ...mem.kept, isUncounted: true }
774 }
775 await carry($)
776}
777
778/** The usage every session has saved, re-read at most once a minute: other sessions save too, but a pane redraws far more often. */
779async function savedUsage($: $): Promise<UsageStore> {
780 const now = await nowOf($)
781 if (mem.savedUsage !== undefined && now - mem.savedUsage.at < LOADED_TTL_MS) return mem.savedUsage.value
782 const stored = await load($, STORE.toolUse)
783 const value = stored === UNREADABLE ? {} : sanitizeUsage(stored)
784 if (stored !== UNREADABLE) mem.savedUsage = { at: now, value }
785 return value
786}
787
788/** Idle MCP servers, plugins and own skills in this repo, and own skills used only here, counting this session's calls not yet saved, with what is loaded and this repo's saved usage. */
789async function idleHere($: $) {
790 const [saved, loaded, now] = await Promise.all([savedUsage($), loadedItems($), nowOf($)])
791 const store = applyUsage(mergeAliases(saved, loaded.aliases), mem.repo, { at: now, isNewSession: false, seen: [], used: mem.kept.used })
792 const idle = idleItems(store, mem.repo, loaded.keys, now, { patience: mem.patience, owners: loaded.owners })
793 return { idle, only: onlyHere(store, mem.repo, Object.keys(loaded.own), now, mem.patience), loaded, repo: saved[mem.repo] }
794}
795
796/** Names to show: what the mod worked out, over the /mcp names of servers /context lists. */
797const withServerNames = (names: Record<ItemKey, string>, servers: readonly { serverName: string }[]) => ({
798 ...Object.fromEntries(servers.map(one => [mcpKey(one.serverName), one.serverName])),
799 ...names,
800})
801
802/** `/route unused`: every idle MCP server and plugin here, or why nothing is listed yet. */
803async function unusedReport($: $): Promise<string> {
804 const [breakdown, { idle, only, loaded, repo }] = await Promise.all([
805 $.session.usage({ breakdown: 'summary' }).then(u => u.context.breakdown).catch(() => undefined),
806 idleHere($),
807 ])
808 const here = printable(mem.repo)
809 const patience = `${patienceText(mem.patience)}${mem.patience === DEV_PATIENCE ? ' (MODEL_ROUTER_IDLE_DEV)' : ''}`
810 if (repo === undefined) return `No usage recorded for ${here} yet. model-router notes which MCP servers, plugins and your own skills each repo uses; idle ones show here after ${patience}.`
811 const lines = [...idleReport(idle, withServerNames(loaded.names, breakdown?.mcpTools ?? []), loaded.keys), ...skillReport(idle, only, loaded.own, mem.root), ...claudeAiReport(idle, loaded.syncedCount)]
812 if (lines.length === 0) {
813 return `Nothing idle in ${here}: every MCP server, plugin and skill of yours loaded here was used in the last 30 days, or has been loaded for under ${patience} (${plural(repo.sessions, 'session')} recorded).`
814 }
815 return [`Unused in ${here} (${plural(repo.sessions, 'session')} recorded):`, ...lines.map(line => ` ${line}`)].join('\n')
816}
817
818function recordOf(meta: { label: string; text: string; at: number }, p: PromptCost): PromptRecord {
819 return {
820 at: meta.at,
821 repo: mem.repo,
822 label: meta.label,
823 text: meta.text,
824 usd: p.usd,
825 requests: p.requests,
826 agents: subagentCount(p),
827 models: modelsOf(p),
828 rewrites: p.rewrites,
829 }
830}
831
832async function recordPrompt($: $) {
833 const p = await read($, promptCost)
834 const meta = mem.kept.current
835 if (meta === undefined) return
836 mem.kept = { ...mem.kept, current: undefined }
837 await carry($)
838 if (p === null || p.usd <= 0) return
839 const rec = recordOf(meta, p)
840 await setHistory($, all => [...all, rec].slice(-HISTORY_LIMIT))
841}
842
843/** Opening an open pane only retitles it, so one open behind another is reopened to come to the front. */
844async function raisePane($: $, pane: keyof typeof PANES) {
845 const open = (await $.ui.panes().catch(() => [])).find(one => one.id === PANES[pane].id)
846 if (open !== undefined && !open.isShown) await $.ui.close({ id: PANES[pane].id })
847 await $.ui.open(PANES[pane])
848}
849
850/** What opening a pane does besides showing it. */
851async function onOpen($: $, pane: keyof typeof PANES) {
852 if (pane === 'handoffs') await refreshHandoffs($)
853}
854
855async function showPane($: $, target: Target) {
856 const { pane, tab } = paneOf(target)
857 if (tab !== undefined) await update($, costTab, () => tab)
858 await Promise.all([onOpen($, pane).catch(() => undefined), raisePane($, pane).catch(() => undefined)])
859}
860
861const openPane = ($: $): Open => target => void showPane($, target)
862
863/** Saved history plus the prompt still running, every repo; the Cost pane's History tab narrows it to one. */
864async function allRecords($: $): Promise<{ records: PromptRecord[]; now: number }> {
865 const [saved, live, now] = await Promise.all([read($, history), read($, promptCost), nowOf($)])
866 const current = mem.kept.current
867 return { records: live && current && live.usd > 0 ? [...saved, recordOf(current, live)] : saved, now }
868}
869
870async function renderStatsFooter($: $, e: Args<'ui.render'>, modes: readonly string[]) {
871 const { records, now } = await allRecords($)
872 return statsFooterView($.ui.resolve(e), { modes, today: todayUsd(records, now) }, { open: openPane($) })
873}
874
875async function renderCostPane($: $, e: Args<'ui.render'>) {
876 const [tab, scope, lastPrompt, costs, cfg] = await Promise.all([read($, costTab), read($, statsScope), read($, promptCost), read($, spend), read($, config)])
877 const { records: all, now } = tab === 'history' ? await allRecords($) : { records: [], now: 0 }
878 const records = scope === 'repo' ? all.filter(r => r.repo === mem.repo) : all
879 return costPaneView($.ui.resolve(e), { tab, lastPrompt, costs, records, scope, repo: mem.repo, now, ttlMinutes: cfg.cacheTtlMinutes }, {
880 setTab: next => void update($, costTab, () => next),
881 setScope: async next => void save($, STORE.statsScope, await update($, statsScope, () => next)),
882 })
883}
884
885const setBand = async ($: $, isShown: boolean) => save($, STORE.bandShown, await update($, isBandShown, () => isShown))
886
887async function renderCard($: $, e: Args<'ui.render'>) {
888 const card = await read($, learnCard)
889 if (card === null) return null
890 const actions = { dismiss: () => void clearCard($), mute: () => void muteFromCard($, card.kind) }
891 return learnCardView($.ui.resolve(e), card, actions)
892}
893
894async function renderBand($: $, e: Args<'ui.render'>) {
895 const [cfg, now, pending, costs, hp, records] = await Promise.all([
896 read($, config),
897 read($, active),
898 read($, draft),
899 read($, spend),
900 read($, health),
901 read($, handoffs),
902 ])
903 const handoffsOpen = !cfg.isHandoffs ? 0 : bandCount(records, segment(mem.repo), mem.kept.sessionId, await nowOf($))
904 const data = { cfg, now, pending, total: sumUsd(Object.values(costs)), health: hp, handoffsOpen }
905 return bandView($.ui.resolve(e), data, { open: openPane($) })
906}
907
908async function renderCtxPane($: $, e: Args<'ui.render'>) {
909 if (e.surface === 'mobile') return contextMobileView($.ui.resolve(e))
910 const columns = (e.component === 'Pane' ? e.props.bodyColumns : undefined) || 80
911 const [{ context }, hp, cfg, { idle, only, loaded }] = await Promise.all([$.session.usage({ breakdown: 'summary', columns }), read($, health), read($, config), idleHere($)])
912 const b = context.breakdown
913 const servers = b?.mcpTools ?? []
914 // Worked out from this breakdown, not the band's last refresh, so it holds before the first request and right after a /clear.
915 const advice = b && cfg.showNext && adviseSession({
916 tokens: b.totalTokens,
917 window: b.rawMaxTokens,
918 compactAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
919 model: hp === null ? b.model : await routedMainModel($, cfg, b.model),
920 ttlMinutes: cfg.cacheTtlMinutes,
921 warmMinutes: hp?.warmMinutes ?? cfg.cacheTtlMinutes,
922 messagesShare: b.totalTokens > 0 ? messagesOf(b) / b.totalTokens : undefined,
923 boundary: mem.boundary,
924 ...signalFacts(),
925 })
926 // Only once there is enough cached to be worth planning a break around: the cold guard's threshold.
927 const facts = (context.tokens ?? 0) >= GUARD_MIN_TOKENS ? await awayFacts($, cfg, context.tokens) : undefined
928 const away = facts && awayOptions(facts)
929 const tokens = listingTokens(b?.skills?.skillFrontmatter ?? [])
930 const tips = [...idleTips(idle, withServerNames(loaded.names, servers), tokensByServer(servers), { root: mem.root, tokens }), ...skillTips(idle, only, loaded.own, mem.root, tokens), ...claudeAiTips(idle, loaded.syncedCount, tokens)]
931 const data = { breakdown: b, isTerminal: e.surface === 'terminal', columns, advice: advice || undefined, explored: mem.explored, unused: tips, away }
932 const fill = (text: string) => void $.prompt.fill({ text, mode: 'replace' }).catch(() => undefined)
933 // A menu such as /mcp opens as if typed; where the engine refuses, it is left in the prompt instead.
934 const act = (action: TipAction) => (action.isSlash ? void $.command.run({ command: action.command }).catch(() => fill(`/${action.command}`)) : fill(action.command))
935 return contextPaneView($.ui.resolve(e), data, { act })
936}
937
938async function renderPane($: $, e: Args<'ui.render'>) {
939 const now = await read($, active)
940 if (e.surface === 'mobile') return routesMobileView($.ui.resolve(e), now)
941 const els = $.ui.resolve(e)
942 const [cfg, costs, isPlanning, pending, lastPrompt, usage, isBand] = await Promise.all([
943 read($, config),
944 read($, spend),
945 read($, isPlan),
946 read($, draft),
947 read($, promptCost),
948 $.session.usage(),
949 read($, isBandShown),
950 ])
951 const sessionModel = now?.model ?? (await $.session.model())
952 const data = { cfg, total: sumUsd(Object.values(costs)), now, isPlanning, pending, lastPrompt, contextTokens: usage.context.tokens ?? 0, sessionModel, isBand }
953 return routesPaneView(els, data, {
954 editDraft: fn =>
955 void update($, draft, d => {
956 const next = fn(d ?? cfg)
957 return JSON.stringify(next) === JSON.stringify(cfg) ? null : next
958 }),
959 toggle: () => void setConfig($, c => ({ ...c, isEnabled: !c.isEnabled })),
960 setLearning: isOn => void setLearning($, isOn),
961 setHandoffs: isOn => void setHandoffsOn($, isOn),
962 setAutoHandoff: isOn => void setAutoHandoff($, isOn),
963 setBand: isShown => void setBand($, isShown),
964 openHandoffs: () => openPane($)('handoffs'),
965 setShowNext: isOn => void setShowNext($, isOn),
966 setGuard: mode => void setConfig($, c => ({ ...c, coldGuard: mode })),
967 setTtl: minutes => void setConfig($, c => ({ ...c, cacheTtlMinutes: minutes })),
968 setKeepText: keep => void setKeepText($, keep),
969 // Only the routes come from the draft, so on/off or a setting changed while it was open is kept.
970 apply: async next => {
971 await update($, draft, () => null)
972 await setConfig($, c => ({ ...c, preset: next.preset, routes: next.routes }))
973 },
974 discard: () => void update($, draft, () => null),
975 })
976}
977
978const GUARD_TEXT: Record<ColdGuard, string> = {
979 refuse: 'Cold sends are dropped once with their price; the resend goes through.',
980 warn: 'Cold sends go through with their price shown.',
981 off: 'Cold-send guard off.',
982}
983
984/** The cold guard's line: the price, what happens next, and with learn mode on, why. */
985const guardText = (cfg: RouterConfig, cold: { text: string }, tail: string) =>
986 `model-router: ${cold.text} ${tail}${cfg.isLearning && !cfg.learnMuted.includes('cold-rewrite') ? `\n\nWhy: ${CONCEPTS['cold-rewrite'].body}` : ''}`
987
988const LEARN_ON_TEXT =
989 'Learn mode on: when a cache rewrite, a pricey model, a growing context or busy subagents cost you, a card above the band explains it with the numbers, until your next prompt.'
990
991const HANDOFFS_ON_TEXT = 'Handoffs on: the band counts handoffs other sessions left open, and auto-handoff runs when it is on (/route handoffs auto on|off).'
992const HANDOFFS_OFF_TEXT = 'Handoffs off: no count in the band and no auto-handoff. Handoffs you save with /handoff are still listed in /route handoffs.'
993
994const handoffOnText = (repo: string) =>
995 `Auto-handoff on: when the cache is about to go cold with steps open or a plan not yet started, model-router asks Claude for a handoff while the cache is still warm and saves it under ~/${HANDOFF_DIR}/${segment(repo)}/. It runs once per prompt and never clears the session itself.`
996
997/**
998 * `/route` words in their current form: `cost <tab>` opens a tab as `<tab>` does, and
999 * `handoff on|off` is the older name of `handoffs auto on|off`.
1000 */
1001function aliased(words: readonly string[]): string[] {
1002 const [verb, ...rest] = words
1003 if (verb === 'cost' && rest.length === 1) return rest
1004 if (verb === 'handoff') return ['handoffs', 'auto', ...rest]
1005 return [...words]
1006}
1007
1008const asEffort = (effort: unknown): Effort | undefined =>
1009 typeof effort === 'string' ? (effort as Effort) : undefined
1010
1011const USAGE = '[cost [last|session|history] | last | stats [clear | text on|off] | context | learn [on|off | mute|unmute <topic>] | handoffs [on|off | auto on|off] | next on|off | away | unused | preset <name> | set <route> <model> [effort] | on | off | band on|off | guard refuse|warn|off | ttl 5|60]'
1012
1013// Origins a person typed or drove: only these may change the routes, and only these are held by the cold guard.
1014const USER_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
1015
1016type Decision = { model: string; effort?: Effort; isHeld: boolean }
1017
1018// Per-load memory: a hot reload starts it over, which only costs one unguarded turn.
1019const mem = {
1020 autoEffort: new Map<string, Effort>(),
1021 decisions: new Map<string, Decision>(),
1022 lastMain: undefined as { route: string; model: string; effort?: Effort; at: number } | undefined,
1023 isAfterCompact: false,
1024 repo: 'unknown',
1025 // The label of the prompt the next turn.start opens; undefined while a prompt's turns run.
1026 pendingPrompt: 'prompt' as string | undefined,
1027 // Each agent's route, label and the prompt that started it (its `current.at`), when spawned in view.
1028 agents: new Map<string, { route: string; label?: string; promptAt?: number }>(),
1029 tick: undefined as Timer | undefined,
1030 current: undefined as { label: string; text: string; at: number } | undefined,
1031 compactAt: undefined as number | undefined,
1032 compactCheckedAt: undefined as number | undefined,
1033 coldAckedAt: undefined as number | undefined,
1034 // Slash commands seen to start a model turn, and the command that ran last, until a turn shows whether it did.
1035 modelCommands: new Set<string>(),
1036 lastCommand: undefined as string | undefined,
1037 // Lessons the running main-loop turn has taught so far; kept or dropped when it ends.
1038 pendingLessons: [] as LessonDraft[],
1039 messagesShare: undefined as number | undefined,
1040 // Work wrapped up (a commit, a PR) since the person last prompted.
1041 boundary: undefined as Boundary | undefined,
1042 // Todos and tasks open, a plan on disk with no other edit since, correcting prompts in a row, and read/search tokens since the last fresh context.
1043 steps: NO_STEPS as Steps,
1044 planPath: undefined as string | undefined,
1045 corrections: 0,
1046 explored: 0,
1047 isExploreTaught: false,
1048 hasHandoffSkill: false,
1049 home: undefined as string | undefined,
1050 // The repo's root, for commands a tip puts in the prompt.
1051 root: undefined as string | undefined,
1052 // An auto-handoff saved since the person last prompted, and whether one may still run before they prompt again.
1053 handoffPath: undefined as string | undefined,
1054 handoffArmed: false,
1055 isTurnRunning: false,
1056 // The auto-handoff asked for and not yet answered: its turn once it starts, and the context it would have rewritten cold.
1057 handoff: undefined as PendingHandoff,
1058 handoffsChain: Promise.resolve() as Promise<unknown>,
1059 // MCP servers and plugins used since the last save, whether this session has been counted yet, and what is loaded.
1060 kept: { used: {}, isUncounted: true } as Carried,
1061 loaded: undefined as { at: number; value: Promise<Loaded> } | undefined,
1062 savedUsage: undefined as { at: number; value: UsageStore } | undefined,
1063 // MODEL_ROUTER_IDLE_DEV=1: report idle items after one counted session instead of two weeks and ten.
1064 patience: PATIENCE as Patience,
1065}
1066
1067async function routeOfAgent($: $, agentId: string): Promise<string | undefined> {
1068 const known = mem.agents.get(agentId)
1069 if (known !== undefined) return known.route
1070 const listed = (await $.agent.list()).find(one => one.id === agentId)
1071 if (listed !== undefined) {
1072 const route = listed.teammateId !== undefined ? 'teammate' : listed.type === FORK ? FORK : `sub:${printable(listed.type)}`
1073 mem.agents.set(agentId, { route })
1074 return route
1075 }
1076 // A workflow's agents and the engine's own forks (compaction, memory) are in no list: left on their model.
1077 return undefined
1078}
1079
1080async function decideMain(
1081 $: $,
1082 cfg: RouterConfig,
1083 turnId: string,
1084 index: number,
1085 route: string,
1086 model: string,
1087 isAfterCompact: boolean,
1088 effort?: Effort,
1089): Promise<Decision> {
1090 const known = mem.decisions.get(turnId)
1091 if (known !== undefined && index > 0) return known
1092 const want = resolve(cfg, route)
1093 const toModel = want.model ?? model
1094 let toEffort: Effort | undefined =
1095 want.effort === 'auto' ? (mem.autoEffort.get(turnId) ?? mem.lastMain?.effort ?? 'medium') : (want.effort ?? effort)
1096 let isHeld = false
1097 const last = mem.lastMain
1098 if (want.effort === 'auto' && last !== undefined && last.route === route) {
1099 const contextTokens = (await $.session.usage()).context.tokens ?? 0
1100 const from = { model: last.model, effort: last.effort }
1101 const to = { model: toModel, effort: toEffort }
1102 if (!shouldSwitch({ contextTokens, isAfterCompact, from, to, ttlMinutes: cfg.cacheTtlMinutes })) {
1103 toEffort = last.effort
1104 isHeld = true
1105 }
1106 }
1107 mem.autoEffort.delete(turnId)
1108 const decision = { model: toModel, effort: toEffort, isHeld }
1109 mem.decisions.clear()
1110 mem.decisions.set(turnId, decision)
1111 return decision
1112}
1113
1114type StepOutcome = {
1115 cfg: RouterConfig
1116 model: string
1117 effort?: Effort
1118 isHeld: boolean
1119 isAfterCompact: boolean
1120 usage: (Parameters<typeof costOf>[0] & { model: string }) | null | undefined
1121}
1122
1123/** Counts one finished request: per-route spend, the prompt's breakdown, and, for the main loop, the band's facts. */
1124async function recordStep($: $, agentId: string | undefined, key: string, s: StepOutcome) {
1125 const stepAt = await nowOf($)
1126 const isMain = agentId === undefined
1127 const usage = s.usage
1128 if (usage) {
1129 const ttl = s.cfg.cacheTtlMinutes
1130 const label = isMain ? (key === 'plan' ? 'Main loop (plan mode)' : 'Main loop') : (mem.agents.get(agentId)?.label ?? key)
1131 const cause = isMain
1132 ? rewriteCause({ isAfterCompact: s.isAfterCompact, prevAt: mem.lastMain?.at, now: stepAt, ttlMinutes: ttl, from: mem.lastMain, to: { model: s.model, effort: s.effort } })
1133 : undefined
1134 const rewrite = rewriteOf(usage, usage.model, cause, ttl)
1135 if (isMain && rewrite !== undefined && s.cfg.isLearning) {
1136 const draft = lessonFromRewrite(rewrite, {
1137 model: usage.model,
1138 ttlMinutes: ttl,
1139 idleMs: mem.lastMain === undefined ? undefined : stepAt - mem.lastMain.at,
1140 from: mem.lastMain,
1141 to: { model: usage.model, effort: s.effort },
1142 })
1143 if (draft !== undefined) mem.pendingLessons.push(draft)
1144 }
1145 await update($, spend, all => ({ ...all, [key]: addSpend(all[key] ?? EMPTY_SPEND, usage, usage.model, ttl) }))
1146 const promptAt = isMain ? undefined : mem.agents.get(agentId)?.promptAt
1147 if (promptAt !== undefined && promptAt !== mem.kept.current?.at) {
1148 // A background agent outlived the prompt that started it: its cost belongs to that prompt's saved record.
1149 await setHistory($, all => all.map(r => (r.at === promptAt && r.repo === mem.repo ? addLateStep(r, { usage, effort: s.effort, ttlMinutes: ttl }) : r)))
1150 } else {
1151 await update($, promptCost, p => addStep(p ?? EMPTY_PROMPT, { id: agentId ?? key, route: key, label, effort: s.effort, usage, ttlMinutes: ttl, rewrite }))
1152 }
1153 if (isMain) {
1154 await update($, lastUsage, () => ({
1155 model: usage.model,
1156 input: usage.input_tokens,
1157 cacheRead: usage.cache_read_input_tokens,
1158 cacheWrite: usage.cache_creation_input_tokens,
1159 usd: costOf(usage, usage.model, ttl),
1160 at: stepAt,
1161 }))
1162 }
1163 }
1164 if (!isMain) return
1165 if (mem.lastMain !== undefined && mem.lastMain.route !== key && s.cfg.isEnabled) $.ui.toast(`Route ${key}: ${routeText(s.model, s.effort)}`)
1166 mem.lastMain = { route: key, model: s.model, effort: s.effort, at: stepAt }
1167 await update($, active, () => ({ route: key, model: s.model, effort: s.effort, isHeld: s.isHeld }))
1168 await refreshStatus($)
1169}
1170
1171export const register: Register = on => {
1172 on('session.start', async ($, e, next) => {
1173 const [stored, saved, scope, band, learned, repo, storedLessons, storedHandoffs, handoffScopeSaved] = await Promise.all([
1174 load($, STORE.config),
1175 load($, STORE.history),
1176 load($, STORE.statsScope),
1177 load($, STORE.bandShown),
1178 load($, STORE.modelCommands),
1179 $.session.repo().catch(() => null),
1180 load($, STORE.lessons),
1181 load($, STORE.handoffs),
1182 load($, STORE.handoffScope),
1183 ])
1184 if (Array.isArray(learned)) for (const one of learned) if (typeof one === 'string') mem.modelCommands.add(one)
1185 const kept = saved === UNREADABLE ? [] : sanitizeHistory(saved)
1186 if (stored === UNREADABLE || saved === UNREADABLE) {
1187 $.ui.log('model-router: could not read saved settings or history; using defaults for this session and saving nothing over them.')
1188 } else {
1189 const cfg = startingConfig(stored, kept.length > 0)
1190 await update($, config, () => cfg)
1191 // Saved on first run so the history check above decides once: a fresh install keeps `inherit` after it gains history.
1192 if (stored === undefined) await save($, STORE.config, cfg)
1193 }
1194 await update($, history, () => kept)
1195 if (storedLessons !== UNREADABLE) await update($, lessons, () => sanitizeLessons(storedLessons))
1196 if (storedHandoffs !== UNREADABLE) await update($, handoffs, () => sanitizeHandoffs(storedHandoffs))
1197 if (handoffScopeSaved === 'repo' || handoffScopeSaved === 'all') await update($, handoffScope, () => handoffScopeSaved)
1198 if (scope === 'repo' || scope === 'all') await update($, statsScope, () => scope)
1199 if (typeof band === 'boolean') await update($, isBandShown, () => band)
1200 const root = repo?.root ?? (await $.session.root().catch(() => ''))hooks/lessons.ts 337 lines1import type { Effort, Health, LearnCard, Lesson, LessonKind, PromptCost, PromptItem, Rewrite } from '../types'
2import type { Advice } from './strategy'
3import { ENGINE, FORK, GUARD_MIN_TOKENS, MODELS, PRICES_AS_OF, costOf, fmtAge, isLessonKind, isRouteKey, isSub, kTokens, labelOf, modelKeyOf, money, plural, printable, rewritePrice, routeText, sumUsd } from './routes'
4
5export type LessonDraft = Pick<Lesson, 'kind' | 'fact' | 'usd'>
6type Level = Health['ctxLevel']
7
8const SEEN_ENOUGH = 3
9const COOLDOWN_MS = 3_600_000
10const PER_KIND = 20
11export const MIN_REWRITE_USD = 0.05
12const MIN_PROMPT_USD = 0.25
13const PRICEY_SHARE = 0.7
14const PRICEY_SAVING = 0.25
15const SUB_SHARE = 0.5
16
17export const CONCEPTS: Record<LessonKind, { title: string; body: string }> = {
18 'cold-rewrite': {
19 title: 'Cache went cold',
20 body: 'Each request re-sends the whole conversation. A warm prompt cache lets the API read it back cheaply, at a tenth of the input price or less. The cache expires after the TTL (1 hour in Claude Code, 5 minutes on some setups). The first send after that writes everything again, at 1.25-2× input. Over a big context that one send can cost as much as a dozen or more warm turns. /clear and start from a short note, or come back before it expires.',
21 },
22 'switch-rewrite': {
23 title: 'Switching model rewrote the cache',
24 body: 'A prompt cache belongs to one model at one effort. Changing either, by hand, by a route or by plan mode, starts a new cache: the next request writes the whole context again. Switch at the start of a task, while the context is small, not in the middle of a long one.',
25 },
26 'pricey-model': {
27 title: 'An expensive model did most of the work',
28 body: 'Models differ in price per token: Opus costs about twice Sonnet for input and output, Fable more, and Haiku a fraction. Effort adds output (thinking) tokens. Use the top model where its judgement pays off (planning, tricky changes) and a cheaper one for routine edits or searches. Opus and Sonnet price cache reads the same, so between them the difference shows in output and cache writes.',
29 },
30 'context-high': {
31 title: 'Context is getting big',
32 body: 'Every turn sends the whole context again. Even read from the cache, a big context makes each turn cost more, and a cold send rewrites all of it. Near the limit Claude Code auto-compacts into a summary. Finishing a task and running /clear keeps turns cheap. /compact keeps a summary when you need the thread: say what to keep (the plan, open TODOs, touched files), and run it at a point you choose, while the cache is warm, rather than let auto-compact fire mid-task.',
33 },
34 compacted: {
35 title: 'Conversation compacted',
36 body: 'Compaction replaces the conversation with a summary written by the model. Later turns are cheaper because there is less to re-read, but anything the summary left out is gone. The summary is written to the cache once, like any new context.',
37 },
38 'cache-expiring': {
39 title: 'Cache about to go cold',
40 body: 'The prompt cache keeps the conversation cheap to re-send only for its TTL after the last request. Any send before it expires keeps it warm for another full TTL; after it expires, the next send writes the whole context again. If you are done for now, /clear costs nothing and the next session starts small. /route away compares the ways to step away, priced for the session.',
41 },
42 'plan-toggle': {
43 title: 'Plan mode switches rewrite the cache',
44 body: 'A prompt cache belongs to one model at one effort. When plan mode is routed differently from normal work, entering and leaving plan mode each start a new cache, and the first send after each switch writes the whole context again. Plan at the start of a task, while the context is small, or route plan and main the same.',
45 },
46 strategy: {
47 title: 'Clear after committing',
48 body: 'Every request re-sends the whole conversation, so what has piled up is paid for again on each turn. Once a task is committed, its back-and-forth rarely helps the next one: /clear starts small and costs nothing, and the commit and its message already record what was done. /compact instead if the next task builds on this one. The band shows "next /clear" or "next /compact" whenever carrying on is not the best move.',
49 },
50 handoff: {
51 title: 'Hand off instead of compacting mid-plan',
52 body: 'A /compact summary is written by the model in one pass and keeps what it judges important; mid-plan that often loses the exact steps left, the files involved and why decisions were made. A handoff written on purpose (the goal, what is done, the open steps in order, files touched, decisions, the next step) keeps all of that on disk. /clear afterwards, then tell the new session to resume from the file: it starts small and reads only what the next step needs.',
53 },
54 'plan-ready': {
55 title: 'Start fresh once the plan is written',
56 body: 'Exploring and planning fill the context with files read, options weighed and questions asked. Once the plan is on disk, that history is dead weight: every request re-reads it and it competes for attention with the plan itself. /clear and ask Claude to implement the plan file: the new session reads the plan with clean context.',
57 },
58 corrections: {
59 title: 'Start over after two failed corrections',
60 body: 'Each failed attempt stays in the context and pulls the next try toward the same approach. After two corrections on the same issue, a fresh session with one better prompt (what you want, what did not work, what you learned) usually beats a third correction. Esc Esc rewinds to before the first attempt if you want to keep the earlier work.',
61 },
62 exploration: {
63 title: 'Exploration filled the main context',
64 body: 'Every file read and search result stays in the conversation and is re-read on each later turn. A subagent explores in its own context and hands back only a summary, so the main conversation stays small for the actual change. Ask for one ("use a subagent to find how X works"), and route it to a cheaper model with /route set sub:Explore haiku.',
65 },
66 'auto-handoff': {
67 title: 'Handoff saved before the cache went cold',
68 body: 'With auto-handoff on, when the prompt cache is about to expire while steps are still open, model-router asks Claude for a handoff while the cache is still warm. That send reads the context at the cache price, a fraction of the rewrite after expiry. The plugin saves the reply under ~/.claude/handoffs/ and never clears the session itself. /route handoffs auto off turns it off.',
69 },
70 subagents: {
71 title: 'Subagents did most of the spending',
72 body: 'A subagent runs in its own fresh context. It has no cache to lose, so routing it to a cheaper model costs nothing up front. Each subagent re-reads the files it needs, so many parallel agents add up. Searches and reads usually do fine on a cheaper model: /route set sub:Explore sonnet.',
73 },
74}
75
76export const TOPICS = Object.keys(CONCEPTS) as LessonKind[]
77const RANK: Record<Level, number> = { success: 0, warning: 1, error: 2 }
78export const isPricey = (model: string) => {
79 const key = modelKeyOf(model)
80 return key === 'opus' || key === 'fable'
81}
82const usageOf = (one: PromptItem) => ({
83 input_tokens: one.input,
84 output_tokens: one.output,
85 cache_read_input_tokens: one.cacheRead,
86 cache_creation_input_tokens: one.cacheWrite,
87})
88export const pct = (share: number) => `${Math.round(share * 100)}%`
89
90type RewriteLessonFacts = {
91 model: string
92 ttlMinutes: number
93 idleMs?: number
94 from?: { model: string; effort?: Effort }
95 to: { model: string; effort?: Effort }
96}
97
98/** A cache rewrite worth explaining: why it happened and what it cost against a warm read. */
99export function lessonFromRewrite(rw: Rewrite, f: RewriteLessonFacts): LessonDraft | undefined {
100 if (rw.usd < MIN_REWRITE_USD) return undefined
101 const { write, warm } = rewritePrice(f.model, rw.tokens, f.ttlMinutes)
102 if (rw.cause === 'cold') {
103 const idle = f.idleMs === undefined ? '' : ` after ${fmtAge(f.idleMs)} idle`
104 return {
105 kind: 'cold-rewrite',
106 usd: rw.usd,
107 fact: `The cache had expired${idle}, so this send re-wrote ${kTokens(rw.tokens)} tokens on ${labelOf(f.model)} for ${money(write)}; read warm they would have cost ${money(warm)}.`,
108 }
109 }
110 const from = f.from === undefined ? 'another route' : routeText(f.from.model, f.from.effort)
111 return {
112 kind: 'switch-rewrite',
113 usd: rw.usd,
114 fact: `Switching from ${from} to ${routeText(f.to.model, f.to.effort)} re-wrote ${kTokens(rw.tokens)} tokens for ${money(write)} (${money(rw.usd)} more than a warm read). Each model and effort keeps its own cache.`,
115 }
116}
117
118function priceyLesson(p: PromptCost, ttlMinutes: number): LessonDraft | undefined {
119 const pricey = p.items.filter(one => isPricey(one.model))
120 if (pricey.some(one => one.isMixed)) return undefined
121 const actual = sumUsd(pricey)
122 const onSonnet = pricey.reduce((acc, one) => acc + costOf(usageOf(one), MODELS.sonnet.id, ttlMinutes), 0)
123 if (p.usd < MIN_PROMPT_USD || actual < PRICEY_SHARE * p.usd || onSonnet > (1 - PRICEY_SAVING) * actual) return undefined
124 const labels = [...new Set(pricey.map(one => labelOf(one.model)))].join(' and ')
125 return {
126 kind: 'pricey-model',
127 usd: actual - onSonnet,
128 fact: `${labels} did ${pct(actual / p.usd)} of this prompt's ${money(p.usd)}. The same tokens on ${MODELS.sonnet.label} would cost about ${money(onSonnet)} (price only, not quality).`,
129 }
130}
131
132function subagentLesson(p: PromptCost): LessonDraft | undefined {
133 // Forks share the parent's context and the engine's own agents are not routed: neither is a reroutable subagent.
134 const subs = p.items.filter(one => isSub(one) && one.route !== FORK && one.route !== ENGINE)
135 const subUsd = sumUsd(subs)
136 if (subs.length < 2 || p.usd < MIN_PROMPT_USD || subUsd < SUB_SHARE * p.usd) return undefined
137 const byRoute = new Map<string, number>()
138 for (const one of subs) byRoute.set(one.route, (byRoute.get(one.route) ?? 0) + one.usd)
139 const route = [...byRoute.entries()].sort((a, b) => b[1] - a[1])[0]![0]
140 const top = subs.filter(one => one.route === route).sort((a, b) => b.usd - a.usd)[0]!
141 const shown = printable(route)
142 const hint = route.startsWith('sub:') && isRouteKey(route) && isPricey(top.model)
143 ? `Subagents start with a fresh context, so moving ${shown} to a cheaper model costs no cache rewrite: /route set ${shown} sonnet`
144 : 'Subagents start with a fresh context, so they never pay a cache rewrite when rerouted.'
145 return {
146 kind: 'subagents',
147 usd: subUsd,
148 fact: `${plural(subs.length, 'subagent')} made up ${pct(subUsd / p.usd)} of this prompt (${money(subUsd)}), most of it ${shown} on ${labelOf(top.model)}. ${hint}`,
149 }
150}
151
152/** The lessons a finished prompt's cost breakdown holds. */
153export function lessonsFromPrompt(p: PromptCost, ttlMinutes: number): LessonDraft[] {
154 return [priceyLesson(p, ttlMinutes), subagentLesson(p)].filter((one): one is LessonDraft => one !== undefined)
155}
156
157/** Context crossing into amber or red, priced per turn ahead on the model the main loop runs; measured against auto-compact when it is on. */
158export function lessonFromContext(
159 prev: Level | undefined,
160 next: Level,
161 tokens: number,
162 model: string,
163 ttlMinutes: number,
164 hasAutoCompact: boolean,
165): LessonDraft | undefined {
166 if (prev === undefined || RANK[next] <= RANK[prev] || next === 'success' || modelKeyOf(model) === undefined) return undefined
167 const { write, warm } = rewritePrice(model, tokens, ttlMinutes)
168 const share = next === 'error' ? '85%' : '60%'
169 const how = hasAutoCompact ? `past ${share} of the way to auto-compact` : `past ${share} of the context window`
170 return {
171 kind: 'context-high',
172 usd: warm,
173 fact: `Context reached ${kTokens(tokens)} tokens, ${how}. From here every turn re-reads all of it: about ${money(warm)} a turn on ${labelOf(model)} while the cache is warm, ${money(write)} if it goes cold. Finishing this task and running /clear brings that back down.`,
174 }
175}
176
177/** Minutes before expiry the warning fires: enough to send something, scaled to the TTL. */
178const warnMinutes = (ttlMinutes: number) => (ttlMinutes >= 60 ? 5 : 2)
179
180/** The cache's last minutes over a big context, while a send still keeps it warm. */
181export function lessonFromExpiry(prevWarm: number | undefined, warm: number, ttlMinutes: number, tokens: number, model: string): LessonDraft | undefined {
182 const at = warnMinutes(ttlMinutes)
183 if (prevWarm === undefined || prevWarm <= at || warm > at || warm <= 0 || tokens < GUARD_MIN_TOKENS || modelKeyOf(model) === undefined) return undefined
184 const price = rewritePrice(model, tokens, ttlMinutes)
185 return {
186 kind: 'cache-expiring',
187 usd: price.premium,
188 fact: `The prompt cache goes cold in ${warm}m. A send before then reads the ${kTokens(tokens)} tokens for ${money(price.warm)}; after, it re-writes them on ${labelOf(model)} for ${money(price.write)}. Leaving for longer? /route away`,
189 }
190}
191
192type LoopRoute = { model: string; effort?: string }
193
194/** What each switch between differently routed plan mode and normal work re-writes, at the current context. */
195export function lessonFromPlanToggle(plan: LoopRoute, main: LoopRoute, tokens: number, ttlMinutes: number): LessonDraft | undefined {
196 if (plan.model === main.model && plan.effort === main.effort) return undefined
197 const enter = rewritePrice(plan.model, tokens, ttlMinutes).write
198 const leave = rewritePrice(main.model, tokens, ttlMinutes).write
199 if (Math.min(enter, leave) < MIN_REWRITE_USD) return undefined
200 return {
201 kind: 'plan-toggle',
202 usd: enter + leave,
203 fact: `Plan mode runs on ${routeText(plan.model, plan.effort)} and normal work on ${routeText(main.model, main.effort)}, so each switch re-writes the cache: at ${kTokens(tokens)} tokens, entering cost about ${money(enter)} and leaving will cost about ${money(leave)}. Routing both the same avoids it; so does switching while the context is small.`,
204 }
205}
206
207const KIND_OF_REASON: Partial<Record<Advice['reason'], LessonKind>> = { boundary: 'strategy', 'plan-ready': 'plan-ready', corrections: 'corrections' }
208
209/**
210 * A lesson the first time advice turns into a handoff, a fresh start after a
211 * commit or a plan, or starting over after corrections. The other advice is
212 * taught already: a growing context by context-high, a cold cache by the
213 * expiry warning and the cold-send guard.
214 */
215export function lessonFromAdvice(prev: Pick<Advice, 'verdict' | 'reason'> | undefined, a: Advice): LessonDraft | undefined {
216 const isNew = a.verdict === 'handoff' ? prev?.verdict !== 'handoff' : a.reason !== prev?.reason
217 const kind = a.verdict === 'handoff' ? 'handoff' : KIND_OF_REASON[a.reason]
218 return isNew && kind !== undefined ? { kind, usd: a.perRequestUsd, fact: a.text } : undefined
219}
220
221export function lessonFromExploration(explored: number, total: number): LessonDraft {
222 return {
223 kind: 'exploration',
224 fact: `File reads and searches put ${kTokens(explored)} tokens (${pct(explored / total)}) into the main context this session. A subagent reads in its own context and hands back a summary.`,
225 }
226}
227
228type AutoHandoffFacts = { spentUsd: number; avoidedUsd: number; tokens: number; path: string }
229
230export function lessonFromAutoHandoff({ spentUsd, avoidedUsd, tokens, path }: AutoHandoffFacts): LessonDraft {
231 const off = '/route handoffs auto off turns this off.'
232 if (avoidedUsd <= 0) return { kind: 'auto-handoff', fact: `Saved a handoff to ${path} before the cache went cold. ${off}` }
233 return {
234 kind: 'auto-handoff',
235 usd: Math.round(Math.max(0, avoidedUsd - spentUsd) * 100) / 100,
236 fact: `Saved a handoff to ${path} for ${money(spentUsd)} before the cache went cold. Resending ${kTokens(tokens)} tokens cold would have cost ${money(avoidedUsd)}. ${off}`,
237 }
238}
239
240export function lessonFromCompact(beforeTokens: number): LessonDraft {
241 const from = beforeTokens > 0 ? ` from about ${kTokens(beforeTokens)} tokens` : ''
242 return {
243 kind: 'compacted',
244 fact: `The conversation was compacted${from} down to a summary. Turns now re-read far less, but details the summary left out are gone.`,
245 }
246}
247
248/** The one fresh lesson worth a card: costliest first, skipping kinds shown enough or within the cooldown. */
249export function pickLesson(fresh: readonly Lesson[], earlier: readonly Lesson[], now: number): Lesson | undefined {
250 const due = fresh.filter(one => {
251 const before = earlier.filter(e => e.kind === one.kind)
252 const shown = before.filter(e => e.isToasted)
253 const lastShown = Math.max(-Infinity, ...shown.map(e => e.at))
254 return shown.length < SEEN_ENOUGH && now - lastShown >= COOLDOWN_MS
255 })
256 return [...due].sort((a, b) => (b.usd ?? 0) - (a.usd ?? 0))[0]
257}
258
259export function cardOf(pick: Lesson, earlier: readonly Lesson[], freshCount: number): LearnCard {
260 const isFirst = !earlier.some(e => e.kind === pick.kind && e.isToasted)
261 return { kind: pick.kind, fact: pick.fact, isFirst, more: Math.max(0, freshCount - 1) }
262}
263
264/** The last PER_KIND lessons of each kind, so a lesson that fires every prompt can't crowd out the rest. */
265export function capLessons(list: readonly Lesson[]): Lesson[] {
266 const counts = new Map<LessonKind, number>()
267 const kept: Lesson[] = []
268 for (let i = list.length - 1; i >= 0; i--) {
269 const one = list[i]!
270 const n = counts.get(one.kind) ?? 0
271 if (n >= PER_KIND) continue
272 counts.set(one.kind, n + 1)
273 kept.push(one)
274 }
275 return kept.reverse()
276}
277
278const isLesson = (l: unknown): l is Lesson => {
279 const x = l as Lesson | null
280 return typeof x === 'object' && x !== null && isLessonKind(x.kind) && typeof x.at === 'number' && typeof x.repo === 'string' && typeof x.fact === 'string' && (x.sessionId === undefined || typeof x.sessionId === 'string')
281}
282
283export const sanitizeLessons = (stored: unknown): Lesson[] =>
284 Array.isArray(stored) ? capLessons(stored.filter(isLesson)).map(one => ({ ...one, repo: printable(one.repo), fact: printable(one.fact) })) : []
285
286const NOTE_TOKENS = 1_000
287const SUMMARY_TOKENS = 4_000
288
289export type AwayFacts = { tokens: number; model: string; ttlMinutes: number; warmMinutes: number }
290
291export type AwayRow = { name: string; now: string; back: string; how: string }
292
293/** One way to step away per row; none when the cache is cold or the model has no prices, which `header` and `notes` then explain. */
294export type Away = { header: string; rows: readonly AwayRow[]; notes: readonly string[] }
295
296/** The ways to step away from a session, priced for its context, routed model and cache state. */
297export function awayOptions(f: AwayFacts): Away {
298 const key = modelKeyOf(f.model)
299 if (key === undefined) {
300 return { header: `No prices for ${f.model}. Before a long break: have Claude write the work in progress to a note, /rename, then /clear; or /compact while the cache is warm.`, rows: [], notes: [] }
301 }
302 const out = (tokens: number) => (tokens * MODELS[key].output) / 1e6
303 const { write, warm } = rewritePrice(f.model, f.tokens, f.ttlMinutes)
304 const ctx = `${kTokens(f.tokens)} tokens of context on ${labelOf(f.model)}`
305 if (f.warmMinutes <= 0) {
306 return {
307 header: `${ctx}. The cache is already cold: anything sent now re-writes the ${kTokens(f.tokens)} tokens for ${money(write)}, so /clear (free) is the cheapest way out.`,
308 rows: [],
309 notes: ['To keep the work: /rename first and /resume later (Pro and Max offer to resume from a summary), or pay the rewrite once and ask Claude for a note before /clear.'],
310 }
311 }
312 return {
313 header: `${ctx}, cache warm for ${f.warmMinutes}m more (${f.ttlMinutes}-minute TTL).`,
314 rows: [
315 { name: 'Note + /clear', now: `now ≈ ${money(warm + out(NOTE_TOKENS))}`, back: 'on return ≈ $0.00', how: 'Ask Claude to write the work in progress to a file, /rename, /clear; start the next session from the note.' },
316 {
317 name: '/compact',
318 now: `now ≈ ${money(warm + out(SUMMARY_TOKENS))}`,
319 back: `on return ≈ ${money(rewritePrice(f.model, SUMMARY_TOKENS, f.ttlMinutes).write)}`,
320 how: 'Say what to keep: /compact keep the plan, open TODOs and touched files. Only while warm: cold, it reads everything at full price.',
321 },
322 { name: '/rename, /resume', now: 'now $0.00', back: 'on return: summary', how: 'On Pro and Max, /resume of a big session offers to continue from a summary.' },
323 { name: 'Keep warm', now: `${money(warm * Math.ceil(60 / f.ttlMinutes))} an hour`, back: '', how: 'An idle ping before each expiry; pays off for a few hours, not overnight.' },
324 { name: 'Do nothing', now: 'now $0.00', back: `on return ${money(write)}`, how: 'The first send re-writes the whole context.' },
325 ],
326 notes: [`Prices as of ${PRICES_AS_OF}, estimates; ≈ also guesses the note (${kTokens(NOTE_TOKENS)}) and summary (${kTokens(SUMMARY_TOKENS)}) sizes.`],
327 }
328}
329
330/** `/route away`: `awayOptions` as a padded table. */
331export function awayText(f: AwayFacts): string {
332 const { header, rows, notes } = awayOptions(f)
333 if (rows.length === 0) return [header, ...notes].join('\n')
334 const row = (one: AwayRow) => ` ${one.name.padEnd(18)}${one.now.padEnd(16)}${one.back.padEnd(22)}${one.how}`
335 return [header, '', ...rows.map(row), '', ...notes].join('\n')
336}
337hooks/routes.ts 649 lines1import type {
2 ColdGuard,
3 Effort,
4 LessonKind,
5 PromptCost,
6 PromptItem,
7 PromptRecord,
8 Rewrite,
9 ModelKey,
10 RouteEffort,
11 RouteSetting,
12 RouteSpend,
13 RouterConfig,
14} from '../types'
15
16export const PRICES_AS_OF = '2026-10-06'
17
18type ModelInfo = {
19 id: string
20 label: string
21 input: number
22 output: number
23 cacheRead: number
24}
25
26// USD per million tokens. Cache writes are priced from input by TTL: see writePrice.
27export const MODELS: Record<ModelKey, ModelInfo> = {
28 fable: { id: 'claude-fable-5-1', label: 'Fable 5.1', input: 10, output: 50, cacheRead: 0.25 },
29 opus: { id: 'claude-opus-5-5', label: 'Opus 5.5', input: 4, output: 20, cacheRead: 0.2 },
30 sonnet: { id: 'claude-sonnet-5-5', label: 'Sonnet 5.5', input: 2, output: 10, cacheRead: 0.2 },
31 haiku: { id: 'claude-haiku-5-5', label: 'Haiku 5.5', input: 0.1, output: 0.5, cacheRead: 0.01 },
32}
33
34export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
35
36type RouteRow = { key: string; label: string; parent?: string }
37
38/** In pane order: the main loop first, then the subagent default and its overrides. */
39export const ROUTES: readonly RouteRow[] = [
40 { key: 'plan', label: 'Plan mode', parent: 'main' },
41 { key: 'main', label: 'Normal work' },
42 { key: 'sub:*', label: 'Default' },
43 { key: 'sub:Explore', label: 'Explore', parent: 'sub:*' },
44 { key: 'sub:Plan', label: 'Plan agent', parent: 'sub:*' },
45 { key: 'sub:general-purpose', label: 'General', parent: 'sub:*' },
46 { key: 'workflow', label: 'Workflow', parent: 'sub:*' },
47 { key: 'teammate', label: 'Teammates', parent: 'sub:*' },
48]
49
50/** The main loop's routes: only these carry a cache across turns, so only they take `auto`. */
51export const isLoopRoute = (route: string) => route === 'main' || route === 'plan'
52
53/** Where the steps of agents the router can't place are counted: a workflow's before its spawn settles, the engine's own forks. */
54export const ENGINE = 'engine'
55
56export const routeLabel = (route: string) => ROUTES.find(one => one.key === route)?.label ?? (route === ENGINE ? 'Engine (forks, workflows)' : route)
57
58/** A route `/route set` may write: a known one, or `sub:<agent-type>`. */
59export const isRouteKey = (route: string) => ROUTES.some(one => one.key === route) || /^sub:[\w.:-]{1,64}$/.test(route)
60
61export const FORK = 'fork'
62
63const r = (model: RouteSetting['model'], effort: RouteEffort): RouteSetting => ({ model, effort })
64
65export const PRESETS: Record<string, { label: string; routes: Record<string, RouteSetting> }> = {
66 inherit: {
67 label: 'Inherit (track only)',
68 routes: {},
69 },
70 mine: {
71 label: 'Mine',
72 routes: {
73 main: r('opus', 'medium'),
74 plan: r('opus', 'high'),
75 'sub:*': r('sonnet', 'high'),
76 },
77 },
78 balanced: {
79 label: 'Balanced (auto)',
80 routes: {
81 main: r('opus', 'auto'),
82 plan: r('opus', 'high'),
83 'sub:*': r('sonnet', 'medium'),
84 'sub:Explore': r('haiku', 'medium'),
85 'sub:Plan': r('opus', 'high'),
86 },
87 },
88 frugal: {
89 label: 'Frugal',
90 routes: {
91 main: r('sonnet', 'auto'),
92 plan: r('opus', 'medium'),
93 'sub:*': r('haiku', 'medium'),
94 'sub:Explore': r('haiku', 'low'),
95 'sub:Plan': r('sonnet', 'high'),
96 },
97 },
98 max: {
99 label: 'Max quality',
100 routes: {
101 main: r('opus', 'xhigh'),
102 plan: r('fable', 'high'),
103 'sub:*': r('opus', 'high'),
104 'sub:Explore': r('sonnet', 'medium'),
105 'sub:Plan': r('opus', 'xhigh'),
106 },
107 },
108}
109
110/** A fresh install changes no model until a preset is picked; costs, the band and the guard still work. */
111export const DEFAULT_CONFIG: RouterConfig = {
112 isEnabled: true,
113 preset: 'inherit',
114 routes: {},
115 cacheTtlMinutes: 60,
116 coldGuard: 'refuse',
117 keepPromptText: true,
118 isLearning: false,
119 learnMuted: [],
120 isHandoffs: true,
121 autoHandoff: false,
122 showNext: true,
123}
124
125/**
126 * The config to start from when the store holds none. An install that already
127 * kept history predates the inherit default and never saved a config, so it
128 * keeps the routing it ran with: the `mine` preset.
129 */
130export function startingConfig(stored: unknown, hasHistory: boolean): RouterConfig {
131 if (stored !== undefined) return sanitizeConfig(stored)
132 return hasHistory ? withPreset(DEFAULT_CONFIG, 'mine')! : DEFAULT_CONFIG
133}
134
135export const presetOf = (name: string) => (Object.hasOwn(PRESETS, name) ? PRESETS[name] : undefined)
136
137/** The config with a preset's routes; undefined for a name that is not a preset. */
138export function withPreset(config: RouterConfig, name: string): RouterConfig | undefined {
139 const preset = presetOf(name)
140 return preset && { ...config, preset: name, routes: { ...preset.routes } }
141}
142
143/** The config with one route set, which makes it a custom preset. */
144export const withRoute = (config: RouterConfig, route: string, setting: RouteSetting): RouterConfig => ({
145 ...config,
146 preset: 'custom',
147 routes: { ...config.routes, [route]: setting },
148})
149
150const isModel = (m: unknown): m is RouteSetting['model'] => m === 'inherit' || (typeof m === 'string' && Object.hasOwn(MODELS, m))
151const isEffort = (e: unknown): e is RouteEffort =>
152 e === 'inherit' || e === 'auto' || (typeof e === 'string' && EFFORTS.includes(e as Effort))
153export const GUARDS: readonly ColdGuard[] = ['refuse', 'warn', 'off']
154
155/** The prompt-cache lifetimes the API offers, in minutes. */
156export const CACHE_TTLS: readonly number[] = [60, 5]
157const LESSON_KINDS: Record<LessonKind, true> = {
158 'cold-rewrite': true,
159 'switch-rewrite': true,
160 'pricey-model': true,
161 'context-high': true,
162 compacted: true,
163 subagents: true,
164 'cache-expiring': true,
165 'plan-toggle': true,
166 strategy: true,
167 handoff: true,
168 'plan-ready': true,
169 corrections: true,
170 exploration: true,
171 'auto-handoff': true,
172}
173export const isLessonKind = (kind: unknown): kind is LessonKind => typeof kind === 'string' && Object.hasOwn(LESSON_KINDS, kind)
174
175/**
176 * The stored config, keeping only what this version understands; a route the
177 * engine would reject (an unknown model or effort) is dropped, not sent.
178 */
179export function sanitizeConfig(stored: unknown): RouterConfig {
180 if (typeof stored !== 'object' || stored === null) return DEFAULT_CONFIG
181 const s = stored as Partial<Record<keyof RouterConfig, unknown>>
182 const routes: Record<string, RouteSetting> = {}
183 if (typeof s.routes === 'object' && s.routes !== null) {
184 for (const [key, one] of Object.entries(s.routes)) {
185 const setting = one as Partial<RouteSetting> | null
186 if (isRouteKey(key) && isModel(setting?.model) && isEffort(setting?.effort)) routes[key] = { model: setting.model, effort: setting.effort }
187 }
188 }
189 return {
190 isEnabled: typeof s.isEnabled === 'boolean' ? s.isEnabled : DEFAULT_CONFIG.isEnabled,
191 preset: typeof s.preset === 'string' ? s.preset : DEFAULT_CONFIG.preset,
192 routes,
193 cacheTtlMinutes: s.cacheTtlMinutes === 5 || s.cacheTtlMinutes === 60 ? s.cacheTtlMinutes : DEFAULT_CONFIG.cacheTtlMinutes,
194 coldGuard: GUARDS.includes(s.coldGuard as ColdGuard) ? (s.coldGuard as ColdGuard) : DEFAULT_CONFIG.coldGuard,
195 keepPromptText: typeof s.keepPromptText === 'boolean' ? s.keepPromptText : DEFAULT_CONFIG.keepPromptText,
196 isLearning: typeof s.isLearning === 'boolean' ? s.isLearning : DEFAULT_CONFIG.isLearning,
197 learnMuted: Array.isArray(s.learnMuted) ? [...new Set(s.learnMuted.filter(isLessonKind))] : DEFAULT_CONFIG.learnMuted,
198 isHandoffs: typeof s.isHandoffs === 'boolean' ? s.isHandoffs : DEFAULT_CONFIG.isHandoffs,
199 autoHandoff: typeof s.autoHandoff === 'boolean' ? s.autoHandoff : DEFAULT_CONFIG.autoHandoff,
200 showNext: typeof s.showNext === 'boolean' ? s.showNext : DEFAULT_CONFIG.showNext,
201 }
202}
203
204const isRecord = (r: unknown): r is PromptRecord => {
205 const x = r as PromptRecord | null
206 return typeof x === 'object' && x !== null && typeof x.at === 'number' && typeof x.repo === 'string' && typeof x.label === 'string' &&
207 typeof x.text === 'string' && typeof x.usd === 'number' && typeof x.requests === 'number' && typeof x.agents === 'number'
208}
209
210/** Also cleans the names the panes show, for history saved before they were cleaned at record time. */
211export const sanitizeHistory = (stored: unknown): PromptRecord[] =>
212 Array.isArray(stored)
213 ? stored.filter(isRecord).slice(-HISTORY_LIMIT).map(r => ({ ...r, repo: printable(r.repo), label: printable(r.label), text: printable(r.text) }))
214 : []
215
216/** Terminal control characters stripped from text the panes show but didn't write. */
217export const printable = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
218
219/** `repo/CLAUDE.md`: a path's last two segments. */
220export const shortPath = (path: string) => path.split('/').slice(-2).join('/')
221
222/**
223 * What a prompt says, out of the tags Claude Code wraps some prompts in: a slash command as `/name args`,
224 * a message from an agent or the app by its tag (`agent message`), reminders dropped. Also takes text cut short mid-tag.
225 */
226export function typedText(raw: string): string {
227 const text = raw.replace(/<system-reminder\b[\s\S]*?(<\/system-reminder>|$)/g, '').trim()
228 const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(text)?.[1]
229 if (name !== undefined) return `${name} ${/<command-args>([\s\S]*?)<\/command-args>/.exec(text)?.[1] ?? ''}`.trim()
230 if (text.startsWith('<command-')) return ''
231 const wrapper = /^<([a-z]+(?:-[a-z]+)+)[\s>]/.exec(text)?.[1]
232 return wrapper === undefined ? text : wrapper.replace(/-/g, ' ')
233}
234
235function parentOf(route: string): string | undefined {
236 const row = ROUTES.find(one => one.key === route)
237 if (row) return row.parent
238 return route.startsWith('sub:') ? 'sub:*' : undefined
239}
240
241type Resolved = { model?: string; effort?: Effort | 'auto' }
242
243/** Walks the route's fallback chain; a field left `inherit` everywhere stays undefined. */
244export function resolve(config: RouterConfig, route: string): Resolved {
245 if (!config.isEnabled || route === FORK) return {}
246 let model: string | undefined
247 let effort: Effort | 'auto' | undefined
248 for (let at: string | undefined = route; at !== undefined; at = parentOf(at)) {
249 const setting = config.routes[at]
250 if (setting === undefined) continue
251 if (model === undefined && setting.model !== 'inherit') model = MODELS[setting.model].id
252 if (effort === undefined && setting.effort !== 'inherit') effort = setting.effort
253 if (model !== undefined && effort !== undefined) break
254 }
255 return { model, effort }
256}
257
258type SpawnFacts = {
259 subagentType: string
260 fork: boolean
261 isTeammate?: true
262 workflow?: unknown
263}
264
265export function routeForSpawn(e: SpawnFacts): string {
266 if (e.fork) return FORK
267 if (e.workflow !== undefined) return 'workflow'
268 if (e.isTeammate) return 'teammate'
269 return `sub:${printable(e.subagentType)}`
270}
271
272/** The model an API id (`claude-opus-5-5`) or a display label (`Opus 5.5 high`) names. */
273export function modelKeyOf(id: string): ModelKey | undefined {
274 const lower = id.toLowerCase()
275 return (Object.keys(MODELS) as ModelKey[]).find(key => lower.includes(key))
276}
277
278export function labelOf(id: string): string {
279 const key = modelKeyOf(id)
280 return key ? MODELS[key].label : printable(id)
281}
282
283/** `Opus` for `Opus 5.5`: the label without its version. */
284export const shortLabel = (label: string) => label.replace(/ [\d.]+$/, '')
285
286/** `Opus 5.5 high`, or just the model when the effort is unset. */
287export const routeText = (model: string, effort?: string) => `${labelOf(model)}${effort ? ` ${effort}` : ''}`
288
289export type Usage = {
290 input_tokens: number
291 output_tokens: number
292 cache_read_input_tokens: number
293 cache_creation_input_tokens: number
294}
295
296const DEFAULT_TTL = 60
297
298/** Cache writes cost 2x input with the 1-hour TTL Claude Code uses, 1.25x with the 5-minute one. */
299function writePrice(p: ModelInfo, ttlMinutes = DEFAULT_TTL): number {
300 return p.input * (ttlMinutes >= 60 ? 2 : 1.25)
301}
302
303export function costOf(usage: Usage, modelId: string, ttlMinutes = DEFAULT_TTL): number {
304 const key = modelKeyOf(modelId)
305 if (key === undefined) return 0
306 const p = MODELS[key]
307 return (
308 (usage.input_tokens * p.input +
309 usage.output_tokens * p.output +
310 usage.cache_read_input_tokens * p.cacheRead +
311 usage.cache_creation_input_tokens * writePrice(p, ttlMinutes)) /
312 1e6
313 )
314}
315
316/** How long ago the cache expired; negative while it is still warm. */
317export const coldForMs = (lastAt: number, now: number, ttlMinutes: number) => now - lastAt - ttlMinutes * 60_000
318
319/** Whole minutes the cache stays warm, 0 once cold. */
320export const warmMinutesLeft = (lastAt: number, now: number, ttlMinutes: number) => Math.max(0, Math.ceil(-coldForMs(lastAt, now, ttlMinutes) / 60_000))
321
322/** Writing `tokens` to the cache, reading them warm instead, and the premium the write pays over the read. */
323export function rewritePrice(modelId: string, tokens: number, ttlMinutes = DEFAULT_TTL): { write: number; warm: number; premium: number } {
324 const key = modelKeyOf(modelId)
325 if (key === undefined) return { write: 0, warm: 0, premium: 0 }
326 const p = MODELS[key]
327 const write = (tokens * writePrice(p, ttlMinutes)) / 1e6
328 const warm = (tokens * p.cacheRead) / 1e6
329 return { write, warm, premium: write - warm }
330}
331
332/** Below this the rewrite costs cents, so the guard stays out of the way. */
333export const GUARD_MIN_TOKENS = 50_000
334
335type RewriteFacts = {
336 isAfterCompact: boolean
337 prevAt?: number
338 now: number
339 ttlMinutes: number
340 from?: { model: string; effort?: Effort }
341 to: { model: string; effort?: Effort }
342}
343
344/** Why a main-loop request would rebuild the cache; compaction rebuilds it anyway, so that one is not counted. */
345export function rewriteCause(f: RewriteFacts): Rewrite['cause'] | undefined {
346 if (f.isAfterCompact) return undefined
347 if (f.prevAt !== undefined && coldForMs(f.prevAt, f.now, f.ttlMinutes) >= 0) return 'cold'
348 if (f.from !== undefined && (f.from.model !== f.to.model || f.from.effort !== f.to.effort)) return 'switch'
349 return undefined
350}
351
352/**
353 * With a cause, a request that wrote most of a sizeable prompt rebuilt the
354 * cache; without one the write is just new content, such as a big file read.
355 */
356export function rewriteOf(usage: Usage, modelId: string, cause: Rewrite['cause'] | undefined, ttlMinutes = DEFAULT_TTL): Rewrite | undefined {
357 const prompt = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
358 const write = usage.cache_creation_input_tokens
359 if (cause === undefined || prompt <= 20_000 || write < 0.5 * prompt || modelKeyOf(modelId) === undefined) return undefined
360 return { cause, tokens: write, usd: rewritePrice(modelId, write, ttlMinutes).premium }
361}
362
363export function fmtAge(ms: number): string {
364 const minutes = Math.max(0, Math.round(ms / 60_000))
365 const h = Math.floor(minutes / 60)
366 return h > 0 ? `${h}h${String(minutes % 60).padStart(2, '0')}m` : `${minutes}m`
367}
368
369export const EMPTY_SPEND: RouteSpend = { requests: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0, usd: 0 }
370
371export const EMPTY_PROMPT: PromptCost = { usd: 0, requests: 0, items: [] }
372
373export function addSpend(spend: RouteSpend, usage: Usage, modelId: string, ttlMinutes = DEFAULT_TTL): RouteSpend {
374 return {
375 requests: spend.requests + 1,
376 input: spend.input + usage.input_tokens,
377 cacheRead: spend.cacheRead + usage.cache_read_input_tokens,
378 cacheWrite: spend.cacheWrite + usage.cache_creation_input_tokens,
379 output: spend.output + usage.output_tokens,
380 usd: spend.usd + costOf(usage, modelId, ttlMinutes),
381 }
382}
383
384type StepFacts = { id: string; route: string; label: string; effort?: Effort; usage: Usage & { model: string }; ttlMinutes: number; rewrite?: Rewrite }
385
386/** The prompt's cost with one more request added to the item it belongs to. */
387export function addStep(p: PromptCost, s: StepFacts): PromptCost {
388 const at = p.items.findIndex(one => one.id === s.id)
389 const old: PromptItem = at >= 0 ? p.items[at]! : { id: s.id, route: s.route, label: s.label, model: s.usage.model, ...EMPTY_SPEND }
390 const item: PromptItem = {
391 ...old,
392 ...addSpend(old, s.usage, s.usage.model, s.ttlMinutes),
393 model: s.usage.model,
394 ...(s.effort ? { effort: s.effort } : {}),
395 ...(old.isMixed || old.model !== s.usage.model ? { isMixed: true } : {}),
396 }
397 const items = at >= 0 ? p.items.with(at, item) : [...p.items, item]
398 return {
399 usd: p.usd + item.usd - old.usd,
400 requests: p.requests + 1,
401 items,
402 rewrites: s.rewrite ? [...(p.rewrites ?? []), s.rewrite] : p.rewrites,
403 }
404}
405
406/** A subagent's item is keyed by its agent id; the main loop's by its route. */
407export const isSub = (item: PromptItem) => item.id !== item.route
408
409/** A saved prompt with one more request from a subagent it started that outlived it. */
410export function addLateStep(r: PromptRecord, s: { usage: Usage & { model: string }; effort?: Effort; ttlMinutes: number }): PromptRecord {
411 const usd = costOf(s.usage, s.usage.model, s.ttlMinutes)
412 const key = routeText(s.usage.model, s.effort)
413 return { ...r, usd: r.usd + usd, requests: r.requests + 1, models: { ...r.models, [key]: (r.models?.[key] ?? 0) + usd } }
414}
415
416export const subagentCount = (p: PromptCost) => p.items.filter(isSub).length
417
418export function modelsOf(p: PromptCost): Record<string, number> {
419 const out: Record<string, number> = {}
420 for (const item of p.items) {
421 const key = routeText(item.model, item.effort)
422 out[key] = (out[key] ?? 0) + item.usd
423 }
424 return out
425}
426
427export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
428
429export const sumUsd = (xs: readonly { usd: number }[]) => xs.reduce((acc, one) => acc + one.usd, 0)
430
431export const AUTO_LABELS = ['trivial', 'standard', 'hard'] as const
432
433const CODING = /\b(implement|refactor|debug|fix|bug|test|build|migrate|rewrite|code|function|class|compile)\b/i
434
435export function effortForLabel(label: string | undefined, text: string): Effort {
436 if (label === 'trivial') return 'low'
437 if (label === 'hard') return CODING.test(text) ? 'xhigh' : 'high'
438 return 'medium'
439}
440
441// Rough output tokens a main turn spends per effort level; only used to weigh a downgrade.
442const TURN_OUTPUT: Record<Effort, number> = { low: 1500, medium: 4000, high: 9000, xhigh: 16000, max: 30000 }
443
444export const SMALL_CONTEXT = 20_000
445
446type SwitchFacts = {
447 contextTokens: number
448 isAfterCompact: boolean
449 from: { model: string; effort?: Effort }
450 to: { model: string; effort?: Effort }
451 ttlMinutes?: number
452}
453
454/**
455 * A model or effort change on the main loop re-writes the whole context to the
456 * prompt cache. Upgrades always go through (quality first); a downgrade only
457 * when the context is small, the cache just restarted, or it pays for itself.
458 */
459export function shouldSwitch(f: SwitchFacts): boolean {
460 if (f.from.model === f.to.model && f.from.effort === f.to.effort) return true
461 if (f.isAfterCompact || f.contextTokens < SMALL_CONTEXT) return true
462 const fromKey = modelKeyOf(f.from.model)
463 const toKey = modelKeyOf(f.to.model)
464 if (fromKey === undefined || toKey === undefined) return true
465 const from = MODELS[fromKey]
466 const to = MODELS[toKey]
467 const fromOut = TURN_OUTPUT[f.from.effort ?? 'medium'] * from.output
468 const toOut = TURN_OUTPUT[f.to.effort ?? 'medium'] * to.output
469 if (toOut >= fromOut) return true
470 const saving = (fromOut - toOut) / 1e6
471 return saving > rewritePrice(f.to.model, f.contextTokens, f.ttlMinutes).premium
472}
473
474export function parseSetting(route: string, model: string | undefined, effort: string | undefined): RouteSetting | string {
475 const m = (model ?? '').toLowerCase()
476 const e = (effort ?? 'inherit').toLowerCase()
477 if (!isRouteKey(route)) return `Unknown route "${route}". Use ${ROUTES.map(one => one.key).join(', ')}, or sub:<agent-type>.`
478 if (!isModel(m)) return `Unknown model "${model}". Use ${Object.keys(MODELS).join(', ')} or inherit.`
479 if (!isEffort(e)) return `Unknown effort "${effort}". Use ${EFFORTS.join(', ')}, auto or inherit.`
480 if (e === 'auto' && !isLoopRoute(route)) return 'auto effort is for main and plan only; subagents start fresh, so pick a level.'
481 return { model: m, effort: e }
482}
483
484/**
485 * Colour for context use, measured against where auto-compaction fires when
486 * known (else the whole window): green below 60%, amber to 85%, red after.
487 */
488export function contextLevel(tokens: number, window: number, compactAt?: number): 'success' | 'warning' | 'error' {
489 const toward = contextFill(tokens, window, compactAt)
490 return toward >= CONTEXT_CRITICAL ? 'error' : toward >= CONTEXT_WARN ? 'warning' : 'success'
491}
492
493export const CONTEXT_WARN = 0.6
494export const CONTEXT_CRITICAL = 0.85
495
496/** How far along the context is: toward auto-compaction when known, else toward the whole window. */
497export const contextFill = (tokens: number, window: number, compactAt?: number) => tokens / (compactAt && compactAt > 0 ? compactAt : window)
498
499export const HISTORY_LIMIT = 2000
500
501/** The `version` of a plugin.json, when it is a plain semver one: anything else is never shown. */
502export function versionOf(manifest: string): string | undefined {
503 try {
504 const version = (JSON.parse(manifest) as { version?: unknown }).version
505 return typeof version === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version) ? version : undefined
506 } catch {
507 return undefined
508 }
509}
510
511export function commandLabel(text: string): string {
512 const first = printable(text).trim().split(/\s+/)[0] ?? ''
513 return first.startsWith('/') && first.length > 1 ? first : 'prompt'
514}
515
516export function sameDay(a: number, b: number): boolean {
517 const x = new Date(a)
518 const y = new Date(b)
519 return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth() && x.getDate() === y.getDate()
520}
521
522/** The history without prompt text, for when it should not be kept. */
523export const withoutText = (records: readonly PromptRecord[]): PromptRecord[] => records.map(r => ({ ...r, text: '' }))
524
525/** What the Stats pane shows for a set of records. */
526export function statsSummary(records: readonly PromptRecord[], now: number) {
527 return {
528 today: todayUsd(records, now),
529 week: sumUsd(records.filter(r => now - r.at < 7 * 86_400_000)),
530 rewrites: records.flatMap(r => r.rewrites ?? []),
531 commands: commandStats(records).slice(0, 10),
532 priciest: [...records].sort((x, y) => y.usd - x.usd).slice(0, 8),
533 }
534}
535
536/** Today's spend; records are kept in time order, so only today's tail is read. */
537export function todayUsd(records: readonly PromptRecord[], now: number): number {
538 let usd = 0
539 for (let i = records.length - 1; i >= 0 && sameDay(records[i]!.at, now); i--) usd += records[i]!.usd
540 return usd
541}
542
543type CommandStats = { label: string; runs: number; total: number; avg: number; max: number; models: Record<string, number> }
544
545export function commandStats(records: readonly PromptRecord[]): CommandStats[] {
546 const by = new Map<string, CommandStats>()
547 for (const r of records) {
548 const was = by.get(r.label) ?? { label: r.label, runs: 0, total: 0, avg: 0, max: 0, models: {} }
549 const runs = was.runs + 1
550 const total = was.total + r.usd
551 const models = { ...was.models }
552 for (const [model, usd] of Object.entries(r.models ?? {})) models[model] = (models[model] ?? 0) + usd
553 by.set(r.label, { label: r.label, runs, total, avg: total / runs, max: Math.max(was.max, r.usd), models })
554 }
555 return [...by.values()].sort((a, b) => b.avg - a.avg)
556}
557
558/** `Opus 5.5 $4.10 · Sonnet 5.5 $2.00`, most expensive first. */
559export function modelMix(models: Record<string, number> | undefined, format: (usd: number) => string, limit = 3): string {
560 return Object.entries(models ?? {})
561 .sort((a, b) => b[1] - a[1])
562 .slice(0, limit)
563 .map(([model, usd]) => `${model} ${format(usd)}`)
564 .join(' · ')
565}
566
567export const kTokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
568
569export function money(usd: number): string {
570 return usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`
571}
572
573export const SUB_CONTEXT = 30_000
574
575type ChangeRow = {
576 route: string
577 from: string
578 to: string
579 /** One-off cache rewrite the change causes now; 0 where the loop has no warm cache. */
580 switchCost: number
581 /** Rough change in cost per turn (main, plan) or per subagent run. */
582 perTurnDelta: number
583}
584
585function describe(r: Resolved): string {
586 return `${r.model ? labelOf(r.model) : 'inherit'} · ${r.effort ?? 'inherit'}`
587}
588
589function turnCost(r: Resolved, fallbackModel: string, context: number): number {
590 const key = modelKeyOf(r.model ?? fallbackModel)
591 if (key === undefined) return 0
592 const effort = r.effort === undefined || r.effort === 'auto' ? 'medium' : r.effort
593 const p = MODELS[key]
594 return (TURN_OUTPUT[effort] * p.output + context * p.cacheRead) / 1e6
595}
596
597function rewriteCost(r: Resolved, fallbackModel: string, context: number, ttlMinutes: number): number {
598 return rewritePrice(r.model ?? fallbackModel, context, ttlMinutes).premium
599}
600
601const same = (a: Resolved, b: Resolved) => a.model === b.model && a.effort === b.effort
602
603/**
604 * What applying `next` over `current` would change, route by route. Only the
605 * route the main loop is on right now (`liveRoute`) pays a cache rewrite at
606 * once; subagents start fresh, so a change there costs nothing up front.
607 */
608export function estimateChanges(
609 current: RouterConfig,
610 next: RouterConfig,
611 facts: DraftFacts,
612): ChangeRow[] {
613 const rows: ChangeRow[] = []
614 for (const { key } of ROUTES) {
615 const a = resolve(current, key)
616 const b = resolve(next, key)
617 if (same(a, b)) continue
618 const context = isLoopRoute(key) ? facts.contextTokens : SUB_CONTEXT
619 rows.push({
620 route: key,
621 from: describe(a),
622 to: describe(b),
623 switchCost: key === facts.liveRoute ? rewriteCost(b, facts.sessionModel, facts.contextTokens, facts.ttlMinutes) : 0,
624 perTurnDelta: turnCost(b, facts.sessionModel, context) - turnCost(a, facts.sessionModel, context),
625 })
626 }
627 return rows
628}
629
630type DraftFacts = { contextTokens: number; liveRoute: 'main' | 'plan'; sessionModel: string; ttlMinutes: number }
631
632/** What applying `pending` would cost: per-route changes, the cache rewrite now, the plan toggle before and after, and the payback in turns. */
633export function draftSummary(current: RouterConfig, pending: RouterConfig | null, facts: DraftFacts) {
634 const changes = pending ? estimateChanges(current, pending, facts) : []
635 const upFront = changes.reduce((sum, one) => sum + one.switchCost, 0)
636 const toggleNow = modeSwitchCost(current, facts.contextTokens, facts.sessionModel)
637 const toggleAfter = pending ? modeSwitchCost(pending, facts.contextTokens, facts.sessionModel) : toggleNow
638 const livePerTurn = changes.filter(one => one.route === facts.liveRoute).reduce((sum, one) => sum + one.perTurnDelta, 0)
639 const payback = upFront > 0 && livePerTurn < 0 ? Math.ceil(upFront / -livePerTurn) : undefined
640 return { changes, upFront, toggleNow, toggleAfter, payback }
641}
642
643/** Each plan-mode toggle rewrites the cache when plan and main differ. */
644export function modeSwitchCost(config: RouterConfig, contextTokens: number, sessionModel: string): number {
645 const plan = resolve(config, 'plan')
646 const main = resolve(config, 'main')
647 return same(plan, main) ? 0 : rewriteCost(plan, sessionModel, contextTokens, config.cacheTtlMinutes)
648}
649hooks/handoff.ts 76 lines1import { GUARD_MIN_TOKENS } from './routes'
2import { OPEN_MIN } from './signals'
3
4export const HANDOFF_LABEL = 'auto-handoff'
5export const HANDOFF_DIR = '.claude/handoffs'
6
7export const HANDOFF_PROMPT =
8 'The prompt cache expires in a few minutes and work is still open. Reply with a handoff document in Markdown and nothing else: the goal, what is done, files touched, decisions and their reasons, and the exact next step, then last the open steps in order under an "## Open steps" heading, as a numbered list, each step starting with (you) or (claude) for who does it. Do not call any tools.'
9
10export type HandoffFacts = {
11 isOn: boolean
12 /** No auto-handoff since the person last prompted. */
13 isArmed: boolean
14 /** No turn and no subagent running. */
15 isIdle: boolean
16 isPromptEmpty: boolean
17 /** Somewhere to save it: without a home directory the turn would be spent for nothing. */
18 canSave: boolean
19 warmMinutes: number
20 ttlMinutes: number
21 tokens: number
22 openSteps: number
23 hasPlan: boolean
24}
25
26// The status tick runs once a minute, so a 5-minute cache needs a window it cannot step over.
27export const handoffWindow = (ttlMinutes: number) => (ttlMinutes >= 60 ? 3 : 2)
28
29/** Whether to ask for a handoff now: the cache's last minutes, work open, and nobody typing or running. */
30export function shouldAutoHandoff(f: HandoffFacts): boolean {
31 return (
32 f.isOn &&
33 f.isArmed &&
34 f.isIdle &&
35 f.isPromptEmpty &&
36 f.canSave &&
37 f.warmMinutes > 0 &&
38 f.warmMinutes <= handoffWindow(f.ttlMinutes) &&
39 f.tokens >= GUARD_MIN_TOKENS &&
40 (f.openSteps >= OPEN_MIN || f.hasPlan)
41 )
42}
43
44/** `waiting` is held on its own background work or a plan's approval, so still busy; `idle` may wait for a message forever and must not block the handoff. */
45export const isAgentBusy = (status: string) => status === 'pending' || status === 'running' || status === 'waiting'
46
47export type PendingHandoff ={ turnId?: string; tokens: number; model: string } | undefined
48type PendingEvent = { kind: 'submitted'; isDropped: boolean } | { kind: 'user-prompt' } | { kind: 'fresh-context' }
49
50/** A handoff asked for and not yet started is dropped once a refused submit, a prompt of yours or a fresh context outlives it; one already running is kept for its reply. */
51export function pendingAfter(pending: PendingHandoff, event: PendingEvent): PendingHandoff {
52 if (pending === undefined) return undefined
53 if (event.kind === 'submitted') return event.isDropped ? undefined : pending
54 if (event.kind === 'user-prompt') return pending.turnId === undefined ? undefined : pending
55 return undefined
56}
57
58const pad = (n: number) => String(n).padStart(2, '0')
59export const segment = (text: string) => text.replace(/[^A-Za-z0-9._-]/g, '-').replace(/^\.+/, '-') || '-'
60
61export const handoffsRoot = (home: string) => `${home.replace(/\/+$/, '')}/${HANDOFF_DIR}`
62export const handoffDir = (home: string, repo: string) => `${handoffsRoot(home)}/${segment(repo)}`
63
64/** `<home>/.claude/handoffs/<repo>/<YYYY-MM-DD-HHMM>-<session id, 8 chars>.md`, in local time. */
65export function handoffPath(home: string, repo: string, now: number, sessionId: string): string {
66 const d = new Date(now)
67 const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
68 return `${handoffDir(home, repo)}/${stamp}-${segment(sessionId).slice(0, 8)}.md`
69}
70
71export function tildePath(path: string, home: string | undefined): string
72export function tildePath(path: string | undefined, home: string | undefined): string | undefined
73export function tildePath(path: string | undefined, home: string | undefined): string | undefined {
74 return path !== undefined && home !== undefined && home !== '' && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
75}
76hooks/handoffs.ts 219 lines1import type { HandoffRecord, HandoffStatus, Handoffs } from '../types'
2import { handoffDir, handoffsRoot, tildePath } from './handoff'
3import { printable } from './routes'
4
5export type { HandoffRecord, HandoffStatus, Handoffs }
6export { handoffDir, handoffsRoot }
7export type HandoffRow = { path: string; repo: string; at: number; title: string; gist?: string; steps?: readonly string[]; stepCount?: number; isOpen: boolean; reason?: string }
8
9export const STALE_MS = 14 * 86_400_000
10const KEPT = 500
11const GIST_MAX = 320
12const STEP_MAX = 120
13const STEPS_KEPT = 6
14const REASON: Record<Exclude<HandoffStatus, 'open'>, string> = { done: 'done', dropped: 'not relevant', resumed: 'resumed', superseded: 'superseded' }
15const STATUSES: ReadonlySet<string> = new Set(['open', ...Object.keys(REASON)])
16
17export function repoOfHandoff(path: string, home: string | undefined): string | undefined {
18 if (home === undefined || home === '') return undefined
19 const root = `${handoffsRoot(home)}/`
20 if (!path.startsWith(root) || !path.endsWith('.md')) return undefined
21 const parts = path.slice(root.length).split('/')
22 // `..` would name a file outside the handoffs folder.
23 return parts.length === 2 && parts[0] !== '' && parts[0] !== '.' && parts[0] !== '..' ? parts[0] : undefined
24}
25
26function isRecord(value: unknown): value is HandoffRecord {
27 const r = value as Partial<HandoffRecord> | null
28 return (
29 typeof r === 'object' && r !== null &&
30 typeof r.repo === 'string' &&
31 typeof r.at === 'number' && Number.isFinite(r.at) &&
32 typeof r.status === 'string' && STATUSES.has(r.status) &&
33 (r.sessionId === undefined || typeof r.sessionId === 'string') &&
34 (r.title === undefined || typeof r.title === 'string') &&
35 (r.gist === undefined || typeof r.gist === 'string') &&
36 (r.steps === undefined || (Array.isArray(r.steps) && r.steps.every(one => typeof one === 'string'))) &&
37 (r.stepCount === undefined || (typeof r.stepCount === 'number' && Number.isFinite(r.stepCount))) &&
38 (r.readMtimeMs === undefined || (typeof r.readMtimeMs === 'number' && Number.isFinite(r.readMtimeMs))) &&
39 (r.reopenedAt === undefined || (typeof r.reopenedAt === 'number' && Number.isFinite(r.reopenedAt)))
40 )
41}
42
43const clean = ({ repo, at, sessionId, title, gist, steps, stepCount, readMtimeMs, status, reopenedAt }: HandoffRecord): HandoffRecord => ({
44 repo: printable(repo),
45 at,
46 ...(sessionId === undefined ? {} : { sessionId }),
47 ...(title === undefined ? {} : { title: printable(title) }),
48 ...(gist === undefined ? {} : { gist: printable(gist) }),
49 ...(steps === undefined ? {} : { steps: steps.slice(0, STEPS_KEPT).map(printable) }),
50 ...(stepCount === undefined ? {} : { stepCount }),
51 ...(readMtimeMs === undefined ? {} : { readMtimeMs }),
52 status,
53 ...(reopenedAt === undefined ? {} : { reopenedAt }),
54})
55
56const newest = (records: Handoffs): Handoffs =>
57 Object.fromEntries(Object.entries(records).sort((a, b) => b[1].at - a[1].at).slice(0, KEPT))
58
59export function sanitizeHandoffs(raw: unknown): Handoffs {
60 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
61 const ok = Object.entries(raw as Record<string, unknown>).filter((one): one is [string, HandoffRecord] => isRecord(one[1]))
62 return newest(Object.fromEntries(ok.map(([path, r]) => [path, clean(r)])))
63}
64
65/** The newest handoff of a session carries its latest state, so the session's older open ones are superseded. */
66export function recordWritten(records: Handoffs, path: string, rec: Omit<HandoffRecord, 'status'>): Handoffs {
67 const next: Handoffs = {}
68 for (const [p, r] of Object.entries(records)) {
69 const isOlderOfSession = rec.sessionId !== undefined && r.sessionId === rec.sessionId && r.status === 'open' && p !== path
70 next[p] = isOlderOfSession ? { ...r, status: 'superseded' } : r
71 }
72 next[path] = clean({ ...rec, status: 'open' })
73 return newest(next)
74}
75
76/** Each listed repo's files as they are on disk; repos not in the listing, and handoffs recorded after `listedAt`, are left as they were. */
77export function mergeListing(records: Handoffs, listing: Readonly<Record<string, readonly { path: string; mtimeMs: number }[]>>, listedAt: number): Handoffs {
78 const isUnchanged =
79 Object.entries(records).every(([path, r]) => listing[r.repo] === undefined || r.at > listedAt || listing[r.repo]!.some(one => one.path === path)) &&
80 Object.values(listing).every(files => files.every(one => records[one.path] !== undefined))
81 if (isUnchanged) return records
82 const next: Handoffs = {}
83 for (const [path, r] of Object.entries(records)) {
84 const files = listing[r.repo]
85 if (files === undefined || r.at > listedAt || files.some(one => one.path === path)) next[path] = r
86 }
87 for (const [repo, files] of Object.entries(listing)) {
88 for (const one of files) if (next[one.path] === undefined) next[one.path] = { repo, at: one.mtimeMs, status: 'open' }
89 }
90 return newest(next)
91}
92
93export function setStatus(records: Handoffs, path: string, status: HandoffStatus): Handoffs {
94 const r = records[path]
95 return r === undefined ? records : { ...records, [path]: { ...r, status } }
96}
97
98/** Open handoffs another tool closed, from each `.md` path to its `.md.closed` marker's mtime; a reopen after the marker wins. */
99export function closeMarked(records: Handoffs, markers: Readonly<Record<string, number>>): Handoffs {
100 const closed = Object.entries(markers).flatMap(([path, at]) => {
101 const r = records[path]
102 return r?.status === 'open' && (r.reopenedAt ?? 0) <= at ? [[path, { ...r, status: 'done' as const }]] : []
103 })
104 return closed.length === 0 ? records : { ...records, ...Object.fromEntries(closed) }
105}
106
107/** Reopening restarts the stale clock, or an old handoff would fold straight back. */
108export function reopen(records: Handoffs, path: string, now: number): Handoffs {
109 const r = records[path]
110 return r === undefined ? records : { ...records, [path]: { ...r, status: 'open', reopenedAt: now } }
111}
112
113/** The open handoffs `text` names, marked resumed; the same records when it names none. */
114export function resumeIn(records: Handoffs, text: string, home: string | undefined): Handoffs {
115 return resumedBy(text, records, home).reduce((acc, path) => setStatus(acc, path, 'resumed'), records)
116}
117
118export type Details = { title: string; gist: string; steps: string[]; stepCount: number }
119
120/** Titles, gists and steps read from disk, with the file's mtime when read, applied to the records they belong to; the same records when none apply. */
121export function withDetails(records: Handoffs, details: Readonly<Record<string, Details & Pick<HandoffRecord, 'readMtimeMs'>>>): Handoffs {
122 const known = Object.entries(details).filter(([path]) => records[path] !== undefined)
123 return known.length === 0 ? records : { ...records, ...Object.fromEntries(known.map(([path, d]) => [path, clean({ ...records[path]!, ...d })])) }
124}
125
126/** What a handoff's text yields for its record: empty where it has no title, gist or steps. */
127export function detailsOf(markdown: string): Details {
128 const steps = stepsOf(markdown)
129 return { title: titleOf(markdown) ?? '', gist: gistOf(markdown) ?? '', steps: steps.slice(0, STEPS_KEPT), stepCount: steps.length }
130}
131
132export function resumedBy(text: string, records: Handoffs, home: string | undefined): string[] {
133 return Object.entries(records)
134 .filter(([path, r]) => r.status === 'open' && (text.includes(path) || text.includes(tildePath(path, home))))
135 .map(([path]) => path)
136}
137
138export const isStale = (rec: HandoffRecord, now: number) => rec.status === 'open' && now - Math.max(rec.at, rec.reopenedAt ?? 0) > STALE_MS
139
140/** A handoff on disk with no session is another session's: this one would have recorded its own. */
141export function bandCount(records: Handoffs, repo: string, sessionId: string | undefined, now: number): number {
142 return Object.values(records).filter(r => r.repo === repo && r.status === 'open' && !isStale(r, now) && (sessionId === undefined || r.sessionId !== sessionId)).length
143}
144
145export function handoffRows(records: Handoffs, repo: string | undefined, now: number): { open: HandoffRow[]; closed: HandoffRow[] } {
146 const rows = Object.entries(records)
147 .filter(([, r]) => repo === undefined || r.repo === repo)
148 .sort((a, b) => b[1].at - a[1].at)
149 .map(([path, r]) => {
150 const reason = r.status !== 'open' ? REASON[r.status] : isStale(r, now) ? 'stale, 14+ days' : undefined
151 const steps = r.steps?.length ? { steps: r.steps, stepCount: r.stepCount ?? r.steps.length } : {}
152 return { path, repo: r.repo, at: r.at, title: r.title || path.split('/').pop()!, ...(r.gist ? { gist: r.gist } : {}), ...steps, isOpen: r.status === 'open', ...(reason === undefined ? {} : { reason }) }
153 })
154 return { open: rows.filter(one => one.reason === undefined), closed: rows.filter(one => one.reason !== undefined) }
155}
156
157export function titleOf(markdown: string): string | undefined {
158 const heading = /^#\s+(.+)$/m.exec(markdown)?.[1]?.trim()
159 return heading === undefined || heading === '' ? undefined : heading.slice(0, 80)
160}
161
162const HEADING = /^#{1,6}\s/
163const TOP_ITEM = /^(?:[-*+]|\d+[.)])\s+/
164const STEPS_HEADING = /^#{1,6}\s+open steps\b/i
165const plain = (line: string) =>
166 line
167 .replace(/^\s*(?:[-*+]|\d+[.)]|>)\s+/, '')
168 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
169 .replace(/\*\*|__|`/g, '')
170 .trim()
171
172/**
173 * What a handoff is about, in a few lines: the first paragraph under its Goal heading, as an auto-handoff writes one,
174 * else the first paragraph after the title and before its open steps. Plain text, cut at a word.
175 */
176export function gistOf(markdown: string): string | undefined {
177 const lines = markdown.split('\n')
178 const goal = lines.findIndex(line => /^#{2,6}\s+goal\b/i.test(line))
179 const start = goal >= 0 ? goal + 1 : lines.findIndex(line => /^#\s/.test(line)) + 1
180 const paragraph: string[] = []
181 for (const line of lines.slice(start)) {
182 if (STEPS_HEADING.test(line)) break
183 if (HEADING.test(line) && (goal >= 0 || paragraph.length > 0)) break
184 if (HEADING.test(line) || line.trim() === '') {
185 if (paragraph.length > 0) break
186 continue
187 }
188 paragraph.push(plain(line))
189 }
190 const text = paragraph.join(' ').replace(/\s+/g, ' ').trim()
191 return text === '' ? undefined : cutAt(text, GIST_MAX)
192}
193
194function cutAt(text: string, max: number): string {
195 if (text.length <= max) return text
196 const cut = text.slice(0, max - 1)
197 const space = cut.lastIndexOf(' ')
198 return `${(space > 0 ? cut.slice(0, space) : cut).replace(/[\s,;:.]+$/, '')}…`
199}
200
201/**
202 * The top-level list items under the first heading starting "Open steps", up to the next heading of its level or higher,
203 * as plain text cut to a line or two. Indented sub-items are left out; owner tags such as `(you)` stay in the text.
204 */
205export function stepsOf(markdown: string): string[] {
206 const lines = markdown.split('\n')
207 const at = lines.findIndex(line => STEPS_HEADING.test(line))
208 if (at < 0) return []
209 const level = /^#+/.exec(lines[at]!)![0].length
210 const steps: string[] = []
211 for (const line of lines.slice(at + 1)) {
212 const heading = /^(#{1,6})\s/.exec(line)
213 if (heading !== null && heading[1]!.length <= level) break
214 const text = TOP_ITEM.test(line) ? plain(line).replace(/\s+/g, ' ') : ''
215 if (text !== '') steps.push(cutAt(text, STEP_MAX))
216 }
217 return steps
218}
219hooks/signals.ts 81 lines1export type StepStatus = 'pending' | 'in_progress' | 'completed'
2export type Steps = { todos: number; tasks: Record<string, StepStatus> }
3export type PlanEvent = { kind: 'saved'; path: string } | { kind: 'work' } | undefined
4
5export const NO_STEPS: Steps = { todos: 0, tasks: {} }
6export const OPEN_MIN = 2
7export const CORRECTIONS_MIN = 2
8export const EXPLORE_MIN = 30_000
9export const EXPLORE_SHARE = 0.3
10
11const isOpen = (status: unknown) => status === 'pending' || status === 'in_progress'
12const isStatus = (status: unknown): status is StepStatus => isOpen(status) || status === 'completed'
13
14/** The steps after one finished tool call: a todo list replaces the count, a task is added, moved or dropped. */
15export function stepsAfter(steps: Steps, tool: string, input: Record<string, unknown>, result: unknown): Steps {
16 if (tool === 'TodoWrite') {
17 const todos = (result as { newTodos?: unknown } | null | undefined)?.newTodos ?? input.todos
18 return Array.isArray(todos) ? { ...steps, todos: todos.filter(one => isOpen((one as { status?: unknown } | null)?.status)).length } : steps
19 }
20 if (tool === 'TaskCreate') {
21 const id = (result as { task?: { id?: unknown } } | null | undefined)?.task?.id
22 return typeof id === 'string' ? { ...steps, tasks: { ...steps.tasks, [id]: 'pending' } } : steps
23 }
24 if (tool === 'TaskUpdate' && typeof input.taskId === 'string') {
25 const id = input.taskId
26 const rest = Object.fromEntries(Object.entries(steps.tasks).filter(([one]) => one !== id))
27 if (input.status === 'deleted') return { ...steps, tasks: rest }
28 if (isStatus(input.status)) return { ...steps, tasks: { ...rest, [id]: input.status } }
29 }
30 return steps
31}
32
33export const openSteps = (steps: Steps) => steps.todos + Object.values(steps.tasks).filter(isOpen).length
34
35const PLAN_FILE = /(^|\/)(specs|plans)\/[^/]+\.md$|(^|\/)(SPEC|PLAN)\.md$/
36const EDIT_TOOLS: ReadonlySet<string> = new Set(['Write', 'Edit', 'NotebookEdit'])
37
38/** A plan written to disk, or work started on something else, from one finished tool call. */
39export function planEventOf(tool: string, input: Record<string, unknown>, result: unknown): PlanEvent {
40 if (tool === 'ExitPlanMode') {
41 const path = (result as { filePath?: unknown } | null | undefined)?.filePath
42 return typeof path === 'string' && path !== '' ? { kind: 'saved', path } : undefined
43 }
44 if (!EDIT_TOOLS.has(tool)) return undefined
45 const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
46 if (path === undefined) return undefined
47 return PLAN_FILE.test(path) ? { kind: 'saved', path } : { kind: 'work' }
48}
49
50const CORRECTION = [
51 /^(no|nope)([,.!]|\s*$)/,
52 /^(wrong|again)\b/,
53 /\bstill (fail|failing|fails|broken|wrong|not|doesn'?t|does not|isn'?t|is not|the same)\b/,
54 /\b(that'?s|this is|it'?s|that is|it is) (wrong|not (it|right|what))/,
55 /\b(didn'?t|doesn'?t|does not|did not) work\b/,
56 /\bnot what i (asked|meant|wanted)\b/,
57 /\bsame (error|problem|issue|bug)\b/,
58]
59
60/** Whether a prompt you typed corrects the last attempt rather than asking for something new. */
61export function isCorrection(text: string): boolean {
62 const head = text.trim().slice(0, 200).toLowerCase().replace(/’/g, "'")
63 return head !== '' && !head.startsWith('/') && CORRECTION.some(re => re.test(head))
64}
65
66const EXPLORE_TOOLS: ReadonlySet<string> = new Set(['Read', 'Grep', 'Glob', 'WebFetch'])
67
68function textChars(value: unknown, depth: number): number {
69 if (typeof value === 'string') return value.length
70 if (depth > 4 || typeof value !== 'object' || value === null) return 0
71 if (Array.isArray(value)) return value.reduce((n: number, one) => n + textChars(one, depth + 1), 0)
72 return Object.entries(value).reduce((n, [key, one]) => (key === 'base64' ? n : n + textChars(one, depth + 1)), 0)
73}
74
75/** Tokens a read or search put into the context, estimated at four characters a token; other tools count nothing. */
76export const exploredTokens = (tool: string, result: unknown) => (EXPLORE_TOOLS.has(tool) ? Math.round(textChars(result, 0) / 4) : 0)
77
78export const isExplorationHeavy = (explored: number, total: number) => total > 0 && explored >= EXPLORE_MIN && explored >= total * EXPLORE_SHARE
79
80export const hasHandoffCommand = (names: readonly string[]) => names.some(name => name === 'handoff' || name.endsWith(':handoff'))
81hooks/strategy.ts 122 lines1import type { AdviceReason } from '../types'
2import { CONTEXT_CRITICAL, CONTEXT_WARN, GUARD_MIN_TOKENS, SMALL_CONTEXT, contextFill, kTokens, modelKeyOf, money, plural, printable, rewritePrice } from './routes'
3import { CORRECTIONS_MIN, OPEN_MIN } from './signals'
4
5const CONVERSATION_SHARE = 0.6
6
7export type Boundary = 'commit' | 'pr'
8export type Verdict = 'continue' | 'compact' | 'clear' | 'handoff'
9
10export type StrategyFacts = {
11 tokens: number
12 window: number
13 compactAt?: number
14 model: string
15 ttlMinutes: number
16 warmMinutes: number
17 /** Share of the context the conversation itself takes, from the /context breakdown. */
18 messagesShare?: number
19 /** Work wrapped up since the person last prompted. */
20 boundary?: Boundary
21 /** Todos and tasks still pending or in progress. */
22 openSteps?: number
23 /** A plan written to disk, while no other file has been edited since. */
24 planPath?: string
25 /** Correcting prompts in a row. */
26 corrections?: number
27 hasHandoffSkill?: boolean
28 /** An auto-handoff saved since the person last prompted. */
29 handoffPath?: string
30}
31
32type Priced = { text: string; perRequestUsd: number }
33export type Advice =
34 | (Priced & { verdict: 'continue'; reason: 'small' | 'fine' })
35 | (Priced & { verdict: 'compact' | 'clear' | 'handoff'; reason: AdviceReason; command: string })
36
37/** Whether a shell command wraps up work: a commit, or a pull request opened or merged. */
38export function boundaryOf(command: string): Boundary | undefined {
39 // Quoted text is an argument, such as a commit message or an echo, never the command itself.
40 const bare = command.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, "''")
41 if (/\bgh\s+pr\s+(create|merge)\b/.test(bare)) return 'pr'
42 if (/\bgit(?:\s+--?[\w-]+(?:=\S+|\s+[^\s-]\S*)?)*\s+commit(?![\w-])/.test(bare) && !/--dry-run/.test(bare)) return 'commit'
43 return undefined
44}
45
46const BOUNDARY_TEXT: Record<Boundary, string> = { commit: 'Work was committed', pr: 'A pull request went up' }
47
48const handoffStep = (hasSkill?: boolean) =>
49 hasSkill ? 'write a handoff with /handoff, /clear, then resume from it' : 'ask Claude to save the plan, progress and next steps to a file, /clear, then point the new session at it'
50
51/** Whether to carry on, /compact, /clear or hand off, with the reason in dollars where the model has prices. */
52export function adviseSession(f: StrategyFacts): Advice {
53 const { warm, write } = rewritePrice(f.model, f.tokens, f.ttlMinutes)
54 const isPriced = modelKeyOf(f.model) !== undefined
55 const priced = (fragment: string) => (isPriced ? fragment : '')
56 const advise = (verdict: Verdict, reason: Advice['reason'], text = '') =>
57 ({
58 verdict,
59 reason,
60 text,
61 perRequestUsd: warm,
62 ...(verdict === 'continue' ? {} : { command: verdict === 'handoff' && !f.hasHandoffSkill ? 'handoff' : `/${verdict}` }),
63 }) as Advice
64 const toward = contextFill(f.tokens, f.window, f.compactAt)
65 const steps = f.openSteps ?? 0
66 const open = steps >= OPEN_MIN ? steps : 0
67 const step = handoffStep(f.hasHandoffSkill)
68
69 if (f.tokens < SMALL_CONTEXT) return advise('continue', 'small')
70 if (f.handoffPath !== undefined && f.tokens >= GUARD_MIN_TOKENS) {
71 const path = printable(f.handoffPath)
72 return advise('clear', 'handoff-saved', `Handoff saved to ${path}: /clear, then say 'resume from ${path}'.`)
73 }
74 if (f.boundary !== undefined && f.tokens >= GUARD_MIN_TOKENS) {
75 if (open > 0) {
76 return advise(
77 'handoff',
78 'boundary',
79 `${BOUNDARY_TEXT[f.boundary]} with ${plural(open, 'step')} still open. Each request re-reads ${kTokens(f.tokens)} tokens${priced(` (${money(warm)} warm)`)}: ${step}. A /compact summary keeps less of the plan.`,
80 )
81 }
82 return advise(
83 'clear',
84 'boundary',
85 `${BOUNDARY_TEXT[f.boundary]}. Each further request re-reads the ${kTokens(f.tokens)} tokens of finished work${priced(` (${money(warm)} warm)`)}: /clear and start the next task fresh, or /compact to carry a summary.`,
86 )
87 }
88 if (f.planPath !== undefined && f.tokens >= GUARD_MIN_TOKENS) {
89 return advise(
90 'clear',
91 'plan-ready',
92 `The plan is in ${printable(f.planPath)}, and the ${kTokens(f.tokens)} tokens of exploration behind it get re-read on every request${priced(` (${money(warm)} warm)`)}: /clear and ask Claude to implement the plan file in a fresh context.`,
93 )
94 }
95 if (f.warmMinutes === 0 && f.tokens >= GUARD_MIN_TOKENS) {
96 const tail = open > 0 ? ` ${plural(open, 'step')} are open: a handoff keeps them, but writing it also pays the rewrite; with a plan file on disk, /clear and resume from it.` : ''
97 return advise('clear', 'cold', `The cache went cold: the next send, /compact included, re-writes ${kTokens(f.tokens)} tokens${priced(` for ${money(write)}`)}. /clear is free.${tail}`)
98 }
99 if ((f.corrections ?? 0) >= CORRECTIONS_MIN) {
100 return advise(
101 'clear',
102 'corrections',
103 `${f.corrections} corrections in a row over ${kTokens(f.tokens)} tokens: the failed attempts stay in context and steer the next try. /clear and start over with one prompt that says what you learned, or press Esc Esc to rewind to before the first attempt.`,
104 )
105 }
106 if (toward >= CONTEXT_CRITICAL) {
107 const where = f.compactAt ? `${kTokens(Math.max(0, f.compactAt - f.tokens))} tokens to auto-compact` : `${Math.round(toward * 100)}% of the context window used`
108 if (open > 0) return advise('handoff', 'near-compact', `${where} with ${plural(open, 'step')} open: ${step}. A summary written mid-plan drops detail the next steps need.`)
109 return advise('compact', 'near-compact', `${where}: /compact now and say what to keep (the plan, open TODOs, touched files), rather than let it fire mid-task.`)
110 }
111 if (toward >= CONTEXT_WARN && (f.messagesShare ?? 0) >= CONVERSATION_SHARE) {
112 const head = `The conversation is ${Math.round((f.messagesShare ?? 0) * 100)}% of ${kTokens(f.tokens)} tokens${priced(`, re-read for ${money(warm)} a request`)}`
113 if (open > 0) return advise('handoff', 'conversation', `${head}: ${plural(open, 'step')} are open, so ${step}.`)
114 return advise(
115 'compact',
116 'conversation',
117 `${head}: /compact to keep a summary, Esc Esc → Summarize up to here condenses old turns and keeps recent ones whole, or /clear once this task is done.`,
118 )
119 }
120 return advise('continue', 'fine')
121}
122hooks/tips.ts 145 lines1import type { SessionContextBreakdown } from 'claude-code'
2
3import type { PromptCost, PromptRecord, Rewrite } from '../types'
4import { MIN_REWRITE_USD, isPricey, lessonsFromPrompt, pct } from './lessons'
5import { commandStats, kTokens, money, printable, shortPath, sumUsd } from './routes'
6import { isExplorationHeavy } from './signals'
7import type { Advice } from './strategy'
8
9export type Breakdown = SessionContextBreakdown
10/**
11 * `weight` ranks tips within one pane: tokens in Context, dollars in Last and Stats; `replaces` names a tip this one says more than;
12 * `action` is what its button does; `title`, when set, leads in bold with `text` dimmed under it.
13 */
14export type Tip = { key: string; text: string; weight: number; replaces?: string; action?: TipAction; title?: string }
15/** What a tip's button does: put `command` in the prompt for the person to run, or with `isSlash` run the slash command `command` names, as a menu like /mcp. */
16export type TipAction = { label: string; command: string; isSlash?: boolean }
17
18const MEMORY_MIN = 3000
19const MCP_SERVER_MIN = 5000
20const SKILLS_MIN = 3000
21const AGENTS_MIN = 2000
22const MAX_TIPS = 3
23const STATS_REWRITES_MIN_USD = 1
24const STATS_REWRITES_SHARE = 0.1
25const COMMAND_MIN_RUNS = 3
26const COMMAND_RATIO = 3
27const PRICEY_SPEND_MIN_USD = 5
28const PRICEY_SPEND_SHARE = 0.8
29
30const AVOID: Record<Rewrite['cause'], string> = {
31 cold: 'send before it expires, or /clear before a break; /route away compares the options',
32 switch: 'switch model or plan mode at the start of a task, while the context is small',
33}
34
35function top(tips: Tip[]): Tip[] {
36 const replaced = new Set(tips.map(one => one.replaces))
37 return tips.filter(one => !replaced.has(one.key)).sort((x, y) => y.weight - x.weight).slice(0, MAX_TIPS)
38}
39
40export function tokensByServer(tools: Breakdown['mcpTools']): Map<string, number> {
41 const servers = new Map<string, number>()
42 for (const tool of tools) servers.set(tool.serverName, (servers.get(tool.serverName) ?? 0) + tool.tokens)
43 return servers
44}
45
46/** Rewrites summed by cause, costliest first. */
47function byCause(rewrites: readonly Rewrite[]): Array<[Rewrite['cause'], number]> {
48 const sums = new Map<Rewrite['cause'], number>()
49 for (const one of rewrites) sums.set(one.cause, (sums.get(one.cause) ?? 0) + one.usd)
50 return [...sums.entries()].sort((x, y) => y[1] - x[1])
51}
52
53// The engine names no kind for the conversation row, so this matches /context's label; a rename only silences the advice that uses it.
54export const messagesOf = (b: Breakdown) => b.categories.find(one => one.kind === 'used' && one.name === 'Messages')?.tokens ?? 0
55
56type ContextExtras = { advice?: Pick<Advice, 'verdict' | 'text'>; more?: readonly Tip[]; explored?: number; drop?: ReadonlySet<string> }
57
58/** What to trim in the context window, biggest first, from the /context breakdown; advice to compact or clear comes first, `more` joins the ranking, and tips keyed in `drop` are left out, said better elsewhere. */
59export function contextTips(b: Breakdown, { advice, more = [], explored = 0, drop = new Set() }: ContextExtras = {}): Tip[] {
60 const tips: Tip[] = [...more]
61 if (advice !== undefined && advice.verdict !== 'continue') tips.push({ key: `advice:${advice.verdict}`, weight: Infinity, text: advice.text })
62
63 for (const file of b.memoryFiles) {
64 if (file.tokens < MEMORY_MIN) continue
65 const action = file.type === 'AutoMem' ? 'prune it with /memory' : 'move rarely needed parts into a skill or a doc Claude reads when it needs it'
66 tips.push({ key: `memory:${file.path}`, weight: file.tokens, text: `${printable(shortPath(file.path))} (${printable(file.type)}) is ${kTokens(file.tokens)}, re-sent every turn: ${action}` })
67 }
68
69 for (const [name, tokens] of tokensByServer(b.mcpTools.filter(tool => tool.isLoaded !== false))) {
70 if (tokens < MCP_SERVER_MIN) continue
71 tips.push({ key: `mcp:${name}`, weight: tokens, text: `MCP server ${printable(name)} loads ${kTokens(tokens)} of tool schemas: disconnect it in /mcp if this repo does not use it` })
72 }
73
74 if (b.skills && b.skills.tokens >= SKILLS_MIN) {
75 tips.push({ key: 'skills', weight: b.skills.tokens, text: `${b.skills.includedSkills} skills listed take ${kTokens(b.skills.tokens)}: disable plugins this repo does not use in /plugin` })
76 }
77
78 const agentTokens = b.agents.reduce((sum, one) => sum + one.tokens, 0)
79 if (agentTokens >= AGENTS_MIN) {
80 tips.push({ key: 'agents', weight: agentTokens, text: `${b.agents.length} custom agents describe themselves in ${kTokens(agentTokens)}: remove the ones you do not use` })
81 }
82
83 if (isExplorationHeavy(explored, b.totalTokens)) {
84 tips.push({
85 key: 'exploration',
86 weight: explored,
87 text: `File reads and searches put ${kTokens(explored)} tokens (${pct(explored / b.totalTokens)}) into the main context: next time ask for a subagent ('use a subagent to find…'). It reads in its own context, and /route set sub:Explore haiku makes it cheap.`,
88 })
89 }
90 return top(tips.filter(one => !drop.has(one.key)))
91}
92
93/** What the last prompt's spend says to change: the learn-mode lessons for it, with an action, and its cache rewrites. */
94export function lastTips(p: PromptCost, ttlMinutes: number): Tip[] {
95 const tips: Tip[] = lessonsFromPrompt(p, ttlMinutes).map(one => ({
96 key: one.kind,
97 weight: one.usd ?? 0,
98 text: one.kind === 'pricey-model' ? `${one.fact} Routine edits and searches fit a cheaper route: /route` : one.fact,
99 }))
100 for (const [cause, usd] of byCause(p.rewrites ?? [])) {
101 if (usd < MIN_REWRITE_USD) continue
102 const what = cause === 'cold' ? 'The cache had gone cold, which cost' : 'Changing model or effort re-wrote the cache for'
103 tips.push({ key: `rewrite:${cause}`, weight: usd, text: `${what} ${money(usd)} over a warm read: ${AVOID[cause]}` })
104 }
105 return top(tips)
106}
107
108/** Patterns across saved prompts: rewrites adding up, a command far pricier than a plain prompt, Opus and Fable doing nearly everything. */
109export function statsTips(records: readonly PromptRecord[]): Tip[] {
110 const tips: Tip[] = []
111 const total = sumUsd(records)
112
113 const rewrites = records.flatMap(r => r.rewrites ?? [])
114 const rewriteUsd = sumUsd(rewrites)
115 const mainCause = byCause(rewrites)[0]?.[0]
116 if (mainCause !== undefined && rewriteUsd >= STATS_REWRITES_MIN_USD && rewriteUsd >= total * STATS_REWRITES_SHARE) {
117 const mostly = mainCause === 'cold' ? 'mostly cold caches' : 'mostly route switches'
118 tips.push({ key: 'rewrites', weight: rewriteUsd, text: `Cache rewrites cost ${money(rewriteUsd)}, ${pct(rewriteUsd / total)} of spend, ${mostly}: ${AVOID[mainCause]}` })
119 }
120
121 const commands = commandStats(records)
122 const plain = commands.find(c => c.label === 'prompt')
123 for (const c of commands) {
124 if (plain === undefined || c === plain || c.runs < COMMAND_MIN_RUNS || c.avg < plain.avg * COMMAND_RATIO) continue
125 const model = Object.entries(c.models).sort((x, y) => y[1] - x[1])[0]?.[0]
126 const on = model === undefined ? '' : `, mostly on ${printable(model)}`
127 tips.push({
128 key: `command:${c.label}`,
129 weight: c.total - c.runs * plain.avg,
130 text: `${printable(c.label)} averages ${money(c.avg)} a run over ${c.runs} runs, ${Math.round(c.avg / plain.avg)}× a plain prompt${on}: give it a narrower scope, or route its subagents cheaper in /route`,
131 })
132 }
133
134 let pricey = 0
135 for (const r of records) for (const [model, usd] of Object.entries(r.models ?? {})) if (isPricey(model)) pricey += usd
136 if (total >= PRICEY_SPEND_MIN_USD && pricey >= total * PRICEY_SPEND_SHARE) {
137 tips.push({
138 key: 'pricey-models',
139 weight: pricey,
140 text: `Opus and Fable did ${pct(pricey / total)} of ${money(total)}: /route preset balanced keeps Opus for planning and the main loop and moves subagents and searches to Sonnet and Haiku`,
141 })
142 }
143 return top(tips)
144}
145hooks/usage.ts 557 lines1import type { CommandInfo } from 'claude-code'
2
3import { plural, printable } from './routes'
4import type { Tip, TipAction } from './tips'
5
6const DAY = 86_400_000
7const IDLE_DAYS = 30
8/** How long an item must have been loaded in a repo before going unused there counts. */
9export type Patience = { days: number; sessions: number }
10export const PATIENCE: Patience = { days: 14, sessions: 10 }
11/** For trying the detector out: one counted session is enough. */
12export const DEV_PATIENCE: Patience = { days: 0, sessions: 1 }
13export const patienceText = (p: Patience) => (p.days === 0 ? plural(p.sessions, 'session') : `${plural(p.days, 'day')} and ${plural(p.sessions, 'session')}`)
14// Weight of an idle item whose loaded schema size is unknown: under a big memory file, over nothing.
15const UNKNOWN_WEIGHT = 1000
16
17/** `mcp:<server>` as tool names spell it, `plugin:<name>` without the marketplace, `skill:<name>` for one of your own skills or commands, or `claudeai:<name>` for a skill synced from claude.ai. */
18export type ItemKey = string
19export type ItemUse = { firstSeen: number; sessionsSeen: number; calls: number; lastUsed?: number }
20export type RepoUsage = { sessions: number; items: Record<ItemKey, ItemUse> }
21export type UsageStore = Record<string, RepoUsage>
22/** What a session saw loaded and used since its last save. */
23export type UsageDelta = { at: number; isNewSession: boolean; seen: readonly ItemKey[]; used: Record<ItemKey, { calls: number; lastUsed: number }> }
24export type IdleItem = { key: ItemKey; sessions: number; idleDays?: number; isEverywhere: boolean }
25
26/** A name as tool names spell it: what Claude Code does to `claude.ai Gmail` to make `claude_ai_Gmail`. */
27const toolSpelling = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, '_')
28export const mcpKey = (server: string): ItemKey => `mcp:${toolSpelling(server)}`
29export const pluginName = (id: string) => id.split('@')[0] ?? id
30export const pluginKey = (id: string): ItemKey => `plugin:${pluginName(id)}`
31export const skillKey = (name: string): ItemKey => `skill:${name}`
32export const claudeAiKey = (name: string): ItemKey => `claudeai:${name}`
33const prefixOf = (name: unknown) => (typeof name === 'string' && name.includes(':') ? name.split(':')[0] : undefined)
34const baseOf = (name: string) => name.slice(name.lastIndexOf(':') + 1)
35
36// The prefix the Skill tool puts on skills synced from claude.ai, the organization's included.
37const SYNCED_PREFIX = 'anthropic-skills'
38
39/**
40 * Skills synced from claude.ai, as /context lists them (`source: syncedSkills`),
41 * by every spelling a Skill call may use for them: the listed name, and its
42 * bare name, which calls also reach with the claude.ai prefix.
43 */
44export function syncedSkillNames(skills: ReadonlyArray<{ name: string; source: string }>): Map<string, string> {
45 const out = new Map<string, string>()
46 for (const one of skills) {
47 if (one.source !== 'syncedSkills') continue
48 out.set(one.name, one.name)
49 if (!out.has(baseOf(one.name))) out.set(baseOf(one.name), one.name)
50 }
51 return out
52}
53
54/** The listed name of the synced skill a Skill call names, if it is one: another plugin's skill of the same bare name is not. */
55function syncedSkillOf(skill: string, synced: ReadonlyMap<string, string>): string | undefined {
56 const exact = synced.get(skill)
57 if (exact !== undefined) return exact
58 const prefix = prefixOf(skill)
59 return prefix === undefined || prefix === SYNCED_PREFIX ? synced.get(baseOf(skill)) : undefined
60}
61
62/**
63 * The MCP server, plugin, own skill or claude.ai skill a tool call uses: an
64 * `mcp__` tool, one of `userSkills`, a skill `synced` from claude.ai, or a
65 * plugin's `name:` skill or agent.
66 */
67const NO_NAMES: ReadonlySet<string> = new Set()
68const NO_SYNCED: ReadonlyMap<string, string> = new Map()
69
70export function itemOfToolCall(tool: string, input: Record<string, unknown>, userSkills: ReadonlySet<string> = NO_NAMES, synced: ReadonlyMap<string, string> = NO_SYNCED): ItemKey | undefined {
71 const mcp = /^mcp__(.+?)__/.exec(tool)
72 if (mcp) return `mcp:${mcp[1]}`
73 if (tool === 'Skill' && typeof input.skill === 'string' && userSkills.has(input.skill)) return skillKey(input.skill)
74 const fromClaudeAi = tool === 'Skill' && typeof input.skill === 'string' ? syncedSkillOf(input.skill, synced) : undefined
75 if (fromClaudeAi !== undefined) return claudeAiKey(fromClaudeAi)
76 const plugin = tool === 'Skill' ? prefixOf(input.skill) : tool === 'Agent' || tool === 'Task' ? prefixOf(input.subagent_type) : undefined
77 return plugin ? pluginKey(plugin) : undefined
78}
79
80/** A plugin-shipped MCP server's tool (`mcp__plugin_<plugin>_<server>__…`): which of the plugins named ships it, and the server's own part. */
81export function pluginServer(tool: string, plugins: readonly string[]): { plugin: string; server: string } | undefined {
82 const spelled = /^mcp__plugin_(.+?)__/.exec(tool)?.[1]
83 if (spelled === undefined) return undefined
84 // The longest match: with plugins `foo` and `foo_bar`, `foo_bar_docs` is foo_bar's server.
85 const plugin = plugins.filter(one => spelled.startsWith(`${toolSpelling(one)}_`)).sort((a, b) => b.length - a.length)[0]
86 return plugin === undefined ? undefined : { plugin, server: spelled.slice(toolSpelling(plugin).length + 1) }
87}
88
89export function pluginOfMcpTool(tool: string, plugins: readonly string[]): ItemKey | undefined {
90 const found = pluginServer(tool, plugins)
91 return found && pluginKey(found.plugin)
92}
93
94// What a server you have not signed in to offers instead of its tools, so Claude can start the sign-in.
95const SIGN_IN = /__(authenticate|complete_authentication)$/
96
97// Servers Claude Code's own apps bring (the desktop app's panes, browser and terminal, Claude in Chrome, the IDE bridge), and claude.ai's own Visualize: nobody can turn them off, so they are never tracked.
98const APP_SERVER = /^mcp:(ccd_.*|Claude_Browser|Claude_Code_iOS_Simulator|Claude_Preview|terminal|visualize|scheduled-tasks|mcp-registry|claude-in-chrome|ide|claude_ai_Visualize)$/
99// claude.ai's Visualize offers exactly these; the desktop app names it by an id, so its tools tell it apart.
100const VISUALIZE_TOOLS = new Set(['read_me', 'show_widget'])
101
102// The id the desktop app gives a claude.ai connector in tool names, where the CLI spells `claude_ai_<Name>`.
103const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
104const CONNECTOR_ID = new RegExp(`^mcp:${UUID}$`)
105const isConnectorId = (key: ItemKey) => CONNECTOR_ID.test(key)
106
107/** A key under the name it is counted by: a desktop-app connector id under its `claude_ai_<Name>`, once that is known. */
108export const aliasOf = (key: ItemKey, aliases: Readonly<Record<ItemKey, ItemKey>>): ItemKey => aliases[key] ?? key
109
110/** Servers that are connected and yours to turn off: some tool of theirs is more than a sign-in placeholder, and no app of Claude Code's or claude.ai's brings it. */
111export function connectedServers(mcpTools: readonly string[], aliases: Readonly<Record<ItemKey, ItemKey>> = {}): ItemKey[] {
112 const keys = new Set<ItemKey>()
113 for (const tool of mcpTools) {
114 const key = SIGN_IN.test(tool) ? undefined : itemOfToolCall(tool, {})
115 if (key === undefined) continue
116 // A connector id whose tools are all Visualize's is claude.ai's Visualize; any other tool keeps it.
117 if (isConnectorId(key) && VISUALIZE_TOOLS.has(tool.slice(tool.indexOf('__', 'mcp__'.length) + 2))) continue
118 const named = aliasOf(key, aliases)
119 if (!APP_SERVER.test(named)) keys.add(named)
120 }
121 return [...keys]
122}
123
124/**
125 * The `claude_ai_<Name>` key for each desktop-app connector id /context lists
126 * under an /mcp name, so a connector used from the CLI and from the desktop app
127 * is counted once. An id listed under no name, or under its id, stays unnamed.
128 */
129export function connectorAliases(mcpTools: ReadonlyArray<{ name: string; serverName: string }>): Record<ItemKey, ItemKey> {
130 const out: Record<ItemKey, ItemKey> = {}
131 for (const tool of mcpTools) {
132 const key = itemOfToolCall(tool.name, {})
133 if (key === undefined || !isConnectorId(key)) continue
134 const name = tool.serverName.trim().replace(/^claude\.ai\s+/i, '').trim()
135 if (name === '' || isConnectorId(`mcp:${name}`)) continue
136 out[key] = mcpKey(`claude.ai ${name}`)
137 }
138 return out
139}
140
141/** Stored connector names, keeping only id-to-name pairs of the right shape. */
142export function sanitizeAliases(stored: unknown): Record<ItemKey, ItemKey> {
143 if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) return {}
144 return Object.fromEntries(Object.entries(stored as Record<string, unknown>).filter((pair): pair is [string, string] => isConnectorId(pair[0]) && typeof pair[1] === 'string' && pair[1].startsWith('mcp:claude_ai_')))
145}
146
147const mergeUse = (a: ItemUse | undefined, b: ItemUse): ItemUse => {
148 if (a === undefined) return b
149 const lastUsed = Math.max(a.lastUsed ?? -Infinity, b.lastUsed ?? -Infinity)
150 return { firstSeen: Math.min(a.firstSeen, b.firstSeen), sessionsSeen: a.sessionsSeen + b.sessionsSeen, calls: a.calls + b.calls, ...(Number.isFinite(lastUsed) ? { lastUsed } : {}) }
151}
152
153/**
154 * Usage stored under a connector id moved onto its name: earliest first seen,
155 * sessions and calls added up (an app's session lists one spelling only), the
156 * latest use. The store comes back unchanged when no stored key has a name.
157 */
158export function mergeAliases(store: UsageStore, aliases: Readonly<Record<ItemKey, ItemKey>>): UsageStore {
159 if (Object.keys(aliases).length === 0 || !Object.values(store).some(r => Object.keys(r.items).some(key => aliases[key] !== undefined))) return store
160 const out: UsageStore = {}
161 for (const [repo, r] of Object.entries(store)) {
162 const items: Record<ItemKey, ItemUse> = {}
163 for (const [key, use] of Object.entries(r.items)) {
164 const named = aliasOf(key, aliases)
165 items[named] = mergeUse(items[named], use)
166 }
167 out[repo] = { sessions: r.sessions, items }
168 }
169 return out
170}
171
172export function itemOfCommand(info: CommandInfo): ItemKey | undefined {
173 if (info.source !== 'plugin') return undefined
174 const plugin = info.plugin ?? prefixOf(info.name)
175 return plugin ? pluginKey(plugin) : undefined
176}
177
178/** Merges one session's delta into the stored usage; a resumed session's backfill (no calls) only moves `lastUsed` forward. */
179export function applyUsage(store: UsageStore, repo: string, d: UsageDelta): UsageStore {
180 const was = store[repo] ?? { sessions: 0, items: {} }
181 const items = { ...was.items }
182 const fresh = (): ItemUse => ({ firstSeen: d.at, sessionsSeen: 0, calls: 0 })
183 for (const key of d.seen) {
184 const one = items[key] ?? fresh()
185 items[key] = { ...one, firstSeen: Math.min(one.firstSeen, d.at), sessionsSeen: one.sessionsSeen + 1 }
186 }
187 for (const [key, use] of Object.entries(d.used)) {
188 const one = items[key] ?? fresh()
189 const lastUsed = Math.max(one.lastUsed ?? -Infinity, use.lastUsed)
190 items[key] = { ...one, calls: one.calls + use.calls, lastUsed }
191 }
192 return { ...store, [repo]: { sessions: was.sessions + (d.isNewSession ? 1 : 0), items } }
193}
194
195const isNum = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
196const isUse = (u: unknown): u is ItemUse => {
197 const x = u as ItemUse | null
198 return typeof x === 'object' && x !== null && isNum(x.firstSeen) && isNum(x.sessionsSeen) && isNum(x.calls) && (x.lastUsed === undefined || isNum(x.lastUsed))
199}
200
201export function sanitizeUsage(stored: unknown): UsageStore {
202 if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) return {}
203 const out: UsageStore = {}
204 for (const [repo, r] of Object.entries(stored as Record<string, unknown>)) {
205 const x = r as Partial<RepoUsage> | null
206 if (typeof x !== 'object' || x === null || !isNum(x.sessions) || typeof x.items !== 'object' || x.items === null) continue
207 out[repo] = { sessions: x.sessions, items: Object.fromEntries(Object.entries(x.items).filter(([, use]) => isUse(use))) }
208 }
209 return out
210}
211
212const isUnused = (use: ItemUse, now: number) => use.lastUsed === undefined || now - use.lastUsed > IDLE_DAYS * DAY
213/** Loaded long enough for going unused to count. */
214const isSettled = (use: ItemUse, now: number, patience: Patience) => now - use.firstSeen >= patience.days * DAY && use.sessionsSeen >= patience.sessions
215/** Every other repo's usage of one item. */
216const usesElsewhere = (store: UsageStore, repo: string, key: ItemKey): ItemUse[] =>
217 Object.entries(store).flatMap(([name, r]) => (name === repo || r.items[key] === undefined ? [] : [r.items[key]!]))
218
219/**
220 * Items loaded now that this repo has had long enough to use and has not,
221 * and whether every other repo left them idle too. `owners` maps a
222 * plugin-shipped server to its plugin: turning an idle plugin off takes its
223 * server with it, so that server is not listed again.
224 */
225export function idleItems(
226 store: UsageStore,
227 repo: string,
228 loaded: readonly ItemKey[],
229 now: number,
230 { patience = PATIENCE, owners = {} }: { patience?: Patience; owners?: Readonly<Record<ItemKey, ItemKey>> } = {},
231): IdleItem[] {
232 const here = store[repo]
233 if (here === undefined) return []
234 const idle: IdleItem[] = []
235 for (const key of loaded) {
236 const use = here.items[key]
237 if (use === undefined || !isSettled(use, now, patience) || !isUnused(use, now)) continue
238 const elsewhere = usesElsewhere(store, repo, key)
239 idle.push({
240 key,
241 sessions: use.sessionsSeen,
242 ...(use.lastUsed === undefined ? {} : { idleDays: Math.floor((now - use.lastUsed) / DAY) }),
243 // A connector known only by its desktop-app id may be used from the CLI under its name, so it is never idle everywhere.
244 isEverywhere: !isConnectorId(key) && elsewhere.length > 0 && elsewhere.every(one => isUnused(one, now)),
245 })
246 }
247 const keys = new Set(idle.map(one => one.key))
248 return idle.filter(one => !keys.has(owners[one.key] ?? ''))
249}
250
251const partsOf = (key: ItemKey) => {
252 const [kind = '', rest = ''] = key.split(/:(.*)/)
253 return { kind, rest }
254}
255
256// A claude.ai connector's tools, and /context for a deferred one, spell `claude.ai Gmail` as `claude_ai_Gmail`.
257const CONNECTOR = 'claude_ai_'
258const isConnector = (key: ItemKey) => key.startsWith(`mcp:${CONNECTOR}`)
259
260/**
261 * What to call an item: `names` holds a server's /mcp name or a plugin's
262 * `name@marketplace` where known; a connector's spelled name is turned back
263 * into the one /mcp shows.
264 */
265function nameOf(key: ItemKey, names: Readonly<Record<ItemKey, string>>): string {
266 const name = names[key] ?? partsOf(key).rest
267 return printable(name.startsWith(CONNECTOR) ? `claude.ai ${name.slice(CONNECTOR.length).replace(/_/g, ' ')}` : name)
268}
269
270// How /route unused says to turn idle items off.
271const FIX = {
272 mcpHere: 'Turn each off in /mcp; it stays off for this repo only.',
273 mcpNowhere: 'Remove them from their MCP config, or turn them off in /mcp per repo.',
274 pluginHere: 'To turn them off for this repo only, merge into .claude/settings.local.json:',
275 pluginNowhere: 'Disable them in /plugin.',
276}
277const settingOf = (keys: readonly ItemKey[], names: Readonly<Record<ItemKey, string>>) => keys.map(key => `"${nameOf(key, names)}": false`).join(', ')
278
279/** What to call an idle item in the pane: a server as /mcp names it, a plugin by its name. */
280export const idleName = (key: ItemKey, names: Readonly<Record<ItemKey, string>>) => (partsOf(key).kind === 'mcp' ? nameOf(key, names) : printable(partsOf(key).rest))
281
282// Claude Code has no command that turns one MCP server off; /mcp does it per repo, and a claude.ai connector goes everywhere from claude.ai.
283const MCP_HOW = {
284 here: 'In /mcp, pick it and choose Disable: it stays off in this repo only.',
285 connector: 'Disconnect it in claude.ai under Settings → Connectors to drop it everywhere, or disable it here in /mcp.',
286 config: 'Remove it from its config (claude mcp remove), or disable it here in /mcp.',
287}
288// A claude.ai connector, by the name tool calls spell: claude_ai_<Name>, or the bare id the desktop app gives it.
289const isConnectorKey = (key: ItemKey) => isConnector(key) || isConnectorId(key)
290
291/** Why an idle item is listed, in one line under its name: what it is, where and for how long it went unused, and for a server how to turn it off. */
292export function idleText(item: IdleItem, tokens?: number): string {
293 const isMcp = partsOf(item.key).kind === 'mcp'
294 const how = item.idleDays === undefined ? `not used in this repo over ${plural(item.sessions, 'session')}` : `not used in this repo for ${plural(item.idleDays, 'day')}`
295 const also = item.isEverywhere ? ', nor in any other repo' : ''
296 const line = `${isMcp ? 'MCP server' : 'plugin'} · ${how}${also}${tokens === undefined ? '' : ` · ~${tokens} tokens a session`}`
297 if (!isMcp) return line
298 return `${line}. ${!item.isEverywhere ? MCP_HOW.here : isConnectorKey(item.key) ? MCP_HOW.connector : MCP_HOW.config}`
299}
300
301
302/**
303 * `/route unused` as finished lines: idle MCP servers named together with
304 * the one way to turn them off, the claude.ai connectors' switch when none
305 * of the loaded ones is used, and a settings block to paste for the plugins.
306 */
307export function idleReport(items: readonly IdleItem[], names: Readonly<Record<ItemKey, string>>, loaded: readonly ItemKey[]): string[] {
308 const when = (one: IdleItem) => (one.idleDays === undefined ? '' : ` (${plural(one.idleDays, 'day')} ago)`)
309 const serverList = (list: readonly IdleItem[]) => list.map(one => `${nameOf(one.key, names)}${when(one)}`).join(', ')
310 const pluginList = (list: readonly IdleItem[]) => list.map(one => `${printable(partsOf(one.key).rest)}${when(one)}`).join(', ')
311 const servers = items.filter(one => partsOf(one.key).kind === 'mcp')
312 const plugins = items.filter(one => partsOf(one.key).kind === 'plugin')
313 const here = servers.filter(one => !one.isEverywhere)
314 const nowhere = servers.filter(one => one.isEverywhere)
315 const local = plugins.filter(one => !one.isEverywhere)
316 const unused = plugins.filter(one => one.isEverywhere)
317 const connectors = loaded.filter(isConnector)
318 const lines: string[] = []
319 const bullet = (text: string) => lines.push(`› ${text}`)
320 if (here.length > 0) bullet(`MCP servers not used in this repo: ${serverList(here)}. ${FIX.mcpHere}`)
321 if (nowhere.length > 0) bullet(`MCP servers not used in any repo: ${serverList(nowhere)}. ${FIX.mcpNowhere}`)
322 if (connectors.length > 1 && connectors.every(key => servers.some(one => one.key === key))) {
323 bullet(`None of the ${connectors.length} claude.ai connectors is used here: "disableClaudeAiConnectors": true in .claude/settings.local.json turns them all off for this repo.`)
324 }
325 if (local.length > 0) {
326 bullet(`Plugins not used in this repo (hooks they add are not counted): ${pluginList(local)}. ${FIX.pluginHere}`)
327 lines.push(` { "enabledPlugins": { ${settingOf(local.map(one => one.key), names)} } }`)
328 }
329 if (unused.length > 0) bullet(`Plugins not used in any repo (hooks they add are not counted): ${pluginList(unused)}. ${FIX.pluginNowhere}`)
330 return lines
331}
332
333const OPEN_MCP = { label: 'Open /mcp', command: 'mcp', isSlash: true }
334
335/**
336 * Idle MCP servers and plugins as Context-pane tips, weighed by the tool
337 * schemas they load where /context knows it; an idle server's tip replaces
338 * its size tip.
339 */
340export function idleTips(
341 items: readonly IdleItem[],
342 names: Readonly<Record<ItemKey, string>>,
343 mcpTokens: ReadonlyMap<string, number>,
344 { root, tokens = new Map() }: { root?: string; tokens?: ReadonlyMap<ItemKey, number> } = {},
345): Tip[] {
346 const servers = new Map<ItemKey, { server: string; tokens: number }>()
347 for (const [server, size] of mcpTokens) servers.set(mcpKey(server), { server, tokens: size })
348 return items
349 .filter(one => partsOf(one.key).kind === 'mcp' || partsOf(one.key).kind === 'plugin')
350 .map(one => {
351 const loaded = servers.get(one.key)
352 const kind = partsOf(one.key).kind
353 // /mcp is interactive: the button opens it rather than running a fix.
354 const action = kind === 'plugin' ? pluginFix(names[one.key] ?? '', one.isEverywhere, root) : OPEN_MCP
355 // A server's /context size counts its deferred tools too, which are not sent, so only a plugin's listing tokens are shown.
356 const listed = kind === 'plugin' ? tokens.get(one.key) : undefined
357 return {
358 key: `idle:${one.key}`,
359 weight: loaded?.tokens ?? tokens.get(one.key) ?? UNKNOWN_WEIGHT,
360 title: idleName(one.key, names),
361 text: idleText(one, listed),
362 ...(loaded ? { replaces: `mcp:${loaded.server}` } : {}),
363 ...(action ? { action } : {}),
364 }
365 })
366}
367
368/** A skill or command under ~/.claude, whether it is a link to files kept elsewhere, and the repo the `skills` CLI installed it from, when it did. */
369export type OwnSkill = { name: string; kind: 'skill' | 'command'; isLink: boolean; source?: string }
370
371/** Skill names and the repo each was installed from, from the `skills` CLI's ~/.agents/.skill-lock.json; nothing when it is not that file. */
372export function sourcesOfLock(text: string): Record<string, string> {
373 let lock: unknown
374 try {
375 lock = JSON.parse(text)
376 } catch {
377 return {}
378 }
379 const skills = (lock as { skills?: unknown } | null)?.skills
380 if (typeof skills !== 'object' || skills === null) return {}
381 const out: Record<string, string> = {}
382 for (const [name, entry] of Object.entries(skills)) {
383 const source = (entry as { source?: unknown } | null)?.source
384 if (typeof source === 'string') out[name] = source
385 }
386 return out
387}
388export type Fix = TipAction
389
390// What may go into a command a button puts in the prompt: names and paths come from disk, and the person runs it with one Enter.
391const SAFE_NAME = /^[A-Za-z0-9._@:-]+$/
392const SAFE_ROOT = /^\/[A-Za-z0-9 ._/@+-]*$/
393const isSafeName = (name: string) => SAFE_NAME.test(name) && !name.startsWith('-')
394// Below this many other settled repos, "only used here" is a guess.
395const ONLY_HERE_OTHERS = 2
396// Weight of an own skill whose listing size /context does not give: about one skill's line.
397const SKILL_WEIGHT = 60
398
399const inRoot = (root: string | undefined, command: string) => (root === undefined ? command : `cd "${root}" && ${command}`)
400
401/** `claude plugin disable` for this repo only (local scope, run from its root) or everywhere. */
402export function pluginFix(id: string, isEverywhere: boolean, root?: string): Fix | undefined {
403 if (!id.includes('@') || !isSafeName(id) || (root !== undefined && !SAFE_ROOT.test(root))) return undefined
404 if (isEverywhere) return { label: 'Disable everywhere', command: `! claude plugin disable ${id} --scope user` }
405 if (root === undefined) return undefined
406 return { label: 'Disable here', command: `! ${inRoot(root, `claude plugin disable ${id} --scope local`)}` }
407}
408
409/** Moves own skills and commands into archive folders beside them, where a move back restores them; a relative link keeps its depth, so it still resolves. */
410export function archiveFix(skills: readonly OwnSkill[]): Fix | undefined {
411 if (skills.length === 0 || !skills.every(one => isSafeName(one.name))) return undefined
412 const dirs = skills.filter(one => one.kind === 'skill').map(one => `~/.claude/skills/${one.name}`)
413 const files = skills.filter(one => one.kind === 'command').map(one => `~/.claude/commands/${one.name}.md`)
414 const steps = [
415 ...(dirs.length > 0 ? [`mkdir -p ~/.claude/skills-archive && mv ${dirs.join(' ')} ~/.claude/skills-archive/`] : []),
416 ...(files.length > 0 ? [`mkdir -p ~/.claude/commands-archive && mv ${files.join(' ')} ~/.claude/commands-archive/`] : []),
417 ]
418 return { label: skills.length === 1 ? 'Archive' : `Archive ${skills.length}`, command: `! ${steps.join(' && ')}` }
419}
420
421/** Makes an own skill or command a project one in `root`; a link is re-made to its resolved target, since a relative one would break in its new folder. */
422export function moveFix(skill: OwnSkill, root: string): Fix | undefined {
423 if (!isSafeName(skill.name) || !SAFE_ROOT.test(root)) return undefined
424 const into = `"${root}/.claude/${skill.kind === 'skill' ? 'skills' : 'commands'}"`
425 const from = skill.kind === 'skill' ? `~/.claude/skills/${skill.name}` : `~/.claude/commands/${skill.name}.md`
426 const to = `${into}/${skill.kind === 'skill' ? skill.name : `${skill.name}.md`}`
427 const place = skill.isLink ? `ln -s "$(readlink -f ${from})" ${to} && rm ${from}` : `mv ${from} ${to}`
428 return { label: 'Move here', command: `! mkdir -p ${into} && ${place}` }
429}
430
431/**
432 * Own skills used in this repo and in no other: at least two other repos
433 * have had them long enough and never called them.
434 */
435export function onlyHere(store: UsageStore, repo: string, keys: readonly ItemKey[], now: number, patience: Patience = PATIENCE): ItemKey[] {
436 const here = store[repo]
437 if (here === undefined) return []
438 return keys.filter(key => {
439 if ((here.items[key]?.calls ?? 0) === 0) return false
440 const others = usesElsewhere(store, repo, key)
441 const settled = others.filter(one => isSettled(one, now, patience))
442 return settled.length >= ONLY_HERE_OTHERS && others.every(one => one.calls === 0)
443 })
444}
445
446/** Own skills no repo uses. */
447const unusedOwn = (idle: readonly IdleItem[], own: Readonly<Record<ItemKey, OwnSkill>>) =>
448 idle.flatMap(one => (one.isEverywhere && own[one.key] !== undefined ? [{ key: one.key, skill: own[one.key]! }] : []))
449
450const skillWord = (one: OwnSkill) => (one.kind === 'skill' ? 'skill' : 'command')
451const labelOf = (one: OwnSkill) => (one.source === undefined ? printable(one.name) : `${printable(one.name)} (from ${printable(one.source)})`)
452// The skills CLI links an installed skill into other agents too; archiving moves only Claude Code's link.
453const INSTALLED_NOTE = '; installed ones stay in ~/.agents/skills for other agents'
454
455/**
456 * Own skills as Context-pane tips: the ones no repo uses as one tip whose
457 * button archives them all, and each one used only here as its own tip.
458 * A skill idle here but used elsewhere gets none: it cannot be turned off
459 * for one repo.
460 */
461export function skillTips(idle: readonly IdleItem[], only: readonly ItemKey[], own: Readonly<Record<ItemKey, OwnSkill>>, root: string | undefined, tokens: ReadonlyMap<ItemKey, number> = new Map()): Tip[] {
462 const weightOf = (key: ItemKey) => tokens.get(key) ?? SKILL_WEIGHT
463 const tips: Tip[] = []
464 const unused = unusedOwn(idle, own)
465 if (unused.length > 0) {
466 const total = unused.reduce((sum, one) => sum + (tokens.get(one.key) ?? 0), 0)
467 const installed = [...new Set(unused.flatMap(one => (one.skill.source === undefined ? [] : [one.skill.source])))]
468 const from = installed.length === 0 ? '' : ` (${installed.map(source => `${unused.filter(one => one.skill.source === source).length} from ${printable(source)}`).join(', ')}; they stay in ~/.agents/skills for other agents)`
469 const action = archiveFix(unused.map(one => one.skill))
470 tips.push({
471 key: 'idle:own-skills',
472 weight: unused.reduce((sum, one) => sum + weightOf(one.key), 0),
473 title: unused.length === 1 ? printable(unused[0]!.skill.name) : `${unused.length} skills and commands`,
474 text: `${unused.length === 1 ? skillWord(unused[0]!.skill) : unused.map(one => printable(one.skill.name)).join(', ')} · not used in any repo for ${IDLE_DAYS} days${total > 0 ? ` · ~${total} tokens a session` : ''}${from}. Archive moves them to ~/.claude/skills-archive; moving back restores`,
475 ...(action ? { action } : {}),
476 })
477 }
478 for (const key of only) {
479 const skill = own[key]
480 if (skill === undefined) continue
481 const action = root === undefined ? undefined : moveFix(skill, root)
482 const cost = tokens.get(key)
483 tips.push({
484 key: `only:${key}`,
485 weight: weightOf(key),
486 title: printable(skill.name),
487 text: `${skillWord(skill)}${skill.source === undefined ? '' : ` from ${printable(skill.source)}`} · used only in this repo${cost === undefined ? '' : ` · ~${cost} tokens a session elsewhere`}. Move it here and other repos stop loading it`,
488 ...(action ? { action } : {}),
489 })
490 }
491 return tips
492}
493
494/** `/route unused` lines for own skills: the archive command for those no repo uses, and the move for each used only here. */
495export function skillReport(idle: readonly IdleItem[], only: readonly ItemKey[], own: Readonly<Record<ItemKey, OwnSkill>>, root: string | undefined): string[] {
496 const lines: string[] = []
497 const unused = unusedOwn(idle, own).map(one => one.skill)
498 if (unused.length > 0) {
499 const note = unused.some(one => one.source !== undefined) ? INSTALLED_NOTE : ''
500 lines.push(`› Skills and commands not used in any repo: ${unused.map(labelOf).join(', ')}. Archive them (moving back restores${note}):`)
501 const fix = archiveFix(unused)
502 if (fix) lines.push(` ${fix.command}`)
503 }
504 for (const key of only) {
505 const skill = own[key]
506 if (skill === undefined) continue
507 lines.push(`› ${labelOf(skill)} is only used in this repo. Make it a project ${skillWord(skill)} here:`)
508 const fix = root === undefined ? undefined : moveFix(skill, root)
509 if (fix) lines.push(` ${fix.command}`)
510 }
511 return lines
512}
513
514/** Listing tokens per item from /context's skills: a plugin's skills summed, each own skill and each claude.ai skill alone. */
515export function listingTokens(skills: ReadonlyArray<{ name: string; source: string; pluginName?: string; tokens: number }>): Map<ItemKey, number> {
516 const tokens = new Map<ItemKey, number>()
517 for (const one of skills) {
518 const key = one.source === 'syncedSkills' ? claudeAiKey(one.name) : one.pluginName !== undefined ? pluginKey(one.pluginName) : one.source === 'userSettings' ? skillKey(one.name) : undefined
519 if (key !== undefined) tokens.set(key, (tokens.get(key) ?? 0) + one.tokens)
520 }
521 return tokens
522}
523
524// Claude Code has no command that turns one claude.ai skill off: claude.ai does, and the next sync drops it here.
525const CLAUDE_AI_OFF = 'Switch them off at claude.ai/customize/skills; Claude Code drops them at its next sync, within about 10 minutes'
526const CLAUDE_AI_ALL = 'Or stop syncing every claude.ai skill, the ones you use too, with "syncClaudeAiSkills": false in ~/.claude/settings.json'
527
528/** claude.ai skills no repo used in 30 days, by listed name; one used anywhere is kept, since claude.ai turns a skill off everywhere at once. */
529const idleClaudeAi = (idle: readonly IdleItem[]) => idle.filter(one => one.isEverywhere && partsOf(one.key).kind === 'claudeai')
530
531/**
532 * The claude.ai skills no repo uses, as one Context-pane tip. With most of
533 * the synced skills idle (`synced`: how many are loaded), it also names the
534 * switch that stops syncing them all. No button: the switch is on claude.ai.
535 */
536export function claudeAiTips(idle: readonly IdleItem[], synced: number, tokens: ReadonlyMap<ItemKey, number> = new Map()): Tip[] {
537 const unused = idleClaudeAi(idle)
538 if (unused.length === 0) return []
539 const names = unused.map(one => printable(partsOf(one.key).rest))
540 const total = unused.reduce((sum, one) => sum + (tokens.get(one.key) ?? 0), 0)
541 const all = unused.length * 2 >= synced ? `. ${CLAUDE_AI_ALL}` : ''
542 return [{
543 key: 'idle:claude-ai-skills',
544 weight: unused.reduce((sum, one) => sum + (tokens.get(one.key) ?? SKILL_WEIGHT), 0),
545 title: unused.length === 1 ? names[0]! : `${unused.length} claude.ai skills`,
546 text: `${unused.length === 1 ? 'claude.ai skill' : names.join(', ')} · not used in any repo for ${IDLE_DAYS} days${total > 0 ? ` · ~${total} tokens a session` : ''}. ${CLAUDE_AI_OFF}${all}`,
547 }]
548}
549
550/** `/route unused` lines for claude.ai skills no repo uses. */
551export function claudeAiReport(idle: readonly IdleItem[], synced: number): string[] {
552 const unused = idleClaudeAi(idle)
553 if (unused.length === 0) return []
554 const all = unused.length * 2 >= synced ? ` ${CLAUDE_AI_ALL}.` : ''
555 return [`› claude.ai skills not used in any repo: ${unused.map(one => printable(partsOf(one.key).rest)).join(', ')}. ${CLAUDE_AI_OFF}.${all}`]
556}
557hooks/ui/band.tsx 84 lines1import type { ActiveRoute, Health, RouterConfig } from '../../types'
2import { labelOf, money, resolve, routeText, shortLabel } from '../routes'
3import { colorOf } from './kit'
4import type { Els, Open } from './kit'
5
6const level = (value: number, warn: number, bad: number) => (value >= bad ? 'error' : value >= warn ? 'warning' : undefined)
7
8const shortRoute = (cfg: RouterConfig, route: string) => {
9 const r = resolve(cfg, route)
10 return `${r.model ? shortLabel(labelOf(r.model)) : 'inherit'}${r.effort ? ` ${r.effort}` : ''}`
11}
12
13function bandHead(cfg: RouterConfig, now: ActiveRoute | null): string {
14 if (!cfg.isEnabled) return 'router off'
15 if (now === null) return '▸ waiting for first request'
16 return `▸ ${now.route} · ${routeText(now.model, now.effort)}${now.isHeld ? ' (held)' : ''}`
17}
18
19/** Text only: a desktop draws every Button as a native control, which would break up the line's spacing. */
20function healthRowView({ Text }: Els, health: Health | null, total: number) {
21 if (health === null) return null
22 const cacheColor = health.cache < 50 ? 'error' : health.cache < 80 ? 'warning' : 'success'
23 const limits = [['5h', health.fiveHour], ['7d', health.sevenDay]].filter((one): one is [string, number] => one[1] !== undefined)
24 return (
25 <Text dimColor wrap="truncate-end">
26 {'cache '}
27 {health.warmMinutes === 0 ? (
28 <Text color="error">{health.coldUsd === undefined ? 'cold' : `cold ${money(health.coldUsd)}`}</Text>
29 ) : (
30 <Text>
31 <Text color={cacheColor}>{health.cache}%</Text>{' '}
32 <Text color={health.warmMinutes <= 5 ? 'warning' : undefined}>{health.warmMinutes}m</Text>
33 </Text>
34 )}
35 {' · context '}
36 <Text color={health.ctxLevel}>{health.ctx}%</Text>
37 {` · last ${money(health.last)} · session ${money(total)}`}
38 {limits.map(([name, pct]) => (
39 <Text key={`l:${name}`}>
40 {` · ${name} `}
41 <Text color={level(pct, 70, 90)}>{pct}%</Text>
42 </Text>
43 ))}
44 </Text>
45 )
46}
47
48type BandData = { cfg: RouterConfig; now: ActiveRoute | null; pending: RouterConfig | null; total: number; health: Health | null; handoffsOpen: number }
49
50/**
51 * Always three rows, so the band never changes height: the route line and the health row are text across the full
52 * width, and every button sits right-aligned on the last row, where a desktop's native button padding can't break the
53 * left edge. Hiding lives in the Routes pane, away from the buttons pressed every day.
54 */
55export function bandView(els: Els, { cfg, now, pending, total, health, handoffsOpen }: BandData, { open }: { open: Open }) {
56 const { Box, Text, Button } = els
57 return (
58 <Box flexDirection="column">
59 <Box key="band-route" flexDirection="row" gap={1}>
60 <Text bold={cfg.isEnabled} dimColor={!cfg.isEnabled} color={cfg.isEnabled && now ? colorOf(now.route) : undefined}>
61 {bandHead(cfg, now)}
62 </Text>
63 <Text dimColor wrap="truncate-end">
64 │ <Text color="planMode">plan</Text> {shortRoute(cfg, 'plan')} · <Text color="claude">main</Text> {shortRoute(cfg, 'main')} ·{' '}
65 <Text color="suggestion">sub</Text> {shortRoute(cfg, 'sub:*')}
66 </Text>
67 {pending ? <Text color="warning">● draft</Text> : null}
68 </Box>
69 <Box key="band-health" height={1}>
70 {healthRowView(els, health, total)}
71 </Box>
72 <Box key="band-actions" flexDirection="row" justifyContent="flex-end" gap={1}>
73 {handoffsOpen > 0 ? <Button key="band-handoffs" label={`Handoffs ●${handoffsOpen}`} plain onPress={() => open('handoffs')} /> : null}
74 {health?.advice === undefined ? null : (
75 <Button key="advice" label={`next ${health.advice.command}`} variant="primary" onPress={() => open('context')} />
76 )}
77 <Button key="ctx" label="Context" plain dimColor onPress={() => open('context')} />
78 <Button key="band-cost" label="Cost" plain dimColor onPress={() => open('last')} />
79 <Button key="band-open" label="Routes" plain onPress={() => open('route')} />
80 </Box>
81 </Box>
82 )
83}
84hooks/ui/context-pane.tsx 179 lines1import { contextLevel, kTokens, printable, shortPath } from '../routes'
2import { contextTips, tokensByServer } from '../tips'
3import type { Breakdown, Tip, TipAction } from '../tips'
4import type { Advice } from '../strategy'
5import type { Away } from '../lessons'
6import { bulletSection, tipsSection } from './kit'
7import type { Els } from './kit'
8
9// Theme keys as hex, for the one place a theme key cannot go: an SVG drawn as an image.
10const HEX: Record<string, string> = {
11 claude: '#d97757',
12 planMode: '#5fb36b',
13 suggestion: '#6ea8e0',
14 success: '#5fb36b',
15 warning: '#e0b24f',
16 error: '#e06c6c',
17 permission: '#9a8fe0',
18 autoAccept: '#b48ee0',
19 remember: '#6ea8e0',
20 merged: '#a87fd6',
21 ide: '#4fa3b8',
22 bashBorder: '#e07fb0',
23}
24const hexOf = (key: string | undefined) => (key && key.startsWith('#') ? key : HEX[key ?? ''] ?? '#9a9893')
25
26type Segment = { tokens: number; color?: string }
27
28/** One horizontal bar of segments, as an SVG for surfaces that draw them. */
29function stackedSvg(segments: readonly Segment[], whole: number): string {
30 const w = 600
31 let x = 0
32 const rects = segments
33 .filter(one => one.tokens > 0)
34 .map(one => {
35 const width = Math.max(1, (one.tokens / whole) * w)
36 const rect = `<rect x="${x.toFixed(1)}" y="0" width="${width.toFixed(1)}" height="14" fill="${hexOf(one.color)}"/>`
37 x += width
38 return rect
39 })
40 .join('')
41 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} 14" width="${w}" height="14"><rect width="${w}" height="14" rx="3" fill="#8b8a86" fill-opacity="0.18"/>${rects}</svg>`
42}
43
44/** The same bar as coloured block characters, for the monospace terminal. */
45function stackedCells(segments: readonly Segment[], whole: number, width: number): Array<{ text: string; color?: string }> {
46 const out = segments
47 .filter(one => one.tokens > 0)
48 .map(one => ({ text: '█'.repeat(Math.max(1, Math.round((one.tokens / whole) * width))), color: one.color }))
49 const used = out.reduce((sum, one) => sum + one.text.length, 0)
50 return used < width ? [...out, { text: '░'.repeat(width - used), color: undefined }] : out
51}
52
53/** `/route away` as a section: the ways to step away from this session, each priced now and on return. */
54function awaySection(els: Els, away: Away) {
55 const { Text } = els
56 const squeeze = (text: string) => text.replace(/ {2,}/g, ' ')
57 const items = away.rows.map(one => ({
58 key: one.name,
59 body: [
60 <Text key="what" wrap="wrap">
61 <Text bold>{one.name}</Text>
62 {` ${[one.now, one.back].filter(Boolean).map(squeeze).join(' · ')}`}
63 </Text>,
64 <Text key="how" dimColor wrap="wrap">{one.how}</Text>,
65 ],
66 }))
67 return bulletSection(els, { title: 'BEFORE A BREAK', color: 'claude', lead: away.header, items, tail: away.notes })
68}
69
70export function contextMobileView({ Text }: Els) {
71 return <Text dimColor>Open the context breakdown from a terminal or desktop.</Text>
72}
73
74/** `unused`: idle MCP servers, plugins and skills, each with its fix, listed in full below the tips; each one's `replaces` drops the size tip it says more than. */
75type ContextData = { breakdown: Breakdown | undefined; isTerminal: boolean; columns: number; advice?: Pick<Advice, 'verdict' | 'text'>; unused?: readonly Tip[]; explored?: number; away?: Away }
76// Beyond this many, the section points at /route unused for the rest.
77const UNUSED_SHOWN = 6
78
79/** Tips the unused list says more than: each idle server's size tip, and the skills-listing tip once plugins or skills are named. */
80function dropped(unused: readonly Tip[]): Set<string> {
81 const keys = new Set(unused.flatMap(one => (one.replaces === undefined ? [] : [one.replaces])))
82 if (unused.some(one => !one.key.startsWith('idle:mcp:'))) keys.add('skills')
83 return keys
84}
85/** `act` does what a tip's button says: puts its command in the prompt, or runs the slash command it names. */
86export type ContextActions = { act: (action: TipAction) => void }
87
88export function contextPaneView(els: Els, { breakdown: b, isTerminal, columns, advice, unused = [], explored, away }: ContextData, act?: ContextActions) {
89 const { Box, Text } = els
90 const Svg = 'Svg' in els ? els.Svg : undefined
91 if (b === undefined) return <Text dimColor>No context breakdown available yet.</Text>
92 const used = b.categories.filter(one => one.kind === 'used').sort((x, y) => y.tokens - x.tokens)
93 const deferred = b.categories.filter(one => one.kind === 'deferred')
94 const free = b.categories.find(one => one.kind === 'free')
95 const buffer = b.categories.find(one => one.kind === 'buffer')
96 const topServers = [...tokensByServer(b.mcpTools)].sort((x, y) => y[1] - x[1]).slice(0, 5)
97 const topMemory = [...b.memoryFiles].sort((x, y) => y.tokens - x.tokens).slice(0, 5)
98 const untilCompact = b.autoCompactThreshold === undefined ? undefined : b.autoCompactThreshold - b.totalTokens
99 const pct = (tokens: number) => `${((tokens / b.rawMaxTokens) * 100).toFixed(1)}%`
100 const width = Math.max(10, columns - 2)
101 const segments: Segment[] = used.map(one => ({ tokens: one.tokens, color: one.color }))
102 const line = (key: string, name: string, tokens: number, color?: string, isDim?: boolean) => (
103 <Box key={key} flexDirection="row" gap={1}>
104 <Box width={2} flexShrink={0}>
105 <Text color={color} dimColor={isDim}>●</Text>
106 </Box>
107 <Box width={6} flexShrink={0} justifyContent="flex-end">
108 <Text dimColor={isDim}>{kTokens(tokens)}</Text>
109 </Box>
110 <Box width={6} flexShrink={0} justifyContent="flex-end">
111 <Text dimColor>{pct(tokens)}</Text>
112 </Box>
113 <Box flexGrow={1} flexShrink={1} minWidth={0}>
114 <Text dimColor={isDim} wrap="truncate-end">{name}</Text>
115 </Box>
116 </Box>
117 )
118 return (
119 <Box flexDirection="column" gap={1}>
120 <Box flexDirection="row" justifyContent="space-between">
121 <Text bold color="claude">Context</Text>
122 <Text bold color={contextLevel(b.totalTokens, b.rawMaxTokens, b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined)}>
123 {kTokens(b.totalTokens)} / {kTokens(b.rawMaxTokens)} · {b.percentage}%
124 </Text>
125 </Box>
126 {isTerminal || Svg === undefined ? (
127 <Text>
128 {stackedCells(segments, b.rawMaxTokens, width).map((one, i) => (
129 <Text key={`c:${i}`} color={one.color} dimColor={one.color === undefined}>
130 {one.text}
131 </Text>
132 ))}
133 </Text>
134 ) : (
135 <Svg source={stackedSvg(segments, b.rawMaxTokens)} alt={`${b.percentage}% of the context window in use`} />
136 )}
137 <Text dimColor>
138 {b.isAutoCompactEnabled && untilCompact !== undefined
139 ? `auto-compact at ${kTokens(b.autoCompactThreshold ?? 0)} · ${kTokens(Math.max(0, untilCompact))} to go`
140 : 'auto-compact off'}
141 </Text>
142 <Box flexDirection="column">
143 <Text bold dimColor>IN USE</Text>
144 {used.map(one => line(`u:${one.name}`, one.name, one.tokens, one.color))}
145 </Box>
146 <Box flexDirection="column">
147 <Text bold dimColor>NOT SENT</Text>
148 {deferred.map(one => line(`d:${one.name}`, one.name.replace(/ \(deferred\)$/, ''), one.tokens, undefined, true))}
149 {free ? line('free', 'Free space', free.tokens, undefined, true) : null}
150 {buffer ? line('buffer', 'Compaction buffer', buffer.tokens, undefined, true) : null}
151 </Box>
152 {topServers.length > 0 ? (
153 <Box flexDirection="column">
154 <Text bold dimColor>MCP SERVERS</Text>
155 {topServers.map(([name, tokens]) => line(`m:${name}`, printable(name), tokens, 'suggestion'))}
156 </Box>
157 ) : null}
158 {topMemory.length > 0 ? (
159 <Box flexDirection="column">
160 <Text bold dimColor>MEMORY FILES</Text>
161 {topMemory.map(one => line(`f:${one.path}`, printable(`${one.type} · ${shortPath(one.path)}`), one.tokens, 'planMode'))}
162 </Box>
163 ) : null}
164 {tipsSection(els, contextTips(b, { advice, explored, drop: dropped(unused) }), act?.act)}
165 {tipsSection(els, [...unused].sort((x, y) => y.weight - x.weight).slice(0, UNUSED_SHOWN), act?.act, {
166 title: 'UNUSED MCP SERVERS, PLUGINS AND SKILLS',
167 color: 'suggestion',
168 lead: 'Each loads into every session in this repo and takes context, but none was called lately. A fix button puts its command in your prompt; nothing changes until you press Enter.',
169 ...(unused.length > UNUSED_SHOWN ? { tail: [`+${unused.length - UNUSED_SHOWN} more in /route unused`] } : {}),
170 })}
171 {away === undefined ? null : awaySection(els, away)}
172 <Text dimColor>
173 {b.skills ? `skills listing ${kTokens(b.skills.tokens)} (${b.skills.includedSkills}/${b.skills.totalSkills}) · ` : ''}
174 {b.model} · estimated locally, as /context does
175 </Text>
176 </Box>
177 )
178}
179hooks/ui/kit.tsx 175 lines1import type { EngineInterface, RenderChildren } from 'claude-code'
2
3import type { CostTab, Rewrite, RouteSpend } from '../../types'
4import { kTokens, money } from '../routes'
5import type { Tip, TipAction } from '../tips'
6
7export const PANES = {
8 route: { id: 'model-router', title: 'Routes' },
9 cost: { id: 'model-router-cost', title: 'Cost' },
10 context: { id: 'model-router-ctx', title: 'Context' },
11 handoffs: { id: 'model-router-handoffs', title: 'Handoffs' },
12} as const
13
14/** Ids of panes merged into Cost: one left open across an update still renders. */
15export const OLD_COST_IDS = ['model-router-last', 'model-router-stats'] as const
16
17/** The Learn pane's id before the card replaced it: one left open across an update is closed at start. */
18export const OLD_LEARN_ID = 'model-router-learn'
19
20export const COST_TABS: readonly CostTab[] = ['last', 'session', 'history']
21export const COST_TAB_LABEL: Record<CostTab, string> = { last: 'Last prompt', session: 'Session', history: 'History' }
22
23type Opened = { pane: keyof typeof PANES; tab?: CostTab }
24
25/** What `/route <name>` and the views can open: a pane, or the Cost pane on a tab. `stats` is the History tab's older name. */
26const TARGETS = {
27 route: { pane: 'route' },
28 cost: { pane: 'cost' },
29 context: { pane: 'context' },
30 handoffs: { pane: 'handoffs' },
31 last: { pane: 'cost', tab: 'last' },
32 session: { pane: 'cost', tab: 'session' },
33 history: { pane: 'cost', tab: 'history' },
34 stats: { pane: 'cost', tab: 'history' },
35} satisfies Record<keyof typeof PANES, Opened> & Record<string, Opened>
36
37export type Target = keyof typeof TARGETS
38
39export const isTarget = (name: string): name is Target => Object.hasOwn(TARGETS, name)
40
41export const paneOf = (target: Target): Opened => TARGETS[target]
42
43/** Opens a pane, or the Cost pane on a tab; the view gets it from the hooks module, which holds `$`. */
44export type Open = (target: Target) => void
45
46export type Els = ReturnType<EngineInterface['ui']['resolve']>
47
48/** `9.10.`: a short local date for a timestamp. */
49export const day = (at: number) => {
50 const d = new Date(at)
51 return `${d.getDate()}.${d.getMonth() + 1}.`
52}
53
54/** Columns for a `day()` gutter: the widest date and a space. */
55export const DATE_W = 7
56
57/** `9.10. 14:05`: `day()` with the local time. */
58export const stamp = (at: number) => {
59 const d = new Date(at)
60 return `${day(at)} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
61}
62
63/** Columns for a `stamp()` gutter: the widest stamp and a space. */
64export const STAMP_W = 13
65
66const ROUTE_COLOR: Record<string, string> = { plan: 'planMode', main: 'claude' }
67export const colorOf = (route: string) => ROUTE_COLOR[route] ?? 'suggestion'
68
69const CAUSE_LABEL: Record<Rewrite['cause'], string> = { cold: 'cold cache', switch: 'route switch' }
70
71export const rewriteText = (one: Rewrite) => `${CAUSE_LABEL[one.cause]} rewrite of ${kTokens(one.tokens)} tokens, ${money(one.usd)}`
72
73export const tokenLine = (one: RouteSpend) =>
74 `${one.requests} req · in ${kTokens(one.input)} · cache ${kTokens(one.cacheRead)}/${kTokens(one.cacheWrite)} · out ${kTokens(one.output)}`
75
76type Row = {
77 key: string
78 name: string
79 usd: number
80 /** Colours the dot and the name by route; `color` alone colours only the dot. */
81 route?: string
82 color?: string
83 share?: number
84 sub?: ReadonlyArray<string | undefined>
85}
86
87/** A pane row: a coloured dot, a name that truncates, the cost right-aligned, an optional share and dim sub-lines. */
88export function ledgerRow({ Box, Text }: Els, r: Row) {
89 const sub = (r.sub ?? []).filter(Boolean)
90 const routeColor = r.route === undefined ? undefined : colorOf(r.route)
91 return (
92 <Box key={r.key} flexDirection="column">
93 <Box flexDirection="row" gap={1}>
94 <Box width={2} flexShrink={0}>
95 <Text color={r.color ?? routeColor}>●</Text>
96 </Box>
97 {/* minWidth 0 on every shrinking text Box: the desktop lays Boxes out as CSS flex, where a Box never shrinks below its text without it, so the text pushes the cost off the row. */}
98 <Box flexGrow={1} flexShrink={1} minWidth={0}>
99 <Text color={routeColor} wrap="truncate-end">{r.name}</Text>
100 </Box>
101 <Box width={8} flexShrink={0} justifyContent="flex-end">
102 <Text bold>{money(r.usd)}</Text>
103 </Box>
104 {r.share === undefined ? null : (
105 <Box width={5} flexShrink={0} justifyContent="flex-end">
106 <Text dimColor>{`${Math.round(r.share * 100)}%`}</Text>
107 </Box>
108 )}
109 </Box>
110 {sub.length > 0 ? <Text dimColor>{` ${sub.join('\n ')}`}</Text> : null}
111 </Box>
112 )
113}
114
115/** Each row's share of `total`, or 0 when nothing was spent. */
116export const shareOf = (usd: number, total: number) => (total > 0 ? usd / total : 0)
117
118type Bullets = {
119 title: string
120 color: string
121 items: ReadonlyArray<{ key: string; body: RenderChildren }>
122 /** Dim lines above and below the bullets. */
123 lead?: string
124 tail?: readonly string[]
125}
126
127/** A section boxed apart from the figures above it: a coloured title, then `›` bullets. */
128export function bulletSection({ Box, Text }: Els, { title, color, items, lead, tail = [] }: Bullets) {
129 return (
130 <Box flexDirection="column" borderStyle="round" borderColor={color} borderDimColor paddingX={1}>
131 <Text bold color={color}>{title}</Text>
132 {lead === undefined ? null : <Text dimColor wrap="wrap">{lead}</Text>}
133 {items.map(one => (
134 <Box key={`t:${one.key}`} flexDirection="row" gap={1}>
135 <Box width={2} flexShrink={0}>
136 <Text color={color}>›</Text>
137 </Box>
138 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
139 {one.body}
140 </Box>
141 </Box>
142 ))}
143 {tail.map((one, i) => (
144 <Text key={`tail:${i}`} dimColor wrap="wrap">{one}</Text>
145 ))}
146 </Box>
147 )
148}
149
150/**
151 * A pane's TIPS section, or nothing when there are none. A tip with an action
152 * gets a button under its text that hands the action on.
153 * `title`, `color` and `tail` let another list of tips, such as the unused
154 * items, take the same shape.
155 */
156export function tipsSection(
157 els: Els,
158 tips: readonly Tip[],
159 onAction?: (action: TipAction) => void,
160 { title = 'TIPS', color = 'warning', lead, tail }: { title?: string; color?: string; lead?: string; tail?: readonly string[] } = {},
161) {
162 const { Box, Text, Button } = els
163 if (tips.length === 0) return null
164 const body = (one: Tip) => {
165 const text =
166 one.title === undefined
167 ? [<Text key="text">{one.text}</Text>]
168 : [<Text key="title" bold>{one.title}</Text>, <Text key="text" dimColor wrap="wrap">{one.text}</Text>]
169 if (one.action === undefined || onAction === undefined) return text
170 const action = one.action
171 return [...text, <Box key="action" flexDirection="row"><Button key={`action:${one.key}`} label={action.label} onPress={() => onAction(action)} /></Box>]
172 }
173 return bulletSection(els, { title, color, items: tips.map(one => ({ key: one.key, body: body(one) })), ...(lead === undefined ? {} : { lead }), ...(tail ? { tail } : {}) })
174}
175