SLOPSHOPPER

model-pin

Pin a project to one model, show a drift badge when the session leaves it, and re-apply the pin once.

newcommandtoaststatustimer
v0.1.0MITupdated 2026-10-12Joseph-Ayoub/claude-mods/model-pin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-pin
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /model-pin ⎿ model-pin: No model pin for this project. Session is on claude-opus-5-5. Set one with /model-pin <model>, or "model" in . ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

model-pin

Keeps a project on one model and shows when the session leaves it.

A model switch is easy to lose: /model resets on resume, the engine falls back to another model on overload, and a stray picker choice sticks. model-pin compares the session's model to a per-project pin and, on drift, shows a status line, shows a toast once, and puts the pin back once. It never fights a deliberate switch, never changes your default model for new sessions, and never polls: every check is driven by an event.

What it does

  1. Finds the pin, in the engine's own order, so a settings pin is the model the engine itself starts on (unless --model or ANTHROPIC_MODEL overrides it at launch) and the mod never fights a setting. Highest wins: managed policy settings, then --settings (flag), then this checkout's .claude/settings.local.json, then the /model-pin override for this repository (kept in $.store under pin:<main working tree root>, so linked worktrees share it; the session root outside a repository), then the project's .claude/settings.json. The user-level ~/.claude/settings.json counts only with includeUserSettings on. A defined but unusable value (anything that is not one model-id token), or a source that cannot be read, means "no pin" rather than a fall-through: the engine obeys that source, so pinning a lower one would fight it. No pin anywhere: the mod does nothing. The engine never reads the override, so a session starts on its settings model and an override that differs shows as drift at every start.
  2. Compares like for like. Both sides are normalized: lowercase, no provider prefix (anthropic., us.anthropic., bedrock/), no date or -v1:0 tail, no [1m], no claude- lead, spaces and dots folded to hyphens. So Fable 5.1, claude-fable-5-1 and anthropic/claude-fable-5-1@20260101 are one model, and a pin written as an alias (fable) matches any id of that family. The one exception is the mod's own /model (item 4): it also needs the same [1m]-style variant, since a variant can change the context window and its pricing. /model-pin <alias> upgrades itself to the resolved id once a switch to that alias lands, so a later alias re-point by the CLI does not look like drift; a one-off /model to another version never re-pins. A pin that cannot be compared (a word outside the fable/opus/sonnet/haiku families such as opusplan or default, or a provider ARN) is shown by /model-pin but never drives a badge, a re-apply or the lock.
  3. Shows drift. model drift: <current> (pinned <pin>) in the status line, a toast the first time per session, cleared when the session is back on the pin.
  4. Puts the pin back once per session, without touching your default. Only when the model was restored or defaulted, not chosen: a resume (PostModelSwitch source resume) or a drifted startup, resume or fork read (not in a session started as an agent, --agent, which runs that agent's model on purpose, and not while a fallback retry is waiting). The built-in /model may save its argument as your default for every new session, so the mod runs it itself only when the pin already is your user-level default (model in ~/.claude/settings.json, equal once normalized, with the same [1m]-style variant), and then with that value exactly as written, so saving it changes nothing. Any other pin (policy, --settings, local or project settings, or the /model-pin override, including a model named by a cloned repository) is offered instead: /model <pin> is filled into the composer for one Enter, only when the draft is empty; otherwise a toast names the command to type. Pressing Enter is then your own /model, so what it saves is your choice. A queued /model the engine refuses (it rejects inside a hook the turn waits on) falls back to the same offer. A switch with source auto (overload or rate-limit fallback) gets the badge now and exactly one re-apply attempt 10 minutes after the latest fallback, if still off the pin; a resume or fork in that window (its SessionStart read and its PostModelSwitch alike) leaves it to the retry. The due time is kept in $.state, so a hot reload (which drops timers) re-arms it from session.start. A switch you made yourself (command, picker, sdk) gets the badge only, and a subagent's own start or switch (agent_id set) is ignored: its model is its own.
  5. Optional lock (lock, default off). A switch away from the pin that you make yourself asks first (project pins <pin>): /model, the picker, or the desktop app and Remote Control, which switch over the SDK (sdk). It asks, never denies, so you can still go on purpose, and it fails open. Its ask replaces an allow from another plugin's hook beneath it (asking is the stricter answer, so this fails safe); a deny beneath stands. A subagent's switch is never asked about.

Hooks it registers

EventPurpose
session.startregisters /model-pin; reads the model once and shows drift (badge only); re-arms a waiting fallback retry
classic.SessionStartreads the model once; badge, plus the one re-apply for startup, resume, fork (observer, .catch falls through)
classic.PostModelSwitchdrift check on to_model; re-apply on resume, timed retry on auto; resolves an alias override (observer, .catch falls through)
classic.PreModelSwitchthe optional lock (gating hook with .catch that falls through)
command.run { command: 'model-pin' }serves /model-pin

$.state keys (PluginState['model-pin'], per session, survive hot reload): reappliedOnce, drifted, toastShown, retryAt.

Commands

  • /model-pin shows the pin, where it came from, and whether the session is on it.
  • /model-pin <model> pins this repository and offers /model <model> in the composer (or names it, if you have a draft); it queues the switch itself only when <model> already is your user-level default, and then says it was queued, not that it ran. If local, --settings or policy settings name a model, the override is saved but the command says those win here, and switches nothing.
  • /model-pin clear removes the override (settings pins still apply).

Anyone may read the pin, but only you can set or clear it: from your own prompt (composer), Remote Control (bridge) or the desktop app, which hosts its sessions over the SDK (sdk). A peer session, a channel message, a schedule, a task notification, a coordinator, an unclassified sender or another plugin is refused, since the pin outlives the session and would steer every later one.

To pin through settings instead, set "model": "claude-fable-5-1" in the project's .claude/settings.json.

Options

OptionTypeDefaultMeaning
lockbooleanfalseask before a manual switch away from the pin
includeUserSettingsbooleanfalsecount the user-level model as a pin for projects that set none

Set them under pluginConfigs["model-pin"].options in settings, or in the config menu.

Load it

claude --plugin-dir /absolute/path/to/mods/model-pin

or CLAUDE_CODE_PLUGIN_DIRS=/absolute/path/to/mods/model-pin. Check and test it with claude plugin validate mods/model-pin and claude plugin test mods/model-pin.

Known limits

  • --model and ANTHROPIC_MODEL are not settings sources. A session started with claude --model sonnet, or with ANTHROPIC_MODEL set, in a project that pins another model is badged and offered /model <pin> at startup (switched silently only when the pin is your own default). Clear the composer line to stay. Leave the project unpinned (the user-level default does not count unless includeUserSettings is on) to let each session choose.
  • An override drifts at every start. The engine never reads the /model-pin override, so each session starts on the settings model and is offered the override. For a pin the engine starts on, put model in .claude/settings.local.json instead.
  • A queued /model inside a hook may be refused. The engine rejects $.command.run inside a hook the turn is waiting on, so a re-apply from PostModelSwitch, or the switch /model-pin <your default> queues, often lands as the one-Enter composer fill instead (or a toast, with a draft). The /model-pin reply is written before that is known, so it says the switch was queued and may be offered instead. The 10-minute retry runs from a timer, outside any hook, and does run /model when the pin is your default.
  • The lock and a headless SDK run. With lock on, a switch over the SDK asks too. In a claude -p or Agent SDK run with nobody to answer, what the host does with that ask is up to the host (it may refuse the switch). The lock is off by default.
  • Variants compare as one model. The badge treats opus[1m] and plain opus as the same model, so running the 1M-context variant of the pin is not drift. Only the mod's own /model tells them apart: a user default that differs from the pin only by its variant is offered, never run.
  • Alias pins match by family. A pin of opus cannot tell Opus 4.1 from Opus 5.5 without switching, so a same-family version change is not drift. Pin the resolved id for an exact match. /model-pin <alias> stores the alias until a switch to it lands; if the queued /model falls back to the composer and you never press Enter, the alias stays.
  • At most one automatic re-apply per session, spent by whichever event gets there first. /model-pin <model> and /model-pin clear reset it.
  • Per-session state, shared store. $.store is one file across sessions with no compare-and-swap. It is written by /model-pin and by the alias swap (item 2, from PostModelSwitch source command), which reads the key again just before writing and writes only if it still holds the alias, so a clear or a new pin from another session stands. Two writes on one repository in the same instant still mean last write wins. The key is per repository, not per session, as the spec's per-root override requires; it is the one store key the mod writes.
  • Fallback retries. Each auto switch restarts the 10-minute wait; only the timer for the latest one acts. Timers die on quit (a new process starts with fresh per-session state, so nothing is re-armed then).
  • Overrides span worktrees, not subprojects. The override is keyed by the repository's main working tree, so a project in a subfolder of a repository shares the repository's override.
  • Status line is plain text (no color), one line per plugin.
  • /model may save your default. The built-in /model may persist its argument as the default for new sessions (not verified against a live 2.1.293 session). That is why the mod runs it only with your own user-level default, unchanged, and offers every other pin for you to run.
Source 3 files
hooks/register.tsx 589 lines
1import { atom, update, read } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModelPinSource } from '../types'
5import { clip, isAlias, isComparable, isValidPin, normalizeModel, sameModel, silentSwitchArg } from './pin'
6
7// Per-session facts live in $.state (a hot reload keeps them; a module
8// variable would not), each named by a literal plugin/key reference.
9const reappliedOnce = atom({ plugin: 'model-pin', key: 'reappliedOnce' } as const, false)
10const drifted = atom({ plugin: 'model-pin', key: 'drifted' } as const, false)
11const toastShown = atom({ plugin: 'model-pin', key: 'toastShown' } as const, false)
12const retryAt = atom({ plugin: 'model-pin', key: 'retryAt' } as const, 0)
13
14/*
15 * Where a pin can come from, highest precedence first. The settings sources
16 * follow the engine's own order (policy, then --settings, then local, project,
17 * user: the merge takes `model` from the highest source that has it), so a
18 * settings pin is the model the engine itself starts on, unless --model or
19 * ANTHROPIC_MODEL overrides it at launch (neither is a source the mod can
20 * read), and the mod never fights a managed setting or a person's own
21 * settings.local.json. The /model-pin override is a personal pin for the whole
22 * repository, so it sits above the committed project file but below this
23 * checkout's local file. The engine never reads the override, so a session
24 * starts on the settings model and an override that differs shows as drift at
25 * every start. User settings only count when the person opts in: a global
26 * default is not a decision about this project, and treating it as one would
27 * push every cheaper session back onto it.
28 */
29const PIN_LAYERS = ['policy', 'flag', 'local', 'override', 'project'] as const
30const pinLayers = (includeUser: boolean): readonly ModelPinSource[] =>
31  includeUser ? [...PIN_LAYERS, 'user'] : PIN_LAYERS
32
33// The engine's own fallback (overload, rate limit) gets this long to clear
34// before the one automatic re-apply, so the pin does not fight it.
35const AUTO_RETRY_MS = 10 * 60 * 1000
36// Sources where the model was restored or defaulted rather than chosen: only
37// there is putting the pin back silently the right call. After a /clear or a
38// compact the person may have switched on purpose, so those get the badge only.
39const REAPPLY_ON_START = new Set(['startup', 'resume', 'fork'])
40// Who may change the pin: the person, at the terminal (`composer`), through
41// Remote Control (`bridge`) or in the desktop app, which hosts its sessions
42// over the SDK (`sdk`). A peer session, a channel, a schedule, a task
43// notification or another plugin can read it but never move it (the pin
44// outlives the session and would steer every later one).
45const PIN_EDITORS = new Set(['composer', 'bridge', 'sdk'])
46// The switches the optional lock asks about: each is the person's own, from
47// /model, the picker, or the desktop app and Remote Control (headless
48// set_model arrives as `sdk`). A source the engine adds later passes unasked.
49const LOCKED_SOURCES = new Set(['command', 'picker', 'sdk'])
50
51type Dollar = EngineInterface
52type Pin = { model: string; from: ModelPinSource }
53type Seen = { pin: Pin; isDrift: boolean }
54
55/**
56 * The $.store key of this repository's override. The store is one file shared
57 * by every session, so the key names the repository's main working tree: a
58 * pin set in one checkout holds in its linked worktrees too, as a committed
59 * settings.json would. Outside a repository the session root stands in.
60 */
61const overrideKey = async ($: Dollar) => {
62  let root: string | undefined
63
64  try {
65    root = (await $.session.repo())?.root
66  } catch {
67    root = undefined
68  }
69
70  return `pin:${root ?? (await $.session.root())}`
71}
72
73/**
74 * The pin in force: the highest layer that defines a model. A defined but
75 * unusable settings value, or a layer that cannot be read, means "no pin"
76 * rather than a fall-through: the engine obeys that layer, so pinning a lower
77 * one would fight it. Only a malformed override (the mod's own data, which
78 * the engine never reads) is skipped.
79 */
80const getPin = async ($: Dollar, includeUser: boolean): Promise<Pin | undefined> => {
81  for (const from of pinLayers(includeUser)) {
82    let model: unknown
83
84    try {
85      model = from === 'override' ? await $.store.get(await overrideKey($)) : (await $.settings.read({ source: from })).model
86    } catch {
87      return undefined
88    }
89
90    if (model === undefined || model === null || (from === 'override' && !isValidPin(model))) {
91      continue
92    }
93
94    return isValidPin(model) ? { model, from } : undefined
95  }
96
97  return undefined
98}
99
100/*
101 * Flip a per-session flag to true and say whether this call was the one that
102 * did. One function per flag: the state library's update must see the atom as
103 * a literal const of this file, so it cannot take the atom as a parameter.
104 */
105const claimReapply = async ($: Dollar): Promise<boolean> => {
106  let won = false
107
108  await update($, reappliedOnce, held => {
109    won = !held
110
111    return true
112  })
113
114  return won
115}
116
117const claimToast = async ($: Dollar): Promise<boolean> => {
118  let won = false
119
120  await update($, toastShown, held => {
121    won = !held
122
123    return true
124  })
125
126  return won
127}
128
129/** Takes the fallback retry due at `due`, unless a later fallback has re-armed it. */
130const claimRetry = async ($: Dollar, due: number): Promise<boolean> => {
131  let won = false
132
133  await update($, retryAt, held => {
134    won = held === due
135
136    return won ? 0 : held
137  })
138
139  return won
140}
141
142const clearBadge = async ($: Dollar) => {
143  if (await read($, drifted)) {
144    await update($, drifted, () => false)
145    await $.ui.status(undefined)
146  }
147}
148
149/**
150 * Compares `current` to the pin and keeps the badge true to it: status line on
151 * drift (every time, in case something cleared it), a toast the first time,
152 * and the badge cleared once back on the pin. Returns undefined when there is
153 * nothing to compare (no pin, no model string, or a pin that cannot be
154 * compared), so callers do nothing.
155 */
156const observe = async ($: Dollar, current: unknown, includeUser: boolean): Promise<Seen | undefined> => {
157  if (typeof current !== 'string' || current.trim() === '') {
158    return undefined
159  }
160
161  const pin = await getPin($, includeUser)
162
163  if (pin === undefined || !isComparable(pin.model)) {
164    await clearBadge($)
165
166    return undefined
167  }
168
169  if (sameModel(current, pin.model)) {
170    await clearBadge($)
171
172    return { pin, isDrift: false }
173  }
174
175  await update($, drifted, () => true)
176  await $.ui.status(`model drift: ${clip(current)} (pinned ${clip(pin.model)})`)
177
178  if (await claimToast($)) {
179    await $.ui.toast(`Model drifted to ${clip(current)}; this project pins ${clip(pin.model)}.`)
180  }
181
182  return { pin, isDrift: true }
183}
184
185/**
186 * Hands the switch to the person: `/model <pin>` filled into an empty composer
187 * for one Enter, else named in a toast (`isQuiet` skips the toast for a caller
188 * that says it in its own reply). A draft the person is typing is never
189 * overwritten. Pressing Enter runs their own /model, so whatever it saves is
190 * their choice, not the mod's. Says whether the composer was filled.
191 */
192const offer = async ($: Dollar, pin: string, isQuiet = false): Promise<boolean> => {
193  const command = `/model ${pin}`
194  let isFilled = false
195
196  try {
197    if ((await $.prompt.read()).text.trim() === '') {
198      isFilled = (await $.prompt.fill({ text: command, mode: 'replace' })).isFilled
199    }
200  } catch {
201    // A composer that cannot be read or filled falls to the toast.
202  }
203
204  if (!isQuiet) {
205    try {
206      await $.ui.toast(
207        isFilled ? `Press Enter to return to ${clip(pin)}.` : `Model drifted: type ${clip(command, 80)} to return to the pin.`,
208      )
209    } catch {
210      // Nothing left to try; the badge already says the model drifted.
211    }
212  }
213
214  return isFilled
215}
216
217type Handoff = 'queued' | 'filled' | 'unfilled'
218
219/**
220 * The person's user-level default as written, when it names `pin` (see
221 * silentSwitchArg); undefined otherwise, or when it cannot be read: a default
222 * that cannot be shown to match is never switched to on their behalf.
223 */
224const ownDefault = async ($: Dollar, pin: string): Promise<string | undefined> => {
225  try {
226    return silentSwitchArg((await $.settings.read({ source: 'user' })).model, pin)
227  } catch {
228    return undefined
229  }
230}
231
232/**
233 * Starts the move back to `pin`. The built-in /model may save its argument as
234 * the person's default for every new session, so the mod queues one itself
235 * only when silentSwitchArg allows it (the pin already is that default, and
236 * the argument is the default as written); any other pin, wherever it came
237 * from, is offered instead. The queued command is not awaited: it runs once
238 * the session is idle, and inside a hook the turn waits on it rejects, which
239 * is what the .catch is for.
240 */
241const restore = async ($: Dollar, pin: string, isQuiet = false): Promise<Handoff> => {
242  const own = await ownDefault($, pin)
243
244  if (own !== undefined) {
245    try {
246      // A refusal offers the default as written too, so even the person's
247      // own Enter never swaps it for another spelling of the same model.
248      $.command.run({ command: 'model', args: own }).catch(() => offer($, own))
249
250      return 'queued'
251    } catch {
252      // Refused outright: offer it instead.
253    }
254  }
255
256  return (await offer($, own ?? pin, isQuiet)) ? 'filled' : 'unfilled'
257}
258
259/**
260 * The one automatic re-apply per session, and the only way any automatic path
261 * (a start or resume read, a resume switch, the fallback timer) reaches
262 * restore. While an engine fallback waits out its ten minutes (retryAt set),
263 * the armed timer owns the re-apply, so a resume or fork in that window must
264 * not cut the wait short; the timer clears retryAt before it calls in. The
265 * flag is claimed before anything is queued or offered, so two events racing
266 * (a resume fires both PostModelSwitch and SessionStart) still make a single
267 * attempt.
268 */
269const reapply = async ($: Dollar, pin: string) => {
270  if ((await read($, retryAt)) !== 0 || !(await claimReapply($))) {
271    return
272  }
273
274  await restore($, pin)
275}
276
277/**
278 * The retry after an engine fallback, due at `due`: re-check, then re-apply
279 * once if still off the pin. Only the timer for the latest fallback acts, so a
280 * fresh fallback pushes the retry back rather than being undone a minute in;
281 * a timer armed twice (a reload re-arm racing the original) acts once.
282 */
283const retryAfterFallback = async ($: Dollar, due: number, includeUser: boolean) => {
284  try {
285    if (!(await claimRetry($, due)) || (await read($, reappliedOnce))) {
286      return
287    }
288
289    const seen = await observe($, await $.session.model(), includeUser)
290
291    if (seen?.isDrift) {
292      await reapply($, seen.pin.model)
293    }
294  } catch {
295    // A failed retry leaves the badge as it was.
296  }
297}
298
299/** Arms the fallback retry for `due`. The due time lives in $.state, so a reload can arm it again. */
300const armRetry = async ($: Dollar, due: number, includeUser: boolean) => {
301  const wait = Math.max(0, due - (await $.clock.now()))
302
303  $.clock.after(wait, () => void retryAfterFallback($, due, includeUser))
304}
305
306/**
307 * Swaps an alias override (`fable`) for the resolved id a switch to that very
308 * alias landed on, so later comparisons are like for like and survive the CLI
309 * re-pointing the alias. A switch the person asked for by another name (a
310 * one-off `/model claude-opus-4-1` under an `opus` pin) never re-pins.
311 */
312const resolveOverride = async ($: Dollar, resolved: string, requested: string | null, stored: string) => {
313  const alias = normalizeModel(stored)
314
315  if (
316    isAlias(alias) &&
317    isComparable(stored) &&
318    requested !== null &&
319    normalizeModel(requested) === alias &&
320    isValidPin(resolved) &&
321    sameModel(resolved, stored)
322  ) {
323    // The store is shared with every session on this repository and has no
324    // compare-and-swap: read again right before writing, so a /model-pin clear
325    // or a new pin from another session since this hook's first read stands.
326    const key = await overrideKey($)
327
328    if ((await $.store.get(key)) === stored) {
329      await $.store.set(key, resolved)
330    }
331  }
332}
333
334/** A new or cleared pin starts the session's drift bookkeeping afresh. */
335const resetSession = async ($: Dollar) => {
336  await update($, reappliedOnce, () => false)
337  await update($, toastShown, () => false)
338  await update($, retryAt, () => 0)
339  await clearBadge($)
340}
341
342/** The session's model, or undefined when it cannot be read (before one resolves). */
343const readModel = async ($: Dollar): Promise<string | undefined> => {
344  try {
345    const model: unknown = await $.session.model()
346
347    return typeof model === 'string' && model.trim() !== '' ? model : undefined
348  } catch {
349    return undefined
350  }
351}
352
353const describe = async ($: Dollar, includeUser: boolean): Promise<string> => {
354  const pin = await getPin($, includeUser)
355  const current = await readModel($)
356  const session = current === undefined ? "The session's model is not known yet" : `Session is on ${clip(current)}`
357
358  if (pin === undefined) {
359    return `No model pin for this project. ${session}. Set one with /model-pin <model>, or "model" in .claude/settings.json.`
360  }
361
362  if (!isComparable(pin.model)) {
363    return `Pinned to ${clip(pin.model)} (${pin.from}), a name that cannot be checked against the session's model, so drift is not tracked. ${session}.`
364  }
365
366  if (current === undefined) {
367    return `Pinned to ${clip(pin.model)} (${pin.from}). ${session}.`
368  }
369
370  const state = sameModel(current, pin.model) ? 'on the pin' : 'DRIFTED'
371
372  return `Pinned to ${clip(pin.model)} (${pin.from}). ${session}: ${state}.`
373}
374
375export const register: Register = (on, options) => {
376  const includeUser = options.includeUserSettings === true
377
378  on('session.start', async ($, e, next) => {
379    try {
380      await $.command.register({
381        name: 'model-pin',
382        description: 'Pin this project to a model, clear the pin, or show it',
383        argumentHint: '[model|clear]',
384      })
385    } catch {
386      // The pin and drift badge work without the command.
387    }
388
389    try {
390      await observe($, await $.session.model(), includeUser)
391    } catch {
392      // Observer: never block the session from starting.
393    }
394
395    // session.start runs again after a reload, which drops timers: put back a
396    // fallback retry that was still waiting.
397    try {
398      const due = await read($, retryAt)
399
400      if (due > 0 && !(await read($, reappliedOnce))) {
401        await armRetry($, due, includeUser)
402      }
403    } catch {
404      // Without the retry the badge still shows; the person can /model back.
405    }
406
407    return next(e)
408  })
409
410  on('classic.SessionStart', async ($, e, next) => {
411    // A subagent's events are its own: the pin and the badge are the main
412    // session's.
413    if (e.agent_id !== undefined) {
414      return next(e)
415    }
416
417    try {
418      const seen = await observe($, await $.session.model(), includeUser)
419
420      // A session started as an agent runs that agent's own model on purpose:
421      // badge only, never a switch. (A waiting fallback retry is checked in
422      // reapply, as it is for every automatic path.)
423      if (seen?.isDrift && REAPPLY_ON_START.has(e.source) && e.agent_type === undefined) {
424        await reapply($, seen.pin.model)
425      }
426    } catch {
427      // Observer: never block the session from starting.
428    }
429
430    return next(e)
431  }).catch(($, e, next) => next(e))
432
433  // Observers that fail open: if a hook throws past its own try, the .catch
434  // replays what is beneath, so a broken pin can never stop a start or a switch.
435  on('classic.PostModelSwitch', async ($, e, next) => {
436    // A subagent's own switch (its fallback, say) is not the main session's
437    // model: it must not badge, toast or arm the retry.
438    if (e.agent_id !== undefined) {
439      return next(e)
440    }
441
442    try {
443      const seen = await observe($, e.to_model, includeUser)
444
445      if (seen?.isDrift && e.source === 'resume') {
446        await reapply($, seen.pin.model)
447      } else if (seen?.isDrift && e.source === 'auto') {
448        // Every fallback restarts the wait, so the retry comes ten minutes
449        // after the latest one, never sooner.
450        if (!(await read($, reappliedOnce))) {
451          const due = (await $.clock.now()) + AUTO_RETRY_MS
452
453          await update($, retryAt, () => due)
454          await armRetry($, due, includeUser)
455        }
456      } else if (seen?.pin.from === 'override' && e.source === 'command') {
457        await resolveOverride($, e.to_model, e.requested_model, seen.pin.model)
458      }
459    } catch {
460      // Observer: a failed check must never block or undo a model switch.
461    }
462
463    return next(e)
464  }).catch(($, e, next) => next(e))
465
466  // Optional lock: ask before a deliberate switch away from the pin. 'ask',
467  // never 'deny', so the person can still go on purpose. An observer that
468  // fails open: if this hook throws, the switch proceeds unasked. The ask
469  // replaces an 'allow' from a hook beneath (another plugin's) on purpose: the
470  // lock is the person's own setting and asking is the stricter answer, so it
471  // fails safe; a 'deny' beneath is kept as it is.
472  on('classic.PreModelSwitch', async ($, e, next) => {
473    const ran = await next(e)
474
475    if (options.lock !== true || ran.permissionDecision === 'deny' || e.agent_id !== undefined) {
476      return ran
477    }
478
479    if (!LOCKED_SOURCES.has(e.source)) {
480      return ran
481    }
482
483    const pin = await getPin($, includeUser)
484
485    // A pin that cannot be compared would ask on every switch, its own included.
486    if (pin === undefined || !isComparable(pin.model) || sameModel(e.to_model, pin.model)) {
487      return ran
488    }
489
490    if (e.requested_model !== null && normalizeModel(e.requested_model) === normalizeModel(pin.model)) {
491      return ran
492    }
493
494    return {
495      ...ran,
496      permissionDecision: 'ask',
497      permissionDecisionReason: `project pins ${clip(pin.model)}`,
498    }
499  }).catch(($, e, next) => next(e))
500
501  // This hook answers its own command, so a throw would reach the person as a
502  // hook error rather than a reply: every failure ends as a plain message
503  // instead (never the raw error, which could carry a path or a value).
504  on('command.run', { command: 'model-pin' }, async ($, e) => {
505    try {
506      const arg = e.args.trim()
507
508      if (arg === '') {
509        return { text: await describe($, includeUser) }
510      }
511
512      // Reading the pin is harmless from anywhere; changing it is the person's alone.
513      if (!PIN_EDITORS.has(e.origin?.kind)) {
514        return { text: 'Only you can change the model pin: type /model-pin at the prompt.' }
515      }
516
517      const key = await overrideKey($)
518
519      if (arg.toLowerCase() === 'clear') {
520        await $.store.delete(key)
521        await resetSession($)
522        const left = await getPin($, includeUser)
523
524        return {
525          text:
526            left === undefined
527              ? 'Model pin cleared for this project.'
528              : `Override cleared. This project is still pinned to ${clip(left.model)} by its ${left.from} settings.`,
529        }
530      }
531
532      if (!isValidPin(arg)) {
533        return { text: 'Give one model id or alias, for example /model-pin claude-fable-5-1, or "clear".' }
534      }
535
536      // Stored as typed first so the optional lock lets the switch to it
537      // through; PostModelSwitch then swaps an alias for the resolved id it
538      // lands on.
539      await $.store.set(key, arg)
540      await resetSession($)
541
542      const pin = await getPin($, includeUser)
543
544      if (pin?.from !== 'override') {
545        return {
546          text:
547            pin === undefined
548              ? `Saved ${clip(arg)} for this project, but it is not in force: a settings source could not be read.`
549              : `Saved ${clip(arg)} for this project, but its ${pin.from} settings pin ${clip(pin.model)} and take precedence here.`,
550        }
551      }
552
553      const pinned = `Pinned this project to ${clip(arg)}.`
554      const unchecked = isComparable(arg) ? '' : ' That name cannot be checked against the session model, so drift will not be tracked.'
555      const current = await readModel($)
556
557      if (current !== undefined && sameModel(current, arg)) {
558        if (isValidPin(current)) {
559          await $.store.set(key, current)
560        }
561
562        return { text: `${pinned} The session is already on it.${unchecked}` }
563      }
564
565      // The override is never the person's default, unless their user
566      // settings already name it, so the switch is usually offered, not run.
567      const handoff = await restore($, arg, true)
568
569      // Queued, not yet run: from inside this hook the engine may refuse it,
570      // and then the offer (a fill or a toast) says so after this reply. So
571      // the reply claims only the queueing, never a switch.
572      if (handoff === 'queued') {
573        return {
574          text: `${pinned} It is your own default, so /model is queued to switch the session; if it cannot run from here, it is offered at the prompt instead.${unchecked}`,
575        }
576      }
577
578      return {
579        text:
580          handoff === 'filled'
581            ? `${pinned} Press Enter on the /model line in the prompt to switch the session to it.${unchecked}`
582            : `${pinned} Type ${clip(`/model ${arg}`, 80)} to switch the session to it.${unchecked}`,
583      }
584    } catch {
585      return { text: 'model-pin could not finish. Run /model-pin to see where the pin stands.' }
586    }
587  })
588}
589
hooks/pin.ts 142 lines
1// Pure helpers for model-pin: no `$`, so the tests can pin their behavior down
2// without an engine. The one hard problem is comparing a pin to the model the
3// session reports: /model shows a display name or a resolved id, a settings
4// pin is often an alias, and a gateway may prefix or date-stamp the id. A
5// naive string compare would show drift on every session.
6
7/** Longest model string accepted anywhere (pin, status text, command args). */
8const MAX_MODEL_LENGTH = 100
9
10/**
11 * Whether `value` may be used as a pin: a single token of the characters model
12 * ids and aliases use. It reaches `/model <pin>` and the composer, so anything
13 * with whitespace, a newline or a shell/command metacharacter is refused
14 * rather than trusted (a settings file is data, not instructions).
15 */
16export const isValidPin = (value: unknown): value is string =>
17  typeof value === 'string' &&
18  value.length <= MAX_MODEL_LENGTH &&
19  /^[A-Za-z0-9][A-Za-z0-9._:/@[\]-]*$/.test(value)
20
21/**
22 * Reduces a model id, alias or display name to one comparable form: lowercase,
23 * no `[1m]`-style or parenthetical suffix, no provider prefix (`anthropic.`,
24 * `us.anthropic.`, `bedrock/`), no date stamp or version tail, no `claude-`
25 * lead, and spaces, dots and underscores folded to hyphens, so the display
26 * name "Fable 5.1" and the id `claude-fable-5-1` meet at `fable-5-1`.
27 */
28export const normalizeModel = (raw: string): string => {
29  let id = raw.trim().toLowerCase()
30  id = id.replace(/\[[^\]]*\]/g, '').replace(/\([^)]*\)/g, '')
31  id = id.replace(/^(?:[a-z]{2,6}\.)?anthropic[./]/, '')
32  id = id.replace(/^(?:bedrock|vertex_ai|vertex|azure|foundry)\//, '')
33  id = id.replace(/[\s._]+/g, '-')
34  id = id.replace(/-v\d+(?::\d+)?$/, '').replace(/:\d+$/, '')
35  id = id.replace(/-latest$/, '').replace(/[@-]\d{8}$/, '')
36  id = id.replace(/^claude-/, '')
37
38  return id.replace(/-+/g, '-').replace(/^-|-$/g, '')
39}
40
41/**
42 * An alias is a bare family word (`opus`, `fable`, `sonnet`): it names no
43 * version, so it can only be matched against the family of a resolved id.
44 */
45export const isAlias = (normalized: string): boolean => /^[a-z]+$/.test(normalized)
46
47/**
48 * The model families an alias can name. A bare word outside this list
49 * (`default`, `opusplan`, a gateway's own name) resolves to something that
50 * never spells the word, so family matching cannot work for it.
51 */
52const FAMILIES = new Set(['fable', 'opus', 'sonnet', 'haiku'])
53
54/**
55 * Whether a pin can be checked against the model the session reports. Not for
56 * a bare word outside the known families (`opusplan` runs Opus or Sonnet by
57 * mode; `default` is whatever the account defaults to) or a provider ARN
58 * (`arn:aws:bedrock:...`, an inference profile whose id never matches the
59 * model's). Such a pin is honored only by exact match and never drives a badge
60 * or a re-apply: comparing it would report drift on every session and spend
61 * the one re-apply for nothing.
62 */
63export const isComparable = (pin: string): boolean => {
64  const id = normalizeModel(pin)
65
66  if (id === '' || /^arn:/i.test(pin.trim())) {
67    return false
68  }
69
70  return !isAlias(id) || FAMILIES.has(id)
71}
72
73/**
74 * Whether two model strings name the same model once normalized. An alias
75 * matches any id of its family (`fable` vs `claude-fable-5-1`), which is a
76 * deliberate trade: a settings pin written as an alias cannot be resolved
77 * without switching, and flagging drift on every session is worse than missing
78 * a same-family version change. A pin stored as a resolved id compares exactly.
79 * Anything empty never matches, so an unknown model is never "the pin".
80 */
81export const sameModel = (a: string, b: string): boolean => {
82  const left = normalizeModel(a)
83  const right = normalizeModel(b)
84
85  if (left === '' || right === '') {
86    return false
87  }
88
89  if (left === right) {
90    return true
91  }
92
93  if (isAlias(left)) {
94    return right.split('-').includes(left)
95  }
96
97  if (isAlias(right)) {
98    return left.split('-').includes(right)
99  }
100
101  return false
102}
103
104/**
105 * The bracketed variant tags of a model string (`[1m]`), lowercased. normalizeModel
106 * drops them so a badge does not flag `opus[1m]` against `claude-opus-5-5`, but
107 * a variant can change the context window and its pricing, so a switch the mod
108 * makes on its own must not add or drop one.
109 */
110const variantOf = (raw: string): string => (raw.match(/\[[^\]]*\]/g) ?? []).join('').toLowerCase()
111
112/**
113 * The argument for a /model the mod may run by itself, or undefined when the
114 * switch must be left to the person. The built-in /model may save its argument
115 * as the default for every new session (user settings), so a pin from a
116 * project, local, policy or --settings file, or the /model-pin override, must
117 * never be run for them: it would quietly rewrite their global default, and a
118 * cloned repository's settings are not theirs to trust with that. Only when
119 * the person's own default already names the pin (equal once normalized, so
120 * not merely the same family, and with the same `[1m]`-style variant) does the
121 * mod run it, and then with that default as written, so saving it again
122 * changes nothing.
123 */
124export const silentSwitchArg = (userModel: unknown, pin: string): string | undefined => {
125  if (!isValidPin(userModel) || !isValidPin(pin)) {
126    return undefined
127  }
128
129  const own = normalizeModel(userModel)
130
131  return own !== '' && own === normalizeModel(pin) && variantOf(userModel) === variantOf(pin) ? userModel : undefined
132}
133
134/**
135 * Makes a model string safe for a toast or status line: control characters
136 * dropped, length capped. Models are not secrets, but the string came from a
137 * file or the engine and is drawn verbatim.
138 */
139export const clip = (text: string, max = 60): string =>
140  // eslint-disable-next-line no-control-regex
141  text.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max)
142
types/index.d.ts 26 lines
1// The $.state contract of model-pin. Every key here is named by a literal
2// plugin/key reference in hooks/register.tsx. The file exports a type so it is
3// a module: without a top-level export, `declare module` below would declare a
4// new ambient module instead of augmenting 'claude-code'.
5
6/** Where the pin in force came from: the /model-pin override or a settings source. */
7export type ModelPinSource = 'override' | 'policy' | 'flag' | 'local' | 'project' | 'user'
8
9declare module 'claude-code' {
10  interface PluginState {
11    'model-pin': {
12      /** The one automatic re-apply of the pin this session has been spent. */
13      reappliedOnce: boolean
14      /** The session is on a model other than the pin (the badge is up). */
15      drifted: boolean
16      /** The drift toast has been shown this session; never shown twice. */
17      toastShown: boolean
18      /**
19       * When the retry after an engine fallback is due (epoch ms), or 0 when
20       * none waits. Kept here, not only in a timer, so a reload can re-arm it.
21       */
22      retryAt: number
23    }
24  }
25}
26