SLOPSHOPPER

subagent-models

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.

newpanebandspinnercommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-models
│ ┃ subagent-agents ✕ › fix the failing auth test and add an audit log call │ ┃ agent 0 of 0 on │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ enable all ] [ disable all ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No agent types offered yet. They appear ⎿ Added 2 lines, removed 1 line │ ┃ after the first spawn. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /subagent-models │ ⎿ subagent-models: opus on │ ⎿ subagent-models: fable off │ ⎿ subagent-models: sonnet off │ ⎿ subagent-models: haiku on │ ⎿ subagent-models: defaultModel opus │ ⎿ subagent-models: effort medium │ │ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ ◈ Subagents opus · medium · 2 of 4 models on [ ▾ minimize ] │ │ │ │ ● haiku [ on ] ● sonnet [ off ] ● opus [ on ] ● fable [ off ] │ │ default: opus ▾ effort: medium ▾ if off: move ▾ running 0: follow ▾ │ │ [ agents all on ▴ ] [ skills all on ▴ ] [ apply to running ] more ▾ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ● ● ● ● [ ◈ ▾ ] opus medium auto-accept edits

Draws

Band
╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ ◈ Subagents opus · medium · 2 of 4 models on [ ▾ minimize ] │ │ │ │ ● haiku [ on ] ● sonnet [ off ] ● opus [ on ] ● fable [ off ] │ │ default: opus ▾ effort: medium ▾ if off: move ▾ running 0: follow ▾ │ │ [ agents all on ▴ ] [ skills all on ▴ ] [ apply to running ] more ▾ ╰───────────────────────────────────────────────────────────────────────────────────────────────────
Pane · subagent-agents
agent 0 of 0 on [ enable all ] [ disable all ] No agent types offered yet. They appear after the first spawn.
Pane · subagent-skills
skill 0 of 0 on [ enable all ] [ disable all ] [ sort: name ] unused afte No skills listed yet. Press skills again once the session has started.
README

subagent-models

A Claude Code mod that decides which models and agent types subagents may run as, and the effort they run at.

What it does

  • A spawn that names a turned-off model runs on the default model instead. With offAction set to deny, the spawn is refused with a message that names the model to use.
  • A full model id counts by its family. claude-fable-5-1 is fable.
  • A turned-off agent type leaves the model's agent listing, and a spawn that names it is refused.
  • A turned-off skill still appears in the model's skill listing. When the model or you run it, the skill's text is replaced with a note that says it is off, so the model skips it. A short name such as tdd also matches pstack:tdd.
  • The default model is always allowed, so a turned-off model always has somewhere to move.
  • Every request a subagent makes runs at the effort you set. The main conversation keeps its own model and effort.
  • With 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.
  • A subagent whose model comes from its agent definition is caught on its first request and moved the same way.
  • A fork inherits its parent and passes untouched. So does a spawn that names no model.
  • The footer pill shows the default model and effort, as opus/medium. A * marks a session that differs from the defaults. Clicking it opens or closes the bar.

Settings

/config holds the defaults every new session starts with:

SettingOptionsDefault
Opus subagentson, offon
Fable subagentson, offoff
Sonnet subagentson, offoff
Haiku subagentson, offon
Default subagent modelopus, sonnet, haiku, fableopus
Subagent effortinherit, low, medium, high, xhigh, maxmedium
When a spawn names a turned-off modelmove, denymove
Effort changes reach running subagentson, offon
Turned-off agent typescomma-separated agent types, such as Explore, Plannone
Turned-off skillscomma-separated skills, such as pstack:tdd, simplifynone
Days before a skill counts as unuseda whole number of days, the starting cutoff of the unused after menu14

The command refuses to turn off the default model, and refuses a default model that is turned off.

This session only

/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.
  • A framed bar above the prompt holds every setting in a grid of four equal columns. The header row has the title, a summary such as 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.
  • The 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.
  • The mod records the time of each skill run in its own store, so the 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.

Backfill last-used dates

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.

Install

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

Test

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.

Source 2 files
hooks/register.tsx 832 lines
1import { 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}
832
types/index.d.ts 47 lines
1export 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