switchroom 0.18.28 → 0.18.29

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.
@@ -11,24 +11,26 @@
11
11
  * When discovery fails (agent mid-turn, CLI UI changed, kill-switched
12
12
  * via SWITCHROOM_MODEL_MENU=0) it falls back to the static v1 text.
13
13
  *
14
- * `/model <alias|full-id>` types claude's own `/model <name>` into the
15
- * agent's tmux pane via the existing allowlisted inject primitive
16
- * (`src/agents/inject.ts` `/model` is already on the allowlist) and
17
- * relays the captured response. This is the Claude-native mechanism:
18
- * the unmodified CLI's REPL command, no API, no SDK, no config
19
- * mutation. The switch is session-scoped it lasts until the agent
20
- * restarts; persisting requires `model:` in switchroom.yaml (cascade)
21
- * and a restart, which the reply spells out.
14
+ * `/model <alias|full-id>` (and every menu tap) is a DETERMINISTIC carrier
15
+ * relaunch (rev 5, session-model-stickiness.md §0.05): the requested token is
16
+ * written to the consume-once `.session-model` carrier and applied by start.sh's
17
+ * `exec claude --model <token>` on the next boot. The inject-into-tmux +
18
+ * terminal-scrape path is RETIRED it silently no-op'd (keystrokes swallowed
19
+ * on a busy pane) then optimistically recorded success, an unverifiable lie to
20
+ * `/status`. A relaunch cannot silently no-op, and `.active-session-model` is a
21
+ * real post-boot signal. Still Claude-native (the unmodified CLI's `--model`
22
+ * flag, no API/SDK). The switch is session-scoped — it lasts until the next
23
+ * restart, then reverts to the configured `model:`; persisting requires
24
+ * `model:` in switchroom.yaml (cascade), which the reply spells out. The cost
25
+ * is a ~30s fresh session (Hindsight memory + the handoff briefing carry
26
+ * context); no `--continue`/`--resume`.
22
27
  *
23
28
  * Split parser/handler shape mirrors `auth-command.ts` so the logic is
24
29
  * unit-testable without booting the bot.
25
30
  */
26
31
 
27
- import type { InjectResult, InjectOpts } from '../../src/agents/inject.js'
28
32
  import {
29
- labelTag,
30
33
  type DiscoverResult,
31
- type SelectResult,
32
34
  type ModelPickerOption,
33
35
  } from '../../src/agents/model-picker.js'
34
36
 
@@ -253,17 +255,6 @@ export function modelCommandReceiptLine(
253
255
  }
254
256
 
255
257
  export interface ModelCommandDeps {
256
- /**
257
- * Inject primitive — wired to injectSlashCommand in the gateway. The optional
258
- * third argument forwards the #3241 poll-until-signal opts (successPattern /
259
- * errorPattern / settleBeforeSendMs); the set path passes them so the `/model`
260
- * confirmation scrape is deterministic instead of racing a fixed window.
261
- */
262
- inject: (
263
- agent: string,
264
- command: string,
265
- opts?: Pick<InjectOpts, 'successPattern' | 'errorPattern' | 'settleBeforeSendMs'>,
266
- ) => Promise<InjectResult>
267
258
  /**
268
259
  * True while the agent is mid-turn. A typed `/model <name>` switch drives
269
260
  * claude's session (either an inject into the input box, or a carrier-backed
@@ -282,14 +273,6 @@ export interface ModelCommandDeps {
282
273
  getConfiguredModel: () => string | null
283
274
  escapeHtml: (s: string) => string
284
275
  preBlock: (s: string) => string
285
- /**
286
- * The active session-model override set by a prior `/model` switch.
287
- * Null when no session override is active (using configured/default model).
288
- * Used to detect whether the current session is on an sr-* (OpenRouter)
289
- * model so a switch back to Claude can trigger a graceful restart instead
290
- * of an in-place inject (which would leave stale sr-* routing in place).
291
- */
292
- getActiveSessionModel: () => string | null
293
276
  /**
294
277
  * Schedule a graceful restart of this agent. Called instead of inject
295
278
  * when switching from an sr-* model back to Claude — the restart clears
@@ -308,38 +291,44 @@ export interface ModelCommandDeps {
308
291
  * on the following restart. Wired to the same restart dispatch as
309
292
  * `scheduleRestart`, plus the carrier write. `model` is the full `sr-*` id
310
293
  * (already alias-expanded); `reason` is stamped as the restart reason.
294
+ *
295
+ * Rev 5 (deterministic switch): EVERY `/model` switch — Claude→Claude,
296
+ * Claude→sr-*, sr-*→Claude, the Fable/alias button, and the picker SELECT —
297
+ * routes through here. The inject-into-tmux + terminal-scrape path is retired,
298
+ * so a switch can no longer silently no-op or optimistically lie to /status:
299
+ * start.sh's `exec claude --model <token>` cannot silently no-op, and the
300
+ * post-boot `.active-session-model` signal is what /status reflects.
311
301
  */
312
302
  scheduleModelRelaunch: (model: string, reason: string) => Promise<void>
303
+ /**
304
+ * Revert TO the configured default (`/model default`) via a relaunch. Clears
305
+ * the consume-once `.session-model` carrier + the in-memory override, then
306
+ * relaunches so the LIVE session actually reverts to `switchroom.yaml model:`
307
+ * (rev 5: with inject retired, a relaunch is the only way to make `default`
308
+ * take effect live — consistent with "all switches relaunch"). Mirrors
309
+ * scheduleModelRelaunch's careful rollback: a `restart_in_flight` throw keeps
310
+ * the cleared state (the in-flight boot reverts anyway); any other dispatch
311
+ * failure restores the prior carrier + override.
312
+ */
313
+ scheduleModelDefaultRelaunch: (reason: string) => Promise<void>
313
314
  }
314
315
 
315
316
  export interface ModelCommandReply {
316
317
  text: string
317
318
  html: true
318
319
  /**
319
- * On a POSITIVELY-CONFIRMED typed switch, the model now running this session
320
- * (parsed from claude's confirmation, falling back to the requested token).
321
- * The gateway records this as the session-model override so `/status` reflects
322
- * what's actually running the SAME code path the menu callback uses via
323
- * `ModelCallbackOutcome.selectedModel`. Absent on every unverified / non-switch
324
- * outcome (silent capture, error, busy refusal) so an unconfirmed switch never
325
- * lies to `/status`.
320
+ * Rev 5: a `/model` switch NEVER carries a live-model field. The inject +
321
+ * terminal-scrape path that produced an optimistic `selectedModel` is retired.
322
+ * Every switch relaunches through `scheduleModelRelaunch`, which owns the
323
+ * in-memory override write for the restart window; the ACTUAL running model is
324
+ * reconciled at boot from `.active-session-model` (and by the transcript's
325
+ * `message.model`), never optimistically asserted from a scraped pane. So
326
+ * there is no `selectedModel`/`optimistic` here to lie to /status with.
326
327
  */
327
- selectedModel?: string
328
- /**
329
- * True when `selectedModel` was recorded OPTIMISTICALLY (#3241 part B): the
330
- * inject SEND succeeded and NO explicit error line was scraped, but claude's
331
- * confirmation line was not read either. Poll-until-signal already waited the
332
- * full window, so a missing line means a silent switch (or a confirmation
333
- * that scrolled off) — NOT a failure — and we record the requested model so
334
- * `/status` is right. The switch is retracted (no `selectedModel`) only when
335
- * an error line IS scraped. Purely a wording hint; the gateway records
336
- * `selectedModel` the same way whether confirmed or optimistic.
337
- */
338
- optimistic?: boolean
339
328
  }
340
329
 
341
330
  const PERSIST_NOTE =
342
- '_Session-only this override lasts until the agent’s next restart, then reverts to the configured \`model:\`. \`/model default\` clears it now. To change the default permanently, set \`model:\` in switchroom.yaml._'
331
+ '_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._'
343
332
 
344
333
  function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
345
334
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' · ')
@@ -350,7 +339,7 @@ function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
350
339
  '\`/model\` — show the configured model',
351
340
  `\`/model <name>\` — switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' · ')} or a full model id)`,
352
341
  `_OpenRouter shortcuts:_ ${srAliasExamples}`,
353
- '_OpenRouter (sr-\\*) switches restart the session (~30s); Claude switches apply instantly._',
342
+ '_Every switch relaunches the session (~30s) on the chosen model — Claude and OpenRouter (sr-\\*) alike._',
354
343
  PERSIST_NOTE,
355
344
  )
356
345
  return { text: lines.join('\n'), html: true }
@@ -388,10 +377,10 @@ export async function handleModelCommand(
388
377
  // Expand short aliases: `flash` → `sr-gemini-2.5-flash`, `codex` → `sr-codex-5.5`, etc.
389
378
  const model = expandSrAlias(parsed.model)
390
379
 
391
- // Busy gate: a typed switch either injects into claude's input box or triggers
392
- // a carrier-backed restart. Both are unsafe mid-turn an inject queues the
393
- // `/model` text instead of switching, and a restart kills the live turn. Refuse
394
- // and ask the operator to retry (parity with the menu callback's isBusy check).
380
+ // Busy gate: a switch RELAUNCHES the session, which is unsafe mid-turn (it
381
+ // would tear down the live turn). The gateway's mid-turn path ACKs + queues +
382
+ // applies-on-idle before this handler is reached; this is the belt-and-braces
383
+ // seam for a caller that skipped it.
395
384
  if (deps.isBusy()) {
396
385
  return {
397
386
  text: '⏳ The agent is mid-turn — a model switch needs an idle session. The switch was not applied.',
@@ -399,182 +388,71 @@ export async function handleModelCommand(
399
388
  }
400
389
  }
401
390
 
402
- // sr-* Claude: an in-place `/model` inject would leave LiteLLM routing
403
- // active in the live session because the sr-* model context was set by
404
- // the proxy at session start, not by claude's own REPL. A graceful restart
405
- // is the only clean path back to the native OAuth route. Route it through the
406
- // SAME carrier mechanism as a Claude sr-* switch (scheduleModelRelaunch)
407
- // so the requested Claude model is written to the durable `.session-model` and
408
- // survives the restart otherwise boot launches the configured default and
409
- // the operator's choice is silently dropped. start.sh's LiteLLM-down guard
410
- // only special-cases `sr-*` overrides, so a Claude token is never dropped.
411
- const currentSession = deps.getActiveSessionModel()
412
- if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(model)) {
413
- try {
414
- await deps.scheduleModelRelaunch(model, `user: /model ${model} (sr-to-claude restart)`)
415
- } catch (err) {
416
- if (isRestartInFlight(err)) {
417
- return {
418
- text: `⏳ A restart is already in flight — your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
419
- html: true,
420
- }
421
- }
422
- const msg = err instanceof Error ? err.message : String(err)
423
- return {
424
- text: `❌ Could not schedule restart: ${deps.escapeHtml(msg)}`,
425
- html: true,
426
- }
427
- }
428
- return {
429
- text: [
430
- `Switching from \`${deps.escapeHtml(currentSession)}\` back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
431
- PERSIST_NOTE,
432
- ].join('\n'),
433
- html: true,
434
- }
391
+ // Rev 5 (deterministic switch): route EVERY target through the consume-once
392
+ // `.session-model` carrier relaunch. `default` clears the carrier + override
393
+ // and relaunches so the live session reverts to the configured `model:`; any
394
+ // other token Claude alias/id, sr-*, Fable is carried and applied by
395
+ // start.sh's `exec claude --model <token>`. The inject-into-tmux +
396
+ // terminal-scrape path is RETIRED: a switch can no longer silently no-op or
397
+ // optimistically lie to /status (start.sh cannot silently no-op, and the
398
+ // post-boot `.active-session-model` signal is the source of truth).
399
+ if (model.toLowerCase() === 'default') {
400
+ return scheduleDefaultRelaunchReply(deps, 'user: /model default (revert relaunch)')
435
401
  }
402
+ return scheduleRelaunchReply(deps, model, `user: /model ${model} (session-only relaunch)`)
403
+ }
436
404
 
437
- // Claude → sr-*: an in-place inject can't set a non-Anthropic model claude's
438
- // native `/model` picker rejects the unknown `sr-*` id ("Model not found").
439
- // Carry the token across a graceful restart and relaunch `claude --model
440
- // sr-*` directly (LiteLLM routes it). Session-only: reverts to the configured
441
- // default on the next restart. The sr-* → Claude direction is handled above.
442
- if (isSrModel(model)) {
443
- try {
444
- await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`)
445
- } catch (err) {
446
- if (isRestartInFlight(err)) {
447
- return {
448
- text: `⏳ A restart is already in flight — your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
449
- html: true,
450
- }
451
- }
452
- const msg = err instanceof Error ? err.message : String(err)
453
- return {
454
- text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`,
455
- html: true,
456
- }
457
- }
405
+ /** The one-line ack shown while a switch relaunches (~30s). */
406
+ function switchingLine(deps: Pick<ModelCommandDeps, 'escapeHtml'>, model: string): string {
407
+ const friendly = isSrModel(model) ? srFriendlyLabel(model) : model
408
+ return `🔄 Switching to \`${deps.escapeHtml(friendly)}\` relaunching the session (~30s).`
409
+ }
410
+
411
+ /** Map a relaunch-dispatch error to an honest reply (debounce vs failure). */
412
+ function relaunchErrorReply(
413
+ deps: Pick<ModelCommandDeps, 'escapeHtml'>,
414
+ model: string,
415
+ err: unknown,
416
+ ): ModelCommandReply {
417
+ if (isRestartInFlight(err)) {
458
418
  return {
459
- text: [
460
- `Switching to \`${deps.escapeHtml(model)}\` — restarting session (~30s).`,
461
- PERSIST_NOTE,
462
- ].join('\n'),
419
+ text: `⏳ A restart is already in flight — your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
463
420
  html: true,
464
421
  }
465
422
  }
423
+ const msg = err instanceof Error ? err.message : String(err)
424
+ return { text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`, html: true }
425
+ }
466
426
 
467
- const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
468
- let result: InjectResult
427
+ /** Schedule a carrier relaunch onto `model`, returning the deterministic ack. */
428
+ async function scheduleRelaunchReply(
429
+ deps: ModelCommandDeps,
430
+ model: string,
431
+ reason: string,
432
+ ): Promise<ModelCommandReply> {
469
433
  try {
470
- // #3241 part A — poll-until-signal. Hand the inject primitive the exact
471
- // confirmation / error line shapes so its capture loop keeps polling until
472
- // claude's "Set model to …" (or an error) actually lands, instead of
473
- // breaking at a fixed settle window on the first pane change (which the
474
- // async access banner tripped, capturing the banner and missing the
475
- // confirmation). settleBeforeSendMs waits for a clean prompt so the keys
476
- // aren't typed into a still-animating pane (symptom 2's silent no-op).
477
- result = await deps.inject(deps.getAgentName(), `/model ${model}`, {
478
- successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
479
- errorPattern: MODEL_SWITCH_ERROR_RE,
480
- settleBeforeSendMs: 1500,
481
- })
434
+ await deps.scheduleModelRelaunch(model, reason)
482
435
  } catch (err) {
483
- const msg = err instanceof Error ? err.message : String(err)
484
- return {
485
- text: `❌ ${verbHtml} — inject failed: ${deps.escapeHtml(msg)}`,
486
- html: true,
487
- }
488
- }
489
-
490
- if (result.outcome === 'ok' || result.outcome === 'ok_no_output') {
491
- // claude's `/model <name>` prints a "Set model to X" acknowledgement, an
492
- // error line ("Model not found"), a "Kept model as X" no-op, or (rarely)
493
- // switches with the confirmation scrolled off. `result.output` on a silent
494
- // path is just pane scrollback (the agent's previous prose) — NEVER a
495
- // confirmation, and it must not be dumped back as a code block (screenshot-
496
- // confirmed leak on klanker, v0.16.47).
497
- //
498
- // Honest reporting (#3241 part B inverts the old "record nothing unless
499
- // confirmed" to "record optimistically, retract only on a scraped error").
500
- // Order matters (#3242 review MEDIUM 1): check the CONFIRMATION line FIRST —
501
- // a genuine switch always prints one, so it can never be flipped to a failure
502
- // by a stray availability/denial word (the widened MODEL_SWITCH_ERROR_RE) in
503
- // the same region. Then:
504
- // (1) "Kept model as X" → genuine no-op; report it, record NOTHING.
505
- // (2) other confirmation → verified switch; relay it, record the display
506
- // name for /status.
507
- // (3) error/denial line scraped (bad id OR access denial) → switch FAILED.
508
- // Report it; record NOTHING (the retract — /status keeps the prior model).
509
- // (4) no line either way → poll-until-signal already waited the full
510
- // window, so this is a SILENT success, not a failure. Record the
511
- // requested model OPTIMISTICALLY (normalized to display form) so
512
- // /status is right, and say so.
513
- const confirmation = result.outcome === 'ok' ? modelSwitchConfirmationLine(result.output) : null
514
- if (confirmation) {
515
- if (isKeptModelConfirmation(confirmation)) {
516
- // "Kept model as X" — nothing changed. Relay it, record no override.
517
- return {
518
- text: [
519
- `${verbHtml}`,
520
- deps.preBlock(confirmation),
521
- ...(result.truncated ? ['_truncated_'] : []),
522
- PERSIST_NOTE,
523
- ].join('\n'),
524
- html: true,
525
- }
526
- }
527
- const confirmed = sessionModelFromConfirmation(confirmation) ?? model
528
- return {
529
- text: [
530
- `${verbHtml}`,
531
- deps.preBlock(confirmation),
532
- ...(result.truncated ? ['_truncated_'] : []),
533
- PERSIST_NOTE,
534
- ].join('\n'),
535
- html: true,
536
- selectedModel: confirmed,
537
- }
538
- }
539
- const errLine = result.outcome === 'ok' ? modelSwitchErrorLine(result.output) : null
540
- if (errLine) {
541
- return {
542
- text: [
543
- `❌ ${verbHtml} — the switch did not take:`,
544
- deps.preBlock(errLine),
545
- 'Check \`/model\` for a valid, available model.',
546
- ].join('\n'),
547
- html: true,
548
- }
549
- }
550
- // No confirmation and no error — optimistic record (#3241 part B). The
551
- // Telegram copy stays PROVISIONAL (#3242 review FIX 2): we couldn't read a
552
- // confirmation, and if the CLI denied the switch with wording our error
553
- // regex misses, an affirmative "recorded X" would be a lie that never
554
- // self-corrects. `/status` DOES self-heal (the override is reclaimed by the
555
- // next transcript line), so point the user there rather than assert success.
556
- const optimisticLabel = optimisticModelRecordLabel(model)
557
- return {
558
- text: [
559
- `${verbHtml} — sent, but couldn't read a confirmation line. \`/status\` will show the live model once it's confirmed.`,
560
- PERSIST_NOTE,
561
- ].join('\n'),
562
- html: true,
563
- selectedModel: optimisticLabel,
564
- optimistic: true,
565
- }
436
+ return relaunchErrorReply(deps, model, err)
566
437
  }
438
+ return { text: [switchingLine(deps, model), PERSIST_NOTE].join('\n'), html: true }
439
+ }
567
440
 
568
- // outcome === 'failed'
569
- if (result.errorCode === 'session_missing') {
570
- return {
571
- text:
572
- '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove \`experimental.legacy_pty: true\` if set.',
573
- html: true,
574
- }
441
+ /** Schedule the `/model default` clear + revert relaunch, returning its ack. */
442
+ async function scheduleDefaultRelaunchReply(
443
+ deps: ModelCommandDeps,
444
+ reason: string,
445
+ ): Promise<ModelCommandReply> {
446
+ try {
447
+ await deps.scheduleModelDefaultRelaunch(reason)
448
+ } catch (err) {
449
+ return relaunchErrorReply(deps, 'default', err)
575
450
  }
576
451
  return {
577
- text: `❌ ${verbHtml} — ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`,
452
+ text: [
453
+ '🔄 Reverting to the configured default model — relaunching the session (~30s).',
454
+ PERSIST_NOTE,
455
+ ].join('\n'),
578
456
  html: true,
579
457
  }
580
458
  }
@@ -584,12 +462,14 @@ export async function handleModelCommand(
584
462
  // ---------------------------------------------------------------------------
585
463
 
586
464
  export interface ModelMenuDeps {
587
- /** Live picker discovery — src/agents/model-picker.ts discoverModels. */
465
+ /**
466
+ * Live picker discovery — src/agents/model-picker.ts discoverModels. Used ONLY
467
+ * to RENDER the model list (buildModelMenu); rev 5 retired the terminal-driving
468
+ * `select` from the switch path, so discovery no longer applies a switch.
469
+ */
588
470
  discover: (agent: string) => Promise<DiscoverResult>
589
- /** Live picker selection by label — selectModel (session-only `s`). */
590
- select: (agent: string, label: string) => Promise<SelectResult>
591
471
  /**
592
- * True while the agent is mid-turn. Driving the picker types into
472
+ * True while the agent is mid-turn. Driving the picker (for RENDER) types into
593
473
  * claude's input box; doing that mid-turn would queue "/model" as
594
474
  * user text instead of opening the modal — refuse instead.
595
475
  */
@@ -628,9 +508,9 @@ export const MODEL_CALLBACK_HEADER = 'mdl:h'
628
508
  /**
629
509
  * Callback prefix for Claude aliases that the CLI picker doesn't render but
630
510
  * the CLI resolves natively (e.g. `fable`). Carries the alias verbatim; its
631
- * handler INJECTS `/model <alias>` — the same mechanism MODEL_CALLBACK_SR
632
- * uses because the cursor-nav select path can only pick rows claude's own
633
- * picker actually renders.
511
+ * handler routes to the carrier relaunch (`scheduleModelRelaunch`) — the same
512
+ * deterministic mechanism every switch uses (rev 5). `fable` boots via the
513
+ * LiteLLM router repoint in start.sh (see the fable case there).
634
514
  */
635
515
  export const MODEL_CALLBACK_ALIAS = 'mdl:alias:'
636
516
  /** Callback: open the nested "External models" keyboard page. */
@@ -644,9 +524,10 @@ export type ModelMenuPage = 'main' | 'external'
644
524
  /**
645
525
  * Static Claude aliases appended to the scraped Claude group. The claude CLI's
646
526
  * own `/model` picker (deps.discover) does NOT list `fable`, but the CLI
647
- * resolves the alias natively, so we render it as an extra button that selects
648
- * by injecting `/model fable` (MODEL_CALLBACK_ALIAS). Extend this list to
649
- * surface further CLI-resolvable aliases the picker omits.
527
+ * resolves the alias natively, so we render it as an extra button that switches
528
+ * via the carrier relaunch (MODEL_CALLBACK_ALIAS scheduleModelRelaunch, rev
529
+ * 5). Extend this list to surface further CLI-resolvable aliases the picker
530
+ * omits.
650
531
  */
651
532
  export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string }> = [
652
533
  { alias: 'fable', label: 'Fable' },
@@ -734,32 +615,6 @@ export function srFriendlyLabel(srName: string): string {
734
615
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
735
616
  }
736
617
 
737
- /**
738
- * #3242 review LOW 4 — display normalization for the OPTIMISTIC `/status` record.
739
- * The confirmed path records the display name claude printed (e.g. "Fable 5" via
740
- * `sessionModelFromConfirmation`); the optimistic path only has the requested
741
- * arg. Without a confirmation we can't know the version suffix, so we normalize
742
- * a bare Claude alias to the same DISPLAY style — Title-case ("fable" → "Fable")
743
- * — and leave a full `claude-*` id as-is (already canonical).
744
- *
745
- * #3242 review FIX 1 (MEDIUM) — sr-* tokens are returned UNCHANGED (with the
746
- * `sr-` prefix). The stored `selectedModel` doubles as the sr-*→Claude sentinel:
747
- * `gateway.ts` `isSrToClaudeTransition` checks `prevModel?.startsWith('sr-')` to
748
- * decide whether a later Claude switch needs the graceful restart that tears
749
- * down LiteLLM routing. De-prefixing here (as the earlier LOW-4 pass did via
750
- * `srFriendlyLabel`) would silently break that restart. So this helper never
751
- * de-prefixes: the caller normalizes only the DISPLAY text separately (see
752
- * `srFriendlyLabel`), never the stored token.
753
- */
754
- export function optimisticModelRecordLabel(token: string): string {
755
- if (isSrModel(token)) return token
756
- const lower = token.toLowerCase()
757
- if ((MODEL_ALIASES as readonly string[]).includes(lower)) {
758
- return lower.charAt(0).toUpperCase() + lower.slice(1)
759
- }
760
- return token
761
- }
762
-
763
618
  /**
764
619
  * Split picker-discovered options into native Claude options and sr-*
765
620
  * (LiteLLM non-Anthropic) options. Options with "/" in the label or
@@ -787,11 +642,41 @@ export function classifyDiscoveredOptions(options: ModelPickerOption[]): {
787
642
  }
788
643
 
789
644
  export function modelSelectCallbackData(label: string): string {
790
- // Identity is the label's hash, not its index a tap re-discovers
791
- // the picker and matches by tag, so a list that shifted between
792
- // render and tap can never select the wrong row. 8 hex chars keeps
793
- // callback_data tiny (well under Telegram's 64-byte cap).
794
- return `${MODEL_CALLBACK_SELECT}${labelTag(label)}`
645
+ // Rev 5: embed the CANONICAL `claude --model` token directly, not a label
646
+ // hash. A tap no longer needs live picker discovery to resolve the row — it
647
+ // goes straight to the carrier relaunch (`mdl:s:<token>`), removing the last
648
+ // terminal-driving step from the switch path.
649
+ const token = canonicalClaudeToken(label)
650
+ if (token) return `${MODEL_CALLBACK_SELECT}${token}`
651
+ // The "Default (recommended)" row has no derivable token (`canonicalClaudeToken`
652
+ // → null) — it carries the `default` sentinel so the tap routes to the
653
+ // clear+revert relaunch.
654
+ if (/^default\b/i.test(label.trim())) return `${MODEL_CALLBACK_SELECT}default`
655
+ // N2: an UNMAPPED non-default Claude row (a label whose first word is neither a
656
+ // known alias nor `claude-*`, nor `default`) has no derivable token. Emit an
657
+ // EMPTY suffix so the tap is REJECTED by the switch-tap gate and re-renders,
658
+ // rather than collapsing to the `default` sentinel — which would silently
659
+ // REVERT to the configured default instead of switching to the labelled model.
660
+ return MODEL_CALLBACK_SELECT
661
+ }
662
+
663
+ /**
664
+ * N1: is `token` a switch target we actually recognize? A stale menu rendered by
665
+ * an OLD gateway carries `mdl:s:<8-hex labelTag>` callback_data, which passes the
666
+ * loose `MODEL_ARG_RE` shape gate — relaunching onto it would write a garbage
667
+ * carrier and `--fallback-model` would silently serve a fallback. Constrain
668
+ * SELECT/alias/sr tokens to a known set before scheduling any relaunch:
669
+ * - the `default` sentinel (clear+revert),
670
+ * - any sr-* (LiteLLM/OpenRouter) id (accepted raw, never picker-validated),
671
+ * - a canonical Claude token (a known alias or a `claude-*` id).
672
+ * An 8-hex tag, an empty suffix, or any other unmapped string returns false → the
673
+ * handler re-renders the menu (the old "Model list changed" graceful degrade)
674
+ * instead of relaunching onto a token claude will silently fall back from.
675
+ */
676
+ export function isRecognizedSwitchToken(token: string): boolean {
677
+ if (token.toLowerCase() === 'default') return true
678
+ if (isSrModel(token)) return true
679
+ return canonicalClaudeToken(token) !== null
795
680
  }
796
681
 
797
682
  const BUSY_REFUSAL_TEXT =
@@ -1044,30 +929,14 @@ export interface ModelCallbackOutcome {
1044
929
  */
1045
930
  busyRefusal?: boolean
1046
931
  /**
1047
- * On a successful session switch, the live model name now running (parsed
1048
- * from claude's confirmation, e.g. "Fable 5"). The gateway records this as
1049
- * the session-model override so `/status` reflects what's actually running.
1050
- * Absent on every non-switch outcome.
1051
- */
1052
- selectedModel?: string
1053
- /**
1054
- * The canonical `claude --model` token (alias or full `claude-*` id) for a
1055
- * Claude selection, when derivable — distinct from `selectedModel` (a display
1056
- * name for /status). Session-scoped (rev 4): a live Claude selection persists
1057
- * NO carrier (the switch applies in-session and reverts on the next boot);
1058
- * the gateway uses this token ONLY on an sr-* → Claude transition, writing it
1059
- * to the consume-once `.session-model` carrier so that transition's own
1060
- * apply-relaunch boots the tapped model (then reverts on the following
1061
- * restart). Absent when the target has no derivable token.
1062
- */
1063
- selectedModelToken?: string
1064
- /**
1065
- * True when the confirmed selection was the "Default (recommended)" row —
1066
- * i.e. the session is now on the configured default and any leftover
1067
- * `.session-model` carrier must be CLEARED (a stale carrier would be
1068
- * consumed — mis-applied — by the next boot).
932
+ * Rev 5: a menu tap NEVER carries a scrape-derived live-model field. Every
933
+ * switch (alias/Fable, picker SELECT, sr-*) relaunches through
934
+ * `scheduleModelRelaunch`/`scheduleModelDefaultRelaunch`, which own the
935
+ * in-memory override + carrier writes; the ACTUAL running model is reconciled
936
+ * at boot from `.active-session-model`. So there is no `selectedModel` /
937
+ * `selectedModelToken` / `clearedDefault` here to record optimistically — the
938
+ * gateway no longer post-processes the outcome for side effects.
1069
939
  */
1070
- clearedDefault?: boolean
1071
940
  /** Short toast for answerCallbackQuery. */
1072
941
  answer: string
1073
942
  /** Replacement dashboard (message edit). */
@@ -1098,98 +967,6 @@ export async function handleModelMenuCallback(
1098
967
  return { answer: 'Back', reply: await buildModelMenu(deps, 'main') }
1099
968
  }
1100
969
 
1101
- // Claude-alias tap (e.g. Fable): the CLI resolves the alias but its picker
1102
- // doesn't render it, so select by injecting `/model <alias>` — same path as
1103
- // the sr-* handler below, no cursor-nav.
1104
- if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
1105
- const alias = data.slice(MODEL_CALLBACK_ALIAS.length)
1106
- if (!isValidModelArg(alias)) {
1107
- return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
1108
- }
1109
- if (deps.isBusy()) {
1110
- return {
1111
- answer: '⏳ Agent is mid-turn — tap again when it’s idle',
1112
- reply: { text: BUSY_REFUSAL_TEXT, html: true },
1113
- toastOnly: true,
1114
- busyRefusal: true,
1115
- }
1116
- }
1117
- let aliasResult: InjectResult
1118
- try {
1119
- // #3241 part A — same poll-until-signal + clean-prompt opts as the typed
1120
- // set path so the alias (e.g. Fable) confirmation scrape is deterministic
1121
- // and immune to the async access banner.
1122
- aliasResult = await deps.inject(deps.getAgentName(), `/model ${alias}`, {
1123
- successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
1124
- errorPattern: MODEL_SWITCH_ERROR_RE,
1125
- settleBeforeSendMs: 1500,
1126
- })
1127
- } catch (err) {
1128
- const msg = err instanceof Error ? err.message : String(err)
1129
- return {
1130
- answer: 'Switch failed',
1131
- reply: await menuWithBanner(deps, `❌ Switch to **${deps.escapeHtml(alias)}** failed: ${deps.escapeHtml(msg)}`),
1132
- }
1133
- }
1134
- // #3242 review MEDIUM 2 — the alias BUTTON (the primary Fable UI, the exact
1135
- // async-banner scenario) must be symmetric with the typed set path: handle
1136
- // BOTH `ok` and `ok_no_output` with record-on-send / retract-on-scraped-error.
1137
- // Previously `ok_no_output` fell through to "Switch failed — agent may be
1138
- // mid-turn" and dropped the override, so a silent successful button-switch
1139
- // was reported as a failure while the identical typed command recorded.
1140
- if (aliasResult.outcome === 'ok' || aliasResult.outcome === 'ok_no_output') {
1141
- // Confirmation first (a genuine switch always prints one) so a stray
1142
- // availability/denial word can't flip it to a failure.
1143
- const confirmation = aliasResult.outcome === 'ok'
1144
- ? modelSwitchConfirmationLine(aliasResult.output)
1145
- : null
1146
- if (confirmation) {
1147
- // "Kept model as X" means no change — don't overwrite the override.
1148
- const kept = isKeptModelConfirmation(confirmation)
1149
- return {
1150
- answer: confirmation,
1151
- reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
1152
- ...(kept ? {} : {
1153
- selectedModel: sessionModelFromConfirmation(confirmation) ?? optimisticModelRecordLabel(alias),
1154
- selectedModelToken: alias,
1155
- }),
1156
- }
1157
- }
1158
- // Scraped error/denial (bad id OR access denial) → genuine failure, record
1159
- // nothing (retract).
1160
- const aliasErr = aliasResult.outcome === 'ok' ? modelSwitchErrorLine(aliasResult.output) : null
1161
- if (aliasErr) {
1162
- return {
1163
- answer: 'Switch failed',
1164
- reply: await menuWithBanner(
1165
- deps,
1166
- `❌ Switch to **${deps.escapeHtml(alias)}** did not take: ${deps.escapeHtml(aliasErr)}`,
1167
- ),
1168
- }
1169
- }
1170
- // Silent success (no confirmation, no error) → optimistic record, same as
1171
- // the typed path. PROVISIONAL copy (#3242 review FIX 2): don't assert the
1172
- // switch succeeded — we couldn't read a confirmation, and /status self-heals.
1173
- const optimisticLabel = optimisticModelRecordLabel(alias)
1174
- return {
1175
- answer: `Sent /model ${alias} — check /status`,
1176
- reply: await menuWithBannerStatic(
1177
- deps,
1178
- `Sent \`/model ${deps.escapeHtml(alias)}\` — couldn’t read a confirmation line. \`/status\` will show the live model once it’s confirmed.`,
1179
- ),
1180
- selectedModel: optimisticLabel,
1181
- selectedModelToken: alias,
1182
- }
1183
- }
1184
- return {
1185
- answer: 'Switch failed',
1186
- reply: await menuWithBanner(
1187
- deps,
1188
- `❌ Switch to **${deps.escapeHtml(alias)}** failed — agent may be mid-turn`,
1189
- ),
1190
- }
1191
- }
1192
-
1193
970
  if (data === MODEL_CALLBACK_HEADER) {
1194
971
  // Section-header row — the gateway handles this with a direct answerCallbackQuery
1195
972
  // before calling this function, so this branch is dead in practice. Guard
@@ -1197,20 +974,45 @@ export async function handleModelMenuCallback(
1197
974
  return { answer: 'Tap a model in this section to switch', reply: { text: '', html: true }, toastOnly: true }
1198
975
  }
1199
976
 
1200
- // sr-* model tap. In the live gateway this branch is DEAD — the gateway
1201
- // intercepts `mdl:sr:` at its callback dispatcher (before calling this
1202
- // function) and routes it straight to scheduleModelRelaunch (carrier + restart).
1203
- // The old body here text-injected `/model sr-<name>`, which is doubly broken if
1204
- // ever reached: claude's picker rejects unknown sr-* ids AND the ANTHROPIC_BASE_URL
1205
- // is never repointed at the LiteLLM router, so the request 4xxs against Anthropic.
1206
- // Delegate to the SAME carrier mechanism so a direct caller (tests, a future
1207
- // refactor that drops the gateway intercept) still does the safe thing.
1208
- if (data.startsWith(MODEL_CALLBACK_SR)) {
1209
- const srName = data.slice(MODEL_CALLBACK_SR.length)
1210
- const friendlyName = srFriendlyLabel(srName)
1211
- if (!isValidModelArg(srName)) {
977
+ // A model-SWITCH tap Fable/alias button (`mdl:alias:`), an sr-* target
978
+ // (`mdl:sr:`), or a picker SELECT row (`mdl:s:<token>`). Rev 5: every one
979
+ // relaunches through the consume-once `.session-model` carrier. No inject, no
980
+ // cursor-nav, no terminal scrape a tap resolves its canonical token and
981
+ // hands off to `scheduleModelRelaunch` (or the clear+revert path for the
982
+ // `default` sentinel, which the "Default (recommended)" row and the Default
983
+ // alias button both carry). This is what makes a tap deterministic: it can no
984
+ // longer silently no-op or optimistically record a switch that never applied.
985
+ if (
986
+ data.startsWith(MODEL_CALLBACK_ALIAS) ||
987
+ data.startsWith(MODEL_CALLBACK_SR) ||
988
+ data.startsWith(MODEL_CALLBACK_SELECT)
989
+ ) {
990
+ let token: string
991
+ let label: string
992
+ if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
993
+ token = data.slice(MODEL_CALLBACK_ALIAS.length)
994
+ label = token
995
+ } else if (data.startsWith(MODEL_CALLBACK_SR)) {
996
+ token = data.slice(MODEL_CALLBACK_SR.length)
997
+ label = srFriendlyLabel(token)
998
+ } else {
999
+ token = data.slice(MODEL_CALLBACK_SELECT.length)
1000
+ label = token
1001
+ }
1002
+ if (!isValidModelArg(token)) {
1212
1003
  return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
1213
1004
  }
1005
+ // N1: reject a token we don't recognize (a stale OLD-gateway `mdl:s:<hex>`
1006
+ // callback, an unmapped SELECT row, or garbage). Relaunching onto it would
1007
+ // write a carrier claude silently falls back from (--fallback-model). Re-render
1008
+ // the fresh menu instead — the graceful degrade the label-tag path used to give.
1009
+ if (!isRecognizedSwitchToken(token)) {
1010
+ return { answer: 'Model list changed — menu refreshed', reply: await buildModelMenu(deps) }
1011
+ }
1012
+ // Mid-turn: refuse WITHOUT touching the message so the menu keeps its
1013
+ // buttons and the operator can tap again once idle. (The gateway dispatcher
1014
+ // already enqueues switch taps mid-turn before calling this handler; this is
1015
+ // the belt-and-braces seam for callers that skip it.)
1214
1016
  if (deps.isBusy()) {
1215
1017
  return {
1216
1018
  answer: '⏳ Agent is mid-turn — tap again when it’s idle',
@@ -1219,197 +1021,57 @@ export async function handleModelMenuCallback(
1219
1021
  busyRefusal: true,
1220
1022
  }
1221
1023
  }
1222
- try {
1223
- await deps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
1224
- } catch (err) {
1225
- const msg = err instanceof Error ? err.message : String(err)
1226
- return {
1227
- answer: 'Switch failed',
1228
- reply: await menuWithBannerStatic(deps, `❌ Switch to **${deps.escapeHtml(friendlyName)}** failed: ${deps.escapeHtml(msg)}`),
1229
- }
1230
- }
1231
- return {
1232
- answer: `Switching to ${friendlyName} — restarting (~30s)`,
1233
- reply: await menuWithBannerStatic(
1234
- deps,
1235
- `🔄 Switching session to **${deps.escapeHtml(friendlyName)}** — restarting (~30s).\n${PERSIST_NOTE}`,
1236
- ),
1237
- selectedModel: srName,
1238
- }
1024
+ return menuRelaunchOutcome(deps, token, label)
1239
1025
  }
1240
1026
 
1241
- if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
1242
- return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
1243
- }
1244
- // Mid-turn: refuse WITHOUT touching the message. Driving the picker types
1245
- // into claude's input box, which mid-turn would queue "/model" as user
1246
- // text. toastOnly keeps the menu (and its buttons) exactly as-is so the
1247
- // operator just taps again when the agent is idle — no button-less
1248
- // "try again" line that read as a dead menu.
1249
- if (deps.isBusy()) {
1250
- return {
1251
- answer: '⏳ Agent is mid-turn — tap again when it’s idle',
1252
- reply: { text: BUSY_REFUSAL_TEXT, html: true },
1253
- toastOnly: true,
1254
- busyRefusal: true,
1255
- }
1256
- }
1027
+ return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
1028
+ }
1257
1029
 
1258
- const tag = data.slice(MODEL_CALLBACK_SELECT.length)
1259
- const discovered = await deps.discover(deps.getAgentName())
1260
- if (!discovered.ok) {
1261
- // Keep the menu interactive: re-render (falls back to v1 text if even
1262
- // the show path can't discover) with the failure as a banner.
1263
- return {
1264
- answer: 'Picker unavailable',
1265
- reply: await menuWithBanner(
1266
- deps,
1267
- `❌ Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`,
1268
- ),
1030
+ /**
1031
+ * Shared menu-tap relaunch: schedule the carrier relaunch onto `token` (or the
1032
+ * clear+revert relaunch for the `default` sentinel) and return the deterministic
1033
+ * "relaunching (~30s)" card. Uses the STATIC banner (no discover()) because the
1034
+ * pane is about to restart. No `selectedModel` — the actual running model is
1035
+ * reconciled at boot from `.active-session-model`.
1036
+ */
1037
+ async function menuRelaunchOutcome(
1038
+ deps: ModelMenuDeps & ModelCommandDeps,
1039
+ token: string,
1040
+ label: string,
1041
+ ): Promise<ModelCallbackOutcome> {
1042
+ const isDefault = token.toLowerCase() === 'default'
1043
+ try {
1044
+ if (isDefault) {
1045
+ await deps.scheduleModelDefaultRelaunch('user: /model default (revert relaunch, menu)')
1046
+ } else {
1047
+ await deps.scheduleModelRelaunch(token, `user: /model ${token} (session-only relaunch, menu)`)
1269
1048
  }
1270
- }
1271
- const target = discovered.options.find((o) => labelTag(o.label) === tag)
1272
- if (!target) {
1273
- // Options changed since the menu rendered — never guess; re-render.
1274
- const fresh = await buildModelMenu(deps)
1275
- return { answer: 'Model list changed — menu refreshed', reply: fresh }
1276
- }
1277
- // NOTE: do NOT short-circuit when target.current is set. The picker's ✔
1278
- // marks claude's DEFAULT FOR NEW SESSIONS, which is a DIFFERENT axis from
1279
- // the model the live session is running (set by --model at launch). Tapping
1280
- // the ✔ row to apply that model to the live session is a legitimate switch
1281
- // — e.g. an agent launched on Fable tapping "Default (Opus)". Skipping it
1282
- // here was the "tapped Default, nothing happened" bug. Always drive the
1283
- // selection; claude harmlessly answers "Kept model as X" if it's already
1284
- // the session model.
1285
- const result = await deps.select(deps.getAgentName(), target.label)
1286
- if (!result.ok) {
1287
- // Switch failed but the agent is reachable — keep the menu so the
1288
- // operator can retry, with the reason as a banner.
1289
- return {
1290
- answer: 'Switch failed — see the menu',
1291
- reply: await menuWithBanner(
1292
- deps,
1293
- `❌ Switch to **${deps.escapeHtml(target.label)}** failed: ${deps.escapeHtml(result.reason)}`,
1294
- ),
1049
+ } catch (err) {
1050
+ if (isRestartInFlight(err)) {
1051
+ return {
1052
+ answer: 'A restart is already in flight (~15s)',
1053
+ reply: await menuWithBannerStatic(
1054
+ deps,
1055
+ `⏳ A restart is already in flight — your switch to **${deps.escapeHtml(label)}** will apply as it completes (~15s).`,
1056
+ ),
1057
+ }
1295
1058
  }
1296
- }
1297
-
1298
- // "Kept model as X" means the tapped model was ALREADY the session model —
1299
- // nothing changed. Do NOT overwrite the override (and never store the display
1300
- // label). Tapping the "Default (recommended)" row on the already-default model
1301
- // previously stored "Default (recommended)" verbatim into /status.
1302
- if (isKeptModelConfirmation(result.confirmation)) {
1059
+ const msg = err instanceof Error ? err.message : String(err)
1303
1060
  return {
1304
- answer: deps.escapeHtml(result.confirmation),
1305
- reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
1061
+ answer: 'Switch failed',
1062
+ reply: await menuWithBannerStatic(deps, `❌ Switch to **${deps.escapeHtml(label)}** failed: ${deps.escapeHtml(msg)}`),
1306
1063
  }
1307
1064
  }
1308
- // Normalize what we store: prefer the model name claude confirmed, else a
1309
- // canonical token derived from the row label — never a pure display label like
1310
- // "Default (recommended)". If neither resolves, record nothing rather than lie.
1311
- const token = canonicalClaudeToken(target.label)
1312
- const selectedModel = sessionModelFromConfirmation(result.confirmation) ?? token ?? undefined
1313
- // The "Default (recommended)" row has no derivable token BY DESIGN — a
1314
- // confirmed switch to it means "back on the configured default", which the
1315
- // gateway must translate into clearing the sticky override.
1316
- const clearedDefault = token == null && /^default\b/i.test(target.label.trim())
1065
+ const friendly = isDefault ? 'the configured default' : label
1317
1066
  return {
1318
- answer: deps.escapeHtml(result.confirmation),
1319
- reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
1320
- ...(selectedModel ? { selectedModel } : {}),
1321
- ...(token ? { selectedModelToken: token } : {}),
1322
- ...(clearedDefault ? { clearedDefault: true } : {}),
1067
+ answer: `Switching to ${isDefault ? 'default' : label} — relaunching (~30s)`,
1068
+ reply: await menuWithBannerStatic(
1069
+ deps,
1070
+ `🔄 Switching session to **${deps.escapeHtml(friendly)}** relaunching (~30s).\n${PERSIST_NOTE}`,
1071
+ ),
1323
1072
  }
1324
1073
  }
1325
1074
 
1326
- /**
1327
- * True when the transition from `prevModel` to `nextModel` is a switch FROM
1328
- * an sr-* (LiteLLM/OpenRouter) model BACK TO a native Claude model. This
1329
- * signals that a session restart is required — an in-place model-picker select
1330
- * cannot undo the LiteLLM routing that the sr-* switch established in the live
1331
- * session. Null / undefined prev means no prior sr-* session — not a transition.
1332
- */
1333
- export function isSrToClaudeTransition(
1334
- prevModel: string | null | undefined,
1335
- nextModel: string,
1336
- ): boolean {
1337
- return !!prevModel?.startsWith('sr-') && !nextModel.startsWith('sr-')
1338
- }
1339
-
1340
- /**
1341
- * Return the single line of a pane capture that actually reads as claude's
1342
- * model-switch acknowledgement ("Set model to X…", "Switched to X", or
1343
- * "Kept model as X"), or null when no such line is present. Used by the
1344
- * direct `/model <name>` path to decide whether `result.output` carries a
1345
- * genuine confirmation worth relaying, versus mere scrollback that must NOT
1346
- * be echoed back to chat. Mirrors the line-scan already used by the picker
1347
- * alias/sr-* callback paths.
1348
- */
1349
- export function modelSwitchConfirmationLine(output: string): string | null {
1350
- const line = output
1351
- .split('\n')
1352
- .map((l) => l.trim())
1353
- .find((l) => MODEL_SWITCH_CONFIRMATION_PREFIX.test(l))
1354
- return line && line.length > 0 ? line : null
1355
- }
1356
-
1357
- /**
1358
- * claude's failure output for a bad `/model <name>` — the CLI rejects an
1359
- * unknown id with "Model not found" / "Invalid model" / "Unknown model".
1360
- * Detecting it lets the typed set path report an HONEST failure instead of
1361
- * falsely claiming "switched (session)". Anchored to LINE START (behind the
1362
- * same optional status glyph + optional "Error:" prefix as
1363
- * MODEL_SWITCH_CONFIRMATION_PREFIX) so ordinary scrollback prose that merely
1364
- * CONTAINS the phrase mid-sentence (e.g. "deploy failed: model not found in
1365
- * registry") can never false-positive a successful switch into a reported
1366
- * failure — the false-FAILURE variant of the scrollback-leak class.
1367
- *
1368
- * Empirically verified against claude v2.1.205 (disposable TUI probe,
1369
- * 2026-07-10): `/model claude-bogus-99` prints
1370
- * `⎿ Model 'claude-bogus-99' not found` — glyph prefix `⎿`, quoted model
1371
- * name between "Model" and "not found". Both shapes are covered.
1372
- *
1373
- * #3242 review MEDIUM 1 — ACCESS/ENTITLEMENT DENIAL. A bad-id shape is not the
1374
- * only failure: `/model fable` on a plan that lacks it prints an availability /
1375
- * access-denial line ("Fable is not available on your plan", "access denied",
1376
- * "requires a subscription", "not enabled for your account", "no access to …").
1377
- * Those match neither the bad-id shapes nor the confirmation prefix, so the poll
1378
- * loop would expire and the optimistic branch would falsely record the switch —
1379
- * exactly the Fable-entitlement case this PR is about. The second alternation
1380
- * group covers those phrasings. It allows up to four leading words (a model
1381
- * name + a linking adverb etc.) BEFORE the denial phrase — unlike the bad-id
1382
- * branches, which keep
1383
- * their original tight line-start anchoring so ordinary scrollback that merely
1384
- * says "model not found" mid-sentence still can't false-fail a silent switch.
1385
- * The handler checks the confirmation line FIRST (below), so a genuine switch —
1386
- * which always prints a confirmation — is never flipped to a failure by a stray
1387
- * availability word in the same region.
1388
- */
1389
- const MODEL_SWITCH_ERROR_RE =
1390
- /^\s*[⏺●•>⎿-]?\s*(?:Error:\s*)?(?:Model(?:\s+'[^']+')?\s+not found|Invalid model|Unknown model|No such model|(?:[\w'’.\-]+\s+){0,4}(?:(?:is |are )?(?:not available|unavailable|not enabled|not supported)|access denied|requires\b[^\n]{0,40}\b(?:subscription|plan)|no access)\b)/i
1391
-
1392
- /** The single capture line that reads as a claude model-switch error, or null. */
1393
- export function modelSwitchErrorLine(output: string): string | null {
1394
- const line = output
1395
- .split('\n')
1396
- .map((l) => l.trim())
1397
- .find((l) => MODEL_SWITCH_ERROR_RE.test(l))
1398
- return line && line.length > 0 ? line : null
1399
- }
1400
-
1401
- /**
1402
- * True when a confirmation line is claude's "Kept model as X" — i.e. the model
1403
- * was ALREADY the session model and nothing changed. The caller must NOT record
1404
- * this as a session-override (there is nothing to override), and must not store
1405
- * a display label in its place. See the menu-select bug where tapping the
1406
- * "Default (recommended)" row on the already-default model stored the display
1407
- * label verbatim into /status.
1408
- */
1409
- export function isKeptModelConfirmation(confirmation: string): boolean {
1410
- return /^\s*[⏺●•>⎿-]?\s*Kept model as\b/i.test(confirmation.trim())
1411
- }
1412
-
1413
1075
  /**
1414
1076
  * Normalize a picker ROW LABEL to a canonical `claude --model` token suitable
1415
1077
  * for the durable `.session-model` override (aliases and full `claude-*` ids —
@@ -1437,43 +1099,6 @@ export function isRestartInFlight(err: unknown): boolean {
1437
1099
  return !!err && typeof err === 'object' && (err as { code?: unknown }).code === 'restart_in_flight'
1438
1100
  }
1439
1101
 
1440
- /**
1441
- * claude's real model-switch confirmation always begins the line (optionally
1442
- * behind a status glyph like `⏺` or `⎿` + whitespace) with one of these exact
1443
- * phrasings. Anchoring to the line start keeps ordinary scrollback prose that
1444
- * merely *contains* words like "switched" or "set model" (e.g. "I switched the
1445
- * deploy to blue-green") from false-positiving as a confirmation worth
1446
- * relaying. Shared by `modelSwitchConfirmationLine` (does this line qualify?)
1447
- * and `sessionModelFromConfirmation` (pull the name out).
1448
- *
1449
- * Empirically verified against claude v2.1.205 (disposable TUI probe,
1450
- * 2026-07-10): the arg form `/model opus` is NOT silent — it prints
1451
- * `⎿ Set model to Opus 4.8 and saved as your default for new sessions`.
1452
- * The `⎿` glyph survives the inject capture (isTuiChromeLine doesn't strip
1453
- * it), so it must be in the glyph class or every typed switch would fall
1454
- * through to the "couldn't confirm" branch and never record the override.
1455
- */
1456
- const MODEL_SWITCH_CONFIRMATION_PREFIX =
1457
- /^\s*[⏺●•>⎿-]?\s*(?:Set model to|Switched to|Kept model as)\b/i
1458
-
1459
- /**
1460
- * Pull the model NAME out of claude's session-switch confirmation so it can
1461
- * be shown in `/status` as the live session model. claude phrases it as
1462
- * "Set model to <name> for this session only" / "Switched to <name>" /
1463
- * (v2.1.205 arg form) "Set model to <name> and saved as your default for new
1464
- * sessions" — the "and saved" tail must terminate the name capture or the
1465
- * whole sentence would be stored as the model. Returns null when the
1466
- * confirmation doesn't carry a recognizable name (the caller falls back to
1467
- * the tapped picker label).
1468
- */
1469
- export function sessionModelFromConfirmation(confirmation: string): string | null {
1470
- const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s+and saved\b|\s*\(|\s*$)/i.exec(
1471
- confirmation.trim(),
1472
- )
1473
- const name = m?.[1]?.trim()
1474
- return name && name.length > 0 ? name : null
1475
- }
1476
-
1477
1102
  /**
1478
1103
  * Re-render the live menu with a one-line banner on top. Used by every
1479
1104
  * post-tap outcome (success, already-default, failure) so the menu ALWAYS