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