Search subagents (Explore) that were given no model of their own run on a cheaper one instead of the main model. An explicit model, forks, teammates and plugin…

Search subagents (Explore) that were given no model of their own run on a cheaper one instead of the main model. An explicit model, forks, teammates and plugin agents stay untouched. With Claude Code's own CLAUDE_CODE_SUBAGENT_MODEL set (for all subagents), the mod stays out.
Part of the Mod Box, which bundles it with other mods and one window to switch and configure them all. This is the same mod on its own.
In a terminal Claude Code session (2.1.287 or newer):
/plugin install subagent-saver --marketplace LupusMalusDeviant/claude-mods
Set them in /config (or when installing).
| Option | Default | What it does |
|---|---|---|
model | sonnet | Model for the listed subagent types. Sonnet searches nearly as well as Opus; Haiku is cheaper but may miss things, which then costs a second search on the main model. |
types | Explore | Built-in subagent types to route, comma separated. |
language | en | Language of the texts this mod shows and writes. |
MIT, see LICENSE.
hooks/entry.tsx 30 lines1// Entry of the standalone Subagent Saver. The mod hands its display to a band (in the Mod Box the
2// band manager draws it); here this hook, registered first and so the outermost, draws it below
3// whatever else is above the prompt.
4import type { Register } from 'claude-code'
5
6import { register as inner } from '../mods/subagent-saver/hooks/register'
7import { openBand, takeBand } from '../mods/settings/hooks/band'
8
9export const register: Register = (on, options) => {
10 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
11 openBand(e)
12 const rest = await next(e)
13 const parts = takeBand(e)
14 if (e.props.hasSurvey || parts.length === 0) return rest
15 const { Box, Text } = $.ui.resolve(e)
16 return (
17 <Box flexDirection="column">
18 {rest}
19 {parts.map(p => (
20 <Box key={`band-${p.id}`} flexDirection="row" paddingX={1}>
21 <Box flexShrink={1}>{p.line as any}</Box>
22 {(p.actions as any[]).flatMap((a, i) => [<Text key={`gap-${i}`}>{' '}</Text>, a])}
23 </Box>
24 ))}
25 </Box>
26 )
27 })
28 ;(inner as any)(on, options)
29}
30mods/subagent-saver/hooks/register.tsx 102 lines1// Subagent Saver: search-type subagents (Explore by default) that were given no model of their own
2// run on a cheaper one instead of inheriting the main model. They start their own context, so the
3// main conversation's prompt cache is untouched. An explicit model, forks, teammates, workflow
4// agents and agents from plugins are always left alone.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { SaverStats } from '../types'
9import { offer as offerBand } from '../../settings/hooks/band'
10import type { BandPart } from '../../settings/hooks/band'
11
12const stats = atom({ plugin: 'subagent-saver', key: 'stats' } as const, { routed: 0, last: '' } as SaverStats)
13
14let lang: 'de' | 'en' = 'en'
15const t = (de: string, en: string) => (lang === 'en' ? en : de)
16
17// Cheapest first; a model is only ever swapped for one further left.
18const RANK = ['haiku', 'sonnet', 'opus', 'fable']
19
20/** Where a model id or alias sits in RANK; -1 when unknown. */
21export function rankOf(model: string): number {
22 const m = model.toLowerCase()
23 return RANK.findIndex(r => m.includes(r))
24}
25
26export type SpawnFacts = {
27 subagentType: string
28 model?: string
29 parentModel: string
30 fork: boolean
31 isTeammate?: true
32 workflow?: unknown
33 provider: { plugin: string }
34}
35
36/** The model to start this subagent on, or null to leave the spawn as it is. */
37export function cheaperModel(e: SpawnFacts, target: string, types: string[]): string | null {
38 if (target === 'off' || rankOf(target) < 0) return null
39 if (e.fork || e.isTeammate || e.workflow || e.model) return null
40 if (e.provider.plugin !== 'engine' || !types.includes(e.subagentType)) return null
41 const parent = rankOf(e.parentModel)
42 // Unknown parent models are left alone; so is a parent that is already as cheap.
43 if (parent < 0 || parent <= rankOf(target)) return null
44 return target
45}
46
47export const register: Register = (on, options) => {
48 lang = options.language === 'de' ? 'de' : 'en'
49 const target = String(options.model ?? 'sonnet')
50 const types = String(options.types ?? 'Explore')
51 .split(',')
52 .map(x => x.trim())
53 .filter(Boolean)
54
55 // Claude Code's own setting wins: with CLAUDE_CODE_SUBAGENT_MODEL set, the mod stays out, so two
56 // rules never pick a subagent's model against each other.
57 let builtIn: string | null = null
58 on('session.start', {}, async ($, e, next) => {
59 const r = await next(e)
60 builtIn = ((await $.env.get('CLAUDE_CODE_SUBAGENT_MODEL').catch(() => undefined)) ?? '').trim() || null
61 return r
62 })
63
64 on('agent.spawn', {}, async ($, e, next) => {
65 if (builtIn) return next(e)
66 const model = cheaperModel(e as SpawnFacts, target, types)
67 if (!model) return next(e)
68 await update($, stats, s => ({ routed: s.routed + 1, last: `${e.subagentType} → ${model}` }))
69 return next({ ...e, model })
70 })
71
72 // Band manager: hand this mod's display to the Mod Box.
73 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
74 const rest = await next(e)
75 if (!e.props.hasSurvey) {
76 const part = await band($, e).catch(() => null)
77 if (part) offerBand(e, part)
78 }
79 return rest
80 })
81}
82
83export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
84 const s = await read($, stats)
85 if (!s.routed) return null
86 const { Text } = $.ui.resolve(e)
87 const text = `🤖 ${s.last}${s.routed > 1 ? ` (${s.routed}×)` : ''}`
88 return {
89 id: 'subagentSaver',
90 urgent: false,
91 short: <Text color="suggestion">{text}</Text>,
92 line: (
93 <Text>
94 <Text color="suggestion">{text}</Text>
95 <Text color="inactive">{t(' Subagenten auf dem günstigeren Modell', ' subagents on the cheaper model')}</Text>
96 </Text>
97 ),
98 actions: [],
99 status: text,
100 }
101}
102mods/settings/hooks/band.ts 44 lines1// The band manager's mailbox. A band mod computes its display in its own AbovePrompt hook (with its
2// own $; the validator does not let $ cross an import) and drops it here with offer(). The settings
3// hook, registered first and so the outermost, runs after all of them, takes the parts and draws
4// them ordered, compact or one line each, or sends them to the status line.
5export type BandPart = {
6 /** The mod's switch key, e.g. "cacheMeter". */
7 id: string
8 /** Wants attention (CI red, sign offer …): always its own line with its buttons. */
9 urgent: boolean
10 /** One short element for the compact line. */
11 short: unknown
12 /** The full element for its own line. */
13 line: unknown
14 /** Buttons that belong to the part. */
15 actions: unknown[]
16 /** Plain text for the status line. */
17 status: string
18}
19
20const parts = new Map<string, BandPart[]>()
21
22function keyOf(e: { requestId?: string }): string {
23 return e.requestId ?? 'band'
24}
25
26/** Called by the manager before the inner hooks run. */
27export function openBand(e: { requestId?: string }) {
28 parts.set(keyOf(e), [])
29}
30
31/** Called by a band mod with its display for this render. */
32export function offer(e: { requestId?: string }, part: BandPart) {
33 const list = parts.get(keyOf(e))
34 if (list) list.push(part)
35 else parts.set(keyOf(e), [part])
36}
37
38/** Called by the manager after the inner hooks ran. */
39export function takeBand(e: { requestId?: string }): BandPart[] {
40 const list = parts.get(keyOf(e)) ?? []
41 parts.delete(keyOf(e))
42 return list
43}
44mods/subagent-saver/types/contract.d.ts 9 lines1/** How many subagents were started on the cheaper model, and the last one. */
2export type SaverStats = { routed: number; last: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'subagent-saver': { stats: SaverStats }
7 }
8}
9