switchroom 0.19.34 โ†’ 0.19.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,7 @@ import {
33
33
  type DiscoverResult,
34
34
  type ModelPickerOption,
35
35
  } from '../../src/agents/model-picker.js'
36
+ import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.js'
36
37
 
37
38
  /**
38
39
  * Aliases the claude CLI resolves natively (`claude --help`: "an alias for
@@ -50,6 +51,34 @@ import {
50
51
  */
51
52
  export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as const
52
53
 
54
+ /**
55
+ * Short SWITCHROOM-side spellings for pinned **Anthropic** model ids. These are
56
+ * expanded to the full `claude-*` id on the `/model` path BEFORE any
57
+ * Claude-vs-external classification runs, so what reaches the `.session-model`
58
+ * carrier (and therefore `claude --model`) is always the canonical id โ€” the CLI
59
+ * never sees the short spelling.
60
+ *
61
+ * Deliberately NOT `SR_MODEL_ALIASES`: that map means "external / OpenRouter /
62
+ * the Anthropic OAuth header is NOT forwarded". These targets are Anthropic
63
+ * OAuth-passthrough models, so putting them there would flip the
64
+ * header-forwarding + external-billing classification and surface them under the
65
+ * "๐ŸŒ External models" keyboard page with an `srFriendlyLabel`.
66
+ *
67
+ * Also NOT `MODEL_ALIASES`: that list is the aliases the claude CLI resolves
68
+ * *itself*, and its members double as FAMILY tokens in `modelFamilyToken` /
69
+ * `canonicalClaudeToken` / `servedModelMatchesRequested`. A pinned-id shortcut
70
+ * is neither.
71
+ *
72
+ * NB the repo prefers family aliases over pinned ids (an alias tracks the
73
+ * current flagship, a pinned id goes stale) โ€” these exist because an operator
74
+ * sometimes wants a specific pinned Opus, and typing `claude-opus-4-8` on a
75
+ * phone is hostile. Keys are matched case-insensitively.
76
+ */
77
+ export const CLAUDE_MODEL_ALIASES: Record<string, string> = {
78
+ opus48: 'claude-opus-4-8',
79
+ 'opus-4-8': 'claude-opus-4-8',
80
+ }
81
+
53
82
  /**
54
83
  * Shape gate for the model argument. This string is typed literally
55
84
  * into the agent's tmux pane, so the gate is strict by construction:
@@ -76,11 +105,15 @@ export function isSrModel(name: string): boolean {
76
105
  * True when `name` is a Claude model โ€” either a well-known alias or a
77
106
  * full `claude-*` id (including `[1m]` variants). This is intentionally
78
107
  * broader than MODEL_ALIASES: any `claude-โ€ฆ` string from claude's own
79
- * picker (e.g. `claude-opus-4-8`) qualifies.
108
+ * picker (e.g. `claude-opus-4-8`) qualifies, as does a switchroom-side short
109
+ * spelling of a pinned Claude id (CLAUDE_MODEL_ALIASES, e.g. `opus48`) โ€” those
110
+ * name an Anthropic OAuth-passthrough model, so a classification consumer must
111
+ * never mistake one for an external/OpenRouter route.
80
112
  */
81
113
  export function isClaudeModel(name: string): boolean {
82
114
  const lower = name.toLowerCase()
83
115
  if ((MODEL_ALIASES as readonly string[]).includes(lower)) return true
116
+ if (lower in CLAUDE_MODEL_ALIASES) return true
84
117
  return lower.startsWith('claude-')
85
118
  }
86
119
 
@@ -621,7 +654,7 @@ export function planModelCommand(
621
654
  ): ModelCommandDisposition {
622
655
  if (parsed.kind === 'show' && ctx.menuEnabled) return { kind: 'menu' }
623
656
  if (parsed.kind === 'set' && isModelCommandBusy(ctx)) {
624
- return { kind: 'queue', target: expandSrAlias(parsed.model) }
657
+ return { kind: 'queue', target: expandModelAlias(parsed.model) }
625
658
  }
626
659
  return { kind: 'apply', parsed }
627
660
  }
@@ -662,6 +695,24 @@ export interface ModelCommandDeps {
662
695
  * rendered as "default".
663
696
  */
664
697
  getConfiguredModel: () => string | null
698
+ /**
699
+ * The agent's cascade-resolved `thinking_effort` from `switchroom agent list`
700
+ * โ€” the value start.sh bakes into `--effort` on the relaunch this command is
701
+ * about to schedule. Null when unset / unreadable (treated as the scaffold
702
+ * floor `low`, i.e. safe).
703
+ *
704
+ * Needed because a `/model` switch is SESSION-scoped and therefore invisible
705
+ * to `switchroom doctor`, whose `thinking_effort ร— adaptive model` check
706
+ * (`src/cli/doctor.ts:572`) reads the CONFIGURED `model:` out of
707
+ * switchroom.yaml. Switching the live session onto a pinned Opus 4.x id โ€” by
708
+ * typing it, by a `CLAUDE_MODEL_ALIASES` shortcut, or by tapping the
709
+ * "Opus 4.8" button โ€” lands the exact (pinned Opus 4.x, effort > low)
710
+ * combination doctor exists to warn about, with doctor none the wiser. So the
711
+ * assessment is re-run HERE, at switch time, against the effort the relaunch
712
+ * will actually use. A relaunch writes no `.session-effort` carrier, so any
713
+ * live `/effort` override is shed and the configured value is the right input.
714
+ */
715
+ getConfiguredEffort: () => string | null
665
716
  escapeHtml: (s: string) => string
666
717
  preBlock: (s: string) => string
667
718
  /**
@@ -721,14 +772,34 @@ export interface ModelCommandReply {
721
772
  const PERSIST_NOTE =
722
773
  '_A `/model` switch relaunches the session (~30s) on the chosen model. Session-only โ€” reverts to the configured \`model:\` on the next restart. \`/model default\` reverts now. Live scrollback is replaced by a fresh session; memory and the handoff briefing carry the context. To change the default permanently, set \`model:\` in switchroom.yaml._'
723
774
 
775
+ /**
776
+ * Discoverability line for CLAUDE_MODEL_ALIASES, grouped by target so several
777
+ * spellings of one id read as one entry (`opus48` ยท `opus-4-8` โ†’ `claude-opus-4-8`).
778
+ * Derived from the map, never hand-listed, so a new shortcut shows up in the
779
+ * help and dashboard text for free.
780
+ */
781
+ function claudeAliasHints(prefix: string): string {
782
+ const byTarget = new Map<string, string[]>()
783
+ for (const [alias, target] of Object.entries(CLAUDE_MODEL_ALIASES)) {
784
+ const spellings = byTarget.get(target) ?? []
785
+ spellings.push(alias)
786
+ byTarget.set(target, spellings)
787
+ }
788
+ return [...byTarget]
789
+ .map(([target, spellings]) => `${spellings.map(a => `\`${prefix}${a}\``).join(' ยท ')} โ†’ \`${target}\``)
790
+ .join('; ')
791
+ }
792
+
724
793
  function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
725
794
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' ยท ')
795
+ const claudeAliasExamples = claudeAliasHints('')
726
796
  const lines: string[] = []
727
797
  if (reason) lines.push(`โš ๏ธ ${deps.escapeHtml(reason)}`)
728
798
  lines.push(
729
799
  '**/model** โ€” show or switch the Claude model',
730
800
  '\`/model\` โ€” show the configured model',
731
801
  `\`/model <name>\` โ€” switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' ยท ')} or a full model id)`,
802
+ `_Pinned Claude shortcuts:_ ${claudeAliasExamples}`,
732
803
  `_OpenRouter shortcuts:_ ${srAliasExamples}`,
733
804
  '_Every switch relaunches the session (~30s) on the chosen model โ€” Claude and OpenRouter (sr-\\*) alike._',
734
805
  PERSIST_NOTE,
@@ -751,6 +822,7 @@ export async function handleModelCommand(
751
822
  `**Model โ€” ${deps.escapeHtml(deps.getAgentName())}**`,
752
823
  `Configured: \`${deps.escapeHtml(shown)}\``,
753
824
  `Switch the live session: ${MODEL_ALIASES.map(a => `\`/model ${a}\``).join(' ยท ')}`,
825
+ `Pinned Claude shortcuts: ${claudeAliasHints('/model ')}`,
754
826
  `OpenRouter shortcuts: ${srAliasExamples}`,
755
827
  'or \`/model <full-model-id>\`',
756
828
  PERSIST_NOTE,
@@ -765,8 +837,9 @@ export async function handleModelCommand(
765
837
  return helpText(deps, `not a valid model name: ${parsed.model}`)
766
838
  }
767
839
 
768
- // Expand short aliases: `flash` โ†’ `sr-gemini-2.5-flash`, `codex` โ†’ `sr-codex-5.5`, etc.
769
- const model = expandSrAlias(parsed.model)
840
+ // Expand short aliases BEFORE any Claude-vs-external decision below:
841
+ // `opus48`/`opus-4-8` โ†’ `claude-opus-4-8`, `flash` โ†’ `sr-gemini-2.5-flash`, etc.
842
+ const model = expandModelAlias(parsed.model)
770
843
 
771
844
  // Busy gate: a switch RELAUNCHES the session, which is unsafe mid-turn (it
772
845
  // would tear down the live turn). The gateway's mid-turn path ACKs + queues +
@@ -822,16 +895,70 @@ function relaunchErrorReply(
822
895
  * silently serves the fallback. Say so IMMEDIATELY in the switch ack, and
823
896
  * point at the first-reply tripwire that will catch it. Aliases and sr-* ids
824
897
  * are vouched by their own gates (MODEL_ALIASES / the LiteLLM route probe),
825
- * so only typed `claude-*` ids carry the caveat. Exported for tests.
898
+ * so only typed `claude-*` ids carry the caveat โ€” and a CLAUDE_MODEL_ALIASES
899
+ * TARGET is vouched by the same curation gate that makes it offline-trustable
900
+ * (isOfflineTrustedModelToken), so it is exempt too; warning "can't be
901
+ * validated" on a shortcut switchroom itself curates would contradict that.
902
+ *
903
+ * The exemption compares `canonicalModelToken(model)` โ€” the SAME form
904
+ * `expandModelAlias` emits โ€” so the token that reaches `claude --model` and the
905
+ * token this gate judges can never disagree. Comparing a lowercased form against
906
+ * a verbatim-emitted one is exactly how `/model CLAUDE-OPUS-4-8` used to launch
907
+ * an uncanonical id under a clean green ack.
908
+ *
909
+ * What the exemption costs, stated honestly: the ONLY remaining signal that an
910
+ * exempted target silently fell back is the first-reply divergence tripwire
911
+ * (`servedModelMatchesRequested`). The post-boot confirmation card is NOT a
912
+ * second signal for a pinned id whose FAMILY equals the configured default's:
913
+ * `modelFamilyToken('claude-opus-4-8')` is `'opus'`, so on an `opus`-default
914
+ * agent `classifyModelSwitchConfirmation` returns `default`, not `not-applied`
915
+ * (see #4005 โ€” a pre-existing property of the family reduction, reachable
916
+ * before this map existed by typing the full id).
917
+ * Exported for tests.
826
918
  */
827
919
  export function unvalidatedIdCaveat(
828
920
  deps: Pick<ModelCommandDeps, 'escapeHtml'>,
829
921
  model: string,
830
922
  ): string | null {
831
- if (!model.trim().toLowerCase().startsWith('claude-')) return null
923
+ const lower = canonicalModelToken(model).toLowerCase()
924
+ if (!lower.startsWith('claude-')) return null
925
+ if (Object.values(CLAUDE_MODEL_ALIASES).includes(lower)) return null
832
926
  return `_\`${deps.escapeHtml(model)}\` can't be validated before launch โ€” if it isn't a real Claude model id, claude will silently serve the configured fallback model instead. I check the first reply and will warn if that happens._`
833
927
  }
834
928
 
929
+ /**
930
+ * Switch-time surfacing of the #1978 adaptive-thinking risk (`thinking_effort`
931
+ * above `low` on a PINNED Opus 4.x id).
932
+ *
933
+ * `assessThinkingEffortRisk` had exactly ONE consumer โ€” `switchroom doctor`
934
+ * (`src/cli/doctor.ts:572`) โ€” which reads the CONFIGURED `model:` from
935
+ * switchroom.yaml. A `/model` switch is session-scoped and never touches that
936
+ * file, so every route onto a pinned Opus 4.x (typed full id, a
937
+ * `CLAUDE_MODEL_ALIASES` shortcut, or the one-tap "Opus 4.8" button) reached the
938
+ * risky combination with no warning anywhere. This is the second consumer, on
939
+ * the path that actually creates the combination.
940
+ *
941
+ * Advisory only โ€” it never rewrites anyone's effort. Returns null when the combo
942
+ * is safe (effort unset/`low`, or any non-Opus-4.x model, including Opus 5 and
943
+ * the bare `opus` alias).
944
+ */
945
+ export function thinkingEffortCaveat(
946
+ deps: Pick<ModelCommandDeps, 'escapeHtml' | 'getConfiguredEffort'>,
947
+ model: string,
948
+ ): string | null {
949
+ let effort: string | null
950
+ try {
951
+ effort = deps.getConfiguredEffort()
952
+ } catch {
953
+ // The effort probe shells out to `switchroom agent list`; a transient
954
+ // failure must never block or fail the switch it is only annotating.
955
+ return null
956
+ }
957
+ const risk = assessThinkingEffortRisk(model, effort ?? undefined)
958
+ if (!risk.risky || !risk.reason) return null
959
+ return `โš ๏ธ _${deps.escapeHtml(risk.reason)}_`
960
+ }
961
+
835
962
  /** Schedule a carrier relaunch onto `model`, returning the deterministic ack. */
836
963
  async function scheduleRelaunchReply(
837
964
  deps: ModelCommandDeps,
@@ -844,8 +971,14 @@ async function scheduleRelaunchReply(
844
971
  return relaunchErrorReply(deps, model, err)
845
972
  }
846
973
  const caveat = unvalidatedIdCaveat(deps, model)
974
+ const effortRisk = thinkingEffortCaveat(deps, model)
847
975
  return {
848
- text: [switchingLine(deps, model), ...(caveat ? [caveat] : []), PERSIST_NOTE].join('\n'),
976
+ text: [
977
+ switchingLine(deps, model),
978
+ ...(caveat ? [caveat] : []),
979
+ ...(effortRisk ? [effortRisk] : []),
980
+ PERSIST_NOTE,
981
+ ].join('\n'),
849
982
  html: true,
850
983
  }
851
984
  }
@@ -934,15 +1067,28 @@ export const MODEL_CALLBACK_PAGE_MAIN = 'mdl:page:main'
934
1067
  export type ModelMenuPage = 'main' | 'external'
935
1068
 
936
1069
  /**
937
- * Static Claude aliases appended to the scraped Claude group. The claude CLI's
938
- * own `/model` picker (deps.discover) does NOT list `fable`, but the CLI
939
- * resolves the alias natively, so we render it as an extra button that switches
940
- * via the carrier relaunch (MODEL_CALLBACK_ALIAS โ†’ scheduleModelRelaunch, rev
941
- * 5). Extend this list to surface further CLI-resolvable aliases the picker
942
- * omits.
1070
+ * Static Claude `--model` TOKENS appended to the scraped Claude group โ€” extra
1071
+ * keyboard rows for targets the claude CLI's own `/model` picker (deps.discover)
1072
+ * does not list. Each renders as a button that switches via the carrier relaunch
1073
+ * (MODEL_CALLBACK_ALIAS โ†’ scheduleModelRelaunch, rev 5).
1074
+ *
1075
+ * NB "token", not "alias": members are not required to be CLI-resolvable aliases
1076
+ * and are treated opaquely by both call sites (they are only concatenated onto
1077
+ * the `mdl:alias:` callback prefix, whose wire format predates this list). The
1078
+ * two kinds present today:
1079
+ * - `fable` โ€” a genuine alias the CLI resolves itself; the picker just omits
1080
+ * it. Boots via the LiteLLM router repoint in start.sh (see the fable case).
1081
+ * - `claude-opus-4-8` โ€” a full PINNED id, not an alias. Rendered because the
1082
+ * picker doesn't offer it and typing it on a phone is hostile.
1083
+ * Extend with either kind; a member only has to be a legal `claude --model` arg.
943
1084
  */
944
- export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string }> = [
945
- { alias: 'fable', label: 'Fable' },
1085
+ export const EXTRA_CLAUDE_MENU_TOKENS: ReadonlyArray<{ token: string; label: string }> = [
1086
+ { token: 'fable', label: 'Fable' },
1087
+ // Pinned Opus 4.8. The button carries the CANONICAL id (not the `opus48`
1088
+ // shortcut) so the tap needs no expansion to be recognized by an older
1089
+ // gateway, and so `canonicalClaudeToken` resolves it directly. Typed
1090
+ // `/model opus48` / `/model opus-4-8` land on the same target.
1091
+ { token: 'claude-opus-4-8', label: 'Opus 4.8' },
946
1092
  ]
947
1093
 
948
1094
  /**
@@ -1039,13 +1185,69 @@ export function isOfflineTrustedModelToken(token: string): boolean {
1039
1185
  const lower = token.toLowerCase()
1040
1186
  if ((MODEL_ALIASES as readonly string[]).includes(lower)) return true
1041
1187
  if (lower in SR_MODEL_ALIASES) return true
1042
- return Object.values(SR_MODEL_ALIASES).includes(lower)
1188
+ if (Object.values(SR_MODEL_ALIASES).includes(lower)) return true
1189
+ // Curated pinned-Claude shortcuts and their targets. Both spellings are
1190
+ // trustable for the same reason the sr-* alias TARGETS are: the id is
1191
+ // switchroom-curated, not a hand-typed guess. A queue that already expanded
1192
+ // (planModelCommand) persists the VALUE, an alias tap persists the KEY โ€” so
1193
+ // both sides must be accepted or `/model opus48` would survive a boot only
1194
+ // on one of the two paths.
1195
+ if (lower in CLAUDE_MODEL_ALIASES) return true
1196
+ return Object.values(CLAUDE_MODEL_ALIASES).includes(lower)
1043
1197
  }
1044
1198
 
1045
1199
  export function expandSrAlias(arg: string): string {
1046
1200
  return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
1047
1201
  }
1048
1202
 
1203
+ /**
1204
+ * The ONE canonical form of a model token โ€” what `claude --model` is handed and
1205
+ * what every downstream gate compares against.
1206
+ *
1207
+ * Two normalizations, both narrow:
1208
+ * - **trim** โ€” unconditional. `unvalidatedIdCaveat` already trimmed while
1209
+ * expansion did not, so the two disagreed on a padded token.
1210
+ * - **lowercase, `claude-*` ONLY.** Every real Anthropic model id is lowercase
1211
+ * and a phone autocapitalizes, so `/model Claude-Opus-4-8` used to be launched
1212
+ * VERBATIM as an unvalidated id while the caveat exemption (which lowercases)
1213
+ * suppressed the warning โ€” a clean green ack for a token the caveat was
1214
+ * written for. Non-`claude-` tokens are left alone: `sr-*` ids are matched
1215
+ * case-insensitively by their own maps, and rewriting an arbitrary external
1216
+ * id's case is not ours to do.
1217
+ *
1218
+ * Applied by `expandModelAlias`, so canonicalization happens on every `/model`
1219
+ * path (typed apply, mid-turn queue, menu tap, queued-across-restart persist)
1220
+ * whether or not the token hit a shortcut map โ€” one canonical form flows to
1221
+ * `claude --model`, and `unvalidatedIdCaveat` sees exactly that form.
1222
+ */
1223
+ export function canonicalModelToken(arg: string): string {
1224
+ const trimmed = arg.trim()
1225
+ const lower = trimmed.toLowerCase()
1226
+ return lower.startsWith('claude-') ? lower : trimmed
1227
+ }
1228
+
1229
+ /** Expand a short pinned-Claude spelling (case-insensitive) to its full `claude-*` id. */
1230
+ export function expandClaudeAlias(arg: string): string {
1231
+ return CLAUDE_MODEL_ALIASES[arg.trim().toLowerCase()] ?? arg
1232
+ }
1233
+
1234
+ /**
1235
+ * The ONE expansion every `/model` argument goes through, on every path that
1236
+ * consumes a user-supplied token (typed apply, mid-turn queue, menu tap,
1237
+ * queued-across-restart persist). Claude shortcuts resolve first, then sr-*
1238
+ * shortcuts; the two key sets are disjoint by construction (a Claude shortcut
1239
+ * never expands to an `sr-*` id and vice versa), so order only documents intent.
1240
+ * The result is `canonicalModelToken`-normalized, so a miss can no longer emit a
1241
+ * verbatim mixed-case `claude-*` id that the caveat exemption then mis-matches.
1242
+ *
1243
+ * Expanding HERE โ€” before `isClaudeModel` / `isSrModel` / `externalModelNames`
1244
+ * ever see the token โ€” is what keeps a pinned-Claude shortcut on the Anthropic
1245
+ * OAuth passthrough instead of being misread as an external route.
1246
+ */
1247
+ export function expandModelAlias(arg: string): string {
1248
+ return canonicalModelToken(expandSrAlias(expandClaudeAlias(arg)))
1249
+ }
1250
+
1049
1251
  export function srFriendlyLabel(srName: string): string {
1050
1252
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
1051
1253
  }
@@ -1161,6 +1363,13 @@ function busyStaticMenu(
1161
1363
  callback_data: `${MODEL_CALLBACK_ALIAS}${alias}`,
1162
1364
  }])
1163
1365
  }
1366
+ // Same extra Claude rows the live keyboard renders (Fable, pinned Opus 4.8),
1367
+ // minus any already covered by a MODEL_ALIASES row above โ€” otherwise a model
1368
+ // is selectable when idle but vanishes from the mid-turn quick list.
1369
+ for (const { token, label } of EXTRA_CLAUDE_MENU_TOKENS) {
1370
+ if ((MODEL_ALIASES as readonly string[]).includes(token.toLowerCase())) continue
1371
+ rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${token}` }])
1372
+ }
1164
1373
  rows.push([{ text: 'Default (configured)', callback_data: `${MODEL_CALLBACK_ALIAS}default` }])
1165
1374
  if (externalNames.length > 0) {
1166
1375
  rows.push([{ text: '๐ŸŒ External models โ–ธ', callback_data: MODEL_CALLBACK_PAGE_EXTERNAL }])
@@ -1230,16 +1439,16 @@ function mainPageKeyboard(
1230
1439
  }])
1231
1440
  }
1232
1441
 
1233
- // Static Claude aliases the CLI picker omits (e.g. Fable). Deduped: if the
1234
- // scraped Claude options already include a matching row, don't render the
1235
- // static one too.
1236
- for (const { alias, label } of EXTRA_CLAUDE_ALIASES) {
1442
+ // Static Claude tokens the CLI picker omits (Fable, pinned Opus 4.8).
1443
+ // Deduped: if the scraped Claude options already include a matching row,
1444
+ // don't render the static one too.
1445
+ for (const { token, label } of EXTRA_CLAUDE_MENU_TOKENS) {
1237
1446
  const already = claudeOptions.some(
1238
1447
  (o) => o.label.toLowerCase() === label.toLowerCase() ||
1239
- o.label.toLowerCase() === alias.toLowerCase(),
1448
+ o.label.toLowerCase() === token.toLowerCase(),
1240
1449
  )
1241
1450
  if (already) continue
1242
- rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${alias}` }])
1451
+ rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${token}` }])
1243
1452
  }
1244
1453
 
1245
1454
  if (hasExternal) {
@@ -1434,9 +1643,17 @@ export async function handleModelMenuCallback(
1434
1643
  token = data.slice(MODEL_CALLBACK_SELECT.length)
1435
1644
  label = token
1436
1645
  }
1646
+ // Shape gate FIRST, on the RAW callback payload. `expandModelAlias` trims,
1647
+ // so gating after it would let a whitespace-padded payload through a regex
1648
+ // that exists precisely to guarantee one whitespace-free token reaches the
1649
+ // tmux pane. Order: shape โ†’ expand โ†’ recognition.
1437
1650
  if (!isValidModelArg(token)) {
1438
1651
  return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
1439
1652
  }
1653
+ // Same expansion the typed path applies, before the recognition gate: a
1654
+ // callback minted by an older gateway (or a hand-crafted `mdl:alias:opus48`)
1655
+ // must resolve to the canonical id rather than be rejected as unrecognized.
1656
+ token = expandModelAlias(token)
1440
1657
  // N1: reject a token we don't recognize (a stale OLD-gateway `mdl:s:<hex>`
1441
1658
  // callback, an unmapped SELECT row, or garbage). Relaunching onto it would
1442
1659
  // write a carrier claude silently falls back from (--fallback-model). Re-render
@@ -1498,11 +1715,16 @@ async function menuRelaunchOutcome(
1498
1715
  }
1499
1716
  }
1500
1717
  const friendly = isDefault ? 'the configured default' : label
1718
+ // Same #1978 switch-time guard the typed path carries โ€” the one-tap
1719
+ // "Opus 4.8" button is the EASIEST route into (pinned Opus 4.x, effort > low)
1720
+ // and doctor cannot see a session-scoped switch. `/model default` reverts to
1721
+ // the configured model, which doctor already covers, so it is not assessed.
1722
+ const effortRisk = isDefault ? null : thinkingEffortCaveat(deps, token)
1501
1723
  return {
1502
1724
  answer: `Switching to ${isDefault ? 'default' : label} โ€” relaunching (~30s)`,
1503
1725
  reply: await menuWithBannerStatic(
1504
1726
  deps,
1505
- `๐Ÿ”„ Switching session to **${deps.escapeHtml(friendly)}** โ€” relaunching (~30s).\n${PERSIST_NOTE}`,
1727
+ `๐Ÿ”„ Switching session to **${deps.escapeHtml(friendly)}** โ€” relaunching (~30s).${effortRisk ? `\n${effortRisk}` : ''}\n${PERSIST_NOTE}`,
1506
1728
  ),
1507
1729
  }
1508
1730
  }