talon-agent 1.5.0 → 1.6.0

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Session warm-up — cold-start optimization.
3
+ *
4
+ * Spawns a throwaway SDK subprocess in streaming input mode, calls
5
+ * getContextUsage() to populate contextWindow and baseline contextTokens,
6
+ * then tears it down. Fire-and-forget — does not block the caller.
7
+ */
8
+
9
+ import { query } from "@anthropic-ai/claude-agent-sdk";
10
+ import { getSession } from "../../storage/sessions.js";
11
+ import { rebuildSystemPrompt } from "../../util/config.js";
12
+ import { getPluginPromptAdditions } from "../../core/plugin.js";
13
+ import { log, logWarn } from "../../util/log.js";
14
+ import { getConfig } from "./state.js";
15
+ import { buildSdkOptions } from "./options.js";
16
+
17
+ export async function warmSession(chatId: string): Promise<void> {
18
+ // Guard against being called before initAgent()
19
+ try {
20
+ getConfig();
21
+ } catch {
22
+ return;
23
+ }
24
+
25
+ const abort = new AbortController();
26
+ try {
27
+ rebuildSystemPrompt(getConfig(), getPluginPromptAdditions());
28
+ const { options } = buildSdkOptions(chatId);
29
+
30
+ // Streaming input mode: pass an async iterable that never yields a user message
31
+ const neverYield = async function* (): AsyncGenerator<never> {
32
+ await new Promise<never>((_, reject) => {
33
+ abort.signal.addEventListener("abort", () =>
34
+ reject(new Error("aborted")),
35
+ );
36
+ });
37
+ };
38
+
39
+ const q = query({
40
+ prompt: neverYield(),
41
+ options: { ...options, abortController: abort },
42
+ });
43
+
44
+ // Drain the stream in the background so the SDK's internal message loop
45
+ // doesn't stall — control responses are processed in readMessages() which
46
+ // needs the inputStream consumer to not back-pressure.
47
+ const drainPromise = (async () => {
48
+ try {
49
+ for await (const _ of q) {
50
+ // discard SDK messages; we only care about the control response
51
+ }
52
+ } catch {
53
+ // expected: abort causes the stream to end with an error
54
+ }
55
+ })();
56
+
57
+ // Race getContextUsage against a timeout so /reset doesn't hang
58
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
59
+ const timeout = new Promise<never>((_, reject) => {
60
+ timeoutId = setTimeout(
61
+ () => reject(new Error("warm-up timed out")),
62
+ 15_000,
63
+ );
64
+ });
65
+ let ctx: Awaited<ReturnType<typeof q.getContextUsage>>;
66
+ try {
67
+ ctx = await Promise.race([q.getContextUsage(), timeout]);
68
+ } finally {
69
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
70
+ }
71
+ const session = getSession(chatId);
72
+ if (ctx.maxTokens > 0) session.usage.contextWindow = ctx.maxTokens;
73
+ if (ctx.totalTokens > 0) session.usage.contextTokens = ctx.totalTokens;
74
+ log(
75
+ "agent",
76
+ `[${chatId}] warm-up: context ${ctx.totalTokens}/${ctx.maxTokens} (${ctx.percentage.toFixed(1)}%) model=${ctx.model}`,
77
+ );
78
+
79
+ abort.abort();
80
+ await drainPromise;
81
+ } catch (err) {
82
+ abort.abort();
83
+ // Non-fatal — /status will just show 0 until first real message
84
+ logWarn(
85
+ "agent",
86
+ `[${chatId}] warm-up failed: ${err instanceof Error ? err.message : err}`,
87
+ );
88
+ }
89
+ }
package/src/bootstrap.ts CHANGED
@@ -47,6 +47,10 @@ export type BootstrapResult = {
47
47
  config: TalonConfig;
48
48
  };
49
49
 
50
+ export type BackendAndDispatcherResult = {
51
+ backend: QueryBackend;
52
+ };
53
+
50
54
  // ── Bootstrap: config, env, plugins, workspace, storage ──────────────────────
51
55
 
52
56
  /**
@@ -102,7 +106,7 @@ export async function bootstrap(
102
106
  export async function initBackendAndDispatcher(
103
107
  config: TalonConfig,
104
108
  frontend: Frontend,
105
- ): Promise<void> {
109
+ ): Promise<BackendAndDispatcherResult> {
106
110
  let backend: QueryBackend;
107
111
 
108
112
  if (config.backend === "opencode") {
@@ -112,10 +116,18 @@ export async function initBackendAndDispatcher(
112
116
  backend = { query: (params) => opencodeHandleMessage(params) };
113
117
  log("bot", "Backend: OpenCode");
114
118
  } else {
115
- const { initAgent: initClaudeAgent, handleMessage: claudeHandleMessage } =
116
- await import("./backend/claude-sdk/index.js");
117
- initClaudeAgent(config, frontend.getBridgePort);
118
- backend = { query: (params) => claudeHandleMessage(params) };
119
+ const {
120
+ initAgent: initClaudeAgent,
121
+ handleMessage: claudeHandleMessage,
122
+ warmSession: claudeWarmSession,
123
+ updateSystemPrompt: claudeUpdateSystemPrompt,
124
+ } = await import("./backend/claude-sdk/index.js");
125
+ await initClaudeAgent(config, frontend.getBridgePort);
126
+ backend = {
127
+ query: (params) => claudeHandleMessage(params),
128
+ warmSession: (chatId) => claudeWarmSession(chatId),
129
+ updateSystemPrompt: (prompt) => claudeUpdateSystemPrompt(prompt),
130
+ };
119
131
  log("bot", "Backend: Claude SDK");
120
132
  }
121
133
 
@@ -160,4 +172,6 @@ export async function initBackendAndDispatcher(
160
172
  claudeBinary: config.claudeBinary,
161
173
  workspace: config.workspace,
162
174
  });
175
+
176
+ return { backend };
163
177
  }
package/src/cli.ts CHANGED
@@ -282,23 +282,37 @@ async function runSetup(): Promise<void> {
282
282
  if (botName) teamsBotDisplayName = botName;
283
283
  }
284
284
 
285
+ // Discover models from SDK; fall back to static list if SDK isn't available
286
+ const {
287
+ registerClaudeModels,
288
+ registerClaudeModelsStatic,
289
+ CLAUDE_MODELS_STATIC,
290
+ } = await import("./backend/claude-sdk/models.js");
291
+ try {
292
+ const { dirs } = await import("./util/paths.js");
293
+ await registerClaudeModels({
294
+ model: config.model,
295
+ cwd: dirs.workspace,
296
+ permissionMode: "bypassPermissions",
297
+ allowDangerouslySkipPermissions: true,
298
+ ...(config.claudeBinary
299
+ ? { pathToClaudeCodeExecutable: config.claudeBinary }
300
+ : {}),
301
+ });
302
+ } catch {
303
+ // Setup wizard may run before Claude Code is installed — use static list
304
+ registerClaudeModelsStatic(CLAUDE_MODELS_STATIC);
305
+ }
306
+ const { getModels } = await import("./core/models.js");
307
+ const registeredModels = getModels();
308
+
285
309
  const model = await p.select({
286
310
  message: "Default model",
287
311
  initialValue: config.model,
288
- options: [
289
- {
290
- value: "claude-sonnet-4-6",
291
- label: `Sonnet 4.6 ${pc.dim("\u2014 fast, balanced")}`,
292
- },
293
- {
294
- value: "claude-opus-4-6",
295
- label: `Opus 4.6 ${pc.dim("\u2014 smartest")}`,
296
- },
297
- {
298
- value: "claude-haiku-4-5",
299
- label: `Haiku 4.5 ${pc.dim("\u2014 fastest, cheapest")}`,
300
- },
301
- ],
312
+ options: registeredModels.map((m) => ({
313
+ value: m.id,
314
+ label: `${m.displayName.padEnd(12)}${m.description ? pc.dim(`\u2014 ${m.description}`) : ""}`,
315
+ })),
302
316
  });
303
317
  if (p.isCancel(model)) {
304
318
  p.cancel("Cancelled.");
@@ -652,7 +666,8 @@ async function startChat(): Promise<void> {
652
666
  const gateway = new Gateway();
653
667
  const frontend = createTerminalFrontend(config, gateway);
654
668
  await frontend.init();
655
- await initBackendAndDispatcher(config, frontend);
669
+ const { backend } = await initBackendAndDispatcher(config, frontend);
670
+ gateway.backend = backend;
656
671
 
657
672
  process.on("SIGINT", () => {
658
673
  flushSessions();
package/src/core/dream.ts CHANGED
@@ -20,6 +20,8 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
20
20
  import { files as pathFiles, dirs } from "../util/paths.js";
21
21
  import { log, logError, logWarn } from "../util/log.js";
22
22
  import { getPluginMcpServers } from "./plugin.js";
23
+ import { DISALLOWED_TOOLS_BACKGROUND } from "../backend/claude-sdk/constants.js";
24
+ import { getDefaultModel } from "./models.js";
23
25
 
24
26
  // ── Types ────────────────────────────────────────────────────────────────────
25
27
 
@@ -178,7 +180,8 @@ If commands fail, log the error and continue — this stage is optional.`
178
180
  throw new Error(`Failed to read dream prompt from ${promptPath}`);
179
181
  }
180
182
 
181
- const model = configRef.dreamModel ?? configRef.model ?? "claude-sonnet-4-6";
183
+ const model =
184
+ configRef.dreamModel ?? configRef.model ?? getDefaultModel("balanced");
182
185
  const workspace = configRef.workspace ?? dirs.workspace;
183
186
 
184
187
  // Set up dream log file
@@ -208,22 +211,7 @@ If commands fail, log the error and continue — this stage is optional.`
208
211
  mcpServers: configRef.mempalace
209
212
  ? getPluginMcpServers("", "dream", ["mempalace"])
210
213
  : {},
211
- disallowedTools: [
212
- "EnterPlanMode",
213
- "ExitPlanMode",
214
- "EnterWorktree",
215
- "ExitWorktree",
216
- "TodoWrite",
217
- "TodoRead",
218
- "TaskCreate",
219
- "TaskUpdate",
220
- "TaskGet",
221
- "TaskList",
222
- "TaskOutput",
223
- "TaskStop",
224
- "AskUserQuestion",
225
- "Agent",
226
- ],
214
+ disallowedTools: [...DISALLOWED_TOOLS_BACKGROUND],
227
215
  };
228
216
 
229
217
  const timeoutPromise = new Promise<never>((_, reject) =>
@@ -27,7 +27,7 @@ import {
27
27
  type CronJobType,
28
28
  } from "../storage/cron-store.js";
29
29
  import { log } from "../util/log.js";
30
- import type { ActionResult } from "./types.js";
30
+ import type { ActionResult, QueryBackend } from "./types.js";
31
31
 
32
32
  /** Extract readable text from HTML using cheerio (proper DOM parser). */
33
33
  function extractText(html: string, maxLength = 8000): string {
@@ -42,6 +42,7 @@ function extractText(html: string, maxLength = 8000): string {
42
42
  export async function handleSharedAction(
43
43
  body: Record<string, unknown>,
44
44
  chatId: number,
45
+ backend?: QueryBackend | null,
45
46
  ): Promise<ActionResult | null> {
46
47
  const action = body.action as string;
47
48
 
@@ -324,17 +325,7 @@ export async function handleSharedAction(
324
325
  // Rebuild system prompt on the freshConfig, then update the backend's
325
326
  // live config reference so subsequent messages use the new prompt
326
327
  rebuildSystemPrompt(freshConfig, getPluginPromptAdditions());
327
- try {
328
- const { updateSystemPrompt } =
329
- await import("../backend/claude-sdk/index.js");
330
- updateSystemPrompt(freshConfig.systemPrompt);
331
- } catch (err) {
332
- // Non-fatal — OpenCode backend doesn't expose updateSystemPrompt
333
- log(
334
- "gateway",
335
- `reload_plugins: could not update backend prompt: ${err instanceof Error ? err.message : err}`,
336
- );
337
- }
328
+ backend?.updateSystemPrompt?.(freshConfig.systemPrompt);
338
329
 
339
330
  log("gateway", `reload_plugins: ${names.length} plugins loaded`);
340
331
  return {
@@ -21,7 +21,7 @@ import { getActiveSessionCount } from "../storage/sessions.js";
21
21
  import { log, logError, logDebug } from "../util/log.js";
22
22
  import { handleSharedAction } from "./gateway-actions.js";
23
23
  import { handlePluginAction } from "./plugin.js";
24
- import type { FrontendActionHandler } from "./types.js";
24
+ import type { FrontendActionHandler, QueryBackend } from "./types.js";
25
25
 
26
26
  // ── Per-chat context state ───────────────────────────────────────────────────
27
27
 
@@ -80,6 +80,9 @@ export class Gateway {
80
80
  private server: ReturnType<typeof createServer> | null = null;
81
81
  private port = 0;
82
82
 
83
+ /** The active backend — set by bootstrap after initialization. */
84
+ backend: QueryBackend | null = null;
85
+
83
86
  // ── Frontend handler registration ────────────────────────────────────────
84
87
 
85
88
  setFrontendHandler(handler: FrontendActionHandler | null): void {
@@ -195,7 +198,7 @@ export class Gateway {
195
198
  }
196
199
 
197
200
  // Shared actions last — provides in-memory fallbacks for history, cron, etc.
198
- const shared = await handleSharedAction(body, chatId);
201
+ const shared = await handleSharedAction(body, chatId, this.backend);
199
202
  if (shared) {
200
203
  logDebug(
201
204
  "gateway",
@@ -18,6 +18,8 @@ import { files as pathFiles, dirs } from "../util/paths.js";
18
18
  import { log, logError, logWarn } from "../util/log.js";
19
19
  import { toYMD } from "../util/time.js";
20
20
  import { getPluginMcpServers } from "./plugin.js";
21
+ import { DISALLOWED_TOOLS_BACKGROUND } from "../backend/claude-sdk/constants.js";
22
+ import { getDefaultModel } from "./models.js";
21
23
 
22
24
  // ── Types ────────────────────────────────────────────────────────────────────
23
25
 
@@ -263,7 +265,7 @@ async function runHeartbeatAgent(
263
265
  }
264
266
 
265
267
  const model =
266
- configRef.heartbeatModel ?? configRef.model ?? "claude-sonnet-4-6";
268
+ configRef.heartbeatModel ?? configRef.model ?? getDefaultModel("balanced");
267
269
 
268
270
  // Set up heartbeat log file
269
271
  const heartbeatLogFile = await createHeartbeatLogFile();
@@ -292,22 +294,7 @@ async function runHeartbeatAgent(
292
294
  : {}),
293
295
  // Load all registered plugin MCP servers (excludes frontend-specific tools like telegram)
294
296
  mcpServers: getPluginMcpServers("", "heartbeat"),
295
- disallowedTools: [
296
- "EnterPlanMode",
297
- "ExitPlanMode",
298
- "EnterWorktree",
299
- "ExitWorktree",
300
- "TodoWrite",
301
- "TodoRead",
302
- "TaskCreate",
303
- "TaskUpdate",
304
- "TaskGet",
305
- "TaskList",
306
- "TaskOutput",
307
- "TaskStop",
308
- "AskUserQuestion",
309
- "Agent",
310
- ],
297
+ disallowedTools: [...DISALLOWED_TOOLS_BACKGROUND],
311
298
  };
312
299
 
313
300
  // NOTE: The timeout races against the agent promise but cannot abort the
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Model registry — single source of truth for available models.
3
+ *
4
+ * Backends register their models during initialization. Frontends read
5
+ * from the registry to build dynamic model pickers, resolve aliases,
6
+ * and query capabilities. No model names are hardcoded outside this
7
+ * system and the backend-specific model definition files.
8
+ */
9
+
10
+ // ── Types ────────────────────────────────────────────────────────────────────
11
+
12
+ export type ModelTier = "premium" | "balanced" | "economy";
13
+
14
+ export type ModelCapabilities = {
15
+ /** Whether the model supports the 1M token context window. */
16
+ supports1mContext: boolean;
17
+ };
18
+
19
+ export type ModelInfo = {
20
+ /** Canonical model ID (e.g. "claude-sonnet-4-6"). */
21
+ id: string;
22
+ /** Human-readable display name for UIs (e.g. "Sonnet 4.6"). */
23
+ displayName: string;
24
+ /** Short description for setup wizard (e.g. "fast, balanced"). */
25
+ description?: string;
26
+ /** Aliases that resolve to this model (e.g. ["sonnet", "sonnet-4.6"]). */
27
+ aliases: string[];
28
+ /** Provider identifier (e.g. "anthropic", "openai"). */
29
+ provider: string;
30
+ /** Model capabilities used for backend configuration. */
31
+ capabilities: ModelCapabilities;
32
+ /** Tier for UI grouping and fallback ordering. */
33
+ tier: ModelTier;
34
+ /** Model to fall back to on overload/timeout. */
35
+ fallback?: string;
36
+ };
37
+
38
+ // ── Tier sort order ─────────────────────────────────────────────────────────
39
+
40
+ const TIER_ORDER: Record<ModelTier, number> = {
41
+ premium: 0,
42
+ balanced: 1,
43
+ economy: 2,
44
+ };
45
+
46
+ // ── Registry state ──────────────────────────────────────────────────────────
47
+
48
+ const models = new Map<string, ModelInfo>();
49
+ const aliasIndex = new Map<string, string>();
50
+
51
+ // ── Registration ────────────────────────────────────────────────────────────
52
+
53
+ /** Register one or more models. Idempotent — re-registration overwrites. */
54
+ export function registerModels(infos: ModelInfo[]): void {
55
+ for (const info of infos) {
56
+ // Clear stale aliases from any previous registration of this model ID
57
+ const prev = models.get(info.id);
58
+ if (prev) {
59
+ aliasIndex.delete(prev.id.toLowerCase());
60
+ for (const alias of prev.aliases) {
61
+ aliasIndex.delete(alias.toLowerCase());
62
+ }
63
+ }
64
+ models.set(info.id, info);
65
+ // Index the canonical ID itself as an alias
66
+ aliasIndex.set(info.id.toLowerCase(), info.id);
67
+ for (const alias of info.aliases) {
68
+ aliasIndex.set(alias.toLowerCase(), info.id);
69
+ }
70
+ }
71
+ }
72
+
73
+ // ── Queries ─────────────────────────────────────────────────────────────────
74
+
75
+ /** Get a model by canonical ID. */
76
+ export function getModel(id: string): ModelInfo | undefined {
77
+ return models.get(id);
78
+ }
79
+
80
+ /** List all registered models, optionally filtered by provider. Sorted by tier. */
81
+ export function getModels(provider?: string): ModelInfo[] {
82
+ let result = [...models.values()];
83
+ if (provider) {
84
+ result = result.filter((m) => m.provider === provider);
85
+ }
86
+ return result.sort((a, b) => TIER_ORDER[a.tier] - TIER_ORDER[b.tier]);
87
+ }
88
+
89
+ /**
90
+ * Resolve a user input (alias or full ID) to the canonical model ID.
91
+ * Returns the input unchanged if no match is found (passthrough for
92
+ * unknown/custom model names).
93
+ */
94
+ export function resolveModelId(input: string): string {
95
+ const lower = input.trim().toLowerCase();
96
+ return aliasIndex.get(lower) ?? input.trim();
97
+ }
98
+
99
+ /**
100
+ * Resolve a user input to the full ModelInfo, or undefined if not found.
101
+ */
102
+ export function resolveModel(input: string): ModelInfo | undefined {
103
+ const id = resolveModelId(input);
104
+ return models.get(id);
105
+ }
106
+
107
+ /** Get the fallback model ID for a given model, or null if none configured. */
108
+ export function getFallbackModel(modelId: string): string | null {
109
+ return models.get(modelId)?.fallback ?? null;
110
+ }
111
+
112
+ /** Check whether a model supports the 1M token context window. */
113
+ export function supports1mContext(modelId: string): boolean {
114
+ const info = models.get(modelId);
115
+ // Default to true for unknown models (don't restrict capabilities we can't check)
116
+ return info?.capabilities.supports1mContext ?? true;
117
+ }
118
+
119
+ /**
120
+ * Get the default model for a given tier. Returns the first registered model
121
+ * matching the tier, or the first model overall, or the hardcoded fallback.
122
+ */
123
+ export function getDefaultModel(tier: ModelTier = "balanced"): string {
124
+ const byTier = [...models.values()].find((m) => m.tier === tier);
125
+ if (byTier) return byTier.id;
126
+ const first = models.values().next();
127
+ if (!first.done) return first.value.id;
128
+ return "claude-sonnet-4-6"; // ultimate fallback if registry is empty
129
+ }
130
+
131
+ // ── Provider-scoped clearing ────────────────────────────────────────────────
132
+
133
+ /** Remove all models for a specific provider (and their aliases). */
134
+ export function clearModelsByProvider(provider: string): void {
135
+ for (const [id, info] of models) {
136
+ if (info.provider !== provider) continue;
137
+ aliasIndex.delete(id.toLowerCase());
138
+ for (const alias of info.aliases) {
139
+ aliasIndex.delete(alias.toLowerCase());
140
+ }
141
+ models.delete(id);
142
+ }
143
+ }
144
+
145
+ /** Clear the entire registry. For tests only. */
146
+ export function clearModels(): void {
147
+ models.clear();
148
+ aliasIndex.clear();
149
+ }
package/src/core/types.ts CHANGED
@@ -33,6 +33,10 @@ export type QueryResult = {
33
33
  /** Backend interface — any AI provider implements this. */
34
34
  export interface QueryBackend {
35
35
  query(params: QueryParams): Promise<QueryResult>;
36
+ /** Pre-warm a session (cold-start optimization). Optional — not all backends support this. */
37
+ warmSession?(chatId: string): Promise<void>;
38
+ /** Update the system prompt on the live backend config. Optional — used by plugin hot-reload. */
39
+ updateSystemPrompt?(prompt: string): void;
36
40
  }
37
41
 
38
42
  // ── Execution context ───────────────────────────────────────────────────────
@@ -217,9 +217,7 @@ export function createTeamsFrontend(
217
217
  resetSession(talonChatId);
218
218
  clearHistory(talonChatId);
219
219
  log("teams", `Session reset by ${msg.senderName}`);
220
- const { warmSession } =
221
- await import("../../backend/claude-sdk/index.js");
222
- await warmSession(talonChatId);
220
+ await gateway.backend?.warmSession?.(talonChatId);
223
221
  const card = buildAdaptiveCard("Session cleared.");
224
222
  await proxyFetch(webhookUrl, {
225
223
  method: "POST",
@@ -22,6 +22,7 @@ import {
22
22
  import { handleCallbackQuery } from "./handlers.js";
23
23
  import { escapeHtml } from "./formatting.js";
24
24
  import { renderSettingsText, renderSettingsKeyboard } from "./helpers.js";
25
+ import { getModels } from "../../core/models.js";
25
26
 
26
27
  export function registerCallbacks(bot: Bot, config: TalonConfig): void {
27
28
  // ── Callback query handler ──────────────────────────────────────────────────
@@ -214,36 +215,23 @@ export function registerCallbacks(bot: Bot, config: TalonConfig): void {
214
215
  });
215
216
  }
216
217
  const current = getChatSettings(cid).model ?? config.model;
217
- const isModel = (id: string) => current.includes(id);
218
+ // Build model buttons dynamically from the registry
219
+ const models = getModels();
220
+ const modelButtons = models.map((m) => ({
221
+ text: current.includes(m.id)
222
+ ? `\u2713 ${m.displayName}`
223
+ : m.displayName,
224
+ callback_data: `model:${m.aliases[0] ?? m.id}`,
225
+ }));
226
+ const rows: Array<Array<{ text: string; callback_data: string }>> = [];
227
+ for (let i = 0; i < modelButtons.length; i += 2) {
228
+ rows.push(modelButtons.slice(i, i + 2));
229
+ }
230
+ rows.push([{ text: "Reset to default", callback_data: "model:reset" }]);
218
231
  try {
219
232
  await ctx.editMessageText(
220
233
  `<b>Model:</b> <code>${escapeHtml(current)}</code>`,
221
- {
222
- parse_mode: "HTML",
223
- reply_markup: {
224
- inline_keyboard: [
225
- [
226
- {
227
- text: isModel("sonnet")
228
- ? "\u2713 Sonnet 4.6"
229
- : "Sonnet 4.6",
230
- callback_data: "model:sonnet",
231
- },
232
- {
233
- text: isModel("opus") ? "\u2713 Opus 4.6" : "Opus 4.6",
234
- callback_data: "model:opus",
235
- },
236
- ],
237
- [
238
- {
239
- text: isModel("haiku") ? "\u2713 Haiku 4.5" : "Haiku 4.5",
240
- callback_data: "model:haiku",
241
- },
242
- { text: "Reset to default", callback_data: "model:reset" },
243
- ],
244
- ],
245
- },
246
- },
234
+ { parse_mode: "HTML", reply_markup: { inline_keyboard: rows } },
247
235
  );
248
236
  } catch {
249
237
  /* message unchanged */
@@ -38,7 +38,7 @@ import { appendDailyLog } from "../../storage/daily-log.js";
38
38
  import { escapeHtml } from "./formatting.js";
39
39
  import { handleAdminCommand } from "./admin.js";
40
40
  import { getLoadedPlugins } from "../../core/plugin.js";
41
- import { warmSession } from "../../backend/claude-sdk/index.js";
41
+ import { getModels } from "../../core/models.js";
42
42
  import {
43
43
  formatDuration,
44
44
  formatTokenCount,
@@ -56,7 +56,11 @@ export function setAdminUserId(id: number | undefined): void {
56
56
  ADMIN_USER_ID = id ?? 0;
57
57
  }
58
58
 
59
- export function registerCommands(bot: Bot, config: TalonConfig): void {
59
+ export function registerCommands(
60
+ bot: Bot,
61
+ config: TalonConfig,
62
+ gateway?: { backend: import("../../core/types.js").QueryBackend | null },
63
+ ): void {
60
64
  bot.command("start", (ctx) =>
61
65
  ctx.reply(
62
66
  [
@@ -142,7 +146,7 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
142
146
  clearHistory(cid);
143
147
  resetPulseCheckpoint(cid);
144
148
  // Warm up the new session so /status has context data immediately
145
- await warmSession(cid);
149
+ await gateway?.backend?.warmSession?.(cid);
146
150
  await ctx.reply("Session cleared.");
147
151
  });
148
152
 
@@ -179,33 +183,24 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
179
183
 
180
184
  if (!arg) {
181
185
  const current = settings.model ?? config.model;
182
- const isModel = (id: string) => current.includes(id);
186
+ // Build model buttons dynamically from the registry
187
+ const models = getModels();
188
+ const modelButtons = models.map((m) => ({
189
+ text: current.includes(m.id)
190
+ ? `\u2713 ${m.displayName}`
191
+ : m.displayName,
192
+ callback_data: `model:${m.aliases[0] ?? m.id}`,
193
+ }));
194
+ // Two models per row, plus a reset button on the last row
195
+ const rows: Array<Array<{ text: string; callback_data: string }>> = [];
196
+ for (let i = 0; i < modelButtons.length; i += 2) {
197
+ rows.push(modelButtons.slice(i, i + 2));
198
+ }
199
+ rows.push([{ text: "Reset to default", callback_data: "model:reset" }]);
200
+
183
201
  await ctx.reply(
184
202
  `<b>Model:</b> <code>${escapeHtml(current)}</code>\nSelect a model:`,
185
- {
186
- parse_mode: "HTML",
187
- reply_markup: {
188
- inline_keyboard: [
189
- [
190
- {
191
- text: isModel("sonnet") ? "\u2713 Sonnet 4.6" : "Sonnet 4.6",
192
- callback_data: "model:sonnet",
193
- },
194
- {
195
- text: isModel("opus") ? "\u2713 Opus 4.6" : "Opus 4.6",
196
- callback_data: "model:opus",
197
- },
198
- ],
199
- [
200
- {
201
- text: isModel("haiku") ? "\u2713 Haiku 4.5" : "Haiku 4.5",
202
- callback_data: "model:haiku",
203
- },
204
- { text: "Reset to default", callback_data: "model:reset" },
205
- ],
206
- ],
207
- },
208
- },
203
+ { parse_mode: "HTML", reply_markup: { inline_keyboard: rows } },
209
204
  );
210
205
  return;
211
206
  }