SLOPSHOPPER

model-router

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…

newpanebandspinnerguardcommand
★ 1v1.7.1MITupdated 2026-10-15TFoxik/claude-model-router
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-router
│ ┃ Routes ✕ › fix the failing auth test and add an audit log call │ ┃ ⚙ Routes ● on [ Turn off ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ ▸ waiting for the first request $0.0000 │ ⏺ Update(src/auth.ts) │ ┃ │ plan mode off · context 97k tokens · │ ⎿ Added 2 lines, removed 1 line │ ┃ │ plan toggle keeps cache │ ⏺ Bash(bun test) │ ┃ ╰──────────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ │ ┃ PRESET Inherit (track only) ▾ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ✻ Worked for 42s · done 4:20 PM │ ┃ │ MAIN LOOP │ │ ┃ │ Plan mode inherit ▾ inherit ▾ │ › /route │ ┃ │ Normal work inherit ▾ inherit ▾ │ ⎿ model-router: Routes pane opened. │ ┃ │ │ │ ┃ │ SUBAGENTS │ │ ┃ │ Default inherit ▾ inherit ▾ │ │ ┃ │ └ Explore inherit ▾ inherit ▾ │ │ ┃ │ └ Plan agent inherit ▾ inherit ▾ │ │ ┃ │ └ General inherit ▾ inherit ▾ │ │ ┃ │ └ Workflow inherit ▾ inherit ▾ │ │ ┃ │ └ Teammates inherit ▾ inherit ▾ │ │ ┃ │ │ │ ┃ │ inherit → falls back to the row above, │ │ ┃ │ then the session · forks always use the │ ▸ waiting for first request │ plan inherit · main inherit · sub inherit Context Cost Routes ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits ▤ today $0.0000

Draws

Band
▸ waiting for first request │ plan inherit · main inherit · sub inherit Context Cost Routes
Pane · Routes
⚙ Routes ● on [ Turn off ] ╭──────────────────────────────────────────────────────────╮ │ ▸ waiting for the first request $0.0000 │ │ plan mode off · context 97k tokens · plan toggle keeps │ │ cache │ ╰──────────────────────────────────────────────────────────╯ PRESET Inherit (track only) ▾ ╭──────────────────────────────────────────────────────────╮ │ MAIN LOOP │ │ Plan mode inherit ▾ inherit ▾ │ │ Normal work inherit ▾ inherit ▾ │ │ │ │ SUBAGENTS │ │ Default inherit ▾ inherit ▾ │ │ └ Explore inherit ▾ inherit ▾ │ │ └ Plan agent inherit ▾ inherit ▾ │ │ └ General inherit ▾ inherit ▾ │ │ └ Workflow inherit ▾ inherit ▾ │ │ └ Teammates inherit ▾ inherit ▾ │ │ │ │ inherit → falls back to the row above, then the session │ │ · forks always use the parent's model │ ╰──────────────────────────────────────────────────────────╯ Changes above are a draft until you press Apply. ╭─────────────────────────────────────────────────────────── │ SETTINGS │ │ Coaching │ │ Learn mode ○ off [ Tur │ explains cache misses and model costs with your own │ numbers, when they happen │ │ Next-step advice ● on [ Turn off ] │ │ Handoffs │ │ Handoffs ● on [ Turn off ] [ Open ] │ │ Auto-handoff ○ off [ Tu │ saves a handoff while the cache is warm, before it │ goes cold with work open │ │ Cache and display │ │ Cold-cache guard r │ a send onto a cold cache: held once with its price, sent │ with it, or not checked │ │ Cache lifetime 60 mi │ the prompt-cache TTL the warm/cold timer counts down │ │ Keep prompt text ● on [ Tur │ the first 120 characters of each prompt, for Cost › │ History; off also removes text already kept │ │ Band ● shown [ Hide │ the route, cache and cost lines above the prompt; │ routing works either way ╰───────────────────────────────────────────────────────────
Pane · model-router-ctx
No context breakdown available yet.
Pane · model-router-cost
Cost [ Last prompt ] [ Session ] [ History ] No requests in this prompt yet.
Pane · model-router-last
Cost [ Last prompt ] [ Session ] [ History ] No requests in this prompt yet.
Pane · model-router-stats
Cost [ Last prompt ] [ Session ] [ History ] No requests in this prompt yet.
Pane · model-router-handoffs
Handoffs · app This repo All No handoffs in app. Auto-handoff saves them here when it's on; so does /handoff. ~/.claude/handoffs/app
README

model-router

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.

Three-line band above the prompt: routes, then cache and cost, then a right-aligned row of buttons with next /clear highlighted, and today's spend below it

The field guide walks through every part of the screen it adds, with annotated pictures.

What it does

  • Routes models per kind of work. Set a model and effort for 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.
  • Shows cost and cache health in a band above the prompt: 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.
  • Says when to carry on, compact, clear or hand off. When carrying on is not the best move, the band's last line shows a next /clear, next /compact or next /handoff button:
  • Clear after a commit or pull request over a sizeable context, once the cache has gone cold, once a plan is written to disk (implement it in a fresh context), or after two corrections in a row (start over with a better prompt).
  • Hand off instead of clearing after a commit or compacting when two or more todos or tasks are still open: a written handoff keeps the plan's detail, a summary does not. It names your /handoff skill when you have one.
  • Compact when auto-compact is close or the conversation fills most of the context.
  • The Context pane's tips give the reason in dollars, and suggest a subagent when reads and searches fill the main context.
  • It shows with learn mode on or off. /route next off, or the Routes pane's Next-step advice row, hides it.
  • Saves a handoff before the cache goes cold, if you want it to. With /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.
  • Keeps a list of handoffs. /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.
  • Spots MCP servers, plugins and skills you don't use. It notes, per repo, which MCP servers, plugins and personal skills and commands (everything in ~/.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 servers go off per repo in /mcp. When none of your connected claude.ai connectors is used, it also offers "disableClaudeAiConnectors": true.
  • Plugins: Disable here fills 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.
  • Personal skills and commands can't be switched off for one repo. One no repo uses gets Archive, which moves it to ~/.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.
  • claude.ai skills are switched on and off on claude.ai, for every repo at once. Ones no repo uses share one tip that points to claude.ai/customize/skills; Claude Code drops a skill switched off there at its next sync. When most of them are unused, it also names "syncClaudeAiSkills": false, which stops syncing all of them.
  • A claude.ai connector counts once, whether the CLI (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.
  • Shows where the money went, in one Cost pane. Its tabs are Last prompt (each request and subagent, with tips), Session (spend per route) and History, kept across sessions: which commands cost most on average and your most expensive prompts, with the models each one used, for this repo or all repos. Cost in the band opens it on Last prompt; the footer's ▤ today $12.30 opens History.
  • Guards cold sends. When the cache has expired over a big context, your next message is held once with its price: This send re-writes ~200k tokens on Opus 5.5 = $1.60 (warm would be $0.04). Send it again to pay, or /clear.
  • Teaches, if you want it to. Learn mode (off by default) explains each cache miss, model switch, growing context and expensive prompt with the real numbers, warns before the cache goes cold, and says why a commit is a good moment to /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.

The /route pane: routing on, the live route, the preset and the route table, then every setting grouped at the bottom

A learn mode card above the band explaining a cold cache rewrite with its cost

Install

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.

Update

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.

Commands

CommandEffect
/routeOpen 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 / offEnable or disable routing
`/route cost [last\session\history]`Open the Cost pane, on a tab
/route last / stats / contextOpen Cost on Last prompt, Cost on History, or the Context pane
/route stats clearDelete 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 awayBefore a break: the ways to step away, priced for this session; also in the Context pane
/route unusedMCP 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.

Good to know

  • Switching the main loop's model or effort rewrites the cache once, at 1.25-2× input over the whole context. The pane shows that cost before you apply. With plan and main routed differently, entering and leaving plan mode each pay it.
  • Rerouting subagents is free: they start with a fresh context. Forks always inherit the parent's model.
  • Prices are estimates, hard-coded in 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.
  • The guard works from the band's clock, not a server check, and doesn't keep the cache warm while you're away.

Overhead and privacy

  • No tokens added: no system prompt or attachments. Toasts, the band and the panes stay in the UI.
  • Two optional model calls, both off unless you pick them:
  • 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.
  • Auto-handoff (/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.
  • Stored locally in the plugin store, across repos: prompt history for Cost › History (the first 120 characters of each prompt, repo name, cost, models; last 2,000 prompts), learn mode lessons (numbers and model names, never prompt text; last 20 per topic, kept to count how often each topic was shown), handoff records (path, title, a line or two of what it is about, status), per repo which MCP servers, plugins, personal skills and claude.ai skills were loaded and used (names, counts and dates, never tool arguments), and the name behind each connector id the desktop app uses.
  • Files: it writes only under ~/.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.
  • Other reads. It lists ~/.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.
  • No network or process access of its own. Nothing leaves your machine except the model calls above, which go through Claude Code.
  • Settings change only when you type a command or use a pane control, never from a peer session, channel or trigger. Opening a pane works from anywhere.

Develop

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.

License

MIT, see LICENSE.

Source 18 files
hooks/register.tsx 1556 lines
1import { 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 lines
1import 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}
337
hooks/routes.ts 649 lines
1import 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}
649
hooks/handoff.ts 76 lines
1import { 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}
76
hooks/handoffs.ts 219 lines
1import 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}
219
hooks/signals.ts 81 lines
1export 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'))
81
hooks/strategy.ts 122 lines
1import 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}
122
hooks/tips.ts 145 lines
1import 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}
145
hooks/usage.ts 557 lines
1import 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}
557
hooks/ui/band.tsx 84 lines
1import 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}
84
hooks/ui/context-pane.tsx 179 lines
1import { 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}
179
hooks/ui/kit.tsx 175 lines
1import 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