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.
- package/bin/handoff-briefing.sh +15 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -73
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2712 -2219
- package/dist/host-control/main.js +110 -70
- package/dist/vault/approvals/kernel-server.js +116 -70
- package/dist/vault/broker/server.js +314 -202
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +105 -34
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +1128 -666
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +656 -388
- package/telegram-plugin/gateway/model-command.ts +331 -602
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +99 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +135 -0
- package/telegram-plugin/tests/model-command.test.ts +427 -1512
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- 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>`
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
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
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
632
|
-
*
|
|
633
|
-
*
|
|
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
|
|
648
|
-
*
|
|
649
|
-
* surface further CLI-resolvable aliases the picker
|
|
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
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
|
|
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
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
if (
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1242
|
-
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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:
|
|
1305
|
-
reply: await
|
|
1165
|
+
answer: 'Switch failed',
|
|
1166
|
+
reply: await menuWithBannerStatic(deps, `❌ Switch to **${deps.escapeHtml(label)}** failed: ${deps.escapeHtml(msg)}`),
|
|
1306
1167
|
}
|
|
1307
1168
|
}
|
|
1308
|
-
|
|
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:
|
|
1319
|
-
reply: await
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|