switchroom 0.15.1 → 0.15.3

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.
@@ -247,6 +247,7 @@ import {
247
247
  import {
248
248
  fetchQuota,
249
249
  formatQuotaBlock,
250
+ formatQuotaLine,
250
251
  type QuotaResult,
251
252
  } from '../quota-check.js'
252
253
  import {
@@ -258,6 +259,17 @@ import { DEFAULT_SLOT } from '../../src/auth/accounts.js'
258
259
  import { currentActiveSlot, type AuthCodeOutcome } from '../../src/auth/manager.js'
259
260
  import { injectSlashCommand as injectSlashCommandImpl } from '../../src/agents/inject.js'
260
261
  import { handleInjectCommand } from './inject-handler.js'
262
+ import {
263
+ parseModelCommand,
264
+ handleModelCommand,
265
+ buildModelMenu,
266
+ handleModelMenuCallback,
267
+ MODEL_CALLBACK_PREFIX,
268
+ type ModelMenuDeps,
269
+ type ModelCommandDeps,
270
+ type ModelMenuReply,
271
+ } from './model-command.js'
272
+ import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
261
273
  import { type BannerState } from '../slot-banner.js'
262
274
  import { refreshBanner } from '../slot-banner-driver.js'
263
275
  import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
@@ -13921,6 +13933,73 @@ bot.command('inject', async ctx => {
13921
13933
  })
13922
13934
  })
13923
13935
 
13936
+ // /model — model dashboard + switch for this agent's live session.
13937
+ // Bare form: drives claude's own /model picker (open → parse → Esc,
13938
+ // src/agents/model-picker.ts) to discover the live option list — no
13939
+ // hardcoded model names — and renders it as an inline-keyboard menu
13940
+ // with the current model + a brief quota line. A tap re-opens the
13941
+ // picker fresh and applies session-only (`s`). Kill-switch
13942
+ // SWITCHROOM_MODEL_MENU=0 reverts the bare form to the static v1
13943
+ // text. The typed argument form rides the allowlisted inject
13944
+ // primitive unchanged. Implementation in model-command.ts so it's
13945
+ // unit-testable without booting the bot.
13946
+ function buildModelDeps(): ModelMenuDeps & ModelCommandDeps {
13947
+ return {
13948
+ discover: (a) => discoverModels(a),
13949
+ select: (a, label) => selectModel(a, label),
13950
+ isBusy: () => currentTurn !== null,
13951
+ getAgentName: getMyAgentName,
13952
+ getQuotaBrief: async () => {
13953
+ // Broker-routed probe first (authoritative), local headers as
13954
+ // fallback — same ladder as the boot card / legacy /usage.
13955
+ try {
13956
+ const probed = await probeQuotaForBootCard(getMyAgentName(), 4000)
13957
+ if (probed?.ok) return formatQuotaLine(probed.data)
13958
+ } catch { /* fall through */ }
13959
+ try {
13960
+ const agentDir = resolveAgentDirFromEnv()
13961
+ if (agentDir) {
13962
+ const local = await fetchQuota({ claudeConfigDir: join(agentDir, '.claude') })
13963
+ if (local.ok) return formatQuotaLine(local.data)
13964
+ }
13965
+ } catch { /* quota is garnish — never block the menu on it */ }
13966
+ return null
13967
+ },
13968
+ inject: injectSlashCommandImpl,
13969
+ getConfiguredModel: () => {
13970
+ type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
13971
+ const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
13972
+ return data?.agents?.find(a => a.name === getMyAgentName())?.model ?? null
13973
+ },
13974
+ escapeHtml: escapeHtmlForTg,
13975
+ preBlock,
13976
+ }
13977
+ }
13978
+
13979
+ function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined {
13980
+ if (!reply.keyboard) return undefined
13981
+ const kb = new InlineKeyboard()
13982
+ for (const row of reply.keyboard) {
13983
+ for (const btn of row) kb.text(btn.text, btn.callback_data)
13984
+ kb.row()
13985
+ }
13986
+ return kb
13987
+ }
13988
+
13989
+ bot.command('model', async ctx => {
13990
+ if (!isAuthorizedSender(ctx)) return
13991
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
13992
+ const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
13993
+ const deps = buildModelDeps()
13994
+ if (parsed.kind === 'show' && process.env.SWITCHROOM_MODEL_MENU !== '0') {
13995
+ const menu = await buildModelMenu(deps)
13996
+ await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
13997
+ return
13998
+ }
13999
+ const reply = await handleModelCommand(parsed, deps)
14000
+ await switchroomReply(ctx, reply.text, { html: reply.html })
14001
+ })
14002
+
13924
14003
  bot.command('agentstart', async ctx => {
13925
14004
  if (!isAuthorizedSender(ctx)) return
13926
14005
  const name = ctx.match?.trim() || getMyAgentName()
@@ -15440,9 +15519,11 @@ bot.command('connect', async ctx => {
15440
15519
  let isAdmin = false
15441
15520
  try {
15442
15521
  const cfg = loadSwitchroomConfig()
15443
- const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean }> })
15522
+ const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean; root?: boolean }> })
15444
15523
  ?.agents?.[getMyAgentName()]
15445
- isAdmin = me?.admin === true
15524
+ // `root: true` (the root-tier debugging agent) is above admin and
15525
+ // carries admin authority — see docs/root-agent.md.
15526
+ isAdmin = me?.admin === true || me?.root === true
15446
15527
  } catch { /* non-admin is the safe default */ }
15447
15528
  if (!isAuthAdmin({ isAdmin })) {
15448
15529
  await switchroomReply(
@@ -15602,8 +15683,10 @@ bot.command("auth", async ctx => {
15602
15683
  let isAdmin = false
15603
15684
  try {
15604
15685
  const cfg = loadSwitchroomConfig()
15605
- const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean }> })?.agents?.[currentAgent]
15606
- isAdmin = me?.admin === true
15686
+ const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean; root?: boolean }> })?.agents?.[currentAgent]
15687
+ // `root: true` (the root-tier debugging agent) is above admin and
15688
+ // carries admin authority — see docs/root-agent.md.
15689
+ isAdmin = me?.admin === true || me?.root === true
15607
15690
  } catch { /* best-effort — non-admin is the safe default */ }
15608
15691
 
15609
15692
  // `/auth add` and `/auth cancel` are gateway-routed (drive a
@@ -18301,6 +18384,54 @@ bot.on('callback_query:data', async ctx => {
18301
18384
  return
18302
18385
  }
18303
18386
 
18387
+ // `mdl:*` — model-menu taps (/model dashboard). `mdl:s:<tag>`
18388
+ // selects a model by label-tag via a fresh picker discovery (never
18389
+ // a stale index); `mdl:r` re-renders. Strict allowFrom gate like
18390
+ // every other mutating callback family — a model switch changes the
18391
+ // fleet's quota burn profile.
18392
+ if (data.startsWith(MODEL_CALLBACK_PREFIX)) {
18393
+ const access = loadAccess()
18394
+ const senderId = String(ctx.from?.id ?? '')
18395
+ if (!access.allowFrom.includes(senderId)) {
18396
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
18397
+ return
18398
+ }
18399
+ // Kill-switch covers the callback family too — stale menus keep
18400
+ // their buttons after the flag flips, and the flag exists exactly
18401
+ // for "picker-driving is misbehaving" (#2263 review blocker 2).
18402
+ if (process.env.SWITCHROOM_MODEL_MENU === '0') {
18403
+ await ctx.answerCallbackQuery({ text: 'Model menu is disabled (SWITCHROOM_MODEL_MENU=0).' }).catch(() => {})
18404
+ await ctx
18405
+ .editMessageText('Model menu is disabled on this agent. Use <code>/model &lt;name&gt;</code>.', {
18406
+ parse_mode: 'HTML',
18407
+ reply_markup: { inline_keyboard: [] },
18408
+ })
18409
+ .catch(() => {})
18410
+ return
18411
+ }
18412
+ // Answer the callback IMMEDIATELY — the select path drives the
18413
+ // picker up to three times (multi-second); leaving the tap
18414
+ // spinning invites a double-tap, which queues a second drive
18415
+ // behind the pane lock and confuses the user. The final state is
18416
+ // conveyed by the message edit (a callback can only be answered
18417
+ // once).
18418
+ await ctx.answerCallbackQuery({ text: 'Working…' }).catch(() => {})
18419
+ try {
18420
+ const outcome = await handleModelMenuCallback(data, buildModelDeps())
18421
+ await ctx
18422
+ .editMessageText(outcome.reply.text, {
18423
+ parse_mode: 'HTML',
18424
+ reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
18425
+ })
18426
+ .catch(() => {})
18427
+ } catch (err) {
18428
+ process.stderr.write(
18429
+ `telegram gateway: model-menu callback failed: ${(err as Error)?.message ?? String(err)}\n`,
18430
+ )
18431
+ }
18432
+ return
18433
+ }
18434
+
18304
18435
  // `cn:cancel:<key>` — cancel a pending Microsoft connect flow (the
18305
18436
  // Cancel button on the /connect card). RFC #1873 Phase 2.
18306
18437
  if (data.startsWith('cn:')) {
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Telegram `/model` command — show or switch the Claude model for this
3
+ * agent's live session.
4
+ *
5
+ * `/model` (bare) renders the model dashboard: the live model, a brief
6
+ * quota line, and an inline-keyboard menu of the options claude's own
7
+ * `/model` picker offers (discovered live via `src/agents/model-picker.ts`
8
+ * — opened, parsed, Esc'd; never hardcoded, so new models appear the
9
+ * moment the installed CLI offers them). A button tap re-opens the
10
+ * picker fresh, matches the row by label, and applies session-only.
11
+ * When discovery fails (agent mid-turn, CLI UI changed, kill-switched
12
+ * via SWITCHROOM_MODEL_MENU=0) it falls back to the static v1 text.
13
+ *
14
+ * `/model <alias|full-id>` types claude's own `/model <name>` into the
15
+ * agent's tmux pane via the existing allowlisted inject primitive
16
+ * (`src/agents/inject.ts` — `/model` is already on the allowlist) and
17
+ * relays the captured response. This is the Claude-native mechanism:
18
+ * the unmodified CLI's REPL command, no API, no SDK, no config
19
+ * mutation. The switch is session-scoped — it lasts until the agent
20
+ * restarts; persisting requires `model:` in switchroom.yaml (cascade)
21
+ * and a restart, which the reply spells out.
22
+ *
23
+ * Split parser/handler shape mirrors `auth-command.ts` so the logic is
24
+ * unit-testable without booting the bot.
25
+ */
26
+
27
+ import type { InjectResult } from '../../src/agents/inject.js'
28
+ import {
29
+ labelTag,
30
+ type DiscoverResult,
31
+ type SelectResult,
32
+ type ModelPickerOption,
33
+ } from '../../src/agents/model-picker.js'
34
+
35
+ /**
36
+ * Aliases the claude CLI resolves natively. Listed in help text only —
37
+ * the handler does NOT restrict to these (a full model id like
38
+ * `claude-opus-4-8` passes through and claude itself validates it, so
39
+ * new aliases/models work without a switchroom release).
40
+ */
41
+ export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'default'] as const
42
+
43
+ /**
44
+ * Shape gate for the model argument. This string is typed literally
45
+ * into the agent's tmux pane, so the gate is strict by construction:
46
+ * one token, alphanumeric start, then alphanumerics plus the chars
47
+ * that appear in real model ids (`.` `_` `-` and the `[1m]`-style
48
+ * variant brackets). No whitespace means no second token can ride
49
+ * along; no control characters means no newline/Enter smuggling.
50
+ */
51
+ const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/
52
+
53
+ export function isValidModelArg(arg: string): boolean {
54
+ return MODEL_ARG_RE.test(arg)
55
+ }
56
+
57
+ export type ParsedModelCommand =
58
+ | { kind: 'show' }
59
+ | { kind: 'set'; model: string }
60
+ | { kind: 'help'; reason?: string }
61
+
62
+ /**
63
+ * Parse a `/model` message. Returns null when the text isn't a /model
64
+ * command at all (caller bug — bot.command should pre-filter).
65
+ */
66
+ export function parseModelCommand(text: string): ParsedModelCommand | null {
67
+ const m = text.match(/^\/model(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/)
68
+ if (!m) return null
69
+ const rest = (m[1] ?? '').trim()
70
+ if (rest.length === 0) return { kind: 'show' }
71
+ const parts = rest.split(/\s+/)
72
+ if (parts.length > 1) {
73
+ return { kind: 'help', reason: 'model takes a single argument' }
74
+ }
75
+ const arg = parts[0]
76
+ if (arg.toLowerCase() === 'help') return { kind: 'help' }
77
+ if (!isValidModelArg(arg)) {
78
+ return { kind: 'help', reason: `not a valid model name: ${arg}` }
79
+ }
80
+ return { kind: 'set', model: arg }
81
+ }
82
+
83
+ export interface ModelCommandDeps {
84
+ /** Inject primitive — wired to injectSlashCommand in the gateway. */
85
+ inject: (agent: string, command: string) => Promise<InjectResult>
86
+ getAgentName: () => string
87
+ /**
88
+ * The agent's configured model from `switchroom agent list` (the
89
+ * cascade-resolved `model:` field). Null when unset / unreadable —
90
+ * rendered as "default".
91
+ */
92
+ getConfiguredModel: () => string | null
93
+ escapeHtml: (s: string) => string
94
+ preBlock: (s: string) => string
95
+ }
96
+
97
+ export interface ModelCommandReply {
98
+ text: string
99
+ html: true
100
+ }
101
+
102
+ const PERSIST_NOTE =
103
+ '<i>Session-only — lasts until restart. To persist, set <code>model:</code> in switchroom.yaml and restart.</i>'
104
+
105
+ function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
106
+ const lines: string[] = []
107
+ if (reason) lines.push(`⚠️ ${deps.escapeHtml(reason)}`)
108
+ lines.push(
109
+ '<b>/model</b> — show or switch the Claude model',
110
+ '<code>/model</code> — show the configured model',
111
+ `<code>/model &lt;name&gt;</code> — switch the live session (${MODEL_ALIASES.map(a => `<code>${a}</code>`).join(' · ')} or a full model id)`,
112
+ PERSIST_NOTE,
113
+ )
114
+ return { text: lines.join('\n'), html: true }
115
+ }
116
+
117
+ export async function handleModelCommand(
118
+ parsed: ParsedModelCommand,
119
+ deps: ModelCommandDeps,
120
+ ): Promise<ModelCommandReply> {
121
+ if (parsed.kind === 'help') return helpText(deps, parsed.reason)
122
+
123
+ if (parsed.kind === 'show') {
124
+ const configured = deps.getConfiguredModel()
125
+ const shown = configured && configured.length > 0 ? configured : 'default'
126
+ return {
127
+ text: [
128
+ `<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`,
129
+ `Configured: <code>${deps.escapeHtml(shown)}</code>`,
130
+ `Switch the live session: ${MODEL_ALIASES.map(a => `<code>/model ${a}</code>`).join(' · ')}`,
131
+ 'or <code>/model &lt;full-model-id&gt;</code>',
132
+ PERSIST_NOTE,
133
+ ].join('\n'),
134
+ html: true,
135
+ }
136
+ }
137
+
138
+ // kind === 'set' — re-gate at the seam so a caller that skipped the
139
+ // parser can't type arbitrary keys into the pane.
140
+ if (!isValidModelArg(parsed.model)) {
141
+ return helpText(deps, `not a valid model name: ${parsed.model}`)
142
+ }
143
+ const verbHtml = `<code>/model ${deps.escapeHtml(parsed.model)}</code>`
144
+ let result: InjectResult
145
+ try {
146
+ result = await deps.inject(deps.getAgentName(), `/model ${parsed.model}`)
147
+ } catch (err) {
148
+ const msg = err instanceof Error ? err.message : String(err)
149
+ return {
150
+ text: `❌ ${verbHtml} — inject failed: ${deps.escapeHtml(msg)}`,
151
+ html: true,
152
+ }
153
+ }
154
+
155
+ if (result.outcome === 'ok') {
156
+ return {
157
+ text: [
158
+ `${verbHtml}`,
159
+ deps.preBlock(result.output),
160
+ ...(result.truncated ? ['<i>truncated</i>'] : []),
161
+ PERSIST_NOTE,
162
+ ].join('\n'),
163
+ html: true,
164
+ }
165
+ }
166
+
167
+ if (result.outcome === 'ok_no_output') {
168
+ return {
169
+ text: [
170
+ `${verbHtml} — sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active model.`,
171
+ PERSIST_NOTE,
172
+ ].join('\n'),
173
+ html: true,
174
+ }
175
+ }
176
+
177
+ // outcome === 'failed'
178
+ if (result.errorCode === 'session_missing') {
179
+ return {
180
+ text:
181
+ '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.',
182
+ html: true,
183
+ }
184
+ }
185
+ return {
186
+ text: `❌ ${verbHtml} — ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`,
187
+ html: true,
188
+ }
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Picker-driven model menu (v2) — discovery, render, callback selection.
193
+ // ---------------------------------------------------------------------------
194
+
195
+ export interface ModelMenuDeps {
196
+ /** Live picker discovery — src/agents/model-picker.ts discoverModels. */
197
+ discover: (agent: string) => Promise<DiscoverResult>
198
+ /** Live picker selection by label — selectModel (session-only `s`). */
199
+ select: (agent: string, label: string) => Promise<SelectResult>
200
+ /**
201
+ * True while the agent is mid-turn. Driving the picker types into
202
+ * claude's input box; doing that mid-turn would queue "/model" as
203
+ * user text instead of opening the modal — refuse instead.
204
+ */
205
+ isBusy: () => boolean
206
+ getAgentName: () => string
207
+ /** One-line quota summary (e.g. "29% / 5h · 33% / 7d") or null. */
208
+ getQuotaBrief: () => Promise<string | null>
209
+ escapeHtml: (s: string) => string
210
+ }
211
+
212
+ /** Raw Telegram inline-keyboard shape (grammY accepts it verbatim). */
213
+ export interface ModelMenuKeyboardButton {
214
+ text: string
215
+ callback_data: string
216
+ }
217
+
218
+ export interface ModelMenuReply {
219
+ text: string
220
+ html: true
221
+ /** Rows of buttons; absent on the no-menu fallback. */
222
+ keyboard?: ModelMenuKeyboardButton[][]
223
+ }
224
+
225
+ export const MODEL_CALLBACK_PREFIX = 'mdl:'
226
+ const MODEL_CALLBACK_SELECT = 'mdl:s:'
227
+ export const MODEL_CALLBACK_REFRESH = 'mdl:r'
228
+
229
+ export function modelSelectCallbackData(label: string): string {
230
+ // Identity is the label's hash, not its index — a tap re-discovers
231
+ // the picker and matches by tag, so a list that shifted between
232
+ // render and tap can never select the wrong row. 8 hex chars keeps
233
+ // callback_data tiny (well under Telegram's 64-byte cap).
234
+ return `${MODEL_CALLBACK_SELECT}${labelTag(label)}`
235
+ }
236
+
237
+ function busyReply(deps: Pick<ModelMenuDeps, 'escapeHtml'>): ModelMenuReply {
238
+ return {
239
+ text: '⏳ The agent is mid-turn — the model picker needs an idle prompt. Try again in a moment.',
240
+ html: true,
241
+ }
242
+ }
243
+
244
+ function menuKeyboard(options: ModelPickerOption[]): ModelMenuKeyboardButton[][] {
245
+ // One option per row (labels + ✔ render cleanly at full width on
246
+ // mobile), refresh on a trailing row.
247
+ const rows: ModelMenuKeyboardButton[][] = options.map((o) => [
248
+ {
249
+ text: o.current ? `✅ ${o.label}` : o.label,
250
+ callback_data: modelSelectCallbackData(o.label),
251
+ },
252
+ ])
253
+ rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
254
+ return rows
255
+ }
256
+
257
+ /**
258
+ * Build the `/model` dashboard: live model + quota brief + tap menu.
259
+ * Returns a keyboard-less fallback (v1-shaped static text) when the
260
+ * picker can't be driven right now — the command never hard-fails.
261
+ */
262
+ export async function buildModelMenu(
263
+ deps: ModelMenuDeps & ModelCommandDeps,
264
+ ): Promise<ModelMenuReply> {
265
+ if (deps.isBusy()) return busyReply(deps)
266
+
267
+ const [discovered, quota] = await Promise.all([
268
+ deps.discover(deps.getAgentName()),
269
+ deps.getQuotaBrief().catch(() => null),
270
+ ])
271
+
272
+ if (!discovered.ok) {
273
+ // Graceful static fallback — same content as the v1 show path,
274
+ // with the discovery failure surfaced.
275
+ const v1 = await handleModelCommand({ kind: 'show' }, deps)
276
+ return {
277
+ text: [`<i>(picker unavailable: ${deps.escapeHtml(discovered.reason)})</i>`, v1.text].join('\n'),
278
+ html: true,
279
+ }
280
+ }
281
+
282
+ const current = discovered.options.find((o) => o.current)
283
+ const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
284
+ if (discovered.dismissFailed) {
285
+ lines.push('⚠️ <i>The picker may still be open on the agent pane — check it before switching.</i>')
286
+ }
287
+ if (current) {
288
+ const detail = current.detail ? ` · ${deps.escapeHtml(current.detail)}` : ''
289
+ lines.push(`Now: <b>${deps.escapeHtml(current.label)}</b>${detail}`)
290
+ } else {
291
+ lines.push('Now: <i>unknown (no ✔ row in picker)</i>')
292
+ }
293
+ if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
294
+ lines.push('', 'Tap to switch (applies to the live session):')
295
+ lines.push(PERSIST_NOTE)
296
+
297
+ return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(discovered.options) }
298
+ }
299
+
300
+ export interface ModelCallbackOutcome {
301
+ /** Short toast for answerCallbackQuery. */
302
+ answer: string
303
+ /** Replacement dashboard (message edit). */
304
+ reply: ModelMenuReply
305
+ }
306
+
307
+ /**
308
+ * Handle a `mdl:*` callback tap. `mdl:r` re-renders the dashboard;
309
+ * `mdl:s:<tag>` re-discovers the picker, resolves the tag back to a
310
+ * live label, and applies it session-only. A tag that no longer
311
+ * matches (claude updated its options since render) re-renders the
312
+ * menu instead of guessing.
313
+ */
314
+ export async function handleModelMenuCallback(
315
+ data: string,
316
+ deps: ModelMenuDeps & ModelCommandDeps,
317
+ ): Promise<ModelCallbackOutcome> {
318
+ if (data === MODEL_CALLBACK_REFRESH) {
319
+ return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
320
+ }
321
+ if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
322
+ return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
323
+ }
324
+ if (deps.isBusy()) {
325
+ return { answer: 'Agent is mid-turn — try again shortly', reply: busyReply(deps) }
326
+ }
327
+
328
+ const tag = data.slice(MODEL_CALLBACK_SELECT.length)
329
+ const discovered = await deps.discover(deps.getAgentName())
330
+ if (!discovered.ok) {
331
+ return {
332
+ answer: 'Picker unavailable',
333
+ reply: {
334
+ text: `❌ Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`,
335
+ html: true,
336
+ },
337
+ }
338
+ }
339
+ const target = discovered.options.find((o) => labelTag(o.label) === tag)
340
+ if (!target) {
341
+ // Options changed since the menu rendered — never guess; re-render.
342
+ const fresh = await buildModelMenu(deps)
343
+ return { answer: 'Model list changed — menu refreshed', reply: fresh }
344
+ }
345
+ if (target.current) {
346
+ const fresh = await buildModelMenu(deps)
347
+ return { answer: `Already on ${target.label}`, reply: fresh }
348
+ }
349
+
350
+ const result = await deps.select(deps.getAgentName(), target.label)
351
+ if (!result.ok) {
352
+ return {
353
+ answer: 'Switch failed',
354
+ reply: {
355
+ text: `❌ Switch to <b>${deps.escapeHtml(target.label)}</b> failed: ${deps.escapeHtml(result.reason)}`,
356
+ html: true,
357
+ },
358
+ }
359
+ }
360
+
361
+ const fresh = await buildModelMenu(deps)
362
+ const confirmed: ModelMenuReply = {
363
+ text: [`✅ ${deps.escapeHtml(result.confirmation)}`, '', fresh.text].join('\n'),
364
+ html: true,
365
+ ...(fresh.keyboard ? { keyboard: fresh.keyboard } : {}),
366
+ }
367
+ return { answer: result.confirmation, reply: confirmed }
368
+ }