SLOPSHOPPER

loadout

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

newpanespinnercommandtoaststatus
v0.6.4MITupdated 2026-10-26gabe4coding/loadout
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loadout
│ ┃ loadout-keep ✕ › fix the failing auth test and add an audit log call │ ┃ loadout never hides a marked item. A change │ ┃ applies from the next session. ⏺ Read(src/auth.ts) │ ┃ Always keep: none ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Filter: server or skill name ⎿ Added 2 lines, removed 1 line │ ┃ Tab moves · Enter marks · Esc closes ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Tool servers 0 │ ┃ None seen yet. The list fills after the ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ first prompt. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Skills 0 │ ┃ None seen yet. The list fills after the › /loadout │ ┃ first prompt. ⎿ loadout: loadout has not picked a loadout for this session yet. │ ⎿ loadout: Effort for each prompt: off, Model for the session: off │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · loadout-keep
loadout never hides a marked item. A change applies from the next session. Always keep: none Filter: server or skill name Tab moves · Enter marks · Esc closes Tool servers 0 None seen yet. The list fills after the first prompt. Skills 0 None seen yet. The list fills after the first prompt.
README

<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.

  • Smaller requests: the plugin removes the tool groups, skills and server instructions that the task does not need.
  • Nothing breaks: a hidden item still works. Claude finds it with ToolSearch or SearchSkills.
  • Valid prompt cache: the listings change one time, before the first request. A later prompt gets a short note instead.
  • Always keep: put the skills and servers that help with each task on a list. The plugin never hides them.
  • Safe failure: if the classifier fails, the plugin keeps all items.

Quick start

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.

Example

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.

Documentation

Page
InstallationRequirements, install, local folder, update
UsageThe first prompt, the always-keep list, /loadout, later prompts, cost
How it worksOne session from start to end, the prompt cache, the data sent
TroubleshootingCommon problems and known limits
DevelopmentLayout, checks, releases

Related Claude Code plugins

The same author maintains these plugins. Each plugin installs and works alone.

  • claude-code-mode: runs MCP tools as one sandboxed JavaScript program, not as many tool calls.
  • plain: end-to-end tests in plain English. Playwright does the actions and the Jev model decides.
  • puredori: shapes AI work into small PRs that a person can review.
  • shall: engineering standards (RFC 2119) that agents apply, with checks that post to PRs.
  • retroagent: syncs your Claude Code and Codex sessions to a private repo and searches them.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 912 lines
1import { 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}
912
hooks/scope.ts 392 lines
1// 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
392
types/index.d.ts 114 lines
1// 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