switchroom 0.18.28 → 0.18.30

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.
Files changed (48) hide show
  1. package/bin/handoff-briefing.sh +15 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -73
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2712 -2219
  9. package/dist/host-control/main.js +110 -70
  10. package/dist/vault/approvals/kernel-server.js +116 -70
  11. package/dist/vault/broker/server.js +314 -202
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +105 -34
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +1128 -666
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  18. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  19. package/telegram-plugin/gateway/gateway.ts +656 -388
  20. package/telegram-plugin/gateway/model-command.ts +331 -602
  21. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  22. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  23. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  24. package/telegram-plugin/history.ts +153 -23
  25. package/telegram-plugin/llm-error-present.ts +24 -0
  26. package/telegram-plugin/model-unavailable.ts +55 -0
  27. package/telegram-plugin/operator-events.ts +113 -0
  28. package/telegram-plugin/pending-user-notice.ts +88 -0
  29. package/telegram-plugin/shared/local-time.ts +99 -0
  30. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  31. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  32. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  34. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
  35. package/telegram-plugin/tests/history.test.ts +88 -0
  36. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  37. package/telegram-plugin/tests/local-time.test.ts +135 -0
  38. package/telegram-plugin/tests/model-command.test.ts +427 -1512
  39. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  41. package/telegram-plugin/tier-downgrade.ts +4 -3
  42. package/telegram-plugin/turn-flush-safety.ts +25 -1
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  44. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  45. package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
  46. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  48. package/vendor/hindsight-memory/tests/test_content.py +63 -7
@@ -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
 
@@ -82,6 +84,110 @@ export function isClaudeModel(name: string): boolean {
82
84
  return lower.startsWith('claude-')
83
85
  }
84
86
 
87
+ /**
88
+ * The outcome of a `/model` apply-boot, derived purely from the DETERMINISTIC
89
+ * post-boot signals (never optimistic):
90
+ *
91
+ * - `reason` — the clean-shutdown marker reason that keyed this boot as a
92
+ * `/model` apply-boot, e.g.
93
+ * `user: /model fable (session-only relaunch, menu)`.
94
+ * - `launched` — the contents of `.active-session-model`: the model start.sh
95
+ * actually passed to `claude --model` this boot.
96
+ * - `configured` — the resolved configured default model.
97
+ *
98
+ * Three outcomes:
99
+ * - `applied` — the launched model differs from the configured default, so
100
+ * the switch landed (a session-only override).
101
+ * - `default` — the operator asked for the configured default (`/model
102
+ * default`, or `/model <configured>`) and got it.
103
+ * - `not-applied` — the operator asked for a NON-default model, but the boot
104
+ * came back on the configured default: the switch SILENTLY
105
+ * failed to apply. The consume-once carrier was consumed by a
106
+ * boot that never launched the target (e.g. a wedged apply-boot
107
+ * that hit boot.lock_stale_recovered_boot_mismatch and reverted
108
+ * to the default). This is the case that previously emitted a
109
+ * MISLEADING green "✅ Now running <default> (the configured
110
+ * default)" card with no signal that the requested switch was
111
+ * lost. It self-corrects across boots: a later boot that
112
+ * genuinely launches the target reports `applied`.
113
+ */
114
+ export type ModelSwitchConfirmation =
115
+ | { kind: 'applied'; launched: string }
116
+ | { kind: 'default'; launched: string }
117
+ | { kind: 'not-applied'; target: string; revertedTo: string }
118
+
119
+ /**
120
+ * Extract the requested `/model <target>` token from a clean-shutdown reason
121
+ * (e.g. `user: /model fable (session-only relaunch, menu)` → `fable`). Returns
122
+ * null when the reason carries no `/model <token>` (a non-switch reason).
123
+ */
124
+ export function parseModelSwitchTarget(reason: string): string | null {
125
+ const m = reason.match(/\/model\s+(\S+)/)
126
+ return m ? m[1] : null
127
+ }
128
+
129
+ /**
130
+ * Reduce a model token to a comparable FAMILY key so an alias and its resolved
131
+ * full id compare equal (`sonnet` ≡ `claude-sonnet-5` ≡ `claude-sonnet-5-<date>`).
132
+ *
133
+ * This is the normalization the silent-revert classifier needs: `target` from
134
+ * the clean-shutdown reason is the RAW user token (an alias like `sonnet`),
135
+ * while `.active-session-model` / the configured default may be the RESOLVED
136
+ * full id start.sh wrote on a revert-with-alert path (config-default-changed at
137
+ * start.sh.hbs:1228, proxy-down at :1234). A naive string compare would flag
138
+ * `/model sonnet` on a `claude-sonnet-5`-default agent as "didn't apply" even
139
+ * though sonnet IS the default.
140
+ *
141
+ * NB `resolveMainModel` (scaffold.ts:1358) is NOT sufficient here — it only
142
+ * remaps the `default` alias / unset to the switchroom default; it passes
143
+ * `sonnet`/`opus`/etc. through unchanged. The alias↔full-id equivalence is a
144
+ * FAMILY reduction (same rule model-label.ts uses one-way), done here:
145
+ * - `claude-<family>-…` → `<family>` (e.g. `claude-sonnet-5` → `sonnet`)
146
+ * - a bare alias / any other token → itself, lowercased (`sonnet`, `sr-glm-5`)
147
+ * sr-* ids never carry a `claude-` prefix, so they stay verbatim — correct,
148
+ * since the reason token and `.active-session-model` both hold the same
149
+ * already-expanded sr-* id and compare equal directly.
150
+ */
151
+ export function modelFamilyToken(token: string): string {
152
+ const t = token.trim().toLowerCase()
153
+ if (t.startsWith('claude-')) {
154
+ const family = t.slice('claude-'.length).split('-').filter((p) => p.length > 0)[0]
155
+ return family ?? t
156
+ }
157
+ return t
158
+ }
159
+
160
+ /**
161
+ * Classify a `/model` apply-boot outcome from the post-boot signals. Pure so the
162
+ * confirmation-card decision is unit-testable without booting the gateway. The
163
+ * `not-applied` branch is the fix for the silent-revert bug: a non-default switch
164
+ * that reverted to the configured default must warn, not print a green ✅.
165
+ */
166
+ export function classifyModelSwitchConfirmation(input: {
167
+ reason: string
168
+ launched: string
169
+ configured: string
170
+ }): ModelSwitchConfirmation {
171
+ const { reason, launched, configured } = input
172
+ const isApplyBoot = launched.length > 0 && launched !== configured
173
+ if (isApplyBoot) return { kind: 'applied', launched }
174
+ // launched === configured (or empty): either an intended default/revert, or a
175
+ // NON-default switch that silently reverted to the configured default.
176
+ const target = parseModelSwitchTarget(reason)
177
+ const revertedTo = launched.length > 0 ? launched : configured
178
+ // Family-normalize both sides so an alias target (`sonnet`) matches its
179
+ // resolved full-id revert (`claude-sonnet-5`) — otherwise a `/model sonnet`
180
+ // that reverted to the sonnet default would emit a WRONG "didn't apply" card.
181
+ if (
182
+ target != null &&
183
+ target.toLowerCase() !== 'default' &&
184
+ modelFamilyToken(target) !== modelFamilyToken(revertedTo)
185
+ ) {
186
+ return { kind: 'not-applied', target, revertedTo }
187
+ }
188
+ return { kind: 'default', launched: revertedTo }
189
+ }
190
+
85
191
  export type ParsedModelCommand =
86
192
  | { kind: 'show' }
87
193
  | { kind: 'set'; model: string }
@@ -253,17 +359,6 @@ export function modelCommandReceiptLine(
253
359
  }
254
360
 
255
361
  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
362
  /**
268
363
  * True while the agent is mid-turn. A typed `/model <name>` switch drives
269
364
  * claude's session (either an inject into the input box, or a carrier-backed
@@ -282,14 +377,6 @@ export interface ModelCommandDeps {
282
377
  getConfiguredModel: () => string | null
283
378
  escapeHtml: (s: string) => string
284
379
  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
380
  /**
294
381
  * Schedule a graceful restart of this agent. Called instead of inject
295
382
  * when switching from an sr-* model back to Claude — the restart clears
@@ -308,38 +395,44 @@ export interface ModelCommandDeps {
308
395
  * on the following restart. Wired to the same restart dispatch as
309
396
  * `scheduleRestart`, plus the carrier write. `model` is the full `sr-*` id
310
397
  * (already alias-expanded); `reason` is stamped as the restart reason.
398
+ *
399
+ * Rev 5 (deterministic switch): EVERY `/model` switch — Claude→Claude,
400
+ * Claude→sr-*, sr-*→Claude, the Fable/alias button, and the picker SELECT —
401
+ * routes through here. The inject-into-tmux + terminal-scrape path is retired,
402
+ * so a switch can no longer silently no-op or optimistically lie to /status:
403
+ * start.sh's `exec claude --model <token>` cannot silently no-op, and the
404
+ * post-boot `.active-session-model` signal is what /status reflects.
311
405
  */
312
406
  scheduleModelRelaunch: (model: string, reason: string) => Promise<void>
407
+ /**
408
+ * Revert TO the configured default (`/model default`) via a relaunch. Clears
409
+ * the consume-once `.session-model` carrier + the in-memory override, then
410
+ * relaunches so the LIVE session actually reverts to `switchroom.yaml model:`
411
+ * (rev 5: with inject retired, a relaunch is the only way to make `default`
412
+ * take effect live — consistent with "all switches relaunch"). Mirrors
413
+ * scheduleModelRelaunch's careful rollback: a `restart_in_flight` throw keeps
414
+ * the cleared state (the in-flight boot reverts anyway); any other dispatch
415
+ * failure restores the prior carrier + override.
416
+ */
417
+ scheduleModelDefaultRelaunch: (reason: string) => Promise<void>
313
418
  }
314
419
 
315
420
  export interface ModelCommandReply {
316
421
  text: string
317
422
  html: true
318
423
  /**
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`.
424
+ * Rev 5: a `/model` switch NEVER carries a live-model field. The inject +
425
+ * terminal-scrape path that produced an optimistic `selectedModel` is retired.
426
+ * Every switch relaunches through `scheduleModelRelaunch`, which owns the
427
+ * in-memory override write for the restart window; the ACTUAL running model is
428
+ * reconciled at boot from `.active-session-model` (and by the transcript's
429
+ * `message.model`), never optimistically asserted from a scraped pane. So
430
+ * there is no `selectedModel`/`optimistic` here to lie to /status with.
326
431
  */
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
432
  }
340
433
 
341
434
  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._'
435
+ '_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
436
 
344
437
  function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
345
438
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' · ')
@@ -350,7 +443,7 @@ function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
350
443
  '\`/model\` — show the configured model',
351
444
  `\`/model <name>\` — switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' · ')} or a full model id)`,
352
445
  `_OpenRouter shortcuts:_ ${srAliasExamples}`,
353
- '_OpenRouter (sr-\\*) switches restart the session (~30s); Claude switches apply instantly._',
446
+ '_Every switch relaunches the session (~30s) on the chosen model — Claude and OpenRouter (sr-\\*) alike._',
354
447
  PERSIST_NOTE,
355
448
  )
356
449
  return { text: lines.join('\n'), html: true }
@@ -388,10 +481,10 @@ export async function handleModelCommand(
388
481
  // Expand short aliases: `flash` → `sr-gemini-2.5-flash`, `codex` → `sr-codex-5.5`, etc.
389
482
  const model = expandSrAlias(parsed.model)
390
483
 
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).
484
+ // Busy gate: a switch RELAUNCHES the session, which is unsafe mid-turn (it
485
+ // would tear down the live turn). The gateway's mid-turn path ACKs + queues +
486
+ // applies-on-idle before this handler is reached; this is the belt-and-braces
487
+ // seam for a caller that skipped it.
395
488
  if (deps.isBusy()) {
396
489
  return {
397
490
  text: '⏳ The agent is mid-turn — a model switch needs an idle session. The switch was not applied.',
@@ -399,182 +492,71 @@ export async function handleModelCommand(
399
492
  }
400
493
  }
401
494
 
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
- }
495
+ // Rev 5 (deterministic switch): route EVERY target through the consume-once
496
+ // `.session-model` carrier relaunch. `default` clears the carrier + override
497
+ // and relaunches so the live session reverts to the configured `model:`; any
498
+ // other token Claude alias/id, sr-*, Fable is carried and applied by
499
+ // start.sh's `exec claude --model <token>`. The inject-into-tmux +
500
+ // terminal-scrape path is RETIRED: a switch can no longer silently no-op or
501
+ // optimistically lie to /status (start.sh cannot silently no-op, and the
502
+ // post-boot `.active-session-model` signal is the source of truth).
503
+ if (model.toLowerCase() === 'default') {
504
+ return scheduleDefaultRelaunchReply(deps, 'user: /model default (revert relaunch)')
435
505
  }
506
+ return scheduleRelaunchReply(deps, model, `user: /model ${model} (session-only relaunch)`)
507
+ }
436
508
 
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
- }
509
+ /** The one-line ack shown while a switch relaunches (~30s). */
510
+ function switchingLine(deps: Pick<ModelCommandDeps, 'escapeHtml'>, model: string): string {
511
+ const friendly = isSrModel(model) ? srFriendlyLabel(model) : model
512
+ return `🔄 Switching to \`${deps.escapeHtml(friendly)}\` relaunching the session (~30s).`
513
+ }
514
+
515
+ /** Map a relaunch-dispatch error to an honest reply (debounce vs failure). */
516
+ function relaunchErrorReply(
517
+ deps: Pick<ModelCommandDeps, 'escapeHtml'>,
518
+ model: string,
519
+ err: unknown,
520
+ ): ModelCommandReply {
521
+ if (isRestartInFlight(err)) {
458
522
  return {
459
- text: [
460
- `Switching to \`${deps.escapeHtml(model)}\` — restarting session (~30s).`,
461
- PERSIST_NOTE,
462
- ].join('\n'),
523
+ text: `⏳ A restart is already in flight — your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
463
524
  html: true,
464
525
  }
465
526
  }
527
+ const msg = err instanceof Error ? err.message : String(err)
528
+ return { text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`, html: true }
529
+ }
466
530
 
467
- const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
468
- let result: InjectResult
531
+ /** Schedule a carrier relaunch onto `model`, returning the deterministic ack. */
532
+ async function scheduleRelaunchReply(
533
+ deps: ModelCommandDeps,
534
+ model: string,
535
+ reason: string,
536
+ ): Promise<ModelCommandReply> {
469
537
  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
- })
538
+ await deps.scheduleModelRelaunch(model, reason)
482
539
  } 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
- }
540
+ return relaunchErrorReply(deps, model, err)
566
541
  }
542
+ return { text: [switchingLine(deps, model), PERSIST_NOTE].join('\n'), html: true }
543
+ }
567
544
 
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
- }
545
+ /** Schedule the `/model default` clear + revert relaunch, returning its ack. */
546
+ async function scheduleDefaultRelaunchReply(
547
+ deps: ModelCommandDeps,
548
+ reason: string,
549
+ ): Promise<ModelCommandReply> {
550
+ try {
551
+ await deps.scheduleModelDefaultRelaunch(reason)
552
+ } catch (err) {
553
+ return relaunchErrorReply(deps, 'default', err)
575
554
  }
576
555
  return {
577
- text: `❌ ${verbHtml} — ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`,
556
+ text: [
557
+ '🔄 Reverting to the configured default model — relaunching the session (~30s).',
558
+ PERSIST_NOTE,
559
+ ].join('\n'),
578
560
  html: true,
579
561
  }
580
562
  }
@@ -584,12 +566,14 @@ export async function handleModelCommand(
584
566
  // ---------------------------------------------------------------------------
585
567
 
586
568
  export interface ModelMenuDeps {
587
- /** Live picker discovery — src/agents/model-picker.ts discoverModels. */
569
+ /**
570
+ * Live picker discovery — src/agents/model-picker.ts discoverModels. Used ONLY
571
+ * to RENDER the model list (buildModelMenu); rev 5 retired the terminal-driving
572
+ * `select` from the switch path, so discovery no longer applies a switch.
573
+ */
588
574
  discover: (agent: string) => Promise<DiscoverResult>
589
- /** Live picker selection by label — selectModel (session-only `s`). */
590
- select: (agent: string, label: string) => Promise<SelectResult>
591
575
  /**
592
- * True while the agent is mid-turn. Driving the picker types into
576
+ * True while the agent is mid-turn. Driving the picker (for RENDER) types into
593
577
  * claude's input box; doing that mid-turn would queue "/model" as
594
578
  * user text instead of opening the modal — refuse instead.
595
579
  */
@@ -628,9 +612,9 @@ export const MODEL_CALLBACK_HEADER = 'mdl:h'
628
612
  /**
629
613
  * Callback prefix for Claude aliases that the CLI picker doesn't render but
630
614
  * 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.
615
+ * handler routes to the carrier relaunch (`scheduleModelRelaunch`) — the same
616
+ * deterministic mechanism every switch uses (rev 5). `fable` boots via the
617
+ * LiteLLM router repoint in start.sh (see the fable case there).
634
618
  */
635
619
  export const MODEL_CALLBACK_ALIAS = 'mdl:alias:'
636
620
  /** Callback: open the nested "External models" keyboard page. */
@@ -644,9 +628,10 @@ export type ModelMenuPage = 'main' | 'external'
644
628
  /**
645
629
  * Static Claude aliases appended to the scraped Claude group. The claude CLI's
646
630
  * 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.
631
+ * resolves the alias natively, so we render it as an extra button that switches
632
+ * via the carrier relaunch (MODEL_CALLBACK_ALIAS scheduleModelRelaunch, rev
633
+ * 5). Extend this list to surface further CLI-resolvable aliases the picker
634
+ * omits.
650
635
  */
651
636
  export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string }> = [
652
637
  { alias: 'fable', label: 'Fable' },
@@ -734,32 +719,6 @@ export function srFriendlyLabel(srName: string): string {
734
719
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
735
720
  }
736
721
 
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
722
  /**
764
723
  * Split picker-discovered options into native Claude options and sr-*
765
724
  * (LiteLLM non-Anthropic) options. Options with "/" in the label or
@@ -787,11 +746,41 @@ export function classifyDiscoveredOptions(options: ModelPickerOption[]): {
787
746
  }
788
747
 
789
748
  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)}`
749
+ // Rev 5: embed the CANONICAL `claude --model` token directly, not a label
750
+ // hash. A tap no longer needs live picker discovery to resolve the row — it
751
+ // goes straight to the carrier relaunch (`mdl:s:<token>`), removing the last
752
+ // terminal-driving step from the switch path.
753
+ const token = canonicalClaudeToken(label)
754
+ if (token) return `${MODEL_CALLBACK_SELECT}${token}`
755
+ // The "Default (recommended)" row has no derivable token (`canonicalClaudeToken`
756
+ // → null) — it carries the `default` sentinel so the tap routes to the
757
+ // clear+revert relaunch.
758
+ if (/^default\b/i.test(label.trim())) return `${MODEL_CALLBACK_SELECT}default`
759
+ // N2: an UNMAPPED non-default Claude row (a label whose first word is neither a
760
+ // known alias nor `claude-*`, nor `default`) has no derivable token. Emit an
761
+ // EMPTY suffix so the tap is REJECTED by the switch-tap gate and re-renders,
762
+ // rather than collapsing to the `default` sentinel — which would silently
763
+ // REVERT to the configured default instead of switching to the labelled model.
764
+ return MODEL_CALLBACK_SELECT
765
+ }
766
+
767
+ /**
768
+ * N1: is `token` a switch target we actually recognize? A stale menu rendered by
769
+ * an OLD gateway carries `mdl:s:<8-hex labelTag>` callback_data, which passes the
770
+ * loose `MODEL_ARG_RE` shape gate — relaunching onto it would write a garbage
771
+ * carrier and `--fallback-model` would silently serve a fallback. Constrain
772
+ * SELECT/alias/sr tokens to a known set before scheduling any relaunch:
773
+ * - the `default` sentinel (clear+revert),
774
+ * - any sr-* (LiteLLM/OpenRouter) id (accepted raw, never picker-validated),
775
+ * - a canonical Claude token (a known alias or a `claude-*` id).
776
+ * An 8-hex tag, an empty suffix, or any other unmapped string returns false → the
777
+ * handler re-renders the menu (the old "Model list changed" graceful degrade)
778
+ * instead of relaunching onto a token claude will silently fall back from.
779
+ */
780
+ export function isRecognizedSwitchToken(token: string): boolean {
781
+ if (token.toLowerCase() === 'default') return true
782
+ if (isSrModel(token)) return true
783
+ return canonicalClaudeToken(token) !== null
795
784
  }
796
785
 
797
786
  const BUSY_REFUSAL_TEXT =
@@ -1044,30 +1033,14 @@ export interface ModelCallbackOutcome {
1044
1033
  */
1045
1034
  busyRefusal?: boolean
1046
1035
  /**
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).
1036
+ * Rev 5: a menu tap NEVER carries a scrape-derived live-model field. Every
1037
+ * switch (alias/Fable, picker SELECT, sr-*) relaunches through
1038
+ * `scheduleModelRelaunch`/`scheduleModelDefaultRelaunch`, which own the
1039
+ * in-memory override + carrier writes; the ACTUAL running model is reconciled
1040
+ * at boot from `.active-session-model`. So there is no `selectedModel` /
1041
+ * `selectedModelToken` / `clearedDefault` here to record optimistically — the
1042
+ * gateway no longer post-processes the outcome for side effects.
1069
1043
  */
1070
- clearedDefault?: boolean
1071
1044
  /** Short toast for answerCallbackQuery. */
1072
1045
  answer: string
1073
1046
  /** Replacement dashboard (message edit). */
@@ -1098,98 +1071,6 @@ export async function handleModelMenuCallback(
1098
1071
  return { answer: 'Back', reply: await buildModelMenu(deps, 'main') }
1099
1072
  }
1100
1073
 
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
1074
  if (data === MODEL_CALLBACK_HEADER) {
1194
1075
  // Section-header row — the gateway handles this with a direct answerCallbackQuery
1195
1076
  // before calling this function, so this branch is dead in practice. Guard
@@ -1197,20 +1078,45 @@ export async function handleModelMenuCallback(
1197
1078
  return { answer: 'Tap a model in this section to switch', reply: { text: '', html: true }, toastOnly: true }
1198
1079
  }
1199
1080
 
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)) {
1081
+ // A model-SWITCH tap Fable/alias button (`mdl:alias:`), an sr-* target
1082
+ // (`mdl:sr:`), or a picker SELECT row (`mdl:s:<token>`). Rev 5: every one
1083
+ // relaunches through the consume-once `.session-model` carrier. No inject, no
1084
+ // cursor-nav, no terminal scrape a tap resolves its canonical token and
1085
+ // hands off to `scheduleModelRelaunch` (or the clear+revert path for the
1086
+ // `default` sentinel, which the "Default (recommended)" row and the Default
1087
+ // alias button both carry). This is what makes a tap deterministic: it can no
1088
+ // longer silently no-op or optimistically record a switch that never applied.
1089
+ if (
1090
+ data.startsWith(MODEL_CALLBACK_ALIAS) ||
1091
+ data.startsWith(MODEL_CALLBACK_SR) ||
1092
+ data.startsWith(MODEL_CALLBACK_SELECT)
1093
+ ) {
1094
+ let token: string
1095
+ let label: string
1096
+ if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
1097
+ token = data.slice(MODEL_CALLBACK_ALIAS.length)
1098
+ label = token
1099
+ } else if (data.startsWith(MODEL_CALLBACK_SR)) {
1100
+ token = data.slice(MODEL_CALLBACK_SR.length)
1101
+ label = srFriendlyLabel(token)
1102
+ } else {
1103
+ token = data.slice(MODEL_CALLBACK_SELECT.length)
1104
+ label = token
1105
+ }
1106
+ if (!isValidModelArg(token)) {
1212
1107
  return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
1213
1108
  }
1109
+ // N1: reject a token we don't recognize (a stale OLD-gateway `mdl:s:<hex>`
1110
+ // callback, an unmapped SELECT row, or garbage). Relaunching onto it would
1111
+ // write a carrier claude silently falls back from (--fallback-model). Re-render
1112
+ // the fresh menu instead — the graceful degrade the label-tag path used to give.
1113
+ if (!isRecognizedSwitchToken(token)) {
1114
+ return { answer: 'Model list changed — menu refreshed', reply: await buildModelMenu(deps) }
1115
+ }
1116
+ // Mid-turn: refuse WITHOUT touching the message so the menu keeps its
1117
+ // buttons and the operator can tap again once idle. (The gateway dispatcher
1118
+ // already enqueues switch taps mid-turn before calling this handler; this is
1119
+ // the belt-and-braces seam for callers that skip it.)
1214
1120
  if (deps.isBusy()) {
1215
1121
  return {
1216
1122
  answer: '⏳ Agent is mid-turn — tap again when it’s idle',
@@ -1219,197 +1125,57 @@ export async function handleModelMenuCallback(
1219
1125
  busyRefusal: true,
1220
1126
  }
1221
1127
  }
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
- }
1128
+ return menuRelaunchOutcome(deps, token, label)
1239
1129
  }
1240
1130
 
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
- }
1131
+ return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
1132
+ }
1257
1133
 
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
- ),
1134
+ /**
1135
+ * Shared menu-tap relaunch: schedule the carrier relaunch onto `token` (or the
1136
+ * clear+revert relaunch for the `default` sentinel) and return the deterministic
1137
+ * "relaunching (~30s)" card. Uses the STATIC banner (no discover()) because the
1138
+ * pane is about to restart. No `selectedModel` — the actual running model is
1139
+ * reconciled at boot from `.active-session-model`.
1140
+ */
1141
+ async function menuRelaunchOutcome(
1142
+ deps: ModelMenuDeps & ModelCommandDeps,
1143
+ token: string,
1144
+ label: string,
1145
+ ): Promise<ModelCallbackOutcome> {
1146
+ const isDefault = token.toLowerCase() === 'default'
1147
+ try {
1148
+ if (isDefault) {
1149
+ await deps.scheduleModelDefaultRelaunch('user: /model default (revert relaunch, menu)')
1150
+ } else {
1151
+ await deps.scheduleModelRelaunch(token, `user: /model ${token} (session-only relaunch, menu)`)
1269
1152
  }
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
- ),
1153
+ } catch (err) {
1154
+ if (isRestartInFlight(err)) {
1155
+ return {
1156
+ answer: 'A restart is already in flight (~15s)',
1157
+ reply: await menuWithBannerStatic(
1158
+ deps,
1159
+ `⏳ A restart is already in flight — your switch to **${deps.escapeHtml(label)}** will apply as it completes (~15s).`,
1160
+ ),
1161
+ }
1295
1162
  }
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)) {
1163
+ const msg = err instanceof Error ? err.message : String(err)
1303
1164
  return {
1304
- answer: deps.escapeHtml(result.confirmation),
1305
- reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
1165
+ answer: 'Switch failed',
1166
+ reply: await menuWithBannerStatic(deps, `❌ Switch to **${deps.escapeHtml(label)}** failed: ${deps.escapeHtml(msg)}`),
1306
1167
  }
1307
1168
  }
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())
1169
+ const friendly = isDefault ? 'the configured default' : label
1317
1170
  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 } : {}),
1171
+ answer: `Switching to ${isDefault ? 'default' : label} — relaunching (~30s)`,
1172
+ reply: await menuWithBannerStatic(
1173
+ deps,
1174
+ `🔄 Switching session to **${deps.escapeHtml(friendly)}** relaunching (~30s).\n${PERSIST_NOTE}`,
1175
+ ),
1323
1176
  }
1324
1177
  }
1325
1178
 
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
1179
  /**
1414
1180
  * Normalize a picker ROW LABEL to a canonical `claude --model` token suitable
1415
1181
  * for the durable `.session-model` override (aliases and full `claude-*` ids —
@@ -1437,43 +1203,6 @@ export function isRestartInFlight(err: unknown): boolean {
1437
1203
  return !!err && typeof err === 'object' && (err as { code?: unknown }).code === 'restart_in_flight'
1438
1204
  }
1439
1205
 
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
1206
  /**
1478
1207
  * Re-render the live menu with a one-line banner on top. Used by every
1479
1208
  * post-tap outcome (success, already-default, failure) so the menu ALWAYS