talon-agent 3.34.0 → 3.35.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.
Files changed (70) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +0 -10
  3. package/package.json +6 -4
  4. package/src/backend/claude-sdk/handler.ts +428 -415
  5. package/src/backend/codex/auth.ts +40 -1
  6. package/src/backend/codex/handler/message.ts +290 -366
  7. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  8. package/src/backend/codex/plan-usage.ts +27 -2
  9. package/src/backend/kilo/handler/message.ts +2 -0
  10. package/src/backend/kilo/server.ts +1 -0
  11. package/src/backend/openai-agents/handler/message.ts +282 -356
  12. package/src/backend/opencode/handler/message.ts +2 -0
  13. package/src/backend/opencode/server.ts +1 -0
  14. package/src/backend/remote-server/chat-turn.ts +76 -122
  15. package/src/backend/remote-server/index.ts +4 -0
  16. package/src/backend/remote-server/lifecycle.ts +37 -0
  17. package/src/backend/remote-server/mcp.ts +73 -9
  18. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  19. package/src/backend/remote-server/server-bindings.ts +12 -0
  20. package/src/backend/remote-server/sessions.ts +2 -2
  21. package/src/backend/remote-server/state.ts +8 -0
  22. package/src/backend/remote-server/turn.ts +93 -23
  23. package/src/backend/shared/cache-telemetry.ts +17 -1
  24. package/src/backend/shared/handler-to-events.ts +11 -16
  25. package/src/backend/shared/index.ts +14 -15
  26. package/src/backend/shared/result-events.ts +30 -0
  27. package/src/backend/shared/turn-phases.ts +277 -0
  28. package/src/bootstrap.ts +24 -10
  29. package/src/core/auth/expiry-monitor.ts +89 -0
  30. package/src/core/auth/login-flow.ts +247 -0
  31. package/src/core/auth/status.ts +193 -0
  32. package/src/core/background/triggers/exit.ts +19 -2
  33. package/src/core/background/triggers/resume.ts +14 -7
  34. package/src/core/background/triggers/state.ts +9 -0
  35. package/src/core/engine/gateway-actions/history.ts +12 -1
  36. package/src/core/errors.ts +1 -1
  37. package/src/core/mcp-hub/child-transport.ts +215 -0
  38. package/src/core/mcp-hub/children.ts +72 -13
  39. package/src/core/mcp-hub/index.ts +32 -13
  40. package/src/core/models/active-model.ts +29 -6
  41. package/src/core/tools/history.ts +10 -1
  42. package/src/core/weaver/shuttle.ts +4 -0
  43. package/src/frontend/shared/model-commands.ts +400 -0
  44. package/src/frontend/telegram/admin.ts +75 -52
  45. package/src/frontend/telegram/auth-panel.ts +205 -0
  46. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  47. package/src/frontend/telegram/callbacks/index.ts +16 -0
  48. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  49. package/src/frontend/telegram/commands/auth.ts +58 -0
  50. package/src/frontend/telegram/commands/definitions.ts +4 -0
  51. package/src/frontend/telegram/commands/index.ts +10 -4
  52. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  53. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  54. package/src/frontend/whatsapp/actions/history.ts +100 -0
  55. package/src/frontend/whatsapp/actions/index.ts +4 -1
  56. package/src/frontend/whatsapp/commands.ts +371 -0
  57. package/src/frontend/whatsapp/inbound.ts +24 -46
  58. package/src/frontend/whatsapp/index.ts +12 -1
  59. package/src/frontend/whatsapp/media-store.ts +20 -1
  60. package/src/frontend/whatsapp/message-store.ts +101 -24
  61. package/src/storage/history.ts +22 -1
  62. package/src/storage/repositories/history-repo.ts +14 -0
  63. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  64. package/src/storage/sql/history.sql +12 -0
  65. package/src/storage/sql/schema.sql +21 -0
  66. package/src/storage/sql/statements.generated.ts +50 -1
  67. package/src/storage/sql/whatsapp-messages.sql +29 -0
  68. package/src/storage/trigger-store.ts +8 -0
  69. package/src/storage/whatsapp-messages.ts +51 -0
  70. package/src/util/watchdog.ts +32 -7
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Frontend-agnostic `/model`, `/effort` and backend-switch operations.
3
+ *
4
+ * Telegram and Discord drive these through inline buttons and select
5
+ * menus; a text-only frontend (WhatsApp) has to do the same work from a
6
+ * typed argument — "the third model in the list", "switch to codex",
7
+ * "effort high". The state changes are identical either way, so they
8
+ * live here, shaped as plain data + plain-text outcomes that every
9
+ * frontend can wrap in its own markup. Nothing in this module knows
10
+ * about a socket, a bot API, or a keyboard.
11
+ */
12
+
13
+ import type { TalonConfig } from "../../util/config.js";
14
+ import type { Backend } from "../../core/agent-runtime/capabilities.js";
15
+ import type { ReasoningEffortLevel } from "../../core/types.js";
16
+ import {
17
+ getChatSettings,
18
+ setChatBackend,
19
+ setChatEffort,
20
+ setChatModelForBackend,
21
+ type EffortLevel,
22
+ } from "../../storage/chat-settings.js";
23
+ import { resolveModelId as resolveModelName } from "../../core/models/catalog.js";
24
+ import { resolveActiveModelForChat } from "../../core/models/active-model.js";
25
+ import {
26
+ getBackendIdForChat,
27
+ hasBackendPool,
28
+ hasChatBackendOverride,
29
+ listAvailableBackends,
30
+ rebindChat,
31
+ releaseChat,
32
+ resolveChatBackend,
33
+ } from "../../core/engine/backend-controller/index.js";
34
+ import { resetSession } from "../../storage/sessions.js";
35
+ import { clearHistory } from "../../storage/history.js";
36
+ import { resetPulseCheckpoint } from "../../core/background/pulse.js";
37
+ import { logWarn } from "../../util/log.js";
38
+ import {
39
+ displayReasoningEffort,
40
+ getActiveReasoningLevels,
41
+ supportsReasoningLevel,
42
+ } from "./reasoning-levels.js";
43
+
44
+ export type ModelCommandDeps = {
45
+ config: TalonConfig;
46
+ gateway?: { backend: Backend | null };
47
+ };
48
+
49
+ /** Outcome of a state-changing command, ready to be sent as-is. */
50
+ export type CommandOutcome = { ok: boolean; text: string };
51
+
52
+ /** One selectable catalog entry, numbered for text pickers. */
53
+ type ModelChoice = {
54
+ /** 1-based position in the listing — what `/model <n>` refers to. */
55
+ index: number;
56
+ id: string;
57
+ displayName: string;
58
+ providerName: string;
59
+ free: boolean;
60
+ };
61
+
62
+ export type ModelOverview = {
63
+ backendId: string;
64
+ backendLabel: string;
65
+ hasBackendOverride: boolean;
66
+ backends: Array<{ id: string; label: string }>;
67
+ /** Resolved active model id, or null when the chat has none to run on. */
68
+ activeModel: string | null;
69
+ activeDisplay: string | null;
70
+ /** True when the chat pins a model other than the backend's default. */
71
+ hasModelOverride: boolean;
72
+ choices: ModelChoice[];
73
+ };
74
+
75
+ /** The backend + id serving a chat right now (pool-aware, test-safe). */
76
+ export function resolveChatBackendPair(
77
+ chatId: string,
78
+ deps: ModelCommandDeps,
79
+ ): { backend: Backend | null; backendId: string } {
80
+ return {
81
+ backend: resolveChatBackend(chatId, deps.gateway?.backend ?? null),
82
+ backendId: hasBackendPool()
83
+ ? getBackendIdForChat(chatId)
84
+ : deps.config.backend,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The selectable catalog of the chat's backend, numbered in catalog
90
+ * order. Same call on the listing and on the pick, so `/model 3` names
91
+ * the same entry the user just read. Empty for fixed-model backends.
92
+ */
93
+ async function listModelChoices(
94
+ backend: Backend | null,
95
+ ): Promise<ModelChoice[]> {
96
+ if (!backend?.models?.listModels) return [];
97
+ try {
98
+ const { models } = await backend.models.listModels("all");
99
+ return models
100
+ .filter((m) => m.selectable)
101
+ .map((m, i) => ({
102
+ index: i + 1,
103
+ id: m.id,
104
+ displayName: m.displayName,
105
+ providerName: m.providerName,
106
+ free: m.free === true,
107
+ }));
108
+ } catch (err) {
109
+ logWarn(
110
+ "settings",
111
+ `listModels failed: ${err instanceof Error ? err.message : String(err)}`,
112
+ );
113
+ return [];
114
+ }
115
+ }
116
+
117
+ /** Everything a text `/model` listing shows. */
118
+ export async function describeChatModels(
119
+ chatId: string,
120
+ deps: ModelCommandDeps,
121
+ ): Promise<ModelOverview> {
122
+ const { backend, backendId } = resolveChatBackendPair(chatId, deps);
123
+ const backends = listAvailableBackends(deps.config);
124
+ const backendLabel =
125
+ backends.find((b) => b.id === backendId)?.label ??
126
+ backend?.label ??
127
+ backendId;
128
+ const { model, ref, source } = await resolveActiveModelForChat(
129
+ chatId,
130
+ backend,
131
+ backendId,
132
+ deps.config,
133
+ );
134
+ return {
135
+ backendId,
136
+ backendLabel,
137
+ hasBackendOverride: hasBackendPool() && hasChatBackendOverride(chatId),
138
+ backends,
139
+ activeModel: model,
140
+ activeDisplay: ref?.displayName ?? model,
141
+ hasModelOverride: source === "override-valid",
142
+ choices: await listModelChoices(backend),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Pin a model for the chat. `query` is a catalog id, a fuzzy name the
148
+ * backend resolves, or a 1-based number from `describeChatModels`.
149
+ * Mirrors Telegram's `/model <arg>`: the pick lands in the backend's
150
+ * own slot and the chat is pinned to that backend so a restart can't
151
+ * orphan the id.
152
+ */
153
+ export async function selectChatModel(
154
+ chatId: string,
155
+ query: string,
156
+ deps: ModelCommandDeps,
157
+ ): Promise<CommandOutcome> {
158
+ const { backend, backendId } = resolveChatBackendPair(chatId, deps);
159
+ let wanted = query.trim();
160
+ if (/^\d+$/.test(wanted)) {
161
+ const choices = await listModelChoices(backend);
162
+ const choice = choices[Number(wanted) - 1];
163
+ if (!choice) {
164
+ return {
165
+ ok: false,
166
+ text: choices.length
167
+ ? `No model #${wanted} — the list has ${choices.length} entries.`
168
+ : `This backend has no numbered model list; give a model id instead.`,
169
+ };
170
+ }
171
+ wanted = choice.id;
172
+ }
173
+
174
+ if (!backend?.models?.resolveModelInfo) {
175
+ const model = resolveModelName(wanted);
176
+ setChatModelForBackend(chatId, backendId, model);
177
+ setChatBackend(chatId, backendId);
178
+ return { ok: true, text: `Model set to ${model}.` };
179
+ }
180
+ const resolution = await backend.models.resolveModelInfo(wanted);
181
+ if (resolution.kind !== "exact") {
182
+ return {
183
+ ok: false,
184
+ text:
185
+ backend.models.formatModelError?.(wanted, resolution) ??
186
+ `No model matched "${wanted}".`,
187
+ };
188
+ }
189
+ if (!resolution.model.selectable) {
190
+ return {
191
+ ok: false,
192
+ text:
193
+ resolution.model.unavailableReason ??
194
+ `${resolution.model.providerName} is not connected.`,
195
+ };
196
+ }
197
+ setChatModelForBackend(chatId, backendId, resolution.storedValue);
198
+ setChatBackend(chatId, backendId);
199
+ const tag = resolution.model.free ? " · free" : "";
200
+ return {
201
+ ok: true,
202
+ text: `Model set to ${resolution.storedValue} (${resolution.model.providerName}${tag}).`,
203
+ };
204
+ }
205
+
206
+ /** Clear the chat's pick on its current backend; other backends' picks stay. */
207
+ export async function resetChatModel(
208
+ chatId: string,
209
+ deps: ModelCommandDeps,
210
+ ): Promise<CommandOutcome> {
211
+ const { backend, backendId } = resolveChatBackendPair(chatId, deps);
212
+ setChatModelForBackend(chatId, backendId, undefined);
213
+ const { model } = await resolveActiveModelForChat(
214
+ chatId,
215
+ backend,
216
+ backendId,
217
+ deps.config,
218
+ );
219
+ return {
220
+ ok: true,
221
+ text: model
222
+ ? `Model reset to default: ${model}.`
223
+ : `Model reset — no default available for backend ${backendId}; pick one with /model.`,
224
+ };
225
+ }
226
+
227
+ /** The backend an argument names, matched by id or label, else null. */
228
+ export function matchBackendArg(
229
+ arg: string,
230
+ config: TalonConfig,
231
+ ): { id: string; label: string } | null {
232
+ const wanted = arg.trim().toLowerCase();
233
+ if (!wanted) return null;
234
+ return (
235
+ listAvailableBackends(config).find(
236
+ (b) => b.id.toLowerCase() === wanted || b.label.toLowerCase() === wanted,
237
+ ) ?? null
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Drop the session state a backend switch invalidates. Session ids are
243
+ * not portable across backends; each backend's remembered model pick IS
244
+ * kept, so switching back restores it. `keepHistory` is for frontends
245
+ * whose local history store is the only record of the chat.
246
+ */
247
+ function handOffChatSession(
248
+ chatId: string,
249
+ previous: Backend | null,
250
+ deps: ModelCommandDeps,
251
+ keepHistory: boolean,
252
+ ): void {
253
+ resetSession(chatId);
254
+ if (!keepHistory) clearHistory(chatId);
255
+ resetPulseCheckpoint(chatId);
256
+ previous?.sessions?.resetChat?.(chatId);
257
+ const next = resolveChatBackend(chatId, deps.gateway?.backend ?? null);
258
+ // Warming can take seconds on OpenCode/Kilo; the reply must not wait.
259
+ if (!next || next === previous) return;
260
+ void Promise.resolve(next.sessions?.warmSession?.(chatId)).catch((err) =>
261
+ logWarn(
262
+ "settings",
263
+ `[${chatId}] warm after backend switch failed: ${err instanceof Error ? err.message : String(err)}`,
264
+ ),
265
+ );
266
+ }
267
+
268
+ /** Model line for the post-switch confirmation. */
269
+ async function describeModelAfterSwitch(
270
+ chatId: string,
271
+ backendId: string,
272
+ deps: ModelCommandDeps,
273
+ ): Promise<string> {
274
+ const backend = resolveChatBackend(chatId, deps.gateway?.backend ?? null);
275
+ const { model } = await resolveActiveModelForChat(
276
+ chatId,
277
+ backend,
278
+ backendId,
279
+ deps.config,
280
+ );
281
+ return model ? `model: ${model}` : "no default model — /model to pick one";
282
+ }
283
+
284
+ /**
285
+ * Rebind the chat to another backend. Same sequence as Telegram's
286
+ * backend submenu; re-selecting the backend already in use only pins
287
+ * the choice and costs the session nothing.
288
+ */
289
+ export async function switchChatBackend(
290
+ chatId: string,
291
+ target: { id: string; label: string },
292
+ deps: ModelCommandDeps,
293
+ opts: { keepHistory?: boolean } = {},
294
+ ): Promise<CommandOutcome> {
295
+ const { backend: previous, backendId: previousId } = resolveChatBackendPair(
296
+ chatId,
297
+ deps,
298
+ );
299
+ if (previousId === target.id) {
300
+ setChatBackend(chatId, target.id);
301
+ return {
302
+ ok: true,
303
+ text: `Already on ${target.label} (${await describeModelAfterSwitch(chatId, target.id, deps)}).`,
304
+ };
305
+ }
306
+ const result = await rebindChat(chatId, target.id, deps.config);
307
+ if (!result.ok) {
308
+ return {
309
+ ok: false,
310
+ text: `Could not switch to ${target.label}: ${result.error ?? "rebind failed"}`,
311
+ };
312
+ }
313
+ setChatBackend(chatId, target.id);
314
+ handOffChatSession(chatId, previous, deps, opts.keepHistory === true);
315
+ return {
316
+ ok: true,
317
+ text: `Backend: ${target.label} (${await describeModelAfterSwitch(chatId, target.id, deps)}). Session started fresh.`,
318
+ };
319
+ }
320
+
321
+ /** Drop the per-chat backend override; the chat reverts to the default. */
322
+ export async function resetChatBackend(
323
+ chatId: string,
324
+ deps: ModelCommandDeps,
325
+ opts: { keepHistory?: boolean } = {},
326
+ ): Promise<CommandOutcome> {
327
+ const { backend: previous } = resolveChatBackendPair(chatId, deps);
328
+ await releaseChat(chatId);
329
+ setChatBackend(chatId, undefined);
330
+ handOffChatSession(chatId, previous, deps, opts.keepHistory === true);
331
+ const { backendId } = resolveChatBackendPair(chatId, deps);
332
+ return {
333
+ ok: true,
334
+ text: `Backend reset to default (${backendId}; ${await describeModelAfterSwitch(chatId, backendId, deps)}).`,
335
+ };
336
+ }
337
+
338
+ export type EffortOverview = {
339
+ current: string;
340
+ levels: ReasoningEffortLevel[];
341
+ activeModel: string | null;
342
+ backendId: string;
343
+ };
344
+
345
+ /** Current effort and the levels the active model accepts. */
346
+ export async function describeChatEffort(
347
+ chatId: string,
348
+ deps: ModelCommandDeps,
349
+ ): Promise<EffortOverview> {
350
+ const { backend, backendId } = resolveChatBackendPair(chatId, deps);
351
+ const reasoning = await getActiveReasoningLevels({
352
+ chatId,
353
+ backend,
354
+ backendId,
355
+ config: deps.config,
356
+ });
357
+ return {
358
+ current: displayReasoningEffort(
359
+ getChatSettings(chatId).effort,
360
+ reasoning.levels,
361
+ ),
362
+ levels: reasoning.levels,
363
+ activeModel: reasoning.activeModel,
364
+ backendId,
365
+ };
366
+ }
367
+
368
+ /** `/effort <level>` — validated against the active model's levels. */
369
+ export async function setChatEffortLevel(
370
+ chatId: string,
371
+ arg: string,
372
+ deps: ModelCommandDeps,
373
+ ): Promise<CommandOutcome> {
374
+ const level = arg.trim().toLowerCase();
375
+ if (level === "reset" || level === "default" || level === "adaptive") {
376
+ setChatEffort(chatId, undefined);
377
+ return {
378
+ ok: true,
379
+ text: "Effort reset to adaptive (model decides when to think).",
380
+ };
381
+ }
382
+ const { levels, activeModel, backendId } = await describeChatEffort(
383
+ chatId,
384
+ deps,
385
+ );
386
+ if (levels.length === 0) {
387
+ return {
388
+ ok: false,
389
+ text: `No reasoning levels available for ${activeModel ?? "the active model"} on backend ${backendId}.`,
390
+ };
391
+ }
392
+ if (!supportsReasoningLevel(level, levels)) {
393
+ return {
394
+ ok: false,
395
+ text: `Unknown level for this model. Valid: ${levels.join(", ")}, or adaptive.`,
396
+ };
397
+ }
398
+ setChatEffort(chatId, level as EffortLevel);
399
+ return { ok: true, text: `Effort set to ${level}.` };
400
+ }
@@ -7,7 +7,8 @@ import { readFileSync } from "node:fs";
7
7
  import type { TalonConfig } from "../../util/config.js";
8
8
  import { files, dirs } from "../../util/paths.js";
9
9
  import { tailFile } from "../../util/tail-file.js";
10
- import { escapeHtml } from "./formatting.js";
10
+ import { escapeHtml, splitMessage } from "./formatting.js";
11
+ import { TELEGRAM_MAX_TEXT } from "./actions/types.js";
11
12
  import { resetSession, getAllSessions } from "../../storage/sessions.js";
12
13
  import { clearHistory } from "../../storage/history.js";
13
14
  import { todayLogDate } from "../../storage/daily-log.js";
@@ -22,6 +23,72 @@ import { getPulseStatus } from "../../core/background/pulse.js";
22
23
  import { getHealthStatus, getRecentErrors } from "../../util/watchdog.js";
23
24
  import { formatDuration, formatModelLabel } from "./helpers/index.js";
24
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(
42
+ ctx: Context,
43
+ bot: Bot,
44
+ 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"),
89
+ );
90
+ }
91
+
25
92
  export async function handleAdminCommand(
26
93
  ctx: Context,
27
94
  bot: Bot,
@@ -32,52 +99,7 @@ export async function handleAdminCommand(
32
99
 
33
100
  switch (subcommand) {
34
101
  case "chats": {
35
- const sessions = getAllSessions();
36
- if (sessions.length === 0) {
37
- await ctx.reply("No active sessions.");
38
- return;
39
- }
40
- sessions.sort(
41
- (a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0),
42
- );
43
-
44
- const titles = new Map<string, string>();
45
- await Promise.all(
46
- sessions.map(async (s) => {
47
- try {
48
- const id = parseInt(s.chatId, 10);
49
- if (isNaN(id)) return;
50
- const chat = await bot.api.getChat(id);
51
- titles.set(
52
- s.chatId,
53
- "title" in chat
54
- ? (chat.title ?? "DM")
55
- : "first_name" in chat
56
- ? (chat.first_name ?? "DM")
57
- : "DM",
58
- );
59
- } catch {
60
- /* inaccessible */
61
- }
62
- }),
63
- );
64
-
65
- const lines = sessions.map((s) => {
66
- const age = s.info.lastActive
67
- ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
68
- : "?";
69
- const title = titles.get(s.chatId) ?? s.chatId;
70
- const model = formatModelLabel(
71
- getChatSettings(s.chatId).model ?? config.model,
72
- );
73
- // `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
74
- // it gets the same escaping the title already had.
75
- return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
76
- });
77
- await ctx.reply(
78
- `<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
79
- { parse_mode: "HTML" },
80
- );
102
+ await replyActiveChats(ctx, bot, config);
81
103
  return;
82
104
  }
83
105
 
@@ -189,9 +211,9 @@ export async function handleAdminCommand(
189
211
  : "?";
190
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}`;
191
213
  });
192
- await ctx.reply(
214
+ await replyHtmlChunked(
215
+ ctx,
193
216
  `<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"),
194
- { parse_mode: "HTML" },
195
217
  );
196
218
  return;
197
219
  }
@@ -217,9 +239,10 @@ export async function handleAdminCommand(
217
239
  return `${p.enabled ? "\u2713" : "\u2717"} ${escapeHtml(title)}`;
218
240
  }),
219
241
  );
220
- await ctx.reply(`<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"), {
221
- parse_mode: "HTML",
222
- });
242
+ await replyHtmlChunked(
243
+ ctx,
244
+ `<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"),
245
+ );
223
246
  return;
224
247
  }
225
248