Choose the model each subagent runs on, by rule. Does nothing until you configure it.

Chooses the model each Claude Code subagent runs on, by rule. It does nothing until you configure it.
/plugin install model-guard --marketplace KrasimirKralev/krasi-mods
Settings, recipes and the exact rules for deciding a spawn are in the repository README.
Unofficial; not affiliated with Anthropic. MIT licence.
hooks/register.ts 94 lines1import type { PluginOptions, Register } from 'claude-code';
2
3// Two settings pick a subagent's model:
4// `default` answers a call that names no model (leaves `model` out, or passes `inherit`);
5// `rules` answer a call that names one: the first rule whose `match` fits decides.
6// Left empty, both pass every spawn through exactly as Claude Code would run it.
7//
8// A fork always runs on the main model (Claude Code ignores `model` for a fork), so the
9// only choice for one is to let it start or refuse it. Claude Code lets a hook only refuse a
10// workflow script's agents, never change them, so they pass through untouched.
11
12type Rule = { readonly match: RegExp; readonly model: string };
13
14type Config = {
15 readonly fallback: string;
16 readonly rules: readonly Rule[];
17 readonly denyForks: boolean;
18 readonly problem?: string;
19};
20
21const FORK_DENIED =
22 'model-guard refuses forks here (its forks setting is deny): a fork always runs on the main model. ' +
23 'Start a regular subagent instead, naming a model if it needs one.';
24
25const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
26
27const namesNoModel = (requested: string | undefined): boolean => {
28 const name = text(requested).toLowerCase();
29 return name === '' || name === 'inherit';
30};
31
32// All or nothing: one broken rule turns every rule off, because skipping just that one
33// would let a later catch-all take the requests it was meant to answer.
34const parseRules = (raw: string): { rules: Rule[]; problem?: string } => {
35 if (text(raw) === '') return { rules: [] };
36 let parsed: unknown;
37 try {
38 parsed = JSON.parse(raw);
39 } catch {
40 return { rules: [], problem: 'rules is not valid JSON, so no rule applies' };
41 }
42 if (!Array.isArray(parsed)) {
43 return { rules: [], problem: 'rules must be a JSON array of {"match", "model"} objects, so no rule applies' };
44 }
45 const rules: Rule[] = [];
46 for (const [index, entry] of parsed.entries()) {
47 const where = `rule ${index + 1}`;
48 const isObject = entry !== null && typeof entry === 'object' && !Array.isArray(entry);
49 const fields: Record<string, unknown> = isObject ? entry : {};
50 const match = text(fields.match);
51 const model = text(fields.model);
52 if (match === '' || model === '') {
53 return { rules: [], problem: `${where} needs a non-empty "match" and "model", so no rule applies` };
54 }
55 try {
56 rules.push({ match: new RegExp(match, 'i'), model });
57 } catch {
58 return { rules: [], problem: `${where} has a "match" that is not a valid regular expression, so no rule applies` };
59 }
60 }
61 return { rules };
62};
63
64const readConfig = (options: PluginOptions): Config => {
65 const { rules, problem } = parseRules(text(options.rules));
66 return { fallback: text(options.default), rules, denyForks: options.forks === 'deny', problem };
67};
68
69// The model to hand the spawn, or undefined to leave it exactly as it was.
70const pick = (config: Config, requested: string | undefined): string | undefined => {
71 if (namesNoModel(requested)) return config.fallback === '' ? undefined : config.fallback;
72 return config.rules.find(rule => rule.match.test(text(requested)))?.model;
73};
74
75// No .catch on the spawn hook: should it ever fail, the spawn goes ahead as Claude Code
76// would run it, which is the safe side for a router.
77export const register: Register = (on, options) => {
78 const config = readConfig(options);
79
80 if (config.problem !== undefined) {
81 on('session.start', ($, e, next) => {
82 $.ui.log(`model-guard: ${config.problem}. Fix it in /config.`);
83 return next(e);
84 });
85 }
86
87 on('agent.spawn', ($, e, next) => {
88 if (e.workflow) return next(e);
89 if (e.fork) return config.denyForks ? { deny: FORK_DENIED } : next(e);
90 const model = pick(config, e.model);
91 return model === undefined ? next(e) : next({ ...e, model });
92 });
93};
94