Turn subagent models and agent types on or off and set the effort subagents run at, as defaults in /config or live for one session with /subagent-models.

A Claude Code mod that decides which models and agent types subagents may run as, and the effort they run at.
offAction set to deny, the spawn is refused with a message that names the model to use.claude-fable-5-1 is fable.tdd also matches pstack:tdd.applyToRunning on, an effort change reaches running subagents on their next request. With it off, each subagent keeps the effort it started with until apply, or the bar's apply now button, moves them all.opus/medium. A * marks a session that differs from the defaults. Clicking it opens or closes the bar./config holds the defaults every new session starts with:
| Setting | Options | Default |
|---|---|---|
| Opus subagents | on, off | on |
| Fable subagents | on, off | off |
| Sonnet subagents | on, off | off |
| Haiku subagents | on, off | on |
| Default subagent model | opus, sonnet, haiku, fable | opus |
| Subagent effort | inherit, low, medium, high, xhigh, max | medium |
| When a spawn names a turned-off model | move, deny | move |
| Effort changes reach running subagents | on, off | on |
| Turned-off agent types | comma-separated agent types, such as Explore, Plan | none |
| Turned-off skills | comma-separated skills, such as pstack:tdd, simplify | none |
| Days before a skill counts as unused | a whole number of days, the starting cutoff of the unused after menu | 14 |
The command refuses to turn off the default model, and refuses a default model that is turned off.
/subagent-models changes the current session from its next spawn, and leaves other sessions alone.
/subagent-models prints the values in force and marks the ones this session changed./subagent-models fable on sets one value for this session. Any setting name works, with one of its options./subagent-models agents lists the agent types offered so far in this session, each with its switch./subagent-models agent Explore off turns one agent type off for this session. on turns it back on./subagent-models skills lists the skills the session has, each with its switch. /subagent-models skill pstack:tdd off turns one skill off for this session.opus · medium · 2 of 4 models on, and a minimize button. Under it a strip of four colored segments shows which models are on, and a row of four model entries below it has a dot, the name and an on or off button each. The next row has dropdowns for the default model, effort, the turned-off action, and running subagents. The last row has the agents and skills buttons, apply to running, and a more menu that saves or resets. A pill in the footer, ◈ opus/medium ▴, opens and closes the bar with one click.agents and skills buttons open a panel with enable all, disable all, and one row per agent type or skill: a dot, the name, and an on or off button at the right edge. Every row sits in a cell as wide as the longest name, so the rows line up in columns. A name longer than 44 characters keeps its tail and loses its middle. The rows sit under a small-caps heading per namespace, such as PSTACK or BRAND-VOICE, with a count. Press a heading to fold or open its group. A group of more than 16 starts folded, and opens 40 rows at a time with a show more button. A list of more than 16 opens in a side pane beside the transcript, laid out in as many equal columns as the pane is wide. The button shows ▸ for a side pane and ▴ for an inline panel. A panel stays open across picks until you press its button again or close the pane. A pick applies at once and a toast says what changed.skills listing and panel can show when a skill was last used. The skills panel has sort: recent, which groups the chips as used in the last N days, unused N+ days, and never seen, newest first, with the age on each chip. The unused after menu picks the cutoff from 14, 28 or 56 days, starting at the staleDays setting, and turn off unused Nd+ (count) turns off the skills with a recorded date older than that cutoff. A skill with no recorded date is never turned off by that button./subagent-models bar minimizes the bar or expands it./subagent-models seed <file> loads last-used dates from a backfill file into the mod's own store. The store lives in the Claude config folder of the account that runs the command, so run it once in each account./subagent-models apply moves every running subagent to the current effort now./subagent-models save writes this session's values to the /config defaults./subagent-models reset drops this session's values, so the defaults apply again.The mod only records runs from the day it loads. scripts/backfill-last-used.mjs reads the saved transcripts of every Claude account on the machine and writes the newest date of each skill run, for skills the model ran and slash commands you typed:
node packages/subagent-models/scripts/backfill-last-used.mjs --out dates.json
It scans every ~/.claude* folder and every ~/.claude-profiles/* folder that holds a projects folder, skips a folder that is a link to one it already scanned, and prints the transcript and skill-run count for each. Add --root <config dir> to scan only the folders you name, or --exclude <text> to skip a folder whose path contains the text. Then run /subagent-models seed dates.json in each account. Run the script's tests with node --test packages/subagent-models/scripts/backfill-last-used.test.mjs.
A full npx claude-dev-env install adds the claude-dev-env marketplace and installs the mod at user scope, so every Claude Code session under that config folder loads it. --no-subagent-models or CDE_INSTALL_SUBAGENT_MODELS=0 skips that step.
The installer removes and reinstalls the plugin on each run. Claude Code drops a plugin's saved /config values when it uninstalls the plugin, so after an installer run the defaults in the Settings table apply again.
To install it by hand:
claude plugin marketplace add jl-cmd/claude-dev-env
claude plugin install subagent-models@claude-dev-env
Or load it from a clone for one session:
claude --plugin-dir packages/subagent-models
claude plugin validate .
claude plugin test .
Built and tested on Claude Code 2.1.288. The mod API is in early access and can change between releases.
hooks/register.tsx 832 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, StateDollar } from 'claude-code'
3
4import type { AgentSwitches, Effort, Family, Kind, LastUsed, PinnedEfforts, SessionOverrides, Settings, Switch } from '../types'
5
6const PLUGIN = 'subagent-models'
7
8const ALL_CHOICES: { readonly [Field in keyof Settings]: readonly Settings[Field][] } = {
9 opus: ['on', 'off'],
10 fable: ['on', 'off'],
11 sonnet: ['on', 'off'],
12 haiku: ['on', 'off'],
13 defaultModel: ['opus', 'sonnet', 'haiku', 'fable'],
14 effort: ['inherit', 'low', 'medium', 'high', 'xhigh', 'max'],
15 offAction: ['move', 'deny'],
16 applyToRunning: ['on', 'off'],
17}
18
19const ALL_FIELDS = Object.keys(ALL_CHOICES) as (keyof Settings)[]
20
21const ALL_FAMILIES: readonly Family[] = ['haiku', 'sonnet', 'opus', 'fable']
22
23const ALL_SWITCHES: readonly Switch[] = ['on', 'off']
24
25const ALL_KINDS: readonly Kind[] = ['agents', 'skills']
26
27const KIND_DETAILS: { readonly [K in Kind]: { word: string; noun: string; title: string; core: string; empty: string; configKey: string } } = {
28 agents: {
29 word: 'agent',
30 noun: 'agent type',
31 title: 'Agent types',
32 core: 'core',
33 empty: 'No agent types offered yet. They appear after the first spawn.',
34 configKey: 'disabledAgents',
35 },
36 skills: {
37 word: 'skill',
38 noun: 'skill',
39 title: 'Skills',
40 core: 'local',
41 empty: 'No skills listed yet. Press skills again once the session has started.',
42 configKey: 'disabledSkills',
43 },
44}
45
46const overrides = atom({ plugin: 'subagent-models', key: 'overrides' } as const, {} as SessionOverrides)
47
48const offeredAgents = atom({ plugin: 'subagent-models', key: 'offeredAgents' } as const, [] as readonly string[])
49
50const knownSkills = atom({ plugin: 'subagent-models', key: 'knownSkills' } as const, [] as readonly string[])
51
52const pinnedEfforts = atom({ plugin: 'subagent-models', key: 'pinnedEfforts' } as const, {} as PinnedEfforts)
53
54const isBarOpen = atom({ plugin: 'subagent-models', key: 'isBarOpen' } as const, true)
55
56const isAgentsOpen = atom({ plugin: 'subagent-models', key: 'isAgentsOpen' } as const, false)
57
58const isSkillsOpen = atom({ plugin: 'subagent-models', key: 'isSkillsOpen' } as const, false)
59
60const lastUsedDates = atom({ plugin: 'subagent-models', key: 'lastUsed' } as const, {} as LastUsed)
61
62const isSortedByRecent = atom({ plugin: 'subagent-models', key: 'isSortedByRecent' } as const, false)
63
64const staleDaysPick = atom({ plugin: 'subagent-models', key: 'staleDaysPick' } as const, 0)
65
66const groupViews = atom({ plugin: 'subagent-models', key: 'groupViews' } as const, {} as Record<string, number>)
67
68const STALE_CHOICES: readonly number[] = [14, 28, 56]
69
70const LAST_USED_KEY = 'lastUsed'
71
72const DAY_MS = 86_400_000
73
74const DEFAULT_STALE_DAYS = 14
75
76const BACKFILL_COMMAND = 'node packages/subagent-models/scripts/backfill-last-used.mjs --out <file>'
77
78const MORE_SUMMARY = '__more'
79
80const ON_MARK = '●'
81
82const OFF_MARK = '○'
83
84const ICON_MARK = '◈'
85
86const ACCENT = '#7aa2f7'
87
88const STRIP_OFF_COLOR = '#3b4261'
89
90const FAMILY_COLORS: Record<Family, string> = { opus: '#bb9af7', sonnet: '#7aa2f7', haiku: '#9ece6a', fable: '#e0af68' }
91
92const MINIMIZE_MARK = '▾'
93
94const EXPAND_MARK = '▴'
95
96const SIDE_OPEN_MARK = '◂'
97
98const SIDE_CLOSED_MARK = '▸'
99
100const MIN_CELL_CHARS = 24
101
102const MAX_CELL_CHARS = 44
103
104const DOT_CHARS = 2
105
106const TOGGLE_CHARS = 8
107
108const COLUMN_GAP = 4
109
110const BAR_COLUMNS = 4
111
112const BAR_CELL_CHARS = 18
113
114const BAR_COLUMN_GAP = 1
115
116const INLINE_LIMIT = 16
117
118const GROUP_PAGE = 40
119
120const COLUMN_SAFETY = 0.85
121
122const PANE_IDS: { readonly [K in Kind]: string } = { agents: 'subagent-agents', skills: 'subagent-skills' }
123
124type Ui = ReturnType<EngineInterface['ui']['resolve']>
125
126type Defaults = { settings: Settings; switches: Record<Kind, AgentSwitches> }
127
128type State = {
129 sessionOverrides: SessionOverrides
130 settings: Settings
131 merged: Record<Kind, AgentSwitches>
132 switchOf: (kind: Kind, name: string) => Switch
133}
134
135type Group = { title: string; items: { name: string; label: string }[] }
136
137type SkillsView = { isRecent: boolean; staleNames: string[]; staleDays: number; staleChoices: number[] }
138
139type PanelData = { state: State; kind: Kind; names: string[]; groups: Group[]; views: Record<string, number>; skillsView?: SkillsView }
140
141function familyOf(model: string | undefined): Family | undefined {
142 if (model === undefined) return undefined
143 const normalized = model.trim().toLowerCase()
144 return ALL_FAMILIES.find(family => normalized === family || normalized.startsWith(`claude-${family}`))
145}
146
147function isField(name: string): name is keyof Settings {
148 return (ALL_FIELDS as string[]).includes(name)
149}
150
151function isFamily(name: string): name is Family {
152 return (ALL_FAMILIES as string[]).includes(name)
153}
154
155function isSwitch(value: string): value is Switch {
156 return (ALL_SWITCHES as string[]).includes(value)
157}
158
159function isAllowed(settings: Settings, family: Family | undefined): boolean {
160 return family === undefined || family === settings.defaultModel || settings[family] === 'on'
161}
162
163function flipped(value: Switch): Switch {
164 return value === 'on' ? 'off' : 'on'
165}
166
167function switchesOf(text: string): AgentSwitches {
168 return Object.fromEntries(text.split(',').map(name => name.trim()).filter(Boolean).map(name => [name, 'off' as Switch]))
169}
170
171function turnedOffOf(merged: AgentSwitches): string[] {
172 return Object.keys(merged).filter(name => merged[name] === 'off').sort()
173}
174
175function knownNamesOf(merged: AgentSwitches, allSeen: readonly string[]): string[] {
176 return [...new Set([...allSeen, ...Object.keys(merged)])].sort()
177}
178
179function isSkillOff(merged: AgentSwitches, skill: string): boolean {
180 if (merged[skill] !== undefined) return merged[skill] === 'off'
181 return Object.entries(merged).some(([name, value]) => value === 'off' && (name.endsWith(`:${skill}`) || skill.endsWith(`:${name}`)))
182}
183
184function groupsOf(names: readonly string[], coreTitle: string): Group[] {
185 const itemsByTitle = new Map<string, Group['items']>()
186 for (const name of names) {
187 const cut = name.indexOf(':')
188 const title = cut === -1 ? coreTitle : name.slice(0, cut)
189 const label = cut === -1 ? name : name.slice(cut + 1)
190 itemsByTitle.set(title, [...(itemsByTitle.get(title) ?? []), { name, label }])
191 }
192 return [...itemsByTitle.entries()]
193 .sort(([first], [second]) => (first === coreTitle ? -1 : second === coreTitle ? 1 : first.localeCompare(second)))
194 .map(([title, items]) => ({ title, items }))
195}
196
197function isDates(value: unknown): value is LastUsed {
198 return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.values(value).every(when => typeof when === 'number' && Number.isFinite(when))
199}
200
201function mergedDates(first: LastUsed, second: LastUsed): LastUsed {
202 const merged: Record<string, number> = { ...first }
203 for (const [name, when] of Object.entries(second)) {
204 if (when > (merged[name] ?? 0)) merged[name] = when
205 }
206 return merged
207}
208
209function lastUsedOf(lastUsed: LastUsed, skill: string): number | undefined {
210 const dates = Object.entries(lastUsed)
211 .filter(([name]) => name === skill || name.endsWith(`:${skill}`) || skill.endsWith(`:${name}`))
212 .map(([, when]) => when)
213 return dates.length === 0 ? undefined : Math.max(...dates)
214}
215
216function ageDaysOf(when: number, now: number): number {
217 return Math.max(0, Math.floor((now - when) / DAY_MS))
218}
219
220function ageTextOf(when: number | undefined, now: number): string {
221 if (when === undefined) return 'never seen'
222 const days = ageDaysOf(when, now)
223 return days === 0 ? 'today' : `${days}d ago`
224}
225
226function staleNamesOf(names: readonly string[], lastUsed: LastUsed, now: number, staleDays: number): string[] {
227 return names.filter(name => {
228 const when = lastUsedOf(lastUsed, name)
229 return when !== undefined && ageDaysOf(when, now) > staleDays
230 })
231}
232
233function recentGroupsOf(names: readonly string[], lastUsed: LastUsed, now: number, staleDays: number): Group[] {
234 const dated = names.map(name => ({ name, when: lastUsedOf(lastUsed, name) }))
235 const newestFirst = (first: { when?: number; name: string }, second: { when?: number; name: string }) =>
236 (second.when ?? 0) - (first.when ?? 0) || first.name.localeCompare(second.name)
237 const itemOf = ({ name, when }: { name: string; when?: number }) => ({
238 name,
239 label: when === undefined ? name : `${name} ${ageDaysOf(when, now)}d`,
240 })
241 const isFresh = ({ when }: { when?: number }) => when !== undefined && ageDaysOf(when, now) <= staleDays
242 const isStale = ({ when }: { when?: number }) => when !== undefined && ageDaysOf(when, now) > staleDays
243 return [
244 { title: `used in the last ${staleDays} days`, items: dated.filter(isFresh).sort(newestFirst).map(itemOf) },
245 { title: `unused ${staleDays}+ days`, items: dated.filter(isStale).sort(newestFirst).map(itemOf) },
246 { title: 'never seen', items: dated.filter(({ when }) => when === undefined).sort(newestFirst).map(itemOf) },
247 ].filter(group => group.items.length > 0)
248}
249
250function chipLabelOf(isOn: boolean, label: string): string {
251 return `${isOn ? ON_MARK : OFF_MARK} ${label}`
252}
253
254function cellCharsOf(labels: readonly string[]): number {
255 return Math.min(MAX_CELL_CHARS, Math.max(MIN_CELL_CHARS, ...labels.map(label => label.length + DOT_CHARS + TOGGLE_CHARS)))
256}
257
258function fitLabelOf(label: string, cellChars: number): string {
259 const room = cellChars - DOT_CHARS - TOGGLE_CHARS
260 if (label.length <= room) return label
261 const tail = Math.ceil((room - 1) * 0.6)
262 return `${label.slice(0, room - 1 - tail)}…${label.slice(label.length - tail)}`
263}
264
265function cellOf(ui: Ui, key: string, cellChars: number, child: unknown) {
266 const { Box } = ui
267 return (
268 <Box key={key} width={cellChars}>
269 {child}
270 </Box>
271 )
272}
273
274function rowsOf<T>(items: readonly T[], size: number): T[][] {
275 return Array.from({ length: Math.ceil(items.length / size) }, (_, index) => items.slice(index * size, (index + 1) * size))
276}
277
278function columnsFor(bodyColumns: number, cellChars: number): number {
279 return Math.max(1, Math.floor((bodyColumns + COLUMN_GAP) / (cellChars + COLUMN_GAP)))
280}
281
282function denyTextOf(model: string, settings: Settings): string {
283 const effortAsk = settings.effort === 'inherit' ? '' : ` and ask for ${settings.effort} effort in the brief`
284 return `Subagent model ${model} is turned off. Spawn subagents on ${settings.defaultModel}${effortAsk}.`
285}
286
287function skillOffTextOf(skill: string): string {
288 return `The ${skill} skill is turned off for this session. Do not follow it. Tell the user it is off and continue without it.`
289}
290
291function listingOf(settings: Settings, sessionOverrides: SessionOverrides, merged: Record<Kind, AgentSwitches>): string {
292 const fieldLines = ALL_FIELDS.map(field => `${field} ${settings[field]}${field in sessionOverrides ? ' (this session)' : ''}`)
293 const kindLines = [...ALL_KINDS].reverse().map(kind => {
294 const turnedOff = turnedOffOf(merged[kind])
295 const sessionMark = sessionOverrides[kind] === undefined ? '' : ' (this session)'
296 return `${kind} off: ${turnedOff.length > 0 ? turnedOff.join(', ') : 'none'}${sessionMark}`
297 })
298 return [...fieldLines, ...kindLines].join('\n')
299}
300
301function kindListingOf(kind: Kind, state: State, allSeen: readonly string[], lastUsed?: LastUsed): string {
302 const names = knownNamesOf(state.merged[kind], allSeen)
303 if (names.length === 0) return KIND_DETAILS[kind].empty
304 const now = Date.now()
305 return names
306 .map(name => {
307 const sessionMark = state.sessionOverrides[kind]?.[name] === undefined ? '' : ' (this session)'
308 const usedMark = lastUsed === undefined ? '' : ` · ${ageTextOf(lastUsedOf(lastUsed, name), now)}`
309 return `${name} ${state.switchOf(kind, name)}${sessionMark}${usedMark}`
310 })
311 .join('\n')
312}
313
314function refusalOf(field: keyof Settings, value: string, settings: Settings): string | undefined {
315 if (isFamily(field) && value === 'off' && settings.defaultModel === field) {
316 return `${field} is the default model. Pick another defaultModel before turning ${field} off.`
317 }
318 if (field === 'defaultModel' && isFamily(value) && settings[value] === 'off') {
319 return `${value} is turned off. Turn ${value} on before making it the default model.`
320 }
321 return undefined
322}
323
324async function sessionState($: StateDollar, defaults: Defaults): Promise<State> {
325 const sessionOverrides = await read($, overrides)
326 const { agents: sessionAgents = {}, skills: sessionSkills = {}, ...sessionSettings } = sessionOverrides
327 const settings = { ...defaults.settings, ...sessionSettings } as Settings
328 const merged = {
329 agents: { ...defaults.switches.agents, ...sessionAgents },
330 skills: { ...defaults.switches.skills, ...sessionSkills },
331 }
332 const switchOf = (kind: Kind, name: string): Switch => merged[kind][name] ?? 'on'
333 return { sessionOverrides, settings, merged, switchOf }
334}
335
336async function setField($: EngineInterface, defaults: Defaults, field: keyof Settings, value: string): Promise<string> {
337 const allChoices = ALL_CHOICES[field] as readonly string[]
338 if (!allChoices.includes(value)) return `${field} takes ${allChoices.join(', ')}.`
339 const { settings } = await sessionState($, defaults)
340 const refusal = refusalOf(field, value, settings)
341 if (refusal !== undefined) return refusal
342 await update($, overrides, current => ({ ...current, [field]: value }))
343 return `${field} ${value} for this session.`
344}
345
346async function setSwitch($: EngineInterface, kind: Kind, name: string, nameSwitch: Switch): Promise<string> {
347 await update($, overrides, current => ({ ...current, [kind]: { ...current[kind], [name]: nameSwitch } }))
348 return `${KIND_DETAILS[kind].word} ${name} ${nameSwitch} for this session.`
349}
350
351async function setAllSwitches($: EngineInterface, kind: Kind, names: readonly string[], nameSwitch: Switch): Promise<string> {
352 await update($, overrides, current => ({
353 ...current,
354 [kind]: { ...current[kind], ...Object.fromEntries(names.map(name => [name, nameSwitch])) },
355 }))
356 return `${names.length} ${KIND_DETAILS[kind].noun}${names.length === 1 ? '' : 's'} ${nameSwitch} for this session.`
357}
358
359async function refreshSkills($: EngineInterface): Promise<void> {
360 const usage = await $.session.usage({ breakdown: 'summary' })
361 const listed = usage.context.breakdown?.skills?.skillFrontmatter ?? []
362 const names = listed.map(skill => (skill.pluginName !== undefined && !skill.name.includes(':') ? `${skill.pluginName}:${skill.name}` : skill.name))
363 await update($, knownSkills, current => [...new Set([...current, ...names])].sort())
364}
365
366async function loadLastUsed($: EngineInterface): Promise<void> {
367 const stored = await $.store.get(LAST_USED_KEY)
368 if (isDates(stored)) await update($, lastUsedDates, current => mergedDates(current, stored))
369}
370
371async function recordUse($: EngineInterface, skill: string): Promise<void> {
372 const used = { [skill]: Date.now() }
373 const stored = await $.store.get(LAST_USED_KEY)
374 await $.store.set(LAST_USED_KEY, mergedDates(isDates(stored) ? stored : {}, used))
375 await update($, lastUsedDates, current => mergedDates(current, used))
376}
377
378async function seedFrom($: EngineInterface, path: string): Promise<string> {
379 let incoming: unknown
380 try {
381 incoming = JSON.parse(String(await $.fs.read(path))).lastUsed
382 } catch {
383 return `Could not read ${path} as JSON. Make it with: ${BACKFILL_COMMAND}`
384 }
385 if (!isDates(incoming) || Object.keys(incoming).length === 0) return `${path} holds no lastUsed dates. Make it with: ${BACKFILL_COMMAND}`
386 const stored = await $.store.get(LAST_USED_KEY)
387 const before = isDates(stored) ? stored : {}
388 const newer = Object.keys(incoming).filter(name => incoming[name] > (before[name] ?? 0)).length
389 const merged = mergedDates(before, incoming)
390 await $.store.set(LAST_USED_KEY, merged)
391 await update($, lastUsedDates, current => mergedDates(current, merged))
392 return `Seeded ${Object.keys(incoming).length} names from ${path}. ${newer} newer than what was stored.`
393}
394
395async function panelDataOf($: StateDollar, defaults: Defaults, kind: Kind, defaultStaleDays: number): Promise<PanelData> {
396 const state = await sessionState($, defaults)
397 const names = await panelNamesOf($, defaults, kind)
398 const { core } = KIND_DETAILS[kind]
399 const views = await read($, groupViews)
400 if (kind !== 'skills') return { state, kind, names, groups: groupsOf(names, core), views, skillsView: undefined }
401 const lastUsed = await read($, lastUsedDates)
402 const isRecent = await read($, isSortedByRecent)
403 const pick = await read($, staleDaysPick)
404 const staleDays = pick > 0 ? pick : defaultStaleDays
405 const staleChoices = [...new Set([...STALE_CHOICES, defaultStaleDays])].sort((first, second) => first - second)
406 const now = Date.now()
407 const staleNames = staleNamesOf(names, lastUsed, now, staleDays).filter(name => state.switchOf(kind, name) === 'on')
408 const groups = isRecent ? recentGroupsOf(names, lastUsed, now, staleDays) : groupsOf(names, core)
409 return { state, kind, names, groups, views, skillsView: { isRecent, staleNames, staleDays, staleChoices } }
410}
411
412async function panelNamesOf($: StateDollar, defaults: Defaults, kind: Kind): Promise<string[]> {
413 const { merged } = await sessionState($, defaults)
414 const allSeen = kind === 'agents' ? await read($, offeredAgents) : await read($, knownSkills)
415 return knownNamesOf(merged[kind], allSeen)
416}
417
418async function isPanelOpenOf($: StateDollar, kind: Kind): Promise<boolean> {
419 return kind === 'agents' ? read($, isAgentsOpen) : read($, isSkillsOpen)
420}
421
422async function setPanelOpen($: StateDollar, kind: Kind, isOpen: boolean): Promise<void> {
423 if (kind === 'agents') await update($, isAgentsOpen, () => isOpen)
424 else await update($, isSkillsOpen, () => isOpen)
425}
426
427async function togglePanel($: EngineInterface, defaults: Defaults, kind: Kind): Promise<void> {
428 const wasOpen = await isPanelOpenOf($, kind)
429 if (!wasOpen && kind === 'skills') {
430 await refreshSkills($)
431 await loadLastUsed($)
432 }
433 const isSide = (await panelNamesOf($, defaults, kind)).length > INLINE_LIMIT
434 await setPanelOpen($, kind, !wasOpen)
435 if (!isSide) return
436 if (wasOpen) await $.ui.close({ id: PANE_IDS[kind] })
437 else await $.ui.open({ id: PANE_IDS[kind], title: KIND_DETAILS[kind].title })
438}
439
440async function resetSession($: EngineInterface): Promise<string> {
441 await update($, overrides, () => ({}))
442 return 'Session values cleared. The /config defaults apply again.'
443}
444
445async function saveDefaults($: EngineInterface, defaults: Defaults): Promise<string> {
446 const { settings, sessionOverrides, merged } = await sessionState($, defaults)
447 const { agents: _sessionAgents, skills: _sessionSkills, ...sessionSettings } = sessionOverrides
448 for (const field of Object.keys(sessionSettings) as (keyof Settings)[]) {
449 await $.config.set({ key: `${PLUGIN}.${field}`, value: settings[field] })
450 }
451 for (const kind of ALL_KINDS) {
452 if (sessionOverrides[kind] !== undefined) {
453 await $.config.set({ key: `${PLUGIN}.${KIND_DETAILS[kind].configKey}`, value: turnedOffOf(merged[kind]).join(', ') })
454 }
455 }
456 return `Saved as defaults:\n${listingOf(settings, {}, merged)}`
457}
458
459async function runningAgentIdsOf($: EngineInterface): Promise<string[]> {
460 return (await $.agent.list()).filter(agent => agent.status === 'running').map(agent => agent.id)
461}
462
463async function applyNow($: EngineInterface, defaults: Defaults): Promise<string> {
464 const { settings } = await sessionState($, defaults)
465 const allRunning = await runningAgentIdsOf($)
466 await update($, pinnedEfforts, current => ({ ...current, ...Object.fromEntries(allRunning.map(agentId => [agentId, settings.effort])) }))
467 return `effort ${settings.effort} applied to ${allRunning.length} running subagent${allRunning.length === 1 ? '' : 's'}.`
468}
469
470async function effortFor($: StateDollar, settings: Settings, agentId: string, requestEffort: Effort | undefined) {
471 if (requestEffort === undefined) return undefined
472 const pinned = (await read($, pinnedEfforts))[agentId]
473 const isFollowingSettings = settings.applyToRunning === 'on' || pinned === undefined
474 if (isFollowingSettings && pinned !== settings.effort) {
475 await update($, pinnedEfforts, current => ({ ...current, [agentId]: settings.effort }))
476 }
477 const target = isFollowingSettings || pinned === undefined ? settings.effort : pinned
478 return target === 'inherit' ? requestEffort : target
479}
480
481async function toastAfter($: EngineInterface, action: Promise<string>) {
482 $.ui.toast(await action)
483}
484
485function panelOf($: EngineInterface, ui: Ui, data: PanelData, columnsOf: (cellChars: number) => number, hasTitle: boolean) {
486 const { Box, Text, Button, Select } = ui
487 const { state, kind, names, groups, views, skillsView } = data
488 const { word, title, empty } = KIND_DETAILS[kind]
489 const onCount = names.filter(name => state.switchOf(kind, name) === 'on').length
490 const cellChars = cellCharsOf(groups.flatMap(group => group.items.map(item => item.label)))
491 const columns = columnsOf(cellChars)
492 const toolbarButton = (key: string, label: string, onPress: () => void) => (
493 <Button key={key} dimColor onPress={onPress}>
494 {label}
495 </Button>
496 )
497 return (
498 <Box flexDirection="column" gap={1}>
499 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
500 {hasTitle ? <Text bold color={ACCENT}>{title}</Text> : <Text dimColor>{word}</Text>}
501 <Text dimColor>{`${onCount} of ${names.length} on`}</Text>
502 </Box>
503 <Box flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center">
504 {toolbarButton(`${kind}-enable-all`, 'enable all', () => toastAfter($, setAllSwitches($, kind, names, 'on')))}
505 {toolbarButton(`${kind}-disable-all`, 'disable all', () => toastAfter($, setAllSwitches($, kind, names, 'off')))}
506 {skillsView !== undefined &&
507 toolbarButton('skills-sort', skillsView.isRecent ? 'sort: recent' : 'sort: name', () => update($, isSortedByRecent, current => !current))}
508 {skillsView !== undefined && (
509 <Select
510 key="skills-stale-days"
511 label="unused after"
512 options={skillsView.staleChoices.map(days => ({ value: String(days), label: `${days} days` }))}
513 value={String(skillsView.staleDays)}
514 onSelect={choice => update($, staleDaysPick, () => Number(choice))}
515 />
516 )}
517 {skillsView !== undefined &&
518 toolbarButton('skills-turn-off-stale', `turn off unused ${skillsView.staleDays}d+ (${skillsView.staleNames.length})`, () =>
519 toastAfter($, setAllSwitches($, kind, skillsView.staleNames, 'off')),
520 )}
521 </Box>
522 {names.length === 0 ? (
523 <Text dimColor>{empty}</Text>
524 ) : (
525 groups.map(group => {
526 const groupKey = `${kind}:${group.title}`
527 const shown = Math.min(group.items.length, views[groupKey] ?? (group.items.length <= INLINE_LIMIT ? group.items.length : 0))
528 const setShown = (count: number) => update($, groupViews, current => ({ ...current, [groupKey]: count }))
529 return (
530 <Box key={`group-${group.title}`} flexDirection="column" gap={1}>
531 <Button key={`group-toggle-${groupKey}`} dimColor onPress={() => setShown(shown > 0 ? 0 : GROUP_PAGE)}>
532 {`${shown > 0 ? MINIMIZE_MARK : SIDE_CLOSED_MARK} ${group.title.toUpperCase()} · ${group.items.length}`}
533 </Button>
534 {rowsOf(group.items.slice(0, shown), columns).map(row => (
535 <Box key={`row-${row[0].name}`} flexDirection="row" columnGap={COLUMN_GAP}>
536 {row.map(item => {
537 const isOn = state.switchOf(kind, item.name) === 'on'
538 return (
539 <Box key={`cell-${word}-${item.name}`} width={cellChars} flexDirection="row" justifyContent="space-between" alignItems="center">
540 <Box flexDirection="row" columnGap={1}>
541 <Text color={isOn ? ACCENT : undefined} dimColor={!isOn}>{isOn ? ON_MARK : OFF_MARK}</Text>
542 <Text dimColor={!isOn}>{fitLabelOf(item.label, cellChars)}</Text>
543 </Box>
544 <Button
545 key={`${word}-${item.name}`}
546 dimColor
547 onPress={() => toastAfter($, setSwitch($, kind, item.name, flipped(state.switchOf(kind, item.name))))}
548 >
549 {isOn ? 'on' : 'off'}
550 </Button>
551 </Box>
552 )
553 })}
554 </Box>
555 ))}
556 {shown > 0 && shown < group.items.length && (
557 <Button key={`group-more-${groupKey}`} dimColor onPress={() => setShown(shown + GROUP_PAGE)}>
558 {`show ${Math.min(GROUP_PAGE, group.items.length - shown)} more (${group.items.length - shown} left)`}
559 </Button>
560 )}
561 </Box>
562 )
563 })
564 )}
565 </Box>
566 )
567}
568
569const USAGE = [
570 `Usage: /${PLUGIN} <command>`,
571 `- <field> <value>: set one value for this session. Fields: ${ALL_FIELDS.join(', ')}`,
572 '- agents: list the agent types, each with its switch',
573 '- agent <type> on|off: switch one agent type',
574 '- skills: list the skills, each with its switch',
575 '- skill <name> on|off: switch one skill',
576 `- seed <file>: load last-used dates, made by ${BACKFILL_COMMAND}`,
577 '- bar: minimize or expand the bar',
578 '- apply: move running subagents to the current effort',
579 '- save: write this session to the defaults',
580 '- reset: drop the values this session set',
581 'With no command, it prints the values in force.',
582].join('\n')
583
584export const register: Register = (on, options: PluginOptions) => {
585 const defaults: Defaults = {
586 settings: Object.fromEntries(ALL_FIELDS.map(field => [field, String(options[field])])) as Settings,
587 switches: {
588 agents: switchesOf(String(options.disabledAgents ?? '')),
589 skills: switchesOf(String(options.disabledSkills ?? '')),
590 },
591 }
592
593 const configuredStaleDays = Number(options.staleDays)
594 const staleDays = Number.isInteger(configuredStaleDays) && configuredStaleDays > 0 ? configuredStaleDays : DEFAULT_STALE_DAYS
595
596 on('session.start', async ($, e, next) => {
597 await $.command.register({
598 name: PLUGIN,
599 description: 'Show or change which models, agent types and skills subagents may use, and their effort, for this session',
600 })
601 $.ui.status(undefined)
602 return next(e)
603 })
604
605 on('command.run', { command: PLUGIN }, async ($, e) => {
606 const [word = '', value, ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
607 const state = await sessionState($, defaults)
608 if (word === '') return { text: listingOf(state.settings, state.sessionOverrides, state.merged) }
609 if (value === undefined) {
610 if (word === 'reset') return { text: await resetSession($) }
611 if (word === 'save') return { text: await saveDefaults($, defaults) }
612 if (word === 'apply') return { text: await applyNow($, defaults) }
613 if (word === 'agents') return { text: kindListingOf('agents', state, await read($, offeredAgents)) }
614 if (word === 'skills') {
615 await refreshSkills($)
616 await loadLastUsed($)
617 return { text: kindListingOf('skills', state, await read($, knownSkills), await read($, lastUsedDates)) }
618 }
619 if (word === 'bar') {
620 const isOpen = await update($, isBarOpen, current => !current)
621 return { text: isOpen ? 'Subagent bar expanded.' : 'Subagent bar minimized.' }
622 }
623 }
624 if (word === 'seed') {
625 const seedPath = e.args.trim().slice(word.length).trim().replace(/^(["'])(.*)\1$/, '$2')
626 return { text: seedPath === '' ? USAGE : await seedFrom($, seedPath) }
627 }
628 if (word === 'agent' || word === 'skill') {
629 const [nameSwitch, ...extra] = rest
630 if (value === undefined || nameSwitch === undefined || !isSwitch(nameSwitch) || extra.length > 0) return { text: USAGE }
631 return { text: await setSwitch($, word === 'agent' ? 'agents' : 'skills', value, nameSwitch) }
632 }
633 if (!isField(word) || value === undefined || rest.length > 0) return { text: USAGE }
634 return { text: await setField($, defaults, word, value) }
635 })
636
637 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
638 if (e.props.hasSurvey || e.surface === 'mobile' || !(await read($, isBarOpen))) return next(e)
639 const ui = $.ui.resolve(e)
640 const { Box, Text, Button, Select } = ui
641 const state = await sessionState($, defaults)
642 const { settings } = state
643 const allNames = { agents: await panelNamesOf($, defaults, 'agents'), skills: await panelNamesOf($, defaults, 'skills') }
644 const inlineData = {
645 agents: await panelDataOf($, defaults, 'agents', staleDays),
646 skills: await panelDataOf($, defaults, 'skills', staleDays),
647 }
648 const isPanelOpen = { agents: await isPanelOpenOf($, 'agents'), skills: await isPanelOpenOf($, 'skills') }
649 const isSide = { agents: allNames.agents.length > INLINE_LIMIT, skills: allNames.skills.length > INLINE_LIMIT }
650 const turnedOffCountOf = (kind: Kind) => allNames[kind].filter(name => state.switchOf(kind, name) === 'off').length
651 const runningCount = (await runningAgentIdsOf($)).length
652 const optionsOf = (field: keyof Settings) => (ALL_CHOICES[field] as readonly string[]).map(choice => ({ value: choice }))
653 const barCell = (key: string, child: unknown) => cellOf(ui, key, BAR_CELL_CHARS, child)
654 const gridRowOf = (key: string, cells: unknown[]) => (
655 <Box key={key} flexDirection="row" columnGap={BAR_COLUMN_GAP}>
656 {cells}
657 </Box>
658 )
659
660 const panelButtonOf = (kind: Kind) => {
661 const mark = isSide[kind] ? (isPanelOpen[kind] ? SIDE_OPEN_MARK : SIDE_CLOSED_MARK) : isPanelOpen[kind] ? MINIMIZE_MARK : EXPAND_MARK
662 const count = turnedOffCountOf(kind)
663 return (
664 <Button key={kind} variant={isPanelOpen[kind] ? 'primary' : 'secondary'} onPress={() => togglePanel($, defaults, kind)}>
665 {`${kind} ${count === 0 ? 'all on' : `${count} off`} ${mark}`}
666 </Button>
667 )
668 }
669
670 const selectOf = (field: keyof Settings, label: string, choices?: { value: string; label: string }[]) => (
671 <Select
672 key={field}
673 label={label}
674 options={choices ?? optionsOf(field)}
675 value={settings[field]}
676 onSelect={choice => toastAfter($, setField($, defaults, field, choice))}
677 />
678 )
679
680 return (
681 <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={2} gap={1}>
682 <Box flexDirection="row" columnGap={COLUMN_GAP} alignItems="center" justifyContent="space-between">
683 <Text bold color={ACCENT}>{`${ICON_MARK} Subagents`}</Text>
684 <Box flexDirection="row" columnGap={COLUMN_GAP} alignItems="center">
685 <Text dimColor>{`${settings.defaultModel} · ${settings.effort} · ${ALL_FAMILIES.filter(family => settings[family] === 'on').length} of ${ALL_FAMILIES.length} models on`}</Text>
686 <Button key="minimize" dimColor onPress={() => update($, isBarOpen, () => false)}>
687 {`${MINIMIZE_MARK} minimize`}
688 </Button>
689 </Box>
690 </Box>
691 <Box key="strip" flexDirection="row" columnGap={BAR_COLUMN_GAP}>
692 {ALL_FAMILIES.map(family => (
693 <Box key={`strip-${family}`} flexGrow={1} height={1} backgroundColor={settings[family] === 'on' ? FAMILY_COLORS[family] : STRIP_OFF_COLOR} />
694 ))}
695 </Box>
696 {gridRowOf(
697 'models',
698 ALL_FAMILIES.map(family => (
699 <Box key={`cell-model-${family}`} width={BAR_CELL_CHARS} flexDirection="row" justifyContent="space-between" alignItems="center">
700 <Box flexDirection="row" columnGap={1}>
701 <Text color={settings[family] === 'on' ? FAMILY_COLORS[family] : undefined} dimColor={settings[family] !== 'on'}>{ON_MARK}</Text>
702 <Text dimColor={settings[family] !== 'on'}>{family}</Text>
703 </Box>
704 <Button
705 key={`model-${family}`}
706 dimColor
707 onPress={() => toastAfter($, setField($, defaults, family, flipped(settings[family])))}
708 >
709 {settings[family] === 'on' ? 'on' : 'off'}
710 </Button>
711 </Box>
712 )),
713 )}
714 {gridRowOf('settings', [
715 barCell('cell-defaultModel', selectOf('defaultModel', 'default')),
716 barCell('cell-effort', selectOf('effort', 'effort')),
717 barCell('cell-offAction', selectOf('offAction', 'if off')),
718 barCell(
719 'cell-applyToRunning',
720 selectOf('applyToRunning', `running ${runningCount}`, [
721 { value: 'on', label: 'follow' },
722 { value: 'off', label: 'pinned' },
723 ]),
724 ),
725 ])}
726 {gridRowOf('actions', [
727 barCell('cell-agents', panelButtonOf('agents')),
728 barCell('cell-skills', panelButtonOf('skills')),
729 barCell(
730 'cell-apply',
731 <Button key="apply" onPress={() => toastAfter($, applyNow($, defaults))}>
732 apply to running
733 </Button>,
734 ),
735 barCell(
736 'cell-more',
737 <Select
738 key="more"
739 options={[
740 { value: MORE_SUMMARY, label: 'more' },
741 { value: 'save', label: 'save as defaults' },
742 { value: 'reset', label: 'reset session' },
743 ]}
744 value={MORE_SUMMARY}
745 onSelect={choice => {
746 if (choice === 'save') void toastAfter($, saveDefaults($, defaults))
747 if (choice === 'reset') void toastAfter($, resetSession($))
748 }}
749 />,
750 ),
751 ])}
752 {ALL_KINDS.map(kind =>
753 isPanelOpen[kind] && !isSide[kind] ? (
754 <Box key={`panel-${kind}`} flexDirection="column">
755 {panelOf($, ui, inlineData[kind], () => BAR_COLUMNS, true)}
756 </Box>
757 ) : null,
758 )}
759 </Box>
760 )
761 })
762
763 for (const kind of ALL_KINDS) {
764 on('ui.render', { component: 'Pane', requestId: PANE_IDS[kind] }, async ($, e) => {
765 const data = await panelDataOf($, defaults, kind, staleDays)
766 return panelOf($, $.ui.resolve(e), data, cellChars => columnsFor(Math.floor(e.props.bodyColumns * COLUMN_SAFETY), cellChars), false)
767 })
768 }
769
770 on('ui.close', async ($, e, next) => {
771 const kind = ALL_KINDS.find(each => PANE_IDS[each] === e.id)
772 if (kind !== undefined) await setPanelOpen($, kind, false)
773 return next(e)
774 })
775
776 on('ui.render', { component: 'SessionMode' }, async ($, e) => {
777 const { Box, Text, Button } = $.ui.resolve(e)
778 const { settings, sessionOverrides } = await sessionState($, defaults)
779 const isOpen = await read($, isBarOpen)
780 const sessionMark = Object.keys(sessionOverrides).length > 0 ? '*' : ''
781 return (
782 <Box flexDirection="row" columnGap={2} alignItems="center">
783 <Box key="footer-dots" flexDirection="row" columnGap={1}>
784 {ALL_FAMILIES.map(family => (
785 <Text key={`footer-dot-${family}`} color={settings[family] === 'on' ? FAMILY_COLORS[family] : STRIP_OFF_COLOR}>{ON_MARK}</Text>
786 ))}
787 </Box>
788 <Button key="pill" onPress={() => update($, isBarOpen, current => !current)}>
789 {`${ICON_MARK} ${isOpen ? MINIMIZE_MARK : EXPAND_MARK}`}
790 </Button>
791 <Text key="footer-model" bold color={FAMILY_COLORS[settings.defaultModel as Family] ?? ACCENT}>{settings.defaultModel}</Text>
792 <Text key="footer-effort" color={ACCENT}>{`${settings.effort}${sessionMark}`}</Text>
793 <Text color={ACCENT}>{e.props.modes.join(' & ')}</Text>
794 </Box>
795 )
796 })
797
798 on('agent.offer', async ($, e, next) => {
799 const allOffered = await read($, offeredAgents)
800 if (!allOffered.includes(e.agent)) await update($, offeredAgents, current => [...current, e.agent])
801 const { switchOf } = await sessionState($, defaults)
802 if (switchOf('agents', e.agent) === 'off') return { isOffered: false }
803 return next(e)
804 })
805
806 on('agent.spawn', async ($, e, next) => {
807 if (e.fork) return next(e)
808 const { settings, switchOf } = await sessionState($, defaults)
809 if (switchOf('agents', e.subagentType) === 'off') return { deny: `Subagent type ${e.subagentType} is turned off. Pick another subagent_type.` }
810 if (e.model === undefined || isAllowed(settings, familyOf(e.model))) return next(e)
811 if (settings.offAction === 'deny') return { deny: denyTextOf(e.model, settings) }
812 return next({ ...e, model: settings.defaultModel })
813 })
814
815 on('skill.prompt', async ($, e, next) => {
816 const allSeen = await read($, knownSkills)
817 if (!allSeen.includes(e.skill)) await update($, knownSkills, current => [...current, e.skill].sort())
818 const { merged } = await sessionState($, defaults)
819 if (isSkillOff(merged.skills, e.skill)) return { text: skillOffTextOf(e.skill) }
820 await recordUse($, e.skill)
821 return next(e)
822 })
823
824 on('turn.step', async function* ($, e, next) {
825 if (e.agentId === undefined) return yield* next(e)
826 const { settings } = await sessionState($, defaults)
827 const model = isAllowed(settings, familyOf(e.model)) ? e.model : settings.defaultModel
828 const effort = await effortFor($, settings, e.agentId, e.effort)
829 return yield* next({ ...e, model, effort })
830 })
831}
832types/index.d.ts 47 lines1export type Switch = 'on' | 'off'
2
3export type Family = 'opus' | 'sonnet' | 'haiku' | 'fable'
4
5export type Effort = 'inherit' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
6
7export type OffAction = 'move' | 'deny'
8
9export type Settings = {
10 opus: Switch
11 fable: Switch
12 sonnet: Switch
13 haiku: Switch
14 defaultModel: Family
15 effort: Effort
16 offAction: OffAction
17 applyToRunning: Switch
18}
19
20export type AgentSwitches = Readonly<Record<string, Switch>>
21
22export type Kind = 'agents' | 'skills'
23
24export type SessionOverrides = Partial<Settings> & { agents?: AgentSwitches; skills?: AgentSwitches }
25
26export type LastUsed = Readonly<Record<string, number>>
27
28export type PinnedEfforts = Readonly<Record<string, Effort>>
29
30declare module 'claude-code' {
31 interface PluginState {
32 'subagent-models': {
33 overrides: SessionOverrides
34 offeredAgents: readonly string[]
35 pinnedEfforts: PinnedEfforts
36 isBarOpen: boolean
37 isAgentsOpen: boolean
38 isSkillsOpen: boolean
39 knownSkills: readonly string[]
40 lastUsed: LastUsed
41 isSortedByRecent: boolean
42 staleDaysPick: number
43 groupViews: Record<string, number>
44 }
45 }
46}
47