talon-agent 4.3.2 → 4.3.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.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/backend/claude-sdk/doctor.ts +1 -1
  3. package/src/backend/codex/doctor.ts +1 -1
  4. package/src/backend/openai-agents/doctor.ts +1 -1
  5. package/src/bootstrap.ts +1 -1
  6. package/src/cli/doctor.ts +1 -1
  7. package/src/core/agent-runtime/backend-registry.ts +1 -1
  8. package/src/core/auth/expiry-monitor.ts +1 -1
  9. package/src/core/{doctor.ts → doctor/index.ts} +11 -11
  10. package/src/core/{notify.ts → frontend-runtime/admin-notify.ts} +1 -1
  11. package/src/core/plugin/native-runtimes.ts +1 -1
  12. package/src/frontend/discord/commands/admin.ts +1 -1
  13. package/src/frontend/discord/render.ts +1 -1
  14. package/src/frontend/telegram/actions/index.ts +1 -1
  15. package/src/frontend/telegram/actions/moderation/chat.ts +63 -0
  16. package/src/frontend/telegram/actions/moderation/index.ts +74 -0
  17. package/src/frontend/telegram/actions/moderation/members.ts +77 -0
  18. package/src/frontend/telegram/actions/moderation/permissions.ts +69 -0
  19. package/src/frontend/telegram/actions/moderation/topics.ts +48 -0
  20. package/src/frontend/telegram/actions/moderation/types.ts +16 -0
  21. package/src/frontend/telegram/admin/background.ts +66 -0
  22. package/src/frontend/telegram/admin/chunked-reply.ts +20 -0
  23. package/src/frontend/telegram/admin/health.ts +81 -0
  24. package/src/frontend/telegram/admin/sessions.ts +105 -0
  25. package/src/frontend/telegram/admin.ts +56 -261
  26. package/src/frontend/telegram/callbacks/model/backend.ts +156 -0
  27. package/src/frontend/telegram/callbacks/model/control.ts +29 -0
  28. package/src/frontend/telegram/callbacks/model/nav.ts +54 -0
  29. package/src/frontend/telegram/callbacks/model/select.ts +100 -0
  30. package/src/frontend/telegram/callbacks/model/types.ts +39 -0
  31. package/src/frontend/telegram/callbacks/model/views.ts +116 -0
  32. package/src/frontend/telegram/callbacks/model.ts +61 -331
  33. package/src/frontend/telegram/commands/admin.ts +108 -77
  34. package/src/frontend/telegram/commands/settings.ts +65 -43
  35. package/src/frontend/telegram/commands/whatsapp-pairing.ts +1 -1
  36. package/src/frontend/telegram/helpers/diagnostics.ts +1 -1
  37. package/src/frontend/telegram/middleware.ts +166 -201
  38. package/src/frontend/terminal/builtins/context.ts +184 -0
  39. package/src/frontend/terminal/builtins/help.ts +35 -0
  40. package/src/frontend/terminal/builtins/index.ts +23 -0
  41. package/src/frontend/terminal/builtins/model.ts +198 -0
  42. package/src/frontend/terminal/builtins/session.ts +101 -0
  43. package/src/frontend/terminal/builtins/status.ts +202 -0
  44. package/src/frontend/terminal/command-registry.ts +80 -0
  45. package/src/frontend/terminal/commands.ts +11 -674
  46. package/src/frontend/terminal/input/history.ts +64 -0
  47. package/src/frontend/terminal/input/keys.ts +143 -0
  48. package/src/frontend/terminal/input/parts.ts +33 -0
  49. package/src/frontend/terminal/input/paste.ts +53 -0
  50. package/src/frontend/terminal/input/render.ts +42 -0
  51. package/src/frontend/terminal/input/state.ts +60 -0
  52. package/src/frontend/terminal/input.ts +27 -342
  53. package/src/frontend/whatsapp/connection.ts +1 -1
  54. package/src/frontend/whatsapp/index.ts +1 -1
  55. package/src/frontend/whatsapp/pairing-service.ts +1 -1
  56. package/src/plugins/github/provision.ts +1 -1
  57. package/src/plugins/mempalace/provision.ts +1 -1
  58. package/src/plugins/playwright/provision.ts +1 -1
  59. package/src/frontend/telegram/actions/moderation.ts +0 -255
  60. /package/src/core/{doctor-types.ts → doctor/types.ts} +0 -0
  61. /package/src/core/{pairing-broker.ts → frontend-runtime/pairing-broker.ts} +0 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `/admin stats` / `errors` / `logs` / `daily` — the daemon-health
3
+ * subcommands.
4
+ */
5
+
6
+ import type { Context } from "grammy";
7
+ import { readFileSync } from "node:fs";
8
+ import { files, dirs } from "../../../util/paths.js";
9
+ import { tailFile } from "../../../util/tail-file.js";
10
+ import { escapeHtml } from "../formatting.js";
11
+ import { getAllSessions } from "../../../storage/sessions.js";
12
+ import { todayLogDate } from "../../../storage/daily-log.js";
13
+ import { getActiveCount } from "../../../core/engine/dispatcher.js";
14
+ import { getHealthStatus, getRecentErrors } from "../../../util/watchdog.js";
15
+ import { formatDuration } from "../helpers/index.js";
16
+
17
+ export async function replyStats(ctx: Context): Promise<void> {
18
+ const h = getHealthStatus();
19
+ const sessions = getAllSessions();
20
+ const turns = sessions.reduce((s, x) => s + x.info.turns, 0);
21
+ const mem = process.memoryUsage();
22
+ await ctx.reply(
23
+ [
24
+ `<b>🦅 Talon Stats</b>`,
25
+ "",
26
+ `<b>Uptime:</b> ${formatDuration(h.uptimeMs)}`,
27
+ `<b>Messages:</b> ${h.totalMessagesProcessed}`,
28
+ `<b>Sessions:</b> ${sessions.length}`,
29
+ `<b>Turns:</b> ${turns}`,
30
+ `<b>Last active:</b> ${h.msSinceLastMessage < 60000 ? "now" : formatDuration(h.msSinceLastMessage) + " ago"}`,
31
+ "",
32
+ `<b>Memory:</b> ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB heap / ${(mem.rss / 1024 / 1024).toFixed(1)}MB rss`,
33
+ `<b>Queue:</b> ${getActiveCount()}`,
34
+ `<b>Errors:</b> ${h.recentErrorCount}`,
35
+ ].join("\n"),
36
+ { parse_mode: "HTML" },
37
+ );
38
+ }
39
+
40
+ export async function replyRecentErrors(ctx: Context): Promise<void> {
41
+ const errors = getRecentErrors(5);
42
+ if (errors.length === 0) {
43
+ await ctx.reply("No recent errors.");
44
+ return;
45
+ }
46
+ const lines = errors.map(
47
+ (e) =>
48
+ `<code>[${new Date(e.timestamp).toISOString().slice(11, 19)}]</code> ${escapeHtml(e.message.slice(0, 200))}`,
49
+ );
50
+ await ctx.reply(
51
+ `<b>Recent Errors (${errors.length})</b>\n\n` + lines.join("\n\n"),
52
+ { parse_mode: "HTML" },
53
+ );
54
+ }
55
+
56
+ export async function replyLogTail(ctx: Context): Promise<void> {
57
+ const logPath = files.log;
58
+ try {
59
+ const lines = tailFile(logPath);
60
+ await ctx.reply(`<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`, {
61
+ parse_mode: "HTML",
62
+ });
63
+ } catch {
64
+ await ctx.reply(`Could not read ${logPath}`);
65
+ }
66
+ }
67
+
68
+ export async function replyDailyLog(ctx: Context): Promise<void> {
69
+ const today = todayLogDate();
70
+ const logPath = `${dirs.logs}/${today}.md`;
71
+ try {
72
+ const content = readFileSync(logPath, "utf-8");
73
+ const lines = content.trim().split("\n").slice(-30).join("\n");
74
+ await ctx.reply(
75
+ `<b>Daily log (${today})</b>\n\n<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`,
76
+ { parse_mode: "HTML" },
77
+ );
78
+ } catch {
79
+ await ctx.reply(`No daily log for ${today}.`);
80
+ }
81
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `/admin chats` / `broadcast` / `kill` — the session-level subcommands.
3
+ */
4
+
5
+ import type { Bot, Context } from "grammy";
6
+ import type { TalonConfig } from "../../../util/config.js";
7
+ import { escapeHtml } from "../formatting.js";
8
+ import { resetSession, getAllSessions } from "../../../storage/sessions.js";
9
+ import { clearHistory } from "../../../storage/history.js";
10
+ import { getChatSettings } from "../../../storage/chat-settings.js";
11
+ import { formatModelLabel } from "../helpers/index.js";
12
+ import { replyHtmlChunked } from "./chunked-reply.js";
13
+
14
+ /** `/admin chats` — every active session, newest first, titled via getChat. */
15
+ export async function replyActiveChats(
16
+ ctx: Context,
17
+ _rest: string[],
18
+ bot: Bot,
19
+ config: TalonConfig,
20
+ ): Promise<void> {
21
+ const sessions = getAllSessions();
22
+ if (sessions.length === 0) {
23
+ await ctx.reply("No active sessions.");
24
+ return;
25
+ }
26
+ sessions.sort((a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0));
27
+
28
+ const titles = new Map<string, string>();
29
+ await Promise.all(
30
+ sessions.map(async (s) => {
31
+ try {
32
+ const id = parseInt(s.chatId, 10);
33
+ if (isNaN(id)) return;
34
+ const chat = await bot.api.getChat(id);
35
+ titles.set(
36
+ s.chatId,
37
+ "title" in chat
38
+ ? (chat.title ?? "DM")
39
+ : "first_name" in chat
40
+ ? (chat.first_name ?? "DM")
41
+ : "DM",
42
+ );
43
+ } catch {
44
+ /* inaccessible */
45
+ }
46
+ }),
47
+ );
48
+
49
+ const lines = sessions.map((s) => {
50
+ const age = s.info.lastActive
51
+ ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
52
+ : "?";
53
+ const title = titles.get(s.chatId) ?? s.chatId;
54
+ const model = formatModelLabel(
55
+ getChatSettings(s.chatId).model ?? config.model,
56
+ );
57
+ // `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
58
+ // it gets the same escaping the title already had.
59
+ return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
60
+ });
61
+ await replyHtmlChunked(
62
+ ctx,
63
+ `<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
64
+ );
65
+ }
66
+
67
+ export async function broadcast(
68
+ ctx: Context,
69
+ rest: string[],
70
+ bot: Bot,
71
+ ): Promise<void> {
72
+ const text = rest.join(" ");
73
+ if (!text) {
74
+ await ctx.reply("Usage: /admin broadcast <text>");
75
+ return;
76
+ }
77
+ const sessions = getAllSessions();
78
+ let sent = 0,
79
+ failed = 0;
80
+ for (const s of sessions) {
81
+ const id = parseInt(s.chatId, 10);
82
+ if (isNaN(id)) continue;
83
+ try {
84
+ await bot.api.sendMessage(id, text);
85
+ sent++;
86
+ await new Promise((r) => setTimeout(r, 40));
87
+ } catch {
88
+ failed++;
89
+ }
90
+ }
91
+ await ctx.reply(
92
+ `Broadcast: ${sent} sent, ${failed} failed (${sessions.length} total).`,
93
+ );
94
+ }
95
+
96
+ export async function killSession(ctx: Context, rest: string[]): Promise<void> {
97
+ const target = rest[0];
98
+ if (!target) {
99
+ await ctx.reply("Usage: /admin kill <chatId>");
100
+ return;
101
+ }
102
+ resetSession(target);
103
+ clearHistory(target);
104
+ await ctx.reply(`Session ${target} reset.`);
105
+ }
@@ -1,91 +1,64 @@
1
1
  /**
2
2
  * Admin command handlers — /admin subcommands for bot operators.
3
+ *
4
+ * The table maps each subcommand to its handler in `admin/`:
5
+ * - `sessions` — chats / broadcast / kill
6
+ * - `health` — stats / errors / logs / daily
7
+ * - `background` — cron / pulse
8
+ * Anything else (including no subcommand) gets the usage listing.
3
9
  */
4
10
 
5
11
  import type { Bot, Context } from "grammy";
6
- import { readFileSync } from "node:fs";
7
12
  import type { TalonConfig } from "../../util/config.js";
8
- import { files, dirs } from "../../util/paths.js";
9
- import { tailFile } from "../../util/tail-file.js";
10
- import { escapeHtml, splitMessage } from "./formatting.js";
11
- import { TELEGRAM_MAX_TEXT } from "./actions/types.js";
12
- import { resetSession, getAllSessions } from "../../storage/sessions.js";
13
- import { clearHistory } from "../../storage/history.js";
14
- import { todayLogDate } from "../../storage/daily-log.js";
15
- import { getChatSettings } from "../../storage/chat-settings.js";
13
+ import { replyActiveChats, broadcast, killSession } from "./admin/sessions.js";
16
14
  import {
17
- getAllCronJobs,
18
- describeSchedule,
19
- nextRunAt,
20
- } from "../../storage/cron.js";
21
- import { getActiveCount } from "../../core/engine/dispatcher.js";
22
- import { getPulseStatus } from "../../core/background/pulse.js";
23
- import { getHealthStatus, getRecentErrors } from "../../util/watchdog.js";
24
- import { formatDuration, formatModelLabel } from "./helpers/index.js";
25
-
26
- /**
27
- * Reply with an HTML listing, split across messages when it outgrows
28
- * Telegram's 4096-char cap. The per-item listings below (chats, cron,
29
- * pulse) scale with the daemon's chat count, and a single oversized
30
- * `ctx.reply` fails the whole command with 400 "message is too long".
31
- * Entries are `\n\n`-separated and each carries balanced tags, so the
32
- * paragraph-first splitter never cuts through markup.
33
- */
34
- async function replyHtmlChunked(ctx: Context, text: string): Promise<void> {
35
- for (const chunk of splitMessage(text, TELEGRAM_MAX_TEXT)) {
36
- await ctx.reply(chunk, { parse_mode: "HTML" });
37
- }
38
- }
39
-
40
- /** `/admin chats` — every active session, newest first, titled via getChat. */
41
- async function replyActiveChats(
15
+ replyStats,
16
+ replyRecentErrors,
17
+ replyLogTail,
18
+ replyDailyLog,
19
+ } from "./admin/health.js";
20
+ import { replyCronJobs, replyPulseStatus } from "./admin/background.js";
21
+
22
+ type AdminSubcommand = (
42
23
  ctx: Context,
24
+ rest: string[],
43
25
  bot: Bot,
44
26
  config: TalonConfig,
45
- ): Promise<void> {
46
- const sessions = getAllSessions();
47
- if (sessions.length === 0) {
48
- await ctx.reply("No active sessions.");
49
- return;
50
- }
51
- sessions.sort((a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0));
52
-
53
- const titles = new Map<string, string>();
54
- await Promise.all(
55
- sessions.map(async (s) => {
56
- try {
57
- const id = parseInt(s.chatId, 10);
58
- if (isNaN(id)) return;
59
- const chat = await bot.api.getChat(id);
60
- titles.set(
61
- s.chatId,
62
- "title" in chat
63
- ? (chat.title ?? "DM")
64
- : "first_name" in chat
65
- ? (chat.first_name ?? "DM")
66
- : "DM",
67
- );
68
- } catch {
69
- /* inaccessible */
70
- }
71
- }),
72
- );
73
-
74
- const lines = sessions.map((s) => {
75
- const age = s.info.lastActive
76
- ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
77
- : "?";
78
- const title = titles.get(s.chatId) ?? s.chatId;
79
- const model = formatModelLabel(
80
- getChatSettings(s.chatId).model ?? config.model,
81
- );
82
- // `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
83
- // it gets the same escaping the title already had.
84
- return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
85
- });
86
- await replyHtmlChunked(
87
- ctx,
88
- `<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
27
+ ) => Promise<void>;
28
+
29
+ // Null-prototype so `/admin constructor` can't resolve an inherited
30
+ // Object.prototype method via `handlers[subcommand]`.
31
+ const ADMIN_SUBCOMMANDS: Record<string, AdminSubcommand> = Object.assign(
32
+ Object.create(null),
33
+ {
34
+ chats: replyActiveChats,
35
+ broadcast,
36
+ kill: killSession,
37
+ logs: replyLogTail,
38
+ stats: replyStats,
39
+ errors: replyRecentErrors,
40
+ cron: replyCronJobs,
41
+ pulse: replyPulseStatus,
42
+ daily: replyDailyLog,
43
+ } satisfies Record<string, AdminSubcommand>,
44
+ );
45
+
46
+ async function replyUsage(ctx: Context): Promise<void> {
47
+ await ctx.reply(
48
+ [
49
+ "<b>/admin commands</b>",
50
+ "",
51
+ " stats uptime, messages, memory",
52
+ " errors last 5 errors",
53
+ " chats list all active chats",
54
+ " daily today's interaction log",
55
+ " pulse pulse status per chat",
56
+ " cron list all cron jobs",
57
+ " broadcast &lt;text&gt; send to all chats",
58
+ " kill &lt;chatId&gt; reset a chat session",
59
+ " logs last 20 lines of log",
60
+ ].join("\n"),
61
+ { parse_mode: "HTML" },
89
62
  );
90
63
  }
91
64
 
@@ -96,188 +69,10 @@ export async function handleAdminCommand(
96
69
  ): Promise<void> {
97
70
  const args = ((ctx.match as string) ?? "").trim();
98
71
  const [subcommand, ...rest] = args.split(/\s+/);
99
-
100
- switch (subcommand) {
101
- case "chats": {
102
- await replyActiveChats(ctx, bot, config);
103
- return;
104
- }
105
-
106
- case "broadcast": {
107
- const text = rest.join(" ");
108
- if (!text) {
109
- await ctx.reply("Usage: /admin broadcast <text>");
110
- return;
111
- }
112
- const sessions = getAllSessions();
113
- let sent = 0,
114
- failed = 0;
115
- for (const s of sessions) {
116
- const id = parseInt(s.chatId, 10);
117
- if (isNaN(id)) continue;
118
- try {
119
- await bot.api.sendMessage(id, text);
120
- sent++;
121
- await new Promise((r) => setTimeout(r, 40));
122
- } catch {
123
- failed++;
124
- }
125
- }
126
- await ctx.reply(
127
- `Broadcast: ${sent} sent, ${failed} failed (${sessions.length} total).`,
128
- );
129
- return;
130
- }
131
-
132
- case "kill": {
133
- const target = rest[0];
134
- if (!target) {
135
- await ctx.reply("Usage: /admin kill <chatId>");
136
- return;
137
- }
138
- resetSession(target);
139
- clearHistory(target);
140
- await ctx.reply(`Session ${target} reset.`);
141
- return;
142
- }
143
-
144
- case "logs": {
145
- const logPath = files.log;
146
- try {
147
- const lines = tailFile(logPath);
148
- await ctx.reply(`<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`, {
149
- parse_mode: "HTML",
150
- });
151
- } catch {
152
- await ctx.reply(`Could not read ${logPath}`);
153
- }
154
- return;
155
- }
156
-
157
- case "stats": {
158
- const h = getHealthStatus();
159
- const sessions = getAllSessions();
160
- const turns = sessions.reduce((s, x) => s + x.info.turns, 0);
161
- const mem = process.memoryUsage();
162
- await ctx.reply(
163
- [
164
- `<b>\uD83E\uDD85 Talon Stats</b>`,
165
- "",
166
- `<b>Uptime:</b> ${formatDuration(h.uptimeMs)}`,
167
- `<b>Messages:</b> ${h.totalMessagesProcessed}`,
168
- `<b>Sessions:</b> ${sessions.length}`,
169
- `<b>Turns:</b> ${turns}`,
170
- `<b>Last active:</b> ${h.msSinceLastMessage < 60000 ? "now" : formatDuration(h.msSinceLastMessage) + " ago"}`,
171
- "",
172
- `<b>Memory:</b> ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB heap / ${(mem.rss / 1024 / 1024).toFixed(1)}MB rss`,
173
- `<b>Queue:</b> ${getActiveCount()}`,
174
- `<b>Errors:</b> ${h.recentErrorCount}`,
175
- ].join("\n"),
176
- { parse_mode: "HTML" },
177
- );
178
- return;
179
- }
180
-
181
- case "errors": {
182
- const errors = getRecentErrors(5);
183
- if (errors.length === 0) {
184
- await ctx.reply("No recent errors.");
185
- return;
186
- }
187
- const lines = errors.map(
188
- (e) =>
189
- `<code>[${new Date(e.timestamp).toISOString().slice(11, 19)}]</code> ${escapeHtml(e.message.slice(0, 200))}`,
190
- );
191
- await ctx.reply(
192
- `<b>Recent Errors (${errors.length})</b>\n\n` + lines.join("\n\n"),
193
- { parse_mode: "HTML" },
194
- );
195
- return;
196
- }
197
-
198
- case "cron": {
199
- const jobs = getAllCronJobs();
200
- if (jobs.length === 0) {
201
- await ctx.reply("No cron jobs.");
202
- return;
203
- }
204
- const lines = jobs.map((j) => {
205
- const nextMs = nextRunAt(j);
206
- const last = j.lastRunAt
207
- ? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ")
208
- : "never";
209
- const next = nextMs
210
- ? new Date(nextMs).toISOString().slice(0, 16).replace("T", " ")
211
- : "?";
212
- return `${j.enabled ? "\u2713" : "\u2717"} <b>${escapeHtml(j.name)}</b>\n <code>${escapeHtml(describeSchedule(j))}</code> | ${j.type} | runs: ${j.runCount} | last: ${last} | next: ${next}`;
213
- });
214
- await replyHtmlChunked(
215
- ctx,
216
- `<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"),
217
- );
218
- return;
219
- }
220
-
221
- case "pulse": {
222
- const chats = getPulseStatus();
223
- if (chats.length === 0) {
224
- await ctx.reply("No pulse chats.");
225
- return;
226
- }
227
- const lines = await Promise.all(
228
- chats.map(async (p) => {
229
- let title = p.chatId;
230
- try {
231
- const id = parseInt(p.chatId, 10);
232
- if (!isNaN(id)) {
233
- const chat = await bot.api.getChat(id);
234
- title = "title" in chat ? (chat.title ?? p.chatId) : p.chatId;
235
- }
236
- } catch {
237
- /* skip */
238
- }
239
- return `${p.enabled ? "\u2713" : "\u2717"} ${escapeHtml(title)}`;
240
- }),
241
- );
242
- await replyHtmlChunked(
243
- ctx,
244
- `<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"),
245
- );
246
- return;
247
- }
248
-
249
- case "daily": {
250
- const today = todayLogDate();
251
- const logPath = `${dirs.logs}/${today}.md`;
252
- try {
253
- const content = readFileSync(logPath, "utf-8");
254
- const lines = content.trim().split("\n").slice(-30).join("\n");
255
- await ctx.reply(
256
- `<b>Daily log (${today})</b>\n\n<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`,
257
- { parse_mode: "HTML" },
258
- );
259
- } catch {
260
- await ctx.reply(`No daily log for ${today}.`);
261
- }
262
- return;
263
- }
264
-
265
- default:
266
- await ctx.reply(
267
- [
268
- "<b>/admin commands</b>",
269
- "",
270
- " stats uptime, messages, memory",
271
- " errors last 5 errors",
272
- " chats list all active chats",
273
- " daily today's interaction log",
274
- " pulse pulse status per chat",
275
- " cron list all cron jobs",
276
- " broadcast &lt;text&gt; send to all chats",
277
- " kill &lt;chatId&gt; reset a chat session",
278
- " logs last 20 lines of log",
279
- ].join("\n"),
280
- { parse_mode: "HTML" },
281
- );
72
+ const run = ADMIN_SUBCOMMANDS[subcommand];
73
+ if (!run) {
74
+ await replyUsage(ctx);
75
+ return;
282
76
  }
77
+ await run(ctx, rest, bot, config);
283
78
  }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Backend switching — `model:backend:<id>` and `model:backend-default`.
3
+ * Both drop Talon's per-chat stores for the outgoing backend and hand the
4
+ * session over (see `handOffBackendSession`).
5
+ */
6
+
7
+ import type { Context } from "grammy";
8
+ import { setChatBackend } from "../../../../storage/chat-settings.js";
9
+ import { resetSession } from "../../../../storage/sessions.js";
10
+ import { clearHistory } from "../../../../storage/history.js";
11
+ import {
12
+ getBackendIdForChat,
13
+ listAvailableBackends,
14
+ rebindChat,
15
+ releaseChat,
16
+ } from "../../../../core/engine/backend-controller/index.js";
17
+ import { resetPulseCheckpoint } from "../../../../core/background/pulse.js";
18
+ import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
19
+ import { logWarn } from "../../../../util/log.js";
20
+ import type { ModelCallback } from "../../model-callbacks.js";
21
+ import { resolveBackendForChat } from "../../model-menu.js";
22
+ import { answerCallbackQuerySafe, type CallbackDeps } from "../shared.js";
23
+ import type { ModelOutcome } from "./types.js";
24
+
25
+ /**
26
+ * Run the backend-side half of a chat's session handoff.
27
+ *
28
+ * A backend switch already clears Talon's own stores (session row,
29
+ * history, pulse checkpoint), but those are only half the state.
30
+ * `performSessionReset` also drives the backend capability slots, and
31
+ * the switch path skipped them entirely:
32
+ *
33
+ * - the OUTGOING backend keeps in-process per-chat state that no
34
+ * amount of clearing Talon's stores touches. openai-agents holds a
35
+ * `MemorySession` map keyed by chat id, so switching away and back
36
+ * resurrected the old conversation the operator meant to drop.
37
+ * - the INCOMING backend was never warmed, so the first turn after a
38
+ * switch paid the full cold start — on OpenCode/Kilo that is session
39
+ * creation plus a per-plugin MCP registration sweep.
40
+ *
41
+ * The warm is deliberately fire-and-forget: it can take seconds, and the
42
+ * callback still has a toast to answer and a menu to redraw. Telegram
43
+ * expires an unanswered callback query, so blocking here would trade a
44
+ * cold first turn for a visibly stuck button.
45
+ */
46
+ function handOffBackendSession(
47
+ chatId: string,
48
+ previousBackend: ReturnType<typeof resolveBackendForChat>,
49
+ gateway: CallbackDeps["gateway"],
50
+ ): void {
51
+ previousBackend?.sessions?.resetChat?.(chatId);
52
+ const nextBackend = resolveBackendForChat(chatId, gateway);
53
+ // Same instance on a no-op switch — warming it again is harmless
54
+ // (every step is idempotent) but pointless, so skip it.
55
+ if (!nextBackend || nextBackend === previousBackend) return;
56
+ void Promise.resolve(nextBackend.sessions?.warmSession?.(chatId)).catch(
57
+ (err) =>
58
+ logWarn(
59
+ "bot",
60
+ `[${chatId}] warm after backend switch failed: ${err instanceof Error ? err.message : String(err)}`,
61
+ ),
62
+ );
63
+ }
64
+
65
+ export async function handleBackendSelect(
66
+ ctx: Context,
67
+ action: Extract<ModelCallback, { kind: "backend-select" }>,
68
+ cid: string,
69
+ { config, gateway }: CallbackDeps,
70
+ ): Promise<ModelOutcome | undefined> {
71
+ // Rebind chat to the chosen backend. Verify the requested id
72
+ // is on the enabled list to keep the menu and the operation
73
+ // in sync — `config.enabledBackends` is a UX filter and we
74
+ // honour it here too.
75
+ const available = listAvailableBackends(config);
76
+ if (!available.some((b) => b.id === action.backendId)) {
77
+ await answerCallbackQuerySafe(ctx, {
78
+ text: "Backend not available",
79
+ });
80
+ return undefined;
81
+ }
82
+ // Resolve the outgoing backend BEFORE rebinding — afterwards this
83
+ // chat already points at the new one and the old in-process state
84
+ // would be unreachable.
85
+ const previousBackend = resolveBackendForChat(cid, gateway);
86
+ const result = await rebindChat(cid, action.backendId, config);
87
+ if (!result.ok) {
88
+ await answerCallbackQuerySafe(ctx, {
89
+ text: result.error?.slice(0, 200) ?? "Rebind failed",
90
+ });
91
+ return undefined;
92
+ }
93
+ setChatBackend(cid, action.backendId);
94
+ // Switching backends drops the previous backend's per-chat
95
+ // session state (it's not portable across backends). We DO
96
+ // NOT clear `modelByBackend` — keeping each backend's prior
97
+ // pick means switching back-and-forth restores each side's
98
+ // last choice automatically (Codex chat keeps gpt-5.5,
99
+ // OpenRouter chat keeps owl-alpha, etc).
100
+ resetSession(cid);
101
+ clearHistory(cid);
102
+ resetPulseCheckpoint(cid);
103
+ handOffBackendSession(cid, previousBackend, gateway);
104
+ const label =
105
+ available.find((b) => b.id === action.backendId)?.label ?? action.backendId;
106
+ // Toast names the model the new backend will actually run
107
+ // (per-chat slot if remembered, else canonical / operator
108
+ // default; "no default" if catalog-driven with no config).
109
+ const newBackend = resolveBackendForChat(cid, gateway);
110
+ const { model: resolvedNewModel } = await resolveActiveModelForChat(
111
+ cid,
112
+ newBackend,
113
+ action.backendId,
114
+ config,
115
+ );
116
+ return {
117
+ toast: resolvedNewModel
118
+ ? `Backend: ${label} (model: ${resolvedNewModel})`
119
+ : `Backend: ${label} — no default model, /model to pick one`,
120
+ view: { kind: "menu" },
121
+ };
122
+ }
123
+
124
+ export async function handleBackendDefault(
125
+ _ctx: Context,
126
+ _action: Extract<ModelCallback, { kind: "backend-default" }>,
127
+ cid: string,
128
+ { config, gateway }: CallbackDeps,
129
+ ): Promise<ModelOutcome> {
130
+ // Drop the per-chat backend override; chat reverts to the
131
+ // global chat-role backend. Per-backend model picks are
132
+ // preserved (modelByBackend stays intact) so reverting and
133
+ // switching back later still restores prior choices.
134
+ const previousBackend = resolveBackendForChat(cid, gateway);
135
+ await releaseChat(cid);
136
+ setChatBackend(cid, undefined);
137
+ resetSession(cid);
138
+ clearHistory(cid);
139
+ resetPulseCheckpoint(cid);
140
+ handOffBackendSession(cid, previousBackend, gateway);
141
+ // Resolve the now-default backend's model for the toast.
142
+ const defaultBackend = resolveBackendForChat(cid, gateway);
143
+ const defaultBackendId = getBackendIdForChat(cid);
144
+ const { model: resolvedRoleModel } = await resolveActiveModelForChat(
145
+ cid,
146
+ defaultBackend,
147
+ defaultBackendId,
148
+ config,
149
+ );
150
+ return {
151
+ toast: resolvedRoleModel
152
+ ? `Backend reset to default (model: ${resolvedRoleModel})`
153
+ : `Backend reset to default — no model picked, /model to pick one`,
154
+ view: { kind: "menu" },
155
+ };
156
+ }