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.
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +80 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1784 -1427
- package/dist/cli/ui/index.html +67 -1
- package/dist/host-control/main.js +5 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +33 -0
- package/profiles/default/CLAUDE.md.hbs +27 -0
- package/telegram-plugin/dist/gateway/gateway.js +576 -16
- package/telegram-plugin/gateway/gateway.ts +135 -4
- package/telegram-plugin/gateway/model-command.ts +368 -0
- package/telegram-plugin/tests/model-command.test.ts +349 -0
- package/telegram-plugin/uat/scenarios/jtbd-model-command-dm.test.ts +93 -0
- package/telegram-plugin/welcome-text.ts +7 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 <name></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 <name></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 <full-model-id></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
|
+
}
|