Reads the first prompt of a session and hides the deferred tools, skills and MCP server instructions that the task does not need.

<img src="docs/assets/banner.svg" alt="loadout: equip only what the task needs" width="100%">
A Claude Code plugin that makes the start of each session smaller. It reads your first prompt and hides the tools, skills and MCP server instructions that the task does not need.
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/github/license/gabe4coding/loadout?color=blue"></a> <a href="docs/installation.mdx"><img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude%20Code-plugin-D97757?logo=claude&logoColor=white"></a> <a href="https://github.com/gabe4coding/loadout/actions/workflows/test.yml"><img alt="Tests" src="https://github.com/gabe4coding/loadout/actions/workflows/test.yml/badge.svg"></a>
<a href="#quick-start">Quick start</a> · <a href="docs/usage.mdx">Usage</a> · <a href="docs/how-it-works.mdx">How it works</a> · <a href="docs/troubleshooting.mdx">Troubleshooting</a>
At session start, Claude reads long listings before it reads your prompt: deferred tools, skills and MCP server instructions. With many servers and plugins, these listings are a large part of each request. Most of the items are not related to your task.
Without loadout, you turn off servers and plugins by hand for each task. With loadout, the plugin selects the items from your first prompt.
You need Claude Code 2.1.296 or later.
Type these commands at the prompt of a Claude Code session:
/plugin marketplace add gabe4coding/loadout
/plugin install loadout@loadout
Start a new session and write your task. Then type /loadout to see the items that the plugin kept and hid.
You start a session with this prompt: "Check the login page in the browser and fix the layout bug."
The plugin keeps the browser tools and hides the other tool groups. The tool listing then ends with this note:
(loadout hid 249 more deferred tools that look unrelated to this task, from: datadog (230), slack (19). They still work: ToolSearch finds them by keyword.)
Later, you write: "Now look at the error logs in Datadog." The plugin adds a note to this prompt only. The note tells Claude to load the Datadog tools with ToolSearch.
| Page | |
|---|---|
| Installation | Requirements, install, local folder, update |
| Usage | The first prompt, the always-keep list, /loadout, later prompts, cost |
| How it works | One session from start to end, the prompt cache, the data sent |
| Troubleshooting | Common problems and known limits |
| Development | Layout, checks, releases |
The same author maintains these plugins. Each plugin installs and works alone.
MIT. See LICENSE.
hooks/register.tsx 912 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelCompleteRequest, Register, ToolInfo, TurnStepResult } from 'claude-code'
3
4import type { Decision, Effort, EffortState, Kind, ModelTier, Offer, Purpose, Route, ServerFact, SkillFact, Status, Switches, TraceLine } from '../types'
5import {
6 CATALOG,
7 COMMANDS,
8 EFFORTS,
9 isCommandWord,
10 KINDS,
11 MODELS,
12 PROMPT_CHARS,
13 flipKeep,
14 capEffort,
15 isNewTask,
16 parseEffort,
17 parseModel,
18 routeModel,
19 takesTurnEffort,
20 groupsOf,
21 groupsWith,
22 isAlwaysKept,
23 isEffort,
24 keptGroups,
25 mcpTools,
26 normalize,
27 parseKeep,
28 parseKept,
29 parseSkills,
30 parseTools,
31 readOptions,
32 rebuild,
33 statusOf,
34 toolGroup,
35 removedServers,
36 serverDetail,
37 serverFacts,
38 skillDetail,
39 listedSkillFacts,
40 trimMcpInstructions,
41 unseenGroups,
42 wordList,
43} from './scope'
44import type { Listing } from './scope'
45
46
47const prompt = atom({ plugin: 'loadout', key: 'prompt' } as const, null)
48const toolDecision = atom({ plugin: 'loadout', key: 'toolDecision' } as const, null)
49const skillDecision = atom({ plugin: 'loadout', key: 'skillDecision' } as const, null)
50const isOff = atom({ plugin: 'loadout', key: 'isOff' } as const, false)
51const offers = atom({ plugin: 'loadout', key: 'offers' } as const, [])
52const surfaced = atom({ plugin: 'loadout', key: 'surfaced' } as const, [])
53const recent = atom({ plugin: 'loadout', key: 'recent' } as const, [])
54const effort = atom({ plugin: 'loadout', key: 'effortState' } as const, { kind: 'auto', level: null })
55const route = atom({ plugin: 'loadout', key: 'route' } as const, null)
56const switches = atom({ plugin: 'loadout', key: 'switches' } as const, {})
57const pinned = atom({ plugin: 'loadout', key: 'pinned' } as const, [])
58const marks = atom({ plugin: 'loadout', key: 'marks' } as const, null)
59const skillNames = atom({ plugin: 'loadout', key: 'skillNames' } as const, [])
60const filter = atom({ plugin: 'loadout', key: 'filter' } as const, '')
61const facts = atom({ plugin: 'loadout', key: 'facts' } as const, null)
62
63const PANE = 'loadout-keep'
64// The $.store key of the always-keep marks.
65const MARKS = 'alwaysKeep'
66
67const SYSTEM = [
68 'You choose which tool groups or skills an AI coding agent may need for one task.',
69 'Reply with the ids to keep, one per line, and nothing else.',
70 'Keep generously: keep anything that could plausibly help with the task or its follow-ups.',
71 'A dropped one can still be found later, but that costs the agent a search.',
72].join('\n')
73
74const RECALL = [
75 'An AI coding agent works on a task. Some tool groups and skills were hidden from it.',
76 'Given its newest prompt, pick the hidden ones it clearly needs for that prompt.',
77 'Reply with their ids, one per line, or the word none. Pick few: only clear needs.',
78].join('\n')
79
80// Added to RECALL when there are earlier prompts to compare with. The verdict
81// comes first, so a reply that reasons after it cannot flip it.
82const TASK_CHECK = [
83 'Start the reply with one line: SAME TASK or NEW TASK.',
84 'SAME TASK: the newest prompt continues, fixes or extends the earlier prompts.',
85 'NEW TASK: it has its own goal, unrelated to them. If unsure, SAME TASK.',
86].join('\n')
87
88// What a person reads when a later prompt starts a new task.
89const NEW_TASK = 'This prompt starts a new task. A new session or /clear gives it its own loadout and a smaller context.'
90
91// When the classifier picks each effort. A new level in EFFORTS must get a line.
92const EFFORT_USE: Record<Effort, string> = {
93 low: 'a short answer, a lookup or a one-line change.',
94 medium: 'a normal coding task in a few files.',
95 high: 'a change across many files, or debugging.',
96 xhigh: 'a hard bug, a design decision or a long task with many steps.',
97 max: 'only when correctness matters far more than cost.',
98}
99
100const EFFORT = [
101 'You choose the reasoning effort an AI coding agent needs for its newest prompt.',
102 `Reply with one word: ${wordList(EFFORTS)}.`,
103 ...EFFORTS.map(level => `${level}: ${EFFORT_USE[level]}`),
104 `If unsure, reply ${'medium' satisfies Effort}.`,
105].join('\n')
106
107// When the classifier picks each model. A new tier in MODEL_ID must get a line.
108const MODEL_USE: Record<ModelTier, string> = {
109 haiku: 'a short answer, a lookup or a small change in one file.',
110 sonnet: 'a normal coding task: a feature, a fix or a review in a few files.',
111 opus: 'a hard task: a large change across many files, a hard bug or a design decision.',
112 fable: 'only for the hardest tasks, where correctness matters far more than cost.',
113}
114
115const MODEL = [
116 'You choose the model an AI coding agent needs for a new session, from its first prompt.',
117 `Reply with one word: ${wordList(MODELS)}.`,
118 ...MODELS.map(tier => `${tier}: ${MODEL_USE[tier]}`),
119 `If unsure, reply ${'sonnet' satisfies ModelTier}.`,
120].join('\n')
121
122// Every classifier call goes to this model at this effort.
123const CLASSIFIER = { model: 'haiku', effort: 'low' } as const
124
125// A request that keeps less than this part of the cache of the request before
126// it lost the cache.
127const CACHE_KEPT = 0.5
128
129// The shortest life of the prompt cache. After a longer pause the cache can be
130// gone for any reason, so a small cache read then proves nothing.
131const CACHE_LIFE_MS = 5 * 60 * 1000
132
133// How many prompts of the person the new-task check compares with, and how
134// much of each.
135const RECENT = 3
136const RECENT_CHARS = 1000
137
138// The state of one session that $.state cannot hold: promises, and what the
139// engine said outside a state write. session.start replaces it whole, so
140// nothing of an earlier session stays. Put new session state here or in an
141// atom, never in a module-level variable: tests/contract.check.mjs fails.
142type Local = {
143 // Decisions run one at a time, so two listings raised together (main and a
144 // subagent) do not classify the same groups twice. A listing with no new
145 // group gets the stored decision at once, with no wait behind a call.
146 queue: Promise<unknown>
147 // The effort pick for the newest prompt of the person, until the first main
148 // request of its turn takes it.
149 picking: Promise<Effort | null> | null
150 // The last main request: its effort, model, cached input (read plus
151 // written) and time, for the cache check after a change of effort.
152 last: { sent: Effort | number; model: string; cached: number; at: number } | null
153 // The servers with a tool that a plugin keeps in the prompt's own list while
154 // tool search is on. Their instructions always stay: Claude sees their tools.
155 front: Set<string>
156 // The newest decision of each kind. The reads of $.state in one dispatch see
157 // one moment, so a decision that waited in `queue` would read the state from
158 // before the decision ahead of it, and classify the same groups again.
159 decided: Partial<Record<Kind, Decision>>
160}
161
162function freshLocal(): Local {
163 return { queue: Promise.resolve(), picking: null, last: null, front: new Set(), decided: {} }
164}
165
166let local = freshLocal()
167
168// One JSON line per decision when the traceFile option names a file. The
169// smoke test reads it: the transcript keeps a listing as the engine made it,
170// not as this plugin answered it. Writes run one at a time, so no line is
171// lost. The queue is for the file, not a session, so it is not in `local`.
172let traceQueue: Promise<unknown> = Promise.resolve()
173
174async function choose($: EngineInterface, file: string, kind: Kind, listing: Listing): Promise<Decision> {
175 const before = await latestDecision($, kind)
176 if (before !== null && unseenGroups(listing, kind, before).length === 0) return before
177 const run = local.queue.then(() => decide($, file, kind, listing))
178 local.queue = run.catch(() => undefined)
179 return run
180}
181
182// The listing with only the kept groups, and its trace line.
183async function scoped($: EngineInterface, file: string, type: string, kind: Kind, listing: Listing): Promise<string> {
184 const decision = await choose($, file, kind, listing)
185 const kept = keptGroups(decision)
186 const out = rebuild(listing, kept, kind)
187 trace($, file, { event: 'listing', type, isScoped: true, kept, alwaysKept: groupsWith(decision, 'always'), hidden: out.hidden })
188 return out.text
189}
190
191function trace($: EngineInterface, file: string, line: TraceLine): void {
192 if (file === '') return
193 traceQueue = traceQueue
194 .then(async () => {
195 const before = await $.fs.read(file).catch(() => '')
196 await $.fs.write(file, `${before}${JSON.stringify(line)}\n`)
197 })
198 .catch(() => undefined)
199}
200
201// One classifier call: the reply's text, or null on any failure. Its trace
202// line holds the token use, which the session's own cost leaves out.
203async function complete(
204 $: EngineInterface,
205 file: string,
206 purpose: Purpose,
207 request: Omit<ModelCompleteRequest, keyof typeof CLASSIFIER>,
208): Promise<string | null> {
209 const reply = await $.model.complete({ ...request, ...CLASSIFIER }).catch(() => null)
210 trace($, file, { event: 'classifier', purpose, model: CLASSIFIER.model, isAnswered: reply?.isAnswered === true, usage: reply?.isAnswered ? reply.usage : null })
211 return reply?.isAnswered ? reply.text : null
212}
213
214export const register: Register = (on, raw) => {
215 const { traceFile: file, alwaysKeep: keepOption, newTaskNote: checksTask, effortPerPrompt, modelForSession } = readOptions(raw)
216 const alwaysKeep = parseKeep(keepOption)
217 const defaults: Switches = { effort: effortPerPrompt, model: modelForSession }
218
219 on('session.start', async ($, e, next) => {
220 // A new session starts with no effort history. /clear does not fire this event.
221 local = freshLocal()
222 // The typeahead is built before this hook runs, so commands/loadout.md lists
223 // /loadout from the first keystroke; this call adds the bare name.
224 await $.command.register({
225 name: 'loadout',
226 description: `Show the loadout of this session. Also: ${Object.values(COMMANDS).join(', ')}`,
227 })
228 return next(e)
229 })
230
231 // The first prompt sets the loadout. A later prompt never changes the lists,
232 // which would spend the prompt cache: it gets a note naming the hidden
233 // items it needs, attached to that prompt alone.
234 on('prompt.submit', async ($, e, next) => {
235 const text = e.text.trim()
236 if (text === '') return next(e)
237 const isFirst = (await read($, prompt)) === null
238 if (isFirst) {
239 // The session keeps the names of this moment: a server that connects
240 // later gets the same names, and a change applies from the next session.
241 const names = await keepNow($, alwaysKeep)
242 await update($, pinned, () => names)
243 await update($, prompt, () => text.slice(0, PROMPT_CHARS))
244 }
245 if (!isPerson(e.origin.kind)) return next(e)
246 const { effort: setsEffort, model: setsModel } = await uses($, defaults)
247 // The window keeps the prompts sent while the loadout is off too.
248 const window = await remember($, text)
249 if (await read($, isOff)) return next(e)
250 if (setsEffort && (await read($, effort)).kind === 'auto') local.picking = pickEffort($, file, text, window)
251 if (isFirst) {
252 // The person answers before the first request, which then goes to the model they chose.
253 if (setsModel) await chooseModel($, file, text).catch(() => undefined)
254 return next(e)
255 }
256 const earlier = checksTask ? window : []
257 const named = new Set(await read($, surfaced))
258 const open = (await read($, offers)).filter(offer => !named.has(offer.group))
259 if (open.length === 0) {
260 // Nothing is left to name, so only a toast can come: the prompt does not wait for it.
261 if (earlier.length > 0) void recall($, file, text, earlier, open).catch(() => undefined)
262 return next(e)
263 }
264 const picked = await recall($, file, text, earlier, open)
265 if (picked.length === 0) return next(e)
266 return next({ ...e, context: [...(e.context ?? []), ...recallNotes(picked)] })
267 }).catch(($, e, next) => next(e))
268
269 // The main requests of the session go to the model picked for the first
270 // prompt. Those of a turn get the effort picked for its prompt, at most the
271 // session's own effort, and only on a model that takes the effort in a
272 // message: there a change keeps the cache.
273 on('turn.step', async function* ($, e, next) {
274 if (e.agentId !== undefined) return yield* next(e)
275 const [{ effort: setsEffort, model: setsModel }, state] = await Promise.all([uses($, defaults), read($, effort)])
276 // A fixed effort stays when the switch is off, to keep the cache.
277 if (!setsModel && !setsEffort && state.kind === 'auto') return yield* next(e)
278 const model = setsModel ? await modelFor($, e.model).catch(() => null) : null
279 const base = model === null ? e : { ...e, model }
280 if (e.effort === undefined || !takesTurnEffort(base.model)) return yield* next(base)
281 const level = await effortFor($, setsEffort, state).catch(() => null)
282 const sent = (level === null ? null : capEffort(level, e.effort)) ?? e.effort
283 const result = yield* next(sent === e.effort ? base : { ...base, effort: sent })
284 await watchCache($, file, sent, result).catch(() => undefined)
285 return result
286 })
287
288 on('prompt.attachment', { type: 'deferred_tools_delta' }, async ($, e, next) => {
289 const base = await next(e)
290 if (base.text === null || (await scopedTools($)) === null) {
291 trace($, file, { event: 'listing', type: e.type, isScoped: false })
292 return base
293 }
294 const text = await scoped($, file, e.type, 'tools', parseTools(base.text))
295 $.ui.invalidate('prompt.section')
296 return { text }
297 })
298
299 on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
300 const base = await next(e)
301 if (base.text === null) return base
302 const listing = parseSkills(base.text)
303 if (await isSkipped($)) {
304 await update($, skillNames, names => [...new Set([...names, ...groupsOf(listing, 'skills')])])
305 trace($, file, { event: 'listing', type: e.type, isScoped: false })
306 return base
307 }
308 return { text: await scoped($, file, e.type, 'skills', listing) }
309 })
310
311 on('tool.describe', async ($, e, next) => {
312 const described = await next(e)
313 const isDeferred = described.isDeferred ?? e.isDeferred === true
314 if (e.tool.startsWith('mcp__') && !isDeferred) local.front.add(toolGroup(e.tool))
315 return described
316 })
317
318 // The MCP server instructions, in the system prompt or as an attachment.
319 on('prompt.section', async ($, e, next) => {
320 const base = await next(e)
321 if (base.text === null || !base.text.includes('# MCP Server Instructions')) return base
322 const text = await trimmed($, file, base.text)
323 trace($, file, { event: 'mcp_instructions', where: e.name, removed: removedServers(base.text, text) })
324 return { text }
325 })
326
327 on('prompt.attachment', { type: /mcp_instructions/ }, async ($, e, next) => {
328 const base = await next(e)
329 if (base.text === null) return base
330 const text = await trimmed($, file, base.text)
331 trace($, file, { event: 'mcp_instructions', where: e.type, removed: removedServers(base.text, text) })
332 return { text }
333 })
334
335 // The listing test runs each word: a word that changes a listing fails it.
336 on('command.run', { command: /^(loadout:)?loadout$/ }, async ($, e) => {
337 const [word = '', ...rest] = e.args.trim().split(/\s+/)
338 if (!isCommandWord(word)) return { text: await summary($, defaults) }
339 switch (word) {
340 case 'off':
341 case 'on':
342 await update($, isOff, () => word === 'off')
343 $.ui.invalidate('prompt.attachment')
344 $.ui.invalidate('prompt.section')
345 return { text: `loadout is ${word} for this session. The next request uses it.` }
346 case 'keep':
347 case 'unkeep': {
348 const [name] = rest
349 if (name === undefined || rest.length > 1 || name.includes(',')) {
350 return { text: `Type "/loadout ${COMMANDS[word]}" with one skill, plugin or server name.` }
351 }
352 // The kind of a typed name is not known: a skill or a server.
353 return { text: await setMark($, alwaysKeep, name, KINDS, word === 'keep') }
354 }
355 case 'effort':
356 case 'model':
357 return { text: await toggle($, word, rest) }
358 case 'list':
359 return { text: listText(await items($, null), await keepNow($, alwaysKeep)) }
360 case 'pick': {
361 const held = await readMarks($)
362 await Promise.all([update($, marks, () => held), update($, filter, () => '')])
363 const { isPlaced } = await $.ui.open({ id: PANE, title: 'loadout: always keep', focus: true, closeOnEscape: true })
364 return { text: isPlaced ? 'Opened the always-keep picker.' : `No surface shows the picker here. Use "/loadout ${COMMANDS.list}" and "/loadout ${COMMANDS.keep}".` }
365 }
366 default:
367 return word satisfies never
368 }
369 })
370
371 // Each opening of the picker reads the facts once, so a draw on each key of
372 // the filter reads nothing.
373 on('ui.open', { id: PANE }, async ($, e, next) => {
374 const known = await readFacts($)
375 await update($, facts, () => known)
376 return next(e)
377 }).catch(($, e, next) => next(e))
378
379 // A model the person sets with /model stays for the rest of the session.
380 // The security module skips classic.PostModelSwitch for a user plugin, so
381 // the plugin watches the command, and modelFor each request.
382 on('command.run', { command: 'model' }, async ($, e, next) => {
383 const result = await next(e)
384 const now = await read($, route)
385 if (now !== null && (await $.session.model()) !== now.from) await endRoute($)
386 return result
387 }).catch(($, e, next) => next(e))
388
389 // The status line and /model still show the session's model: the footer
390 // names the model that answers.
391 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
392 const now = await read($, route)
393 if (now === null) return next(e)
394 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, `${now.to.replace(/^claude-/, '')} (loadout)`] } })
395 }).catch(($, e, next) => next(e))
396
397 // The always-keep picker: a section for the tool servers and one for the
398 // skills, one row each. A press flips the mark and saves it at once.
399 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
400 const ui = $.ui.resolve(e)
401 const { Box, Text, Button } = ui
402 const [names, query, held] = await Promise.all([keepNow($, alwaysKeep), read($, filter), read($, facts)])
403 // A draw may not write state: without facts from ui.open, each draw reads them.
404 const all = await items($, held ?? (await readFacts($)))
405 const needle = query.trim().toLowerCase()
406 const shown = all.filter(item => `${item.group} ${item.label}`.toLowerCase().includes(needle))
407 const isOn = (item: Item) => isAlwaysKept(item.group, item.kind, names)
408 const width = Math.min(34, Math.max(12, ...shown.map(item => item.label.length))) + 4
409 const setFilter = (value: string) => void update($, filter, () => value)
410 const labels = new Map(all.filter(item => HAS_LABEL[item.kind]).map(item => [item.group, item.label]))
411
412 const row = (item: Item) => {
413 const marked = isOn(item)
414 const flip = () => void setMark($, alwaysKeep, item.group, [item.kind], !marked).then(text => $.ui.toast(text))
415 return (
416 <Box key={`row:${item.kind}:${item.group}`} flexDirection="row">
417 <Box width={width} flexShrink={0}>
418 <Button key={`${item.kind}:${item.group}`} plain onPress={flip}>
419 <Text color={marked ? 'success' : 'inactive'}>{marked ? '● ' : '○ '}</Text>
420 {item.label.length > width - 2 ? `${item.label.slice(0, width - 3)}…` : item.label}
421 </Button>
422 </Box>
423 <Box width={8} flexShrink={0}>
424 <Text color={item.status === 'kept' ? 'success' : 'warning'}>{item.status}</Text>
425 </Box>
426 <Text dimColor wrap="truncate-end">{item.detail}</Text>
427 </Box>
428 )
429 }
430 const section = (kind: Kind) => {
431 const rows = shown.filter(item => item.kind === kind)
432 const total = all.filter(item => item.kind === kind).length
433 const empty = needle === '' ? 'None seen yet. The list fills after the first prompt.' : 'No match.'
434 return (
435 <Box key={`section:${kind}`} flexDirection="column" marginTop={1}>
436 <Text bold>
437 {SECTION[kind]} <Text dimColor>{rows.length === total ? `${total}` : `${rows.length} of ${total}`}</Text>
438 </Text>
439 {rows.length === 0 ? <Text dimColor>{` ${empty}`}</Text> : rows.map(row)}
440 </Box>
441 )
442 }
443 return (
444 <Box flexDirection="column">
445 <Text dimColor>loadout never hides a marked item. A change applies from the next session.</Text>
446 <Text wrap="truncate-end">{keepLine(names.map(name => labels.get(normalize(name)) ?? name))}</Text>
447 {'Input' in ui && (
448 <Box marginTop={1}>
449 <ui.Input key="filter" label="Filter" placeholder="server or skill name" value={query} autoFocus onInput={setFilter} onSubmit={setFilter} />
450 </Box>
451 )}
452 {e.surface === 'terminal' && <Text dimColor>Tab moves · Enter marks · Esc closes</Text>}
453 {KINDS.map(section)}
454 </Box>
455 )
456 })
457}
458
459const SECTION: Record<Kind, string> = { tools: 'Tool servers', skills: 'Skills' }
460
461// One tool group or skill of the session, with its state in this session's
462// loadout: `kept`, `hidden`, or empty before the first decision. `label` is
463// the name a person knows; `detail` its size and what it is.
464type Item = { kind: Kind; group: string; status: '' | 'kept' | 'hidden'; label: string; detail: string }
465
466type Facts = { servers: ServerFact[]; skills: SkillFact[] }
467
468// What the engine knows about each tool server and skill: the tools, the /mcp
469// name, the tokens and the descriptions.
470async function readFacts($: EngineInterface): Promise<Facts> {
471 const [listed, usage, commands] = await Promise.all([
472 $.tool.list().catch(() => []),
473 $.session.usage({ breakdown: 'summary' }).catch(() => null),
474 $.command.list().catch(() => []),
475 ])
476 const breakdown = usage?.context.breakdown
477 const about = new Map(commands.map(command => [command.name, command.description]))
478 return {
479 servers: serverFacts(listed, breakdown?.mcpTools ?? []),
480 skills: (breakdown?.skills?.skillFrontmatter ?? []).map(skill => {
481 // The breakdown can leave out the plugin of a plugin skill.
482 const name = skill.name.includes(':') || !skill.pluginName ? skill.name : `${skill.pluginName}:${skill.name}`
483 return { name, tokens: skill.tokens, about: about.get(name) ?? about.get(skill.name) ?? '' }
484 }),
485 }
486}
487
488const byName = new Intl.Collator(undefined, { sensitivity: 'base' })
489
490// The status of a group as the picker shows it.
491const SHOWN: Record<Status, Item['status']> = { kept: 'kept', always: 'kept', hidden: 'hidden' }
492
493// Only a server has a label other than its name.
494const HAS_LABEL: Record<Kind, boolean> = { tools: true, skills: false }
495
496// Every tool server and skill the session has seen: from the decisions, the
497// skill listings that the plugin did not scope, and the facts when the pane
498// has them. Without facts, the MCP tools give the servers.
499async function items($: EngineInterface, known: Facts | null): Promise<Item[]> {
500 const [{ tools: t, skills: s }, names] = await Promise.all([readDecisions($), read($, skillNames)])
501 const servers = new Map((known?.servers ?? []).map(fact => [fact.group, fact]))
502 const listed = [...names, ...Object.keys(s ?? {})]
503 const skillFacts = new Map(listedSkillFacts(known?.skills ?? [], listed).map(fact => [fact.name, fact]))
504 if (known === null && t === null) {
505 const listed = await $.tool.list().catch(() => [])
506 for (const group of groupsOf(mcpTools(listed), 'tools')) servers.set(group, { group, label: group, tools: [], tokens: 0 })
507 }
508 const status = (decision: Decision | null, group: string): Item['status'] => {
509 const now = statusOf(decision, group)
510 return now === undefined ? '' : SHOWN[now]
511 }
512 const groups = (decision: Decision | null, seen: Iterable<string>) => [...new Set([...seen, ...Object.keys(decision ?? {})])]
513 const tools = groups(t, servers.keys()).map((group): Item => {
514 const fact = servers.get(group)
515 const detail = fact === undefined || fact.tools.length === 0 ? '' : serverDetail(fact)
516 return { kind: 'tools', group, status: status(t, group), label: fact?.label ?? group, detail }
517 })
518 const skillItems = groups(s, [...names, ...skillFacts.keys()]).map((group): Item => {
519 const fact = skillFacts.get(group)
520 const detail = fact === undefined ? '' : skillDetail(fact.tokens, fact.about)
521 return { kind: 'skills', group, status: status(s, group), label: group, detail }
522 })
523 const sorted = (list: Item[]) => list.sort((a, b) => byName.compare(a.label, b.label))
524 return [...sorted(tools), ...sorted(skillItems)]
525}
526
527// The always-keep list for the next session: the option and the marks.
528async function keepNow($: EngineInterface, option: string[]): Promise<string[]> {
529 return [...new Set([...option, ...(await readMarks($))])]
530}
531
532// The names that /loadout keep and the pane saved. They live in the plugin's
533// store, so they last across sessions. The `marks` atom holds a copy, so a
534// write draws the pane again.
535async function readMarks($: EngineInterface): Promise<string[]> {
536 return (await read($, marks)) ?? (await storedMarks($)) ?? []
537}
538
539// The marks in $.store, or null when the store cannot be read.
540async function storedMarks($: EngineInterface): Promise<string[] | null> {
541 const stored = await $.store.get(MARKS).catch(() => null)
542 if (stored === null) return null
543 return Array.isArray(stored) ? stored.filter((name): name is string => typeof name === 'string') : []
544}
545
546function keepLine(names: readonly string[]): string {
547 return `Always keep: ${names.join(', ') || 'none'}`
548}
549
550const LIST_TITLE: Record<Kind, string> = { tools: 'Tool groups', skills: 'Skills' }
551
552function listText(all: Item[], names: string[]): string {
553 const row = (item: Item) =>
554 `${isAlwaysKept(item.group, item.kind, names) ? '[x]' : '[ ]'} ${item.group}${item.status === '' ? '' : ` (${item.status})`}`
555 const part = (kind: Kind) => {
556 const rows = all.filter(item => item.kind === kind).map(row)
557 return [`${LIST_TITLE[kind]}:`, ...(rows.length === 0 ? ['none seen yet'] : rows)]
558 }
559 return [
560 keepLine(names),
561 '[x] marks an item that the plugin never hides. (kept) and (hidden) give its state in this session.',
562 ...KINDS.flatMap(part),
563 `Change a mark with "/loadout ${COMMANDS.keep}" or "/loadout ${COMMANDS.unkeep}", or with the picker: "/loadout ${COMMANDS.pick}".`,
564 ].join('\n')
565}
566
567// How the alwaysKeep option spells a typed name of each kind.
568const MARK_NAME: Record<Kind, (name: string) => string> = { tools: normalize, skills: name => name }
569
570// Sets or clears the mark of a name and saves the marks. A name that the
571// option covers stays: the plugin does not write the settings.
572async function setMark($: EngineInterface, option: string[], name: string, kinds: readonly Kind[], isOn: boolean): Promise<string> {
573 const isCovered = (names: string[]) => kinds.some(kind => isAlwaysKept(MARK_NAME[kind](name), kind, names))
574 // The store first: another session can have saved marks since this one read them.
575 const before = (await storedMarks($)) ?? (await readMarks($))
576 if (isOn && isCovered([...option, ...before])) return `${name} is already on the always-keep list.`
577 const after = isOn ? [...before, name] : kinds.reduce((names, kind) => flipKeep(names, MARK_NAME[kind](name), kind, false), before)
578 if (isCovered(option)) return `${name} stays on the always-keep list: the option alwaysKeep in your settings covers it. Remove it there.`
579 if (after.length === before.length) return `${name} is not on the always-keep list.`
580 await update($, marks, () => after)
581 const isSaved = await $.store.set(MARKS, after).then(() => true, () => false)
582 if (!isSaved) return 'loadout could not save the always-keep list. It holds for this session only.'
583 const when = (await read($, prompt)) === null ? 'This session uses it from the first prompt.' : 'It applies from the next session.'
584 return `${keepLine([...new Set([...option, ...after])])}. ${when}`
585}
586
587function isPerson(kind: string): boolean {
588 return kind === 'composer' || kind === 'bridge' || kind === 'sdk'
589}
590
591// Adds a prompt of the person to the recent ones. Returns the ones before it.
592async function remember($: EngineInterface, text: string): Promise<string[]> {
593 let earlier: string[] = []
594 await update($, recent, list => {
595 earlier = list
596 return [...list, text.slice(0, RECENT_CHARS)].slice(-RECENT)
597 })
598 return earlier
599}
600
601// Asks the classifier for one word of `parse` about a prompt of the person.
602// Null on any failure.
603async function pickWord<T>(
604 $: EngineInterface,
605 file: string,
606 purpose: Purpose,
607 system: string,
608 text: string,
609 earlier: string[],
610 parse: (reply: string) => T | null,
611): Promise<T | null> {
612 const reply = await complete($, file, purpose, { system, prompt: promptBlock(text, earlier), maxTokens: 20, timeoutMs: 5000 })
613 return reply === null ? null : parse(reply)
614}
615
616// Asks the classifier for the model of the session, from its first prompt. A
617// smaller model goes to the person, who must approve it. On any failure, or a
618// dismissed question, the session keeps its model.
619async function chooseModel($: EngineInterface, file: string, text: string): Promise<void> {
620 const [picked, session] = await Promise.all([pickWord($, file, 'model', MODEL, text, [], parseModel), $.session.model()])
621 const to = picked === null ? null : routeModel(picked, session)
622 trace($, file, { event: 'model_pick', picked, session, to })
623 if (to === null) return
624 const use = `Use ${to}`
625 const answer = await $.ui.ask(
626 `loadout: ${to} looks enough for this task. Use it instead of ${session} for this session? The status line and /model still show ${session}.`,
627 { header: 'Model', options: [use, `Keep ${session}`] },
628 )
629 const isUsed = answer === use
630 trace($, file, { event: 'model_route', from: session, to, isUsed })
631 if (!isUsed) return
632 await update($, route, () => ({ from: session, to }))
633 $.ui.invalidate('ui.render')
634}
635
636// The model for a main request: the one the person approved. A request on
637// another model (a /model change, a fallback) or /loadout off ends the change
638// for the rest of the session.
639async function modelFor($: EngineInterface, model: string): Promise<string | null> {
640 const [now, off] = await Promise.all([read($, route), read($, isOff)])
641 if (now === null) return null
642 if (model === now.from && !off) return now.to
643 await endRoute($)
644 return null
645}
646
647// The effort and model changes: the option, unless /loadout switched them
648// for this session.
649async function uses($: EngineInterface, defaults: Switches): Promise<Switches> {
650 return { ...defaults, ...(await read($, switches)) }
651}
652
653const SWITCH = {
654 effort: { name: 'Effort for each prompt', on: 'from the next prompt', off: 'from the next prompt' },
655 model: { name: 'Model for the session', on: 'from the first prompt', off: 'from the next request' },
656} as const
657
658// `/loadout effort|model on|off`: sets the change for this session.
659async function toggle($: EngineInterface, which: keyof Switches, words: string[]): Promise<string> {
660 const [word] = words
661 if ((word !== 'on' && word !== 'off') || words.length > 1) return `Type "/loadout ${which} on" or "/loadout ${which} off".`
662 const { name } = SWITCH[which]
663 // The model comes from the first prompt only: once it is sent, "on" waits for a new session.
664 if (which === 'model' && word === 'on' && (await read($, prompt)) !== null) {
665 return `${name} picks the model from the first prompt only. Start a new session or run /clear, then type "/loadout model on" before the first prompt.`
666 }
667 await update($, switches, now => ({ ...now, [which]: word === 'on' }))
668 if (which === 'model' && word === 'off') await endRoute($)
669 return `${name} is ${word} for this session, ${SWITCH[which][word]}.`
670}
671
672async function endRoute($: EngineInterface): Promise<void> {
673 if ((await read($, route)) === null) return
674 await update($, route, () => null)
675 $.ui.invalidate('ui.render')
676}
677
678// Asks the classifier for the effort of a prompt of the person. Null on any
679// failure: the request then keeps the effort it has.
680async function pickEffort($: EngineInterface, file: string, text: string, earlier: string[]): Promise<Effort | null> {
681 const picked = await pickWord($, file, 'effort', EFFORT, text, earlier, parseEffort)
682 trace($, file, { event: 'effort_pick', picked })
683 return picked
684}
685
686// The effort for a main request: the pick of the newest prompt once it is
687// there, else the one before. Null when the plugin leaves the effort alone:
688// switched off, unless a fixed effort keeps the cache.
689async function effortFor($: EngineInterface, isOn: boolean, state: EffortState): Promise<Effort | null> {
690 const pending = local.picking
691 local.picking = null
692 const off = await read($, isOff)
693 if (state.kind === 'fixed') return state.level
694 if (off || !isOn) return null
695 const picked = pending === null ? null : await pending
696 if (picked === null || picked === state.level) return state.level
697 await update($, effort, (now): EffortState => (now.kind === 'fixed' ? now : { kind: 'auto', level: picked }))
698 $.ui.status(`effort ${picked}`)
699 return picked
700}
701
702// After a main request with a changed effort: if it lost most of the cache,
703// the model did not take the effort in a message. The effort then stays.
704async function watchCache($: EngineInterface, file: string, sent: Effort | number, result: TurnStepResult): Promise<void> {
705 const usage = result.usage
706 if (usage === null) return
707 const before = local.last
708 const at = await $.clock.now()
709 local.last = { sent, model: usage.model, cached: usage.cache_read_input_tokens + usage.cache_creation_input_tokens, at }
710 if (before === null || before.sent === sent) return
711 trace($, file, { event: 'effort_change', from: before.sent, to: sent, cacheRead: usage.cache_read_input_tokens })
712 // A change of model, or a pause past the life of the cache, also loses it.
713 if (before.model !== usage.model || at - before.at > CACHE_LIFE_MS) return
714 if (before.cached === 0 || usage.cache_read_input_tokens >= before.cached * CACHE_KEPT) return
715 // A number is not a level the plugin can keep: the session's effort stays as it is.
716 if (!isEffort(sent)) return
717 await update($, effort, (): EffortState => ({ kind: 'fixed', level: sent }))
718 trace($, file, { event: 'effort_frozen', level: sent, model: usage.model })
719}
720
721// The prompt of a classifier call on a prompt of the person.
722function promptBlock(text: string, earlier: string[]): string {
723 const before = earlier.length === 0 ? '' : `Earlier prompts:\n${earlier.map(one => `<earlier>\n${one}\n</earlier>`).join('\n')}\n\n`
724 return `${before}Newest prompt:\n<prompt>\n${text.slice(0, PROMPT_CHARS)}\n</prompt>`
725}
726
727// Matches a later prompt against the hidden items not yet named, and against
728// the earlier prompts: a toast when it starts a different task. With no
729// earlier prompts the task is not checked.
730async function recall($: EngineInterface, file: string, text: string, earlier: string[], open: Offer[]): Promise<Offer[]> {
731 const hidden = open.length === 0 ? 'none' : open.map(offer => offer.row).join('\n')
732 const reply = await complete($, file, 'recall', {
733 system: earlier.length === 0 ? RECALL : `${RECALL}\n${TASK_CHECK}`,
734 prompt: `${promptBlock(text, earlier)}\n\nHidden:\n${hidden}`,
735 maxTokens: 300,
736 timeoutMs: 5000,
737 })
738 const answer = reply ?? ''
739 const isNew = earlier.length > 0 && isNewTask(answer)
740 const kept = parseKept(answer, open.map(offer => offer.group))
741 const picked = open.filter(offer => kept.includes(offer.group))
742 trace($, file, { event: 'recall', named: picked.map(offer => offer.group), isNewTask: isNew })
743 if (isNew) $.ui.toast(NEW_TASK, { timeoutMs: 10000 })
744 if (picked.length > 0) {
745 await update($, surfaced, list => [...list, ...picked.map(offer => offer.group)])
746 $.ui.status(`pointed to ${picked.map(offer => offer.group).join(', ')}`)
747 }
748 return picked
749}
750
751// The first line of a later prompt's note for each kind, before the rows.
752const RECALL_NOTE: Record<Kind, string> = {
753 tools: 'loadout: this prompt may need tools hidden at session start. Load them with ToolSearch ("select:<name>" or keywords):',
754 skills: 'loadout: this prompt may need skills hidden from the skill list. Call Skill with the name:',
755}
756
757function recallNotes(picked: Offer[]): string[] {
758 return KINDS.flatMap(kind => {
759 const rows = picked.filter(offer => offer.kind === kind).map(offer => `- ${offer.row}`)
760 return rows.length === 0 ? [] : [`${RECALL_NOTE[kind]}\n${rows.join('\n')}`]
761 })
762}
763
764async function isSkipped($: EngineInterface): Promise<boolean> {
765 return (await read($, isOff)) || (await read($, prompt)) === null
766}
767
768// The tools of the session, or null when no tool group may be hidden: the
769// session is skipped, or it has no ToolSearch. Without ToolSearch (tool search
770// off, or a deny rule on it) the engine puts every MCP tool in the prompt, so
771// Claude sees them all. The engine still says isDeferred for these tools, so
772// ToolSearch in the tool list is the one sign.
773async function scopedTools($: EngineInterface): Promise<ToolInfo[] | null> {
774 if (await isSkipped($)) return null
775 const listed = await $.tool.list()
776 return listed.some(info => info.name === 'ToolSearch') ? listed : null
777}
778
779// Claude Code can raise the instructions before the tool listing, and a server
780// that connects late can raise its instructions before its tools. So the tool
781// groups are classified here from the MCP tools of the session: `decide`
782// classifies only the new groups, and the tool listing reuses the decision.
783async function trimmed($: EngineInterface, file: string, text: string): Promise<string> {
784 const listed = await scopedTools($)
785 if (listed === null) return text
786 const decision = await choose($, file, 'tools', mcpTools(listed))
787 return trimMcpInstructions(text, new Set(groupsWith(decision, 'hidden').filter(group => !local.front.has(group))))
788}
789
790// Classifies only the groups not seen before, so a later delta (an MCP server
791// that connected late) gets a decision of its own and earlier ones stay.
792async function decide($: EngineInterface, file: string, kind: Kind, listing: Listing): Promise<Decision> {
793 const before: Decision = (await latestDecision($, kind)) ?? {}
794 const unseen = unseenGroups(listing, kind, before)
795 if (unseen.length === 0) return before
796
797 // The always-kept groups skip the classifier: it cannot hide them.
798 const names = await read($, pinned)
799 const always = unseen.filter(group => isAlwaysKept(group, kind, names))
800 const open = unseen.filter(group => !always.includes(group))
801 const catalog = CATALOG[kind](listing).filter(row => open.includes(row.group))
802 const picked = open.length === 0 ? [] : await classify($, file, kind, (await read($, prompt)) ?? '', catalog.map(row => row.row), open)
803 // The always-kept groups first, then the picks, then the hidden ones. Built
804 // from entries, so each group is an own key whatever its name.
805 const hidden = open.filter(group => !picked.includes(group))
806 const tagged = (groups: string[], status: Status) => groups.map(group => [group, status] as const)
807 const decision: Decision = Object.fromEntries([...Object.entries(before), ...tagged(always, 'always'), ...tagged(picked, 'kept'), ...tagged(hidden, 'hidden')])
808 const dropped = catalog.filter(row => hidden.includes(row.group)).map(row => ({ kind, group: row.group, row: row.row }))
809 // Two decisions can run at once (the instructions and the tool listing), so
810 // a group can come twice. Keep one of each.
811 await update($, offers, list => [...list, ...dropped.filter(offer => !list.some(one => one.group === offer.group))])
812 local.decided[kind] = decision
813 await writeDecision($, kind, decision)
814 const now = await readDecisions($)
815 $.ui.status(`hid ${groupsWith(now.tools, 'hidden').length} tool groups, ${groupsWith(now.skills, 'hidden').length} skills`)
816 return decision
817}
818
819// The newest decision of a kind: the one this process made, else the stored
820// one (after a hot reload, `local` starts empty).
821async function latestDecision($: EngineInterface, kind: Kind): Promise<Decision | null> {
822 return local.decided[kind] ?? readDecision($, kind)
823}
824
825// The newest decisions, by kind.
826async function readDecisions($: EngineInterface): Promise<Record<Kind, Decision | null>> {
827 const [tools, skills] = await Promise.all([latestDecision($, 'tools'), latestDecision($, 'skills')])
828 return { tools, skills }
829}
830
831// The scan needs each atom spelled at its call, hence a case for each kind.
832async function readDecision($: EngineInterface, kind: Kind): Promise<Decision | null> {
833 switch (kind) {
834 case 'tools':
835 return read($, toolDecision)
836 case 'skills':
837 return read($, skillDecision)
838 default:
839 return kind satisfies never
840 }
841}
842
843async function writeDecision($: EngineInterface, kind: Kind, decision: Decision): Promise<void> {
844 switch (kind) {
845 case 'tools':
846 await update($, toolDecision, () => decision)
847 return
848 case 'skills':
849 await update($, skillDecision, () => decision)
850 return
851 default:
852 return kind satisfies never
853 }
854}
855
856// How the classifier prompt names the rows of each kind.
857const ROWS: Record<Kind, string> = { tools: 'tool groups (id, then sample tools)', skills: 'skills (id: description)' }
858
859// On any failure everything is kept: a wrong cut costs more than no cut.
860async function classify(
861 $: EngineInterface,
862 file: string,
863 kind: Kind,
864 task: string,
865 rows: string[],
866 groups: string[],
867): Promise<string[]> {
868 const reply = await complete($, file, kind, {
869 system: SYSTEM,
870 prompt: `Task:\n<task>\n${task}\n</task>\n\nAvailable ${ROWS[kind]}:\n${rows.join('\n')}`,
871 maxTokens: 800,
872 timeoutMs: 10000,
873 })
874 return reply === null ? groups : parseKept(reply, groups)
875}
876
877async function summary($: EngineInterface, defaults: Switches): Promise<string> {
878 const [off, { tools: t, skills: s }, now, named, routed, state] = await Promise.all([
879 read($, isOff),
880 readDecisions($),
881 uses($, defaults),
882 read($, surfaced),
883 read($, route),
884 read($, effort),
885 ])
886 if (off) return 'loadout is off for this session.'
887 const switchLine = (Object.keys(SWITCH) as (keyof Switches)[]).map(which => `${SWITCH[which].name}: ${now[which] ? 'on' : 'off'}`).join(', ')
888 const tail = [switchLine, ...modelLine(routed), ...effortLine(state, now.effort)]
889 if (t === null && s === null) return ['loadout has not picked a loadout for this session yet. Send a prompt first.', ...tail].join('\n')
890 // No tool decision: the session has no ToolSearch or no MCP tools.
891 const toolLines = t === null
892 ? ['Tool groups hidden: none']
893 : [`Tool groups kept: ${keptGroups(t).join(', ') || 'none'}`, `Tool groups hidden: ${groupsWith(t, 'hidden').join(', ') || 'none'}`]
894 return [
895 `Always kept: ${[...groupsWith(t, 'always'), ...groupsWith(s, 'always')].join(', ') || 'none'}`,
896 ...toolLines,
897 `Skills kept: ${keptGroups(s).join(', ') || 'none'}`,
898 `Skills hidden: ${groupsWith(s, 'hidden').length}`,
899 `Named to later prompts: ${named.join(', ') || 'none'}`,
900 ...tail,
901 ].join('\n')
902}
903
904function modelLine(state: Route | null): string[] {
905 return state === null ? [] : [`Model: ${state.to} (the session's model: ${state.from})`]
906}
907
908function effortLine(state: EffortState, isOn: boolean): string[] {
909 if (state.level === null || (!isOn && state.kind === 'auto')) return []
910 return [`Effort: ${state.level}${state.kind === 'fixed' ? ' (fixed: a change lost the prompt cache on this model)' : ''}`]
911}
912hooks/scope.ts 392 lines1// Pure helpers, with no `$`: parse the listings the engine injects, group their
2// entries and rebuild them with only the groups the classifier kept, and read
3// the words, options and decisions that register uses.
4
5import type { Decision, Effort, Kind, ModelTier, Options, ServerFact, SkillFact, Status } from '../types'
6
7// A deferred tool line is one bare name; an MCP tool is mcp__<server>__<tool>.
8const TOOL_LINE = /^[A-Za-z0-9_.:-]+$/
9const SKILL_LINE = /^- ([^\s:]+(?::[^\s:]+)?)(?::\s|$)/
10
11export const CORE = 'core'
12
13export const KINDS: readonly Kind[] = ['tools', 'skills']
14
15export function toolGroup(name: string): string {
16 const parts = splitMcpName(name)
17 return parts === null ? CORE : normalize(parts.server)
18}
19
20// The server and tool parts of `mcp__<server>__<tool>`, or null for another tool.
21export function splitMcpName(name: string): { server: string; tool: string } | null {
22 const match = /^mcp__(.+?)__(.*)$/.exec(name)
23 return match?.[1] === undefined || match[2] === undefined ? null : { server: match[1], tool: match[2] }
24}
25
26export function normalize(server: string): string {
27 return server.toLowerCase().replace(/[^a-z0-9]+/g, '_')
28}
29
30export type Entry = { name: string; group: string; lines: string[] }
31export type Listing = { head: string[]; entries: Entry[]; tail: string[] }
32
33export function parseTools(text: string): Listing {
34 const head: string[] = []
35 const entries: Entry[] = []
36 const tail: string[] = []
37 for (const line of text.split('\n')) {
38 const name = line.trim()
39 if (TOOL_LINE.test(name)) {
40 entries.push({ name, group: toolGroup(name), lines: [line] })
41 } else if (entries.length === 0) {
42 head.push(line)
43 } else {
44 tail.push(line)
45 }
46 }
47 return { head, entries, tail }
48}
49
50// Each skill is its own group: unprefixed user skills would otherwise be one
51// group that is always kept.
52export function parseSkills(text: string): Listing {
53 const head: string[] = []
54 const entries: Entry[] = []
55 for (const line of text.split('\n')) {
56 const name = SKILL_LINE.exec(line)?.[1]
57 const last = entries[entries.length - 1]
58 if (name) {
59 entries.push({ name, group: name, lines: [line] })
60 } else if (last) {
61 last.lines.push(line)
62 } else {
63 head.push(line)
64 }
65 }
66 return { head, entries, tail: [] }
67}
68
69// One catalog row per group the classifier may drop. `core` tools are never
70// offered: they are few and cheap.
71export type Row = { group: string; row: string }
72
73function toolCatalog(listing: Listing): Row[] {
74 const byGroup = new Map<string, string[]>()
75 for (const entry of listing.entries) {
76 if (entry.group === CORE) continue
77 const short = splitMcpName(entry.name)?.tool ?? entry.name
78 byGroup.set(entry.group, [...(byGroup.get(entry.group) ?? []), short])
79 }
80 return [...byGroup].map(([group, tools]) => ({
81 group,
82 row: `${group} (${tools.length} tools): ${tools.slice(0, 10).join(', ')}`,
83 }))
84}
85
86function skillCatalog(listing: Listing): Row[] {
87 return listing.entries.map(entry => ({
88 group: entry.group,
89 row: entry.lines.join(' ').slice(2, 160).replace(/\s+/g, ' '),
90 }))
91}
92
93export const CATALOG: Record<Kind, (listing: Listing) => Row[]> = { tools: toolCatalog, skills: skillCatalog }
94
95// The groups that a listing of each kind always shows: the `core` tools.
96const ALWAYS_SHOWN: Record<Kind, readonly string[]> = { tools: [CORE], skills: [] }
97
98export function groupsOf(listing: Listing, kind: Kind): string[] {
99 const groups = new Set(listing.entries.map(entry => entry.group))
100 for (const group of ALWAYS_SHOWN[kind]) groups.delete(group)
101 return [...groups]
102}
103
104// The MCP tools of the session ($.tool.list()) as a tool listing.
105export function mcpTools(listed: readonly { name: string; mcp: boolean }[]): Listing {
106 return parseTools(listed.filter(info => info.mcp).map(info => info.name).join('\n'))
107}
108
109// A server name as a person reads it: a claude.ai connector in the tool-name
110// spelling (`claude_ai_Google_Drive`) as the connector list shows it.
111export function serverLabel(name: string): string {
112 const match = /^claude[._]ai[_ ](.+)$/i.exec(name)
113 return match?.[1] ? `claude.ai ${match[1].replace(/_/g, ' ')}` : name
114}
115
116// One fact per MCP server of the session: its tools from $.tool.list(), and
117// the tokens and the /mcp name from the context breakdown.
118export function serverFacts(
119 listed: readonly { name: string; mcp: boolean }[],
120 counted: readonly { name: string; serverName: string; tokens: number }[],
121): ServerFact[] {
122 const byGroup = new Map<string, ServerFact>()
123 for (const info of listed) {
124 const parts = info.mcp ? splitMcpName(info.name) : null
125 if (parts === null) continue
126 const group = normalize(parts.server)
127 const fact = byGroup.get(group) ?? { group, label: serverLabel(parts.server), tools: [], tokens: 0 }
128 fact.tools.push(parts.tool)
129 byGroup.set(group, fact)
130 }
131 for (const tool of counted) {
132 const fact = byGroup.get(toolGroup(tool.name))
133 if (fact === undefined) continue
134 fact.tokens += tool.tokens
135 if (tool.serverName !== '') fact.label = serverLabel(tool.serverName)
136 }
137 return [...byGroup.values()]
138}
139
140// A connector that is not logged in has only its two login tools.
141export function isLoginOnly(tools: readonly string[]): boolean {
142 return tools.length === 2 && tools.includes('authenticate') && tools.includes('complete_authentication')
143}
144
145// "1.2k tokens", or nothing when the engine gave no count.
146export function tokenCount(tokens: number): string {
147 if (tokens <= 0) return ''
148 return `${tokens < 1000 ? tokens : `${(tokens / 1000).toFixed(1)}k`} tokens`
149}
150
151// A server's line in the picker: its size, then its first tools or its login state.
152export function serverDetail(fact: ServerFact): string {
153 const count = `${fact.tools.length} tool${fact.tools.length === 1 ? '' : 's'}`
154 const first = `${fact.tools.slice(0, 3).join(', ')}${fact.tools.length > 3 ? ', …' : ''}`
155 return [count, tokenCount(fact.tokens), isLoginOnly(fact.tools) ? 'not logged in' : first].filter(Boolean).join(' · ')
156}
157
158// A skill's line in the picker: its tokens, then its description.
159export function skillDetail(tokens: number, about: string): string {
160 return [tokenCount(tokens), about].filter(Boolean).join(' · ')
161}
162
163// The skill facts under the names of the skill listing. The context
164// breakdown can name a plugin skill without its plugin (`docx` for
165// `anthropic-skills:docx`): such a fact takes the one listed name that ends
166// with it, so the picker has one row and a mark covers the listed skill.
167export function listedSkillFacts(facts: readonly SkillFact[], listed: readonly string[]): SkillFact[] {
168 const names = new Set(listed)
169 return facts.map(fact => {
170 if (names.has(fact.name) || fact.name.includes(':')) return fact
171 const matches = listed.filter(name => name.endsWith(`:${fact.name}`))
172 return matches.length === 1 && matches[0] !== undefined ? { ...fact, name: matches[0] } : fact
173 })
174}
175
176// The groups of `listing` that no decision covers yet.
177export function unseenGroups(
178 listing: Listing,
179 kind: Kind,
180 before: Decision | null,
181): string[] {
182 return groupsOf(listing, kind).filter(group => statusOf(before, group) === undefined)
183}
184
185// The status of a group, or undefined when the decision has none. Only its own
186// keys count: a group named `constructor` is not a status of every decision.
187export function statusOf(decision: Decision | null, group: string): Status | undefined {
188 return decision !== null && Object.hasOwn(decision, group) ? decision[group] : undefined
189}
190
191// The groups of a decision with one of `statuses`, in the order seen. An
192// object lists a name that is a number first, which only changes the order.
193export function groupsWith(decision: Decision | null, ...statuses: Status[]): string[] {
194 return Object.entries(decision ?? {}).filter(([, status]) => statuses.includes(status)).map(([group]) => group)
195}
196
197// The groups a decision keeps: by the classifier or by the alwaysKeep option.
198export function keptGroups(decision: Decision | null): string[] {
199 return groupsWith(decision, 'kept', 'always')
200}
201
202// Reads the alwaysKeep option: names split on commas or white space, once each.
203export function parseKeep(text: string): string[] {
204 return [...new Set(text.split(/[\s,]+/).filter(name => name !== ''))]
205}
206
207// Whether a name of the alwaysKeep option covers a group. A tool group matches
208// its server name in any spelling (`claude-in-chrome`). A skill matches its full
209// name, its plugin name alone (`retro` covers `retro:recall`), or its own name
210// without the plugin (`recall` covers `retro:recall`): the context breakdown
211// and people often leave the plugin out. A name that covers too much keeps
212// more, which costs less than a wrong cut.
213export function isAlwaysKept(group: string, kind: Kind, names: readonly string[]): boolean {
214 return names.some(name => COVERS[kind](name, group))
215}
216
217const COVERS: Record<Kind, (name: string, group: string) => boolean> = {
218 tools: (name, group) => normalize(name) === group,
219 skills: (name, group) => group === name || group.startsWith(`${name}:`) || (!name.includes(':') && group.endsWith(`:${name}`)),
220}
221
222// The alwaysKeep names after a mark on a group is set or cleared. Clearing
223// removes each name that covers the group, so the group is not kept after.
224export function flipKeep(names: readonly string[], group: string, kind: Kind, isOn: boolean): string[] {
225 if (!isOn) return names.filter(name => !isAlwaysKept(group, kind, [name]))
226 return isAlwaysKept(group, kind, names) ? [...names] : [...names, group]
227}
228
229// Reads the classifier's reply: one group id per line, anything else ignored.
230export function parseKept(reply: string, groups: readonly string[]): string[] {
231 const known = new Set(groups)
232 return reply
233 .split(/[\n,]/)
234 .map(part => part.trim().replace(/^[-*\s`"]+|[`"\s]+$/g, ''))
235 .filter(part => known.has(part))
236}
237
238// The first line of a later prompt's reply says if the prompt starts a new task.
239export function isNewTask(reply: string): boolean {
240 const first = reply.trim().split('\n')[0] ?? ''
241 return /^[-*\s`"]*new task[`"\s.:]*$/i.test(first)
242}
243
244// From the lowest to the highest. The EFFORT prompt names them from this list.
245export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
246// From the smallest model to the largest. The MODEL prompt names them from
247// this list, and each takes the effort in a message (a test checks it).
248export const MODEL_ID: Record<ModelTier, string> = {
249 haiku: 'claude-haiku-5-5',
250 sonnet: 'claude-sonnet-5-5',
251 opus: 'claude-opus-5-5',
252 fable: 'claude-fable-5-1',
253}
254export const MODELS = Object.keys(MODEL_ID) as ModelTier[]
255
256// "a, b or c", for the prompts that list the words of a reply.
257export function wordList(words: readonly string[]): string {
258 return words.length < 2 ? words.join('') : `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`
259}
260
261export function isEffort(value: unknown): value is Effort {
262 return EFFORTS.includes(value as Effort)
263}
264
265// The words of /loadout after the name, each as the command description shows
266// it. Each has a case in the command.run hook of register.
267export const COMMANDS = {
268 list: 'list',
269 pick: 'pick',
270 keep: 'keep <name>',
271 unkeep: 'unkeep <name>',
272 off: 'off',
273 on: 'on',
274 effort: 'effort on|off',
275 model: 'model on|off',
276} as const
277export type CommandWord = keyof typeof COMMANDS
278
279export function isCommandWord(word: string): word is CommandWord {
280 return Object.hasOwn(COMMANDS, word)
281}
282
283const wordOf = (words: readonly string[]) => new RegExp(`(?<![a-z])(${words.join('|')})(?![a-z])`, 'gi')
284const EFFORT_WORD = wordOf(EFFORTS)
285const MODEL_WORD = wordOf(MODELS)
286
287// The one word that a reply names, or null when it names none or several.
288function oneWord<T extends string>(reply: string, pattern: RegExp): T | null {
289 const named = new Set([...reply.matchAll(pattern)].map(match => match[1]?.toLowerCase() as T))
290 return named.size === 1 ? ([...named][0] ?? null) : null
291}
292
293export function parseEffort(reply: string): Effort | null {
294 return oneWord(reply, EFFORT_WORD)
295}
296
297export function parseModel(reply: string): ModelTier | null {
298 return oneWord(reply, MODEL_WORD)
299}
300
301// The model to send instead of the session's: the pick, when it is a smaller
302// model of the same family. Null keeps the session's model: a pick at or above
303// it, or a session model out of the family (also one with a larger context,
304// such as `[1m]`, that the smaller model may not have).
305export function routeModel(picked: ModelTier, session: string): string | null {
306 const ceiling = MODELS.findIndex(tier => MODEL_ID[tier] === session)
307 return MODELS.indexOf(picked) < ceiling ? MODEL_ID[picked] : null
308}
309
310// The lower of two levels: the plugin may lower the session's effort, never
311// raise it. Null when the session's effort is not a level (a number).
312export function capEffort(picked: Effort, session: Effort | number): Effort | null {
313 if (!isEffort(session)) return null
314 return EFFORTS[Math.min(EFFORTS.indexOf(picked), EFFORTS.indexOf(session))] ?? null
315}
316
317// The models that take the effort in a message (per-turn control), so a change
318// keeps the prompt cache. On other models a change rebuilds the whole cache.
319export function takesTurnEffort(model: string): boolean {
320 return /^claude-(sonnet-5-5|opus-5-5|opus-5|fable-5-1|mythos-5-1|haiku-5-5)(?![-.\d])/.test(model)
321}
322
323export function rebuild(
324 listing: Listing,
325 kept: readonly string[],
326 kind: Kind,
327): { text: string; hidden: string[] } {
328 const keep = new Set([...kept, ...ALWAYS_SHOWN[kind]])
329 const shown = listing.entries.filter(entry => keep.has(entry.group))
330 const dropped = listing.entries.filter(entry => !keep.has(entry.group))
331 const lines = [...listing.head, ...shown.flatMap(entry => entry.lines)]
332 if (dropped.length > 0) lines.push(NOTE[kind](dropped))
333 lines.push(...listing.tail)
334 return { text: lines.join('\n'), hidden: dropped.map(entry => entry.name) }
335}
336
337const NOTE: Record<Kind, (dropped: Entry[]) => string> = {
338 skills: dropped =>
339 `(loadout hid ${dropped.length} more skills that look unrelated to this task. They still work: find one with SearchSkills or ListSkills, or call Skill with its name.)`,
340 tools: dropped => {
341 const counts = new Map<string, number>()
342 for (const entry of dropped) counts.set(entry.group, (counts.get(entry.group) ?? 0) + 1)
343 const groups = [...counts].map(([group, n]) => `${group} (${n})`).join(', ')
344 return `(loadout hid ${dropped.length} more deferred tools that look unrelated to this task, from: ${groups}. They still work: ToolSearch finds them by keyword.)`
345 },
346}
347
348// Drops the "## <server>" blocks of the MCP instructions for the hidden
349// servers. A server it does not know stays.
350export function trimMcpInstructions(text: string, hiddenGroups: ReadonlySet<string>): string {
351 // The system prompt section has this header. An attachment has the blocks
352 // alone.
353 const start = Math.max(0, text.indexOf('# MCP Server Instructions'))
354 if (hiddenGroups.size === 0) return text
355 const before = text.slice(0, start)
356 const blocks = text.slice(start).split(/\n(?=## )/)
357 const kept = blocks.filter(block => {
358 const match = /^## (.+)$/m.exec(block)
359 return !(block.startsWith('## ') && match?.[1] && hiddenGroups.has(normalize(match[1])))
360 })
361 return before + kept.join('\n')
362}
363
364// The "## <server>" headers of `before` that `after` lacks.
365export function removedServers(before: string, after: string): string[] {
366 const headers = (text: string) => [...text.matchAll(/^## (.+)$/gm)].map(match => match[1] ?? '')
367 const kept = new Set(headers(after))
368 return headers(before).filter(header => !kept.has(header))
369}
370
371// The options when the settings leave one out or give it with another type.
372// The same as the defaults of .claude-plugin/plugin.json: a test checks it.
373export const OPTION_DEFAULTS: Readonly<Options> = {
374 alwaysKeep: '',
375 newTaskNote: true,
376 effortPerPrompt: false,
377 modelForSession: false,
378 traceFile: '',
379}
380
381// The options of the plugin, each with the type of its default.
382export function readOptions(raw: Readonly<Record<string, unknown>>): Options {
383 const options: Record<string, unknown> = { ...OPTION_DEFAULTS }
384 for (const [key, fallback] of Object.entries(OPTION_DEFAULTS)) {
385 if (typeof raw[key] === typeof fallback) options[key] = raw[key]
386 }
387 return options as Options
388}
389
390// How much of a prompt of the person the plugin keeps and sends to the classifier.
391export const PROMPT_CHARS = 4000
392types/index.d.ts 114 lines1// The two listings that the classifier cuts: the deferred tools, by tool
2// group, and the skills.
3export type Kind = 'tools' | 'skills'
4
5// The state of one tool group or skill in this session's loadout: kept by the
6// classifier, kept because the alwaysKeep option covers it, or hidden.
7export type Status = 'kept' | 'always' | 'hidden'
8
9// What the classifier decided for one listing: each group seen so far, with its
10// status, in the order seen. A group has one status, so it cannot be kept and
11// hidden at once.
12export type Decision = Record<string, Status>
13
14// One hidden tool group or skill, with the catalog row a later prompt is
15// matched against.
16export type Offer = { kind: Kind; group: string; row: string }
17
18export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
19
20export type ModelTier = 'haiku' | 'sonnet' | 'opus' | 'fable'
21
22// The model change the person approved at the first prompt: the main requests
23// on the session's model `from` go to `to`.
24export type Route = { from: string; to: string }
25
26// The effort and model changes, on or off.
27export type Switches = { effort: boolean; model: boolean }
28
29// The effort the plugin sets on the main requests. `auto`: the latest pick
30// sets it, null before the first. `fixed`: a change lost the cache, so the
31// effort then stays for the rest of the session.
32export type EffortState = { kind: 'auto'; level: Effort | null } | { kind: 'fixed'; level: Effort }
33
34// One MCP server as the picker shows it. `group` is the normalized server
35// name; `label` the name a person knows; `tools` the short tool names.
36export type ServerFact = { group: string; label: string; tools: string[]; tokens: number }
37
38// One skill as the picker shows it, with its description.
39export type SkillFact = { name: string; tokens: number; about: string }
40
41// The options of .claude-plugin/plugin.json (`userConfig`), as the hooks read
42// them. tests/contract.check.mjs fails when the two differ.
43export type Options = {
44 alwaysKeep: string
45 newTaskNote: boolean
46 effortPerPrompt: boolean
47 modelForSession: boolean
48 traceFile: string
49}
50
51// The token use of a classifier call, as the engine's ModelUsage gives it. The
52// contract cannot import the engine type: it must be self-contained.
53export type Usage = {
54 input_tokens: number
55 output_tokens: number
56 cache_read_input_tokens: number
57 cache_creation_input_tokens: number
58}
59
60// The purpose of a classifier call.
61export type Purpose = Kind | 'recall' | 'effort' | 'model'
62
63// One line of the trace file (the traceFile option): one per decision. A
64// trace line holds names and counts, never prompt text. tests/smoke.e2e.mjs
65// and evals/impact.mjs read these fields.
66export type TraceLine =
67 | { event: 'classifier'; purpose: Purpose; model: string; isAnswered: boolean; usage: Usage | null }
68 | { event: 'listing'; type: string; isScoped: false }
69 | { event: 'listing'; type: string; isScoped: true; kept: string[]; alwaysKept: string[]; hidden: string[] }
70 | { event: 'mcp_instructions'; where: string; removed: string[] }
71 | { event: 'model_pick'; picked: ModelTier | null; session: string; to: string | null }
72 | { event: 'model_route'; from: string; to: string; isUsed: boolean }
73 | { event: 'effort_pick'; picked: Effort | null }
74 | { event: 'effort_change'; from: Effort | number; to: Effort | number; cacheRead: number }
75 | { event: 'effort_frozen'; level: Effort; model: string }
76 | { event: 'recall'; named: string[]; isNewTask: boolean }
77
78declare module 'claude-code' {
79 interface PluginState {
80 'loadout': {
81 prompt: string | null
82 // Null until the first decision of the session for that listing.
83 toolDecision: Decision | null
84 skillDecision: Decision | null
85 isOff: boolean
86 offers: Offer[]
87 surfaced: string[]
88 // The last prompts of the person, newest last, for the new-task check
89 // and the effort pick.
90 recent: string[]
91 effortState: EffortState
92 route: Route | null
93 // `/loadout effort|model on|off` for this session; a missing key leaves
94 // it to the option.
95 switches: Partial<Switches>
96 // The alwaysKeep names as the first prompt found them. A server that
97 // connects later in the session gets the same names.
98 pinned: string[]
99 // A copy of the marks that /loadout keep and the pane saved in $.store;
100 // null until the session reads or writes them.
101 marks: string[] | null
102 // The skill names of a listing that the plugin did not scope, for
103 // /loadout list and the pane. A scoped listing records them in
104 // `skillDecision`.
105 skillNames: string[]
106 // The text of the pane's filter field.
107 filter: string
108 // What the engine knows about the servers and skills, read when the pane
109 // opens, so typing in the filter reads nothing again.
110 facts: { servers: ServerFact[]; skills: SkillFact[] } | null
111 }
112 }
113}
114