SLOPSHOPPER

model-guard

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

newagents
A shopper browsing a rack in a slop shop
README

model-guard

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.

Source 1 files
hooks/register.ts 94 lines
1import 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