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

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.
--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.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.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.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.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.| Event | Purpose |
|---|---|
session.start | registers /model-pin; reads the model once and shows drift (badge only); re-arms a waiting fallback retry |
classic.SessionStart | reads the model once; badge, plus the one re-apply for startup, resume, fork (observer, .catch falls through) |
classic.PostModelSwitch | drift check on to_model; re-apply on resume, timed retry on auto; resolves an alias override (observer, .catch falls through) |
classic.PreModelSwitch | the 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.
/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.
| Option | Type | Default | Meaning |
|---|---|---|---|
lock | boolean | false | ask before a manual switch away from the pin |
includeUserSettings | boolean | false | count 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.
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.
--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./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./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.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.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.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./model-pin <model> and /model-pin clear reset it.$.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.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)./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.hooks/register.tsx 589 lines1import { 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}
589hooks/pin.ts 142 lines1// 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)
142types/index.d.ts 26 lines1// 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