@nordbyte/nordrelay 0.2.1

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 (45) hide show
  1. package/.env.example +88 -0
  2. package/Dockerfile +19 -0
  3. package/LICENSE +21 -0
  4. package/README.md +749 -0
  5. package/dist/access-control.js +146 -0
  6. package/dist/agent-factory.js +22 -0
  7. package/dist/agent.js +57 -0
  8. package/dist/artifacts.js +515 -0
  9. package/dist/attachments.js +69 -0
  10. package/dist/bot-preferences.js +146 -0
  11. package/dist/bot-ui.js +161 -0
  12. package/dist/bot.js +4520 -0
  13. package/dist/codex-auth.js +150 -0
  14. package/dist/codex-cli.js +79 -0
  15. package/dist/codex-config.js +50 -0
  16. package/dist/codex-launch.js +109 -0
  17. package/dist/codex-session.js +591 -0
  18. package/dist/codex-state.js +573 -0
  19. package/dist/config.js +385 -0
  20. package/dist/context-key.js +23 -0
  21. package/dist/error-messages.js +73 -0
  22. package/dist/format.js +121 -0
  23. package/dist/index.js +140 -0
  24. package/dist/logger.js +27 -0
  25. package/dist/operations.js +133 -0
  26. package/dist/persistence.js +65 -0
  27. package/dist/pi-cli.js +19 -0
  28. package/dist/pi-rpc.js +158 -0
  29. package/dist/pi-session.js +573 -0
  30. package/dist/pi-state.js +226 -0
  31. package/dist/prompt-store.js +241 -0
  32. package/dist/redaction.js +47 -0
  33. package/dist/session-format.js +191 -0
  34. package/dist/session-registry.js +195 -0
  35. package/dist/telegram-rate-limit.js +136 -0
  36. package/dist/voice.js +373 -0
  37. package/dist/workspace-policy.js +41 -0
  38. package/docker-compose.yml +17 -0
  39. package/launchd/start.sh +8 -0
  40. package/package.json +69 -0
  41. package/plugins/nordrelay/.codex-plugin/plugin.json +48 -0
  42. package/plugins/nordrelay/assets/nordrelay.svg +5 -0
  43. package/plugins/nordrelay/commands/remote.md +33 -0
  44. package/plugins/nordrelay/scripts/nordrelay.mjs +396 -0
  45. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +26 -0
package/dist/bot.js ADDED
@@ -0,0 +1,4520 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, unlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { autoRetry } from "@grammyjs/auto-retry";
6
+ import { Bot, InlineKeyboard, InputFile } from "grammy";
7
+ import { hasTelegramPermission, permissionForCallbackData, permissionForCommand, } from "./access-control.js";
8
+ import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
9
+ import { collectArtifactReport, collectRecentWorkspaceArtifacts, createArtifactZipBundle, ensureOutDir, formatArtifactSummary, getArtifactTurnReport, isTelegramImagePreview, listRecentArtifactReports, persistWorkspaceArtifactReport, pruneConnectorTurnDirs, removeArtifactTurn, telegramArtifactFilename, totalArtifactSize, } from "./artifacts.js";
10
+ import { formatSessionLabel, renderHelpMessage, renderWelcomeFirstTime, renderWelcomeReturning, } from "./bot-ui.js";
11
+ import { BotPreferencesStore, formatQuietHours, isQuietNow, parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
12
+ import { CODEX_REASONING_EFFORTS, CODEX_AGENT_CAPABILITIES, PI_THINKING_LEVELS, agentLabel, agentReasoningLabel, } from "./agent.js";
13
+ import { enabledAgents } from "./agent-factory.js";
14
+ import { checkAuthStatus, clearAuthCache, startLogin, startLogout } from "./codex-auth.js";
15
+ import { findLaunchProfile, formatLaunchProfileBehavior, formatLaunchProfileLabel, } from "./codex-launch.js";
16
+ import { getThreadActivity, getThreadActivityLog, getThreadRolloutSnapshot, } from "./codex-state.js";
17
+ import { contextKeyFromCtx, isTopicContextKey, parseContextKey } from "./context-key.js";
18
+ import { friendlyErrorText } from "./error-messages.js";
19
+ import { escapeHTML, formatTelegramHTML } from "./format.js";
20
+ import { getConnectorHealth, getUpdateLogPath, readConnectorState, readLogTail, spawnConnectorRestart, spawnSelfUpdate, } from "./operations.js";
21
+ import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
22
+ import { configureRedaction, redactText } from "./redaction.js";
23
+ import { formatFileSize, renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
24
+ import { SessionRegistry } from "./session-registry.js";
25
+ import { getAvailableBackends, transcribeAudio } from "./voice.js";
26
+ import { getTelegramRateLimitMetrics, telegramRateLimiter } from "./telegram-rate-limit.js";
27
+ import { evaluateWorkspacePolicy, filterAllowedWorkspaces, renderWorkspacePolicyLine, } from "./workspace-policy.js";
28
+ const TELEGRAM_MESSAGE_LIMIT = 4000;
29
+ const EDIT_DEBOUNCE_MS = 1500;
30
+ const TYPING_INTERVAL_MS = 4500;
31
+ const TOOL_OUTPUT_PREVIEW_LIMIT = 500;
32
+ const STREAMING_PREVIEW_LIMIT = 3800;
33
+ const FORMATTED_CHUNK_TARGET = 3000;
34
+ const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
35
+ const MEDIA_GROUP_FLUSH_MS = 1200;
36
+ const KEYBOARD_PAGE_SIZE = 6;
37
+ const NOOP_PAGE_CALLBACK_DATA = "noop_page";
38
+ const LAUNCH_PROFILES_COMMAND = "/launch_profiles";
39
+ function paginateKeyboard(items, page, prefix) {
40
+ const totalPages = Math.max(1, Math.ceil(items.length / KEYBOARD_PAGE_SIZE));
41
+ const currentPage = Math.min(Math.max(page, 0), totalPages - 1);
42
+ const start = currentPage * KEYBOARD_PAGE_SIZE;
43
+ const pageItems = items.slice(start, start + KEYBOARD_PAGE_SIZE);
44
+ const keyboard = new InlineKeyboard();
45
+ pageItems.forEach((item, index) => {
46
+ keyboard.text(item.label, item.callbackData);
47
+ if (index < pageItems.length - 1 || totalPages > 1) {
48
+ keyboard.row();
49
+ }
50
+ });
51
+ if (totalPages > 1) {
52
+ if (currentPage > 0) {
53
+ keyboard.text("◀️ Prev", `${prefix}_page_${currentPage - 1}`);
54
+ }
55
+ keyboard.text(`${currentPage + 1}/${totalPages}`, NOOP_PAGE_CALLBACK_DATA);
56
+ if (currentPage < totalPages - 1) {
57
+ keyboard.text("Next ▶️", `${prefix}_page_${currentPage + 1}`);
58
+ }
59
+ }
60
+ return keyboard;
61
+ }
62
+ export function createBot(config, registry) {
63
+ configureRedaction(config.telegramRedactPatterns);
64
+ telegramRateLimiter.configure({
65
+ minIntervalMs: config.telegramRateLimitMinIntervalMs,
66
+ editMinIntervalMs: config.telegramEditMinIntervalMs,
67
+ maxRetries: 5,
68
+ });
69
+ const bot = new Bot(config.telegramBotToken);
70
+ bot.api.config.use(autoRetry({ maxRetryAttempts: 3, maxDelaySeconds: 10 }));
71
+ const contextBusy = new Map();
72
+ const pendingApprovals = new Map();
73
+ const pendingSessionPicks = new Map();
74
+ const pendingWorkspacePicks = new Map();
75
+ const pendingSessionButtons = new Map();
76
+ const pendingWorkspaceButtons = new Map();
77
+ const pendingLaunchPicks = new Map();
78
+ const pendingLaunchButtons = new Map();
79
+ const pendingUnsafeLaunchConfirmations = new Map();
80
+ const pendingModelButtons = new Map();
81
+ const pendingEffortButtons = new Map();
82
+ const pendingAgentPicks = new Map();
83
+ const pendingMediaGroups = new Map();
84
+ const turnProgress = new Map();
85
+ const promptStore = new PromptStore(config.workspace);
86
+ const preferencesStore = new BotPreferencesStore(config.workspace);
87
+ const drainingQueues = new Set();
88
+ const externalQueueTimers = new Map();
89
+ const externalMirrors = new Map();
90
+ const queueStatusMessages = new Map();
91
+ const syncInterval = config.codexSyncIntervalMs > 0
92
+ ? setInterval(() => {
93
+ try {
94
+ registry.syncAllFromCodexState({ reattach: true });
95
+ }
96
+ catch (error) {
97
+ console.error("Failed to sync sessions from Codex state:", error);
98
+ }
99
+ }, config.codexSyncIntervalMs)
100
+ : undefined;
101
+ syncInterval?.unref?.();
102
+ const externalMonitorInterval = setInterval(() => {
103
+ void monitorExternalContexts().catch((error) => {
104
+ console.error("Failed to monitor external Codex activity:", error);
105
+ });
106
+ }, config.codexExternalBusyCheckMs);
107
+ externalMonitorInterval.unref?.();
108
+ setTimeout(() => {
109
+ void monitorExternalContexts().catch((error) => {
110
+ console.error("Failed to run initial external Codex monitor:", error);
111
+ });
112
+ }, 0).unref?.();
113
+ registry.onRemove((key) => {
114
+ contextBusy.delete(key);
115
+ turnProgress.delete(key);
116
+ externalMirrors.delete(key);
117
+ queueStatusMessages.delete(key);
118
+ const externalQueueTimer = externalQueueTimers.get(key);
119
+ if (externalQueueTimer) {
120
+ clearTimeout(externalQueueTimer);
121
+ externalQueueTimers.delete(key);
122
+ }
123
+ pendingLaunchPicks.delete(key);
124
+ pendingLaunchButtons.delete(key);
125
+ pendingUnsafeLaunchConfirmations.delete(key);
126
+ pendingAgentPicks.delete(key);
127
+ for (const [mediaGroupKey, mediaGroup] of pendingMediaGroups.entries()) {
128
+ if (mediaGroup.contextKey === key) {
129
+ clearTimeout(mediaGroup.timer);
130
+ pendingMediaGroups.delete(mediaGroupKey);
131
+ }
132
+ }
133
+ promptStore.clear(key);
134
+ for (const [approvalId, approval] of pendingApprovals.entries()) {
135
+ if (approval.contextKey === key) {
136
+ clearTimeout(approval.timeout);
137
+ pendingApprovals.delete(approvalId);
138
+ }
139
+ }
140
+ });
141
+ const getBusyState = (contextKey) => {
142
+ let state = contextBusy.get(contextKey);
143
+ if (!state) {
144
+ state = { processing: false, switching: false, transcribing: false, approving: false };
145
+ contextBusy.set(contextKey, state);
146
+ }
147
+ return state;
148
+ };
149
+ const getExternalActivity = (session) => {
150
+ const info = session?.getInfo();
151
+ if (!info || !capabilitiesOf(info).externalActivity) {
152
+ return null;
153
+ }
154
+ const threadId = session?.getActiveThreadId();
155
+ if (!threadId) {
156
+ return null;
157
+ }
158
+ return getThreadActivity(threadId, {
159
+ staleAfterMs: config.codexExternalBusyStaleMs,
160
+ });
161
+ };
162
+ const getBusyReason = (contextKey) => {
163
+ const state = contextBusy.get(contextKey);
164
+ const session = registry.get(contextKey);
165
+ if (state?.processing || state?.switching || state?.transcribing || state?.approving || session?.isProcessing()) {
166
+ return { busy: true, kind: "connector", state: state ?? getBusyState(contextKey) };
167
+ }
168
+ const activity = getExternalActivity(session);
169
+ if (activity?.active) {
170
+ return { busy: true, kind: "external", activity };
171
+ }
172
+ return { busy: false, kind: "idle" };
173
+ };
174
+ const isBusy = (contextKey) => {
175
+ return getBusyReason(contextKey).busy;
176
+ };
177
+ const getContextSession = async (ctx, options) => {
178
+ const contextKey = contextKeyFromCtx(ctx);
179
+ if (!contextKey) {
180
+ return null;
181
+ }
182
+ const session = await registry.getOrCreate(contextKey, options);
183
+ return { contextKey, session };
184
+ };
185
+ const updateSessionMetadata = (contextKey, session) => {
186
+ registry.updateMetadata(contextKey, session);
187
+ };
188
+ const isTopicContext = (contextKey) => isTopicContextKey(contextKey);
189
+ const getPreferences = (contextKey) => preferencesStore.get(contextKey);
190
+ const getEffectiveMirrorMode = (contextKey) => getPreferences(contextKey).mirrorMode ?? config.telegramMirrorMode;
191
+ const getEffectiveNotifyMode = (contextKey) => getPreferences(contextKey).notifyMode ?? config.telegramNotifyMode;
192
+ const getEffectiveQuietHours = (contextKey) => getPreferences(contextKey).quietHours === undefined
193
+ ? config.telegramQuietHours
194
+ : getPreferences(contextKey).quietHours;
195
+ const shouldNotify = (contextKey, level) => {
196
+ const mode = getEffectiveNotifyMode(contextKey);
197
+ if (mode === "off" || isQuietNow(getEffectiveQuietHours(contextKey))) {
198
+ return false;
199
+ }
200
+ return mode === "all" || level === "minimal";
201
+ };
202
+ const getEffectiveVoiceBackend = (contextKey) => getPreferences(contextKey).voiceBackend ?? config.voicePreferredBackend;
203
+ const getEffectiveVoiceLanguage = (contextKey) => getPreferences(contextKey).voiceLanguage === undefined
204
+ ? config.voiceDefaultLanguage ?? null
205
+ : getPreferences(contextKey).voiceLanguage ?? null;
206
+ const isVoiceTranscribeOnly = (contextKey) => getPreferences(contextKey).voiceTranscribeOnly ?? config.voiceTranscribeOnly;
207
+ const clearLaunchSelectionState = (contextKey) => {
208
+ pendingLaunchPicks.delete(contextKey);
209
+ pendingLaunchButtons.delete(contextKey);
210
+ pendingUnsafeLaunchConfirmations.delete(contextKey);
211
+ };
212
+ const handlePageCallback = (pattern, prefix, buttonsMap, expiredMessage) => {
213
+ bot.callbackQuery(pattern, async (ctx) => {
214
+ const ctxKey = contextKeyFromCtx(ctx);
215
+ const messageId = ctx.callbackQuery.message?.message_id;
216
+ const page = Number.parseInt(ctx.match?.[1] ?? "", 10);
217
+ if (!ctxKey || !messageId || Number.isNaN(page)) {
218
+ await ctx.answerCallbackQuery();
219
+ return;
220
+ }
221
+ const chatId = ctx.chat?.id;
222
+ if (!chatId) {
223
+ await ctx.answerCallbackQuery();
224
+ return;
225
+ }
226
+ const buttons = buttonsMap.get(ctxKey);
227
+ if (!buttons) {
228
+ await ctx.answerCallbackQuery({ text: expiredMessage });
229
+ return;
230
+ }
231
+ await ctx.answerCallbackQuery();
232
+ try {
233
+ const keyboard = paginateKeyboard(buttons, page, prefix);
234
+ await safeEditReplyMarkup(bot, chatId, messageId, keyboard);
235
+ }
236
+ catch (error) {
237
+ if (!isMessageNotModifiedError(error)) {
238
+ console.error(`Failed to update ${prefix} keyboard page`, error);
239
+ }
240
+ }
241
+ });
242
+ };
243
+ const sendBusyReply = async (ctx) => {
244
+ await safeReply(ctx, escapeHTML("Still working on previous message..."), {
245
+ fallbackText: "Still working on previous message...",
246
+ });
247
+ };
248
+ const queueCancelCallbackData = (action, contextKey, queueId) => `queue_${action}:${contextKey}:${queueId}`;
249
+ const createQueuedPromptCancelKeyboard = (contextKey, queueId, label = "Cancel queued message") => new InlineKeyboard().text(label, queueCancelCallbackData("cancel", contextKey, queueId));
250
+ const renderQueueList = (contextKey, queue) => {
251
+ const paused = promptStore.isPaused(contextKey);
252
+ if (queue.length === 0) {
253
+ return {
254
+ plain: paused ? "Queue is empty and paused." : "Queue is empty.",
255
+ html: escapeHTML(paused ? "Queue is empty and paused." : "Queue is empty."),
256
+ };
257
+ }
258
+ const lines = queue.map((item, index) => {
259
+ const age = formatRelativeTime(new Date(item.createdAt));
260
+ const attempts = item.attempts && item.attempts > 0 ? ` · attempts ${item.attempts}` : "";
261
+ const error = item.lastError ? ` · last error: ${trimLine(item.lastError, 80)}` : "";
262
+ const eta = index === 0 ? "next" : `after ${index} queued item${index === 1 ? "" : "s"}`;
263
+ return `${index + 1}. ${item.id} · ${age} · ${eta}${attempts}${error} · ${item.description}`;
264
+ });
265
+ const keyboard = new InlineKeyboard();
266
+ queue.forEach((item, index) => {
267
+ keyboard
268
+ .text(`Run ${index + 1}`, queueCancelCallbackData("run", contextKey, item.id))
269
+ .text("Top", queueCancelCallbackData("top", contextKey, item.id))
270
+ .text("Cancel", queueCancelCallbackData("remove", contextKey, item.id))
271
+ .row();
272
+ keyboard
273
+ .text("Up", queueCancelCallbackData("up", contextKey, item.id))
274
+ .text("Down", queueCancelCallbackData("down", contextKey, item.id))
275
+ .row();
276
+ });
277
+ return {
278
+ plain: [paused ? "Queued prompts (paused):" : "Queued prompts:", ...lines].join("\n"),
279
+ html: [paused ? "<b>Queued prompts:</b> <code>paused</code>" : "<b>Queued prompts:</b>", ...lines.map(escapeHTML)].join("\n"),
280
+ keyboard,
281
+ };
282
+ };
283
+ const createSystemContext = (contextKey) => {
284
+ const parsed = parseContextKey(contextKey);
285
+ return {
286
+ api: bot.api,
287
+ chat: { id: parsed.chatId, type: "private" },
288
+ message: parsed.messageThreadId ? { message_thread_id: parsed.messageThreadId } : undefined,
289
+ };
290
+ };
291
+ const updateQueueStatusMessage = async (contextKey, text) => {
292
+ const parsed = parseContextKey(contextKey);
293
+ const html = escapeHTML(text);
294
+ const state = queueStatusMessages.get(contextKey) ?? {};
295
+ if (state.lastText === text && state.messageId) {
296
+ return;
297
+ }
298
+ if (!state.messageId) {
299
+ const message = await sendTextMessage(bot.api, parsed.chatId, html, {
300
+ fallbackText: text,
301
+ messageThreadId: parsed.messageThreadId,
302
+ });
303
+ state.messageId = message.message_id;
304
+ state.lastText = text;
305
+ queueStatusMessages.set(contextKey, state);
306
+ return;
307
+ }
308
+ await safeEditMessage(bot, parsed.chatId, state.messageId, html, { fallbackText: text });
309
+ state.lastText = text;
310
+ queueStatusMessages.set(contextKey, state);
311
+ };
312
+ const maybeRequeuePromptAtFront = (contextKey, prompt) => {
313
+ if (isQueuedPromptLike(prompt)) {
314
+ promptStore.enqueueFront(contextKey, prompt);
315
+ return prompt;
316
+ }
317
+ const item = promptStore.enqueue(contextKey, prompt);
318
+ promptStore.moveToTop(contextKey, item.id);
319
+ return item;
320
+ };
321
+ const monitorExternalContexts = async () => {
322
+ const contextKeys = new Set([
323
+ ...registry.listContexts().map((context) => context.contextKey),
324
+ ...promptStore.listContextKeys(),
325
+ ]);
326
+ for (const contextKey of contextKeys) {
327
+ await monitorExternalContext(contextKey);
328
+ }
329
+ };
330
+ const monitorExternalContext = async (contextKey) => {
331
+ const session = await registry.getOrCreate(contextKey, { deferThreadStart: true }).catch(() => null);
332
+ if (!session) {
333
+ return;
334
+ }
335
+ const info = session.getInfo();
336
+ if (!capabilitiesOf(info).externalActivity) {
337
+ const parsed = parseContextKey(contextKey);
338
+ const queueLength = promptStore.list(contextKey).length;
339
+ if (queueLength > 0 && !promptStore.isPaused(contextKey) && !session.isProcessing()) {
340
+ await drainQueuedPrompts(createSystemContext(contextKey), contextKey, parsed.chatId, session);
341
+ }
342
+ return;
343
+ }
344
+ const threadId = session.getActiveThreadId();
345
+ const parsed = parseContextKey(contextKey);
346
+ const queueLength = promptStore.list(contextKey).length;
347
+ const paused = promptStore.isPaused(contextKey);
348
+ if (!threadId) {
349
+ if (queueLength > 0 && !paused && !session.isProcessing()) {
350
+ await drainQueuedPrompts(createSystemContext(contextKey), contextKey, parsed.chatId, session);
351
+ }
352
+ return;
353
+ }
354
+ const previous = externalMirrors.get(contextKey);
355
+ const snapshot = getThreadRolloutSnapshot(threadId, {
356
+ afterLine: previous?.lastLine ?? Number.MAX_SAFE_INTEGER,
357
+ staleAfterMs: config.codexExternalBusyStaleMs,
358
+ }) ?? getThreadRolloutSnapshot(threadId, {
359
+ staleAfterMs: config.codexExternalBusyStaleMs,
360
+ maxEvents: 0,
361
+ });
362
+ if (!snapshot) {
363
+ if (queueLength > 0 && !paused && !session.isProcessing()) {
364
+ await drainQueuedPrompts(createSystemContext(contextKey), contextKey, parsed.chatId, session);
365
+ }
366
+ return;
367
+ }
368
+ if (!session.isProcessing()) {
369
+ await mirrorExternalSnapshot(contextKey, parsed.chatId, session, snapshot);
370
+ }
371
+ const activity = snapshot.activity;
372
+ if (activity.active && queueLength > 0) {
373
+ await updateQueueStatusMessage(contextKey, `Waiting for Codex CLI task... ${queueLength} queued${paused ? " (paused)" : ""}.`);
374
+ return;
375
+ }
376
+ if (!activity.active && queueLength > 0 && !paused && !session.isProcessing()) {
377
+ await updateQueueStatusMessage(contextKey, `CLI task finished, running queued prompt 1/${queueLength}.`);
378
+ await drainQueuedPrompts(createSystemContext(contextKey), contextKey, parsed.chatId, session);
379
+ }
380
+ };
381
+ const mirrorExternalSnapshot = async (contextKey, chatId, session, snapshot) => {
382
+ const parsed = parseContextKey(contextKey);
383
+ const previous = externalMirrors.get(contextKey);
384
+ let state = previous;
385
+ if (!state || state.threadId !== snapshot.threadId || state.rolloutPath !== snapshot.rolloutPath) {
386
+ state = {
387
+ threadId: snapshot.threadId,
388
+ rolloutPath: snapshot.rolloutPath,
389
+ lastLine: snapshot.lineCount,
390
+ turnId: snapshot.activity.turnId,
391
+ startedAt: snapshot.activity.startedAt,
392
+ };
393
+ externalMirrors.set(contextKey, state);
394
+ }
395
+ const mirrorMode = getEffectiveMirrorMode(contextKey);
396
+ if (snapshot.activity.active) {
397
+ state.turnId = snapshot.activity.turnId;
398
+ state.startedAt = snapshot.activity.startedAt;
399
+ if (mirrorMode === "off" || mirrorMode === "final") {
400
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
401
+ return;
402
+ }
403
+ const status = renderExternalMirrorStatus(snapshot, promptStore.list(contextKey).length);
404
+ const now = Date.now();
405
+ const canUpdateStatus = !state.latestStatusAt || now - state.latestStatusAt >= config.telegramMirrorMinUpdateMs;
406
+ if (!state.statusMessageId) {
407
+ const message = await sendTextMessage(bot.api, chatId, status.html, {
408
+ fallbackText: status.plain,
409
+ messageThreadId: parsed.messageThreadId,
410
+ });
411
+ state.statusMessageId = message.message_id;
412
+ state.latestStatusAt = now;
413
+ }
414
+ else if (state.latestStatus !== status.plain && canUpdateStatus) {
415
+ await safeEditMessage(bot, chatId, state.statusMessageId, status.html, {
416
+ fallbackText: status.plain,
417
+ });
418
+ state.latestStatusAt = now;
419
+ }
420
+ state.latestStatus = status.plain;
421
+ if (mirrorMode === "full") {
422
+ const newEvents = snapshot.events
423
+ .filter((event) => event.lineNumber > (state.latestMirroredEventLine ?? state.lastLine))
424
+ .filter((event) => event.kind === "tool" || event.kind === "task")
425
+ .slice(-4);
426
+ for (const event of newEvents) {
427
+ const rendered = renderExternalMirrorEvent(event);
428
+ if (!rendered) {
429
+ continue;
430
+ }
431
+ await sendTextMessage(bot.api, chatId, rendered.html, {
432
+ fallbackText: rendered.plain,
433
+ messageThreadId: parsed.messageThreadId,
434
+ });
435
+ state.latestMirroredEventLine = event.lineNumber;
436
+ }
437
+ }
438
+ await sendChatActionSafe(bot.api, chatId, "typing", parsed.messageThreadId).catch(() => { });
439
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
440
+ return;
441
+ }
442
+ if (!previous) {
443
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
444
+ return;
445
+ }
446
+ const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
447
+ if (terminalEvent) {
448
+ if (mirrorMode !== "off") {
449
+ const doneText = `Codex CLI task ${terminalEvent.status}.`;
450
+ if (state.statusMessageId) {
451
+ await safeEditMessage(bot, chatId, state.statusMessageId, escapeHTML(doneText), {
452
+ fallbackText: doneText,
453
+ });
454
+ }
455
+ else if (shouldNotify(contextKey, "minimal")) {
456
+ await sendTextMessage(bot.api, chatId, escapeHTML(doneText), {
457
+ fallbackText: doneText,
458
+ messageThreadId: parsed.messageThreadId,
459
+ });
460
+ }
461
+ }
462
+ const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
463
+ if (mirrorMode !== "off" && mirrorMode !== "status" && finalAgent?.text && finalAgent.lineNumber !== state.latestAgentLine) {
464
+ await sendTextMessage(bot.api, chatId, "<b>Codex CLI final answer:</b>", {
465
+ fallbackText: "Codex CLI final answer:",
466
+ messageThreadId: parsed.messageThreadId,
467
+ });
468
+ for (const chunk of splitMarkdownForTelegram(finalAgent.text)) {
469
+ await sendTextMessage(bot.api, chatId, chunk.text, {
470
+ parseMode: chunk.parseMode,
471
+ fallbackText: chunk.fallbackText,
472
+ messageThreadId: parsed.messageThreadId,
473
+ });
474
+ }
475
+ state.latestAgentLine = finalAgent.lineNumber;
476
+ }
477
+ await deliverCliGeneratedArtifacts(contextKey, chatId, session, state.startedAt, terminalEvent.turnId, parsed.messageThreadId);
478
+ }
479
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
480
+ };
481
+ const deliverCliGeneratedArtifacts = async (contextKey, chatId, session, startedAt, turnId, messageThreadId) => {
482
+ if (!startedAt || !turnId) {
483
+ return;
484
+ }
485
+ const state = externalMirrors.get(contextKey);
486
+ if (state?.artifactsDeliveredForTurnId === turnId) {
487
+ return;
488
+ }
489
+ const workspace = session.getInfo().workspace;
490
+ const report = await collectRecentWorkspaceArtifacts(workspace, {
491
+ since: startedAt,
492
+ until: new Date(),
493
+ maxFileSize: config.maxFileSize,
494
+ limit: 5,
495
+ ignoreDirs: config.artifactIgnoreDirs,
496
+ ignoreGlobs: config.artifactIgnoreGlobs,
497
+ });
498
+ if (isEmptyArtifactReport(report)) {
499
+ if (state)
500
+ state.artifactsDeliveredForTurnId = turnId;
501
+ return;
502
+ }
503
+ const persistedReport = await persistWorkspaceArtifactReport(workspace, turnId, report).catch((error) => {
504
+ console.error("Failed to persist CLI artifact report:", error);
505
+ return null;
506
+ });
507
+ if (!config.telegramAutoSendArtifacts) {
508
+ if (state)
509
+ state.artifactsDeliveredForTurnId = turnId;
510
+ return;
511
+ }
512
+ const summary = formatArtifactSummary(report.artifacts, report.skippedCount, report.omittedCount);
513
+ await sendTextMessage(bot.api, chatId, escapeHTML(summary), {
514
+ fallbackText: summary,
515
+ messageThreadId,
516
+ });
517
+ for (const artifact of (persistedReport?.artifacts ?? report.artifacts)) {
518
+ await sendArtifactFileByApi(bot.api, chatId, artifact, messageThreadId);
519
+ }
520
+ if (state)
521
+ state.artifactsDeliveredForTurnId = turnId;
522
+ };
523
+ const scheduleExternalQueueDrain = (ctx, contextKey, chatId, session) => {
524
+ if (externalQueueTimers.has(contextKey)) {
525
+ return;
526
+ }
527
+ const timer = setTimeout(() => {
528
+ externalQueueTimers.delete(contextKey);
529
+ void (async () => {
530
+ if (promptStore.list(contextKey).length === 0) {
531
+ return;
532
+ }
533
+ const busy = getBusyReason(contextKey);
534
+ if (busy.kind === "external") {
535
+ await updateQueueStatusMessage(contextKey, `Waiting for Codex CLI task... ${promptStore.list(contextKey).length} queued${promptStore.isPaused(contextKey) ? " (paused)" : ""}.`);
536
+ scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
537
+ return;
538
+ }
539
+ if (busy.busy) {
540
+ return;
541
+ }
542
+ await updateQueueStatusMessage(contextKey, `CLI task finished, running queued prompt 1/${promptStore.list(contextKey).length}.`);
543
+ await drainQueuedPrompts(ctx, contextKey, chatId, session);
544
+ })().catch((error) => {
545
+ console.error("Failed to drain queue after external Codex activity:", error);
546
+ });
547
+ }, config.codexExternalBusyCheckMs);
548
+ timer.unref?.();
549
+ externalQueueTimers.set(contextKey, timer);
550
+ };
551
+ const getUserRole = (ctx) => {
552
+ const fromId = ctx.from?.id;
553
+ if (fromId !== undefined && config.telegramAdminUserIdSet.has(fromId)) {
554
+ return "admin";
555
+ }
556
+ if (fromId !== undefined && config.telegramReadOnlyUserIdSet.has(fromId)) {
557
+ return "readonly";
558
+ }
559
+ return "operator";
560
+ };
561
+ const getRequiredPermission = (ctx) => {
562
+ if (ctx.callbackQuery?.data) {
563
+ return permissionForCallbackData(ctx.callbackQuery.data);
564
+ }
565
+ if (ctx.message?.voice || ctx.message?.audio || ctx.message?.photo || ctx.message?.document) {
566
+ return "files";
567
+ }
568
+ const text = ctx.message?.text?.trim();
569
+ if (!text) {
570
+ return "inspect";
571
+ }
572
+ if (!text.startsWith("/")) {
573
+ return "prompt";
574
+ }
575
+ const command = extractCommandName(text);
576
+ if (command === "queue") {
577
+ const argument = text.replace(/^\/queue(?:@\w+)?\s*/i, "").trim();
578
+ return argument ? "prompt" : "inspect";
579
+ }
580
+ return permissionForCommand(command);
581
+ };
582
+ const setReaction = async (ctx, emoji) => {
583
+ if (!config.enableTelegramReactions) {
584
+ return;
585
+ }
586
+ try {
587
+ const chatId = ctx.chat?.id;
588
+ const messageId = ctx.message?.message_id;
589
+ if (!chatId || !messageId)
590
+ return;
591
+ await ctx.api.setMessageReaction(chatId, messageId, [{ type: "emoji", emoji }]);
592
+ }
593
+ catch {
594
+ // Reactions may not be available in all chats — fail silently.
595
+ }
596
+ };
597
+ const clearReaction = async (ctx) => {
598
+ if (!config.enableTelegramReactions) {
599
+ return;
600
+ }
601
+ try {
602
+ const chatId = ctx.chat?.id;
603
+ const messageId = ctx.message?.message_id;
604
+ if (!chatId || !messageId)
605
+ return;
606
+ await ctx.api.setMessageReaction(chatId, messageId, []);
607
+ }
608
+ catch {
609
+ // Fail silently.
610
+ }
611
+ };
612
+ const ensureActiveThread = async (ctx, contextKey, session) => {
613
+ if (session.hasActiveThread()) {
614
+ return true;
615
+ }
616
+ try {
617
+ await session.newThread();
618
+ updateSessionMetadata(contextKey, session);
619
+ return true;
620
+ }
621
+ catch (error) {
622
+ await safeReply(ctx, escapeHTML(`Failed to create thread: ${friendlyErrorText(error)}`), {
623
+ fallbackText: `Failed to create thread: ${friendlyErrorText(error)}`,
624
+ });
625
+ return false;
626
+ }
627
+ };
628
+ const requestTurnApproval = async (ctx, contextKey, prompt) => {
629
+ const approvalId = randomUUID().slice(0, 8);
630
+ const busyState = getBusyState(contextKey);
631
+ busyState.approving = true;
632
+ const timeout = setTimeout(() => {
633
+ const pending = pendingApprovals.get(approvalId);
634
+ if (!pending) {
635
+ return;
636
+ }
637
+ pendingApprovals.delete(approvalId);
638
+ getBusyState(contextKey).approving = false;
639
+ const parsed = parseContextKey(contextKey);
640
+ void sendTextMessage(bot.api, parsed.chatId, `Approval timed out for prompt ${approvalId}.`, {
641
+ messageThreadId: parsed.messageThreadId,
642
+ }).catch((error) => {
643
+ console.error("Failed to send approval timeout message:", error);
644
+ });
645
+ }, 5 * 60 * 1000);
646
+ pendingApprovals.set(approvalId, {
647
+ contextKey,
648
+ prompt,
649
+ requestedBy: ctx.from?.id,
650
+ timeout,
651
+ });
652
+ const keyboard = new InlineKeyboard()
653
+ .text("Approve once", `approval_yes:${approvalId}`)
654
+ .row()
655
+ .text("Deny", `approval_no:${approvalId}`);
656
+ const plain = [
657
+ `Approval required for prompt ${approvalId}.`,
658
+ `Prompt: ${prompt.description}`,
659
+ "This is required because the current launch profile is review/unsafe.",
660
+ ].join("\n");
661
+ const html = [
662
+ `<b>Approval required</b> <code>${escapeHTML(approvalId)}</code>`,
663
+ `<b>Prompt:</b> ${escapeHTML(prompt.description)}`,
664
+ "This is required because the current launch profile is review/unsafe.",
665
+ ].join("\n");
666
+ await safeReply(ctx, html, { fallbackText: plain, replyMarkup: keyboard });
667
+ };
668
+ const handleUserPrompt = async (ctx, contextKey, chatId, session, prompt, options = {}) => {
669
+ const parsed = parseContextKey(contextKey);
670
+ const messageThreadId = parsed.messageThreadId;
671
+ const envelope = isPromptEnvelopeLike(prompt) ? prompt : toPromptEnvelope(prompt);
672
+ const busy = getBusyReason(contextKey);
673
+ if (busy.busy) {
674
+ if (options.fromQueue) {
675
+ maybeRequeuePromptAtFront(contextKey, envelope);
676
+ if (busy.kind === "external") {
677
+ scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
678
+ }
679
+ await sendBusyReply(ctx);
680
+ return;
681
+ }
682
+ const item = promptStore.enqueue(contextKey, envelope);
683
+ const position = promptStore.list(contextKey).findIndex((queued) => queued.id === item.id) + 1;
684
+ const label = labelOf(session.getInfo());
685
+ const queuedMessage = busy.kind === "external"
686
+ ? `Queued prompt ${item.id} at position ${position}. The ${label} session is still active and is processing a previous task.`
687
+ : `Queued prompt ${item.id} at position ${position}.`;
688
+ await safeReply(ctx, escapeHTML(queuedMessage), {
689
+ fallbackText: queuedMessage,
690
+ replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
691
+ });
692
+ if (busy.kind === "external") {
693
+ scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
694
+ }
695
+ return;
696
+ }
697
+ if (!options.approved && requiresTurnApproval(session.getInfo())) {
698
+ await requestTurnApproval(ctx, contextKey, envelope);
699
+ return;
700
+ }
701
+ const busyState = getBusyState(contextKey);
702
+ busyState.processing = true;
703
+ const progress = {
704
+ status: "running",
705
+ promptDescription: envelope.description,
706
+ startedAt: Date.now(),
707
+ updatedAt: Date.now(),
708
+ toolCounts: new Map(),
709
+ textCharacters: 0,
710
+ };
711
+ turnProgress.set(contextKey, progress);
712
+ const abortKeyboard = new InlineKeyboard().text("⏹ Abort", `agent_abort:${contextKey}`);
713
+ const toolVerbosity = config.toolVerbosity;
714
+ const toolStates = new Map();
715
+ const toolCounts = new Map();
716
+ let accumulatedText = "";
717
+ let responseMessageId;
718
+ let responseMessagePromise;
719
+ let lastRenderedText = "";
720
+ let lastEditAt = 0;
721
+ let flushTimer;
722
+ let isFlushing = false;
723
+ let flushPending = false;
724
+ let finalized = false;
725
+ let planMessageId;
726
+ let lastRenderedPlan = "";
727
+ let planMessageSending = false;
728
+ let lastTurnUsage;
729
+ const typingInterval = setInterval(() => {
730
+ void sendChatActionSafe(bot.api, chatId, "typing", messageThreadId).catch(() => { });
731
+ }, TYPING_INTERVAL_MS);
732
+ void sendChatActionSafe(bot.api, chatId, "typing", messageThreadId).catch(() => { });
733
+ const stopTyping = () => {
734
+ clearInterval(typingInterval);
735
+ };
736
+ const clearFlushTimer = () => {
737
+ if (flushTimer) {
738
+ clearTimeout(flushTimer);
739
+ flushTimer = undefined;
740
+ }
741
+ };
742
+ const renderPreview = () => {
743
+ const previewText = buildStreamingPreview(accumulatedText);
744
+ return renderMarkdownChunkWithinLimit(previewText);
745
+ };
746
+ const buildFinalResponseText = (text) => {
747
+ const trimmedText = text.trim();
748
+ const usageLine = config.showTurnTokenUsage && lastTurnUsage ? formatTurnUsageLine(lastTurnUsage) : "";
749
+ if (toolVerbosity === "summary") {
750
+ const footerLines = [formatToolSummaryLine(toolCounts), usageLine].filter((line) => Boolean(line));
751
+ if (footerLines.length === 0) {
752
+ return trimmedText;
753
+ }
754
+ const footer = footerLines.join("\n");
755
+ return trimmedText ? `${trimmedText}\n\n${footer}` : footer;
756
+ }
757
+ if (toolVerbosity === "all" && usageLine) {
758
+ return trimmedText ? `${trimmedText}\n\n${usageLine}` : usageLine;
759
+ }
760
+ return trimmedText;
761
+ };
762
+ const ensureResponseMessage = async () => {
763
+ if (responseMessageId) {
764
+ return;
765
+ }
766
+ if (responseMessagePromise) {
767
+ await responseMessagePromise;
768
+ return;
769
+ }
770
+ responseMessagePromise = (async () => {
771
+ const preview = renderPreview();
772
+ const message = await sendTextMessage(bot.api, chatId, preview.text, {
773
+ parseMode: preview.parseMode,
774
+ fallbackText: preview.fallbackText,
775
+ replyMarkup: abortKeyboard,
776
+ messageThreadId,
777
+ });
778
+ responseMessageId = message.message_id;
779
+ lastRenderedText = preview.text;
780
+ lastEditAt = Date.now();
781
+ })();
782
+ try {
783
+ await responseMessagePromise;
784
+ }
785
+ finally {
786
+ responseMessagePromise = undefined;
787
+ }
788
+ };
789
+ const flushResponse = async (force = false) => {
790
+ if (!accumulatedText) {
791
+ return;
792
+ }
793
+ if (!responseMessageId) {
794
+ await ensureResponseMessage();
795
+ return;
796
+ }
797
+ if (isFlushing) {
798
+ flushPending = true;
799
+ return;
800
+ }
801
+ const now = Date.now();
802
+ if (!force && now - lastEditAt < EDIT_DEBOUNCE_MS) {
803
+ return;
804
+ }
805
+ const nextText = renderPreview();
806
+ if (nextText.text === lastRenderedText) {
807
+ return;
808
+ }
809
+ isFlushing = true;
810
+ try {
811
+ await safeEditMessage(bot, chatId, responseMessageId, nextText.text, {
812
+ parseMode: nextText.parseMode,
813
+ fallbackText: nextText.fallbackText,
814
+ replyMarkup: abortKeyboard,
815
+ });
816
+ lastRenderedText = nextText.text;
817
+ lastEditAt = Date.now();
818
+ }
819
+ finally {
820
+ isFlushing = false;
821
+ if (flushPending) {
822
+ flushPending = false;
823
+ scheduleFlush();
824
+ }
825
+ }
826
+ };
827
+ const scheduleFlush = () => {
828
+ if (flushTimer || finalized) {
829
+ return;
830
+ }
831
+ const delay = Math.max(0, EDIT_DEBOUNCE_MS - (Date.now() - lastEditAt));
832
+ flushTimer = setTimeout(() => {
833
+ flushTimer = undefined;
834
+ void flushResponse().catch((error) => {
835
+ console.error("Failed to update Telegram response message", error);
836
+ });
837
+ }, delay);
838
+ };
839
+ const removeAbortKeyboard = async () => {
840
+ if (!responseMessageId) {
841
+ return;
842
+ }
843
+ try {
844
+ await safeEditReplyMarkup(bot, chatId, responseMessageId);
845
+ }
846
+ catch (error) {
847
+ if (!isMessageNotModifiedError(error)) {
848
+ console.error("Failed to clear Abort button", error);
849
+ }
850
+ }
851
+ };
852
+ const deliverRenderedChunks = async (chunks) => {
853
+ if (chunks.length === 0) {
854
+ return;
855
+ }
856
+ const [firstChunk, ...remainingChunks] = chunks;
857
+ if (responseMessageId) {
858
+ await safeEditMessage(bot, chatId, responseMessageId, firstChunk.text, {
859
+ parseMode: firstChunk.parseMode,
860
+ fallbackText: firstChunk.fallbackText,
861
+ });
862
+ await removeAbortKeyboard();
863
+ }
864
+ else {
865
+ const message = await sendTextMessage(bot.api, chatId, firstChunk.text, {
866
+ parseMode: firstChunk.parseMode,
867
+ fallbackText: firstChunk.fallbackText,
868
+ messageThreadId,
869
+ });
870
+ responseMessageId = message.message_id;
871
+ }
872
+ for (const chunk of remainingChunks) {
873
+ await sendTextMessage(bot.api, chatId, chunk.text, {
874
+ parseMode: chunk.parseMode,
875
+ fallbackText: chunk.fallbackText,
876
+ messageThreadId,
877
+ });
878
+ }
879
+ };
880
+ const finalizeResponse = async () => {
881
+ if (finalized) {
882
+ return;
883
+ }
884
+ finalized = true;
885
+ stopTyping();
886
+ clearFlushTimer();
887
+ if (responseMessagePromise) {
888
+ try {
889
+ await responseMessagePromise;
890
+ }
891
+ catch {
892
+ // If the initial send failed, we will fall back to sending the final response below.
893
+ }
894
+ }
895
+ const finalText = buildFinalResponseText(accumulatedText);
896
+ if (!finalText) {
897
+ const html = "<b>✅ Done</b>";
898
+ const plainText = "✅ Done";
899
+ if (responseMessageId) {
900
+ await safeEditMessage(bot, chatId, responseMessageId, html, { fallbackText: plainText });
901
+ await removeAbortKeyboard();
902
+ }
903
+ else {
904
+ await safeReply(ctx, html, { fallbackText: plainText });
905
+ }
906
+ return;
907
+ }
908
+ await deliverRenderedChunks(splitMarkdownForTelegram(finalText));
909
+ };
910
+ const callbacks = {
911
+ onTextDelta: (delta) => {
912
+ accumulatedText += delta;
913
+ progress.textCharacters += delta.length;
914
+ progress.updatedAt = Date.now();
915
+ if (!responseMessageId) {
916
+ void ensureResponseMessage()
917
+ .then(() => {
918
+ scheduleFlush();
919
+ })
920
+ .catch((error) => {
921
+ console.error("Failed to send initial Telegram response message", error);
922
+ });
923
+ return;
924
+ }
925
+ scheduleFlush();
926
+ },
927
+ onToolStart: (toolName, toolCallId) => {
928
+ progress.currentTool = toolName;
929
+ progress.lastTool = toolName;
930
+ progress.updatedAt = Date.now();
931
+ progress.toolCounts.set(toolName, (progress.toolCounts.get(toolName) ?? 0) + 1);
932
+ if (toolVerbosity === "summary") {
933
+ toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1);
934
+ return;
935
+ }
936
+ if (toolVerbosity === "none") {
937
+ return;
938
+ }
939
+ toolStates.set(toolCallId, { toolName, partialResult: "" });
940
+ if (toolVerbosity !== "all") {
941
+ return;
942
+ }
943
+ const messageText = renderToolStartMessage(toolName);
944
+ void (async () => {
945
+ const message = await sendTextMessage(bot.api, chatId, messageText.text, {
946
+ parseMode: messageText.parseMode,
947
+ fallbackText: messageText.fallbackText,
948
+ messageThreadId,
949
+ });
950
+ const state = toolStates.get(toolCallId);
951
+ if (!state) {
952
+ return;
953
+ }
954
+ state.messageId = message.message_id;
955
+ if (state.finalStatus) {
956
+ await safeEditMessage(bot, chatId, state.messageId, state.finalStatus.text, {
957
+ parseMode: state.finalStatus.parseMode,
958
+ fallbackText: state.finalStatus.fallbackText,
959
+ });
960
+ }
961
+ })().catch((error) => {
962
+ console.error(`Failed to send tool start message for ${toolName}`, error);
963
+ });
964
+ },
965
+ onToolUpdate: (toolCallId, partialResult) => {
966
+ progress.updatedAt = Date.now();
967
+ if (toolVerbosity === "none" || toolVerbosity === "summary") {
968
+ return;
969
+ }
970
+ const state = toolStates.get(toolCallId);
971
+ if (!state || !partialResult) {
972
+ return;
973
+ }
974
+ state.partialResult = appendWithCap(state.partialResult, partialResult, TOOL_OUTPUT_PREVIEW_LIMIT);
975
+ },
976
+ onToolEnd: (toolCallId, isError) => {
977
+ progress.currentTool = undefined;
978
+ progress.updatedAt = Date.now();
979
+ if (toolVerbosity === "none" || toolVerbosity === "summary") {
980
+ return;
981
+ }
982
+ const state = toolStates.get(toolCallId);
983
+ if (!state) {
984
+ return;
985
+ }
986
+ state.finalStatus = renderToolEndMessage(state.toolName, state.partialResult, isError);
987
+ if (toolVerbosity === "errors-only") {
988
+ if (!isError) {
989
+ return;
990
+ }
991
+ void sendTextMessage(bot.api, chatId, state.finalStatus.text, {
992
+ parseMode: state.finalStatus.parseMode,
993
+ fallbackText: state.finalStatus.fallbackText,
994
+ messageThreadId,
995
+ }).catch((error) => {
996
+ console.error(`Failed to send tool error message for ${state.toolName}`, error);
997
+ });
998
+ return;
999
+ }
1000
+ if (!state.messageId) {
1001
+ return;
1002
+ }
1003
+ void safeEditMessage(bot, chatId, state.messageId, state.finalStatus.text, {
1004
+ parseMode: state.finalStatus.parseMode,
1005
+ fallbackText: state.finalStatus.fallbackText,
1006
+ }).catch((error) => {
1007
+ console.error(`Failed to update tool message for ${state.toolName}`, error);
1008
+ });
1009
+ },
1010
+ onTodoUpdate: (items) => {
1011
+ progress.updatedAt = Date.now();
1012
+ if (toolVerbosity === "none") {
1013
+ return;
1014
+ }
1015
+ const rendered = renderTodoList(items);
1016
+ if (rendered === lastRenderedPlan) {
1017
+ return;
1018
+ }
1019
+ lastRenderedPlan = rendered;
1020
+ if (!planMessageId) {
1021
+ if (planMessageSending)
1022
+ return;
1023
+ planMessageSending = true;
1024
+ void sendTextMessage(bot.api, chatId, rendered, { parseMode: "HTML", messageThreadId })
1025
+ .then((msg) => {
1026
+ planMessageId = msg.message_id;
1027
+ })
1028
+ .catch((err) => {
1029
+ console.error("Failed to send plan message", err);
1030
+ })
1031
+ .finally(() => {
1032
+ planMessageSending = false;
1033
+ });
1034
+ }
1035
+ else {
1036
+ void safeEditMessage(bot, chatId, planMessageId, rendered, { parseMode: "HTML" }).catch((err) => {
1037
+ console.error("Failed to update plan message", err);
1038
+ });
1039
+ }
1040
+ },
1041
+ onTurnComplete: (usage) => {
1042
+ lastTurnUsage = usage;
1043
+ progress.updatedAt = Date.now();
1044
+ },
1045
+ onAgentEnd: () => {
1046
+ void finalizeResponse().catch((error) => {
1047
+ console.error("Failed to finalize Telegram response message", error);
1048
+ });
1049
+ },
1050
+ };
1051
+ try {
1052
+ const sessionInfo = session.getInfo();
1053
+ if (capabilitiesOf(sessionInfo).auth) {
1054
+ const authStatus = await checkAuthStatus(config.codexApiKey);
1055
+ if (!authStatus.authenticated) {
1056
+ await safeReply(ctx, [
1057
+ `<b>⚠️ ${escapeHTML(labelOf(sessionInfo))} is not authenticated.</b>`,
1058
+ "",
1059
+ `<code>${escapeHTML(authStatus.detail)}</code>`,
1060
+ "",
1061
+ "Use /login to start authentication, or set CODEX_API_KEY on the host.",
1062
+ ].join("\n"), {
1063
+ fallbackText: [
1064
+ `⚠️ ${labelOf(sessionInfo)} is not authenticated.`,
1065
+ "",
1066
+ authStatus.detail,
1067
+ "",
1068
+ "Use /login to start authentication, or set CODEX_API_KEY on the host.",
1069
+ ].join("\n"),
1070
+ });
1071
+ return;
1072
+ }
1073
+ }
1074
+ if (idOf(sessionInfo) === "pi" && !config.piEnabled) {
1075
+ await safeReply(ctx, "<b>⚠️ Pi is disabled.</b>\nEnable it with <code>NORDRELAY_PI_ENABLED=true</code>.", {
1076
+ fallbackText: "⚠️ Pi is disabled.\nEnable it with NORDRELAY_PI_ENABLED=true.",
1077
+ });
1078
+ return;
1079
+ }
1080
+ if (!(await ensureActiveThread(ctx, contextKey, session))) {
1081
+ return;
1082
+ }
1083
+ const workspacePolicy = evaluateWorkspacePolicy(session.getInfo().workspace, config);
1084
+ if (!workspacePolicy.allowed) {
1085
+ await safeReply(ctx, `<b>Workspace blocked:</b> ${escapeHTML(workspacePolicy.warning ?? "Current workspace is blocked by policy.")}`, {
1086
+ fallbackText: `Workspace blocked: ${workspacePolicy.warning ?? "Current workspace is blocked by policy."}`,
1087
+ });
1088
+ return;
1089
+ }
1090
+ const finalExternalActivity = getExternalActivity(session);
1091
+ if (finalExternalActivity?.active) {
1092
+ const item = maybeRequeuePromptAtFront(contextKey, envelope);
1093
+ const message = `Queued prompt ${item.id} at position 1. The Codex session became active in Codex CLI and is processing another task.`;
1094
+ await safeReply(ctx, escapeHTML(message), {
1095
+ fallbackText: message,
1096
+ replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
1097
+ });
1098
+ await updateQueueStatusMessage(contextKey, `Waiting for Codex CLI task... ${promptStore.list(contextKey).length} queued.`);
1099
+ scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
1100
+ turnProgress.delete(contextKey);
1101
+ return;
1102
+ }
1103
+ promptStore.setLastPrompt(contextKey, envelope);
1104
+ await session.prompt(envelope.input, callbacks);
1105
+ updateSessionMetadata(contextKey, session);
1106
+ await finalizeResponse();
1107
+ if (envelope.artifactOutDir) {
1108
+ if (config.telegramAutoSendArtifacts) {
1109
+ await deliverArtifacts(ctx, chatId, envelope.artifactOutDir, session.getInfo().workspace, messageThreadId);
1110
+ }
1111
+ else {
1112
+ await pruneArtifacts(session.getInfo().workspace);
1113
+ }
1114
+ }
1115
+ progress.status = "completed";
1116
+ progress.completedAt = Date.now();
1117
+ progress.updatedAt = progress.completedAt;
1118
+ }
1119
+ catch (error) {
1120
+ progress.status = "failed";
1121
+ progress.error = friendlyErrorText(error);
1122
+ progress.completedAt = Date.now();
1123
+ progress.updatedAt = progress.completedAt;
1124
+ stopTyping();
1125
+ clearFlushTimer();
1126
+ if (responseMessagePromise) {
1127
+ try {
1128
+ await responseMessagePromise;
1129
+ }
1130
+ catch {
1131
+ // Ignore; we will send an error message below.
1132
+ }
1133
+ }
1134
+ if (finalized) {
1135
+ console.error("Codex prompt error after finalization:", formatError(error));
1136
+ }
1137
+ else {
1138
+ finalized = true;
1139
+ const combinedText = buildFinalResponseText(renderPromptFailure(accumulatedText, error));
1140
+ const chunks = splitMarkdownForTelegram(combinedText);
1141
+ try {
1142
+ await deliverRenderedChunks(chunks);
1143
+ }
1144
+ catch (telegramError) {
1145
+ console.error("Failed to send error message to Telegram:", telegramError);
1146
+ }
1147
+ }
1148
+ }
1149
+ finally {
1150
+ stopTyping();
1151
+ clearFlushTimer();
1152
+ busyState.processing = false;
1153
+ void drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
1154
+ console.error("Failed to drain queued prompts:", error);
1155
+ });
1156
+ }
1157
+ };
1158
+ const drainQueuedPrompts = async (ctx, contextKey, chatId, session) => {
1159
+ if (drainingQueues.has(contextKey)) {
1160
+ return;
1161
+ }
1162
+ drainingQueues.add(contextKey);
1163
+ try {
1164
+ while (true) {
1165
+ if (promptStore.isPaused(contextKey)) {
1166
+ await updateQueueStatusMessage(contextKey, `Queue paused. ${promptStore.list(contextKey).length} queued.`);
1167
+ return;
1168
+ }
1169
+ const busy = getBusyReason(contextKey);
1170
+ if (busy.busy) {
1171
+ if (busy.kind === "external") {
1172
+ scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
1173
+ }
1174
+ return;
1175
+ }
1176
+ const next = promptStore.dequeue(contextKey);
1177
+ if (!next) {
1178
+ return;
1179
+ }
1180
+ const remainingBeforeRun = promptStore.list(contextKey).length + 1;
1181
+ await updateQueueStatusMessage(contextKey, `Running queued prompt 1/${remainingBeforeRun}: ${next.description}`);
1182
+ await safeReply(ctx, escapeHTML(`Processing queued prompt ${next.id}: ${next.description}`), {
1183
+ fallbackText: `Processing queued prompt ${next.id}: ${next.description}`,
1184
+ });
1185
+ await handleUserPrompt(ctx, contextKey, chatId, session, next, { fromQueue: true });
1186
+ }
1187
+ }
1188
+ finally {
1189
+ drainingQueues.delete(contextKey);
1190
+ }
1191
+ };
1192
+ const deliverArtifacts = async (ctx, chatId, outDir, workspace, messageThreadId) => {
1193
+ const { artifacts, skippedCount } = await collectArtifactReport(outDir, config.maxFileSize);
1194
+ const report = {
1195
+ turnId: path.basename(path.dirname(outDir)) || "turn",
1196
+ outDir,
1197
+ updatedAt: new Date(),
1198
+ artifacts,
1199
+ skippedCount,
1200
+ totalSizeBytes: totalArtifactSize(artifacts),
1201
+ source: "turn",
1202
+ };
1203
+ await deliverArtifactReport(ctx, chatId, report, messageThreadId);
1204
+ await pruneArtifacts(workspace);
1205
+ };
1206
+ const deliverArtifactReport = async (ctx, chatId, report, messageThreadId) => {
1207
+ if (isEmptyArtifactReport(report)) {
1208
+ return;
1209
+ }
1210
+ await sendChatActionSafe(ctx.api, chatId, "upload_document", messageThreadId).catch(() => { });
1211
+ let failedCount = 0;
1212
+ let bundledArtifact = null;
1213
+ if (report.artifacts.length > 5) {
1214
+ bundledArtifact = await createArtifactZipBundle(report.artifacts, report.outDir, {
1215
+ maxFileSize: config.maxFileSize,
1216
+ });
1217
+ }
1218
+ const deliveredArtifacts = bundledArtifact ? [bundledArtifact] : report.artifacts;
1219
+ for (const artifact of deliveredArtifacts) {
1220
+ const sent = await sendArtifactFile(ctx, chatId, artifact, messageThreadId);
1221
+ if (!sent) {
1222
+ failedCount += 1;
1223
+ }
1224
+ }
1225
+ const summary = formatArtifactSummary(report.artifacts, report.skippedCount + failedCount, report.omittedCount);
1226
+ if (summary) {
1227
+ const bundleNote = bundledArtifact ? `\nSent as ZIP: ${bundledArtifact.name}` : "";
1228
+ await safeReply(ctx, escapeHTML(`${summary}${bundleNote}`), {
1229
+ fallbackText: `${summary}${bundleNote}`,
1230
+ });
1231
+ }
1232
+ };
1233
+ const pruneArtifacts = async (workspace) => {
1234
+ await pruneConnectorTurnDirs(workspace, {
1235
+ maxAgeMs: config.artifactRetentionDays * 24 * 60 * 60 * 1000,
1236
+ maxTurnDirs: config.artifactMaxTurnDirs,
1237
+ maxInboxDirs: config.artifactMaxInboxDirs,
1238
+ }).catch((error) => {
1239
+ console.error("Failed to prune connector artifact directories:", error);
1240
+ });
1241
+ };
1242
+ const deliverArtifactReportZip = async (ctx, chatId, report, messageThreadId) => {
1243
+ const bundle = await createArtifactZipBundle(report.artifacts, report.outDir, {
1244
+ maxFileSize: config.maxFileSize,
1245
+ bundleName: `codex-artifacts-${report.turnId}.zip`,
1246
+ });
1247
+ if (!bundle) {
1248
+ await safeReply(ctx, escapeHTML("Could not create a ZIP bundle for this artifact turn."), {
1249
+ fallbackText: "Could not create a ZIP bundle for this artifact turn.",
1250
+ });
1251
+ return;
1252
+ }
1253
+ const sent = await sendArtifactFile(ctx, chatId, bundle, messageThreadId);
1254
+ if (sent) {
1255
+ const text = `Sent ZIP artifact bundle: ${bundle.name}`;
1256
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1257
+ }
1258
+ };
1259
+ const sendArtifactFile = async (ctx, chatId, artifact, messageThreadId) => {
1260
+ return sendArtifactFileByApi(ctx.api, chatId, artifact, messageThreadId);
1261
+ };
1262
+ const sendArtifactFileByApi = async (api, chatId, artifact, messageThreadId) => {
1263
+ const commonOptions = {
1264
+ ...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
1265
+ };
1266
+ try {
1267
+ if (isTelegramImagePreview(artifact)) {
1268
+ await telegramRateLimiter.run(chatBucket(chatId), "sendPhoto", () => api.sendPhoto(chatId, new InputFile(artifact.localPath, telegramArtifactFilename(artifact)), {
1269
+ ...commonOptions,
1270
+ caption: trimLine(redactText(artifact.name), 1024),
1271
+ }));
1272
+ }
1273
+ else {
1274
+ await telegramRateLimiter.run(chatBucket(chatId), "sendDocument", () => api.sendDocument(chatId, new InputFile(artifact.localPath, telegramArtifactFilename(artifact)), {
1275
+ ...commonOptions,
1276
+ }));
1277
+ }
1278
+ return true;
1279
+ }
1280
+ catch (error) {
1281
+ console.error(`Failed to send artifact ${artifact.name}:`, error);
1282
+ return false;
1283
+ }
1284
+ };
1285
+ const enqueueMediaGroupPart = (ctx, contextKey, chatId, session, mediaGroupId, part) => {
1286
+ const key = `${contextKey}:${mediaGroupId}`;
1287
+ const existing = pendingMediaGroups.get(key);
1288
+ if (existing) {
1289
+ clearTimeout(existing.timer);
1290
+ existing.ctx = ctx;
1291
+ existing.messageThreadId = ctx.message?.message_thread_id;
1292
+ existing.parts.push(part);
1293
+ existing.timer = setTimeout(() => {
1294
+ void flushMediaGroup(key);
1295
+ }, MEDIA_GROUP_FLUSH_MS);
1296
+ return;
1297
+ }
1298
+ const pending = {
1299
+ ctx,
1300
+ contextKey,
1301
+ chatId,
1302
+ session,
1303
+ messageThreadId: ctx.message?.message_thread_id,
1304
+ parts: [part],
1305
+ timer: setTimeout(() => {
1306
+ void flushMediaGroup(key);
1307
+ }, MEDIA_GROUP_FLUSH_MS),
1308
+ };
1309
+ pendingMediaGroups.set(key, pending);
1310
+ };
1311
+ const flushMediaGroup = async (key) => {
1312
+ const pending = pendingMediaGroups.get(key);
1313
+ if (!pending) {
1314
+ return;
1315
+ }
1316
+ clearTimeout(pending.timer);
1317
+ pendingMediaGroups.delete(key);
1318
+ try {
1319
+ await processMediaGroup(pending);
1320
+ }
1321
+ catch (error) {
1322
+ console.error("Failed to process media group:", error);
1323
+ await safeReply(pending.ctx, `<b>Failed to process media group:</b> ${escapeHTML(friendlyErrorText(error))}`, {
1324
+ fallbackText: `Failed to process media group: ${friendlyErrorText(error)}`,
1325
+ });
1326
+ }
1327
+ };
1328
+ const processMediaGroup = async (pending) => {
1329
+ const busyState = getBusyState(pending.contextKey);
1330
+ busyState.transcribing = true;
1331
+ const turnId = randomUUID().slice(0, 12);
1332
+ const workspace = pending.session.getCurrentWorkspace();
1333
+ const outDir = outboxPath(workspace, turnId);
1334
+ const stagedFiles = [];
1335
+ const imagePaths = [];
1336
+ const captions = pending.parts
1337
+ .map((part) => part.caption?.trim())
1338
+ .filter((caption) => Boolean(caption));
1339
+ let skippedCount = 0;
1340
+ try {
1341
+ await sendChatActionSafe(pending.ctx.api, pending.chatId, "typing", pending.messageThreadId).catch(() => { });
1342
+ await ensureOutDir(outDir);
1343
+ for (const [index, part] of pending.parts.entries()) {
1344
+ if (part.kind === "document" && part.fileSize && part.fileSize > config.maxFileSize) {
1345
+ skippedCount += 1;
1346
+ continue;
1347
+ }
1348
+ let tempFilePath;
1349
+ try {
1350
+ const downloadLimit = part.kind === "photo" ? 20 * 1024 * 1024 : config.maxFileSize;
1351
+ tempFilePath = await downloadTelegramFile(pending.ctx.api, config.telegramBotToken, part.fileId, downloadLimit);
1352
+ const buffer = await readFile(tempFilePath);
1353
+ const originalName = part.kind === "photo" ? `photo-${index + 1}-${turnId}.jpg` : `${index + 1}-${part.fileName}`;
1354
+ const staged = await stageFile(buffer, originalName, part.mimeType, {
1355
+ workspace,
1356
+ turnId,
1357
+ maxFileSize: config.maxFileSize,
1358
+ });
1359
+ stagedFiles.push(staged);
1360
+ if (part.kind === "photo") {
1361
+ imagePaths.push(staged.localPath);
1362
+ }
1363
+ }
1364
+ catch (error) {
1365
+ skippedCount += 1;
1366
+ console.error(`Failed to stage media group item ${index + 1}:`, error);
1367
+ }
1368
+ finally {
1369
+ if (tempFilePath) {
1370
+ await unlink(tempFilePath).catch(() => { });
1371
+ }
1372
+ }
1373
+ }
1374
+ }
1375
+ finally {
1376
+ busyState.transcribing = false;
1377
+ }
1378
+ if (stagedFiles.length === 0) {
1379
+ const text = skippedCount > 0 ? "No media group files could be staged." : "Media group was empty.";
1380
+ await safeReply(pending.ctx, escapeHTML(text), { fallbackText: text });
1381
+ return;
1382
+ }
1383
+ const receivedText = `Received ${stagedFiles.length} media group file${stagedFiles.length === 1 ? "" : "s"}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}.`;
1384
+ await safeReply(pending.ctx, escapeHTML(receivedText), { fallbackText: receivedText });
1385
+ await sendChatActionSafe(pending.ctx.api, pending.chatId, "typing", pending.messageThreadId).catch(() => { });
1386
+ const promptInput = {
1387
+ stagedFileInstructions: buildFileInstructions(stagedFiles, outDir),
1388
+ };
1389
+ if (imagePaths.length > 0) {
1390
+ promptInput.imagePaths = imagePaths;
1391
+ }
1392
+ if (captions.length > 0) {
1393
+ promptInput.text = Array.from(new Set(captions)).join("\n\n");
1394
+ }
1395
+ await setReaction(pending.ctx, "👀");
1396
+ try {
1397
+ await handleUserPrompt(pending.ctx, pending.contextKey, pending.chatId, pending.session, toPromptEnvelope(promptInput, outDir));
1398
+ await setReaction(pending.ctx, "👍");
1399
+ }
1400
+ catch {
1401
+ await clearReaction(pending.ctx);
1402
+ }
1403
+ };
1404
+ bot.use(async (ctx, next) => {
1405
+ const fromId = ctx.from?.id;
1406
+ const chatId = ctx.chat?.id;
1407
+ const authorized = config.telegramAllowAnyChat ||
1408
+ (fromId !== undefined && config.telegramAllowedUserIdSet.has(fromId)) ||
1409
+ (chatId !== undefined && config.telegramAllowedChatIdSet.has(chatId));
1410
+ if (!authorized) {
1411
+ if (ctx.callbackQuery) {
1412
+ await ctx.answerCallbackQuery({ text: "Unauthorized" }).catch(() => { });
1413
+ }
1414
+ else if (ctx.chat) {
1415
+ await safeReply(ctx, escapeHTML("Unauthorized"), { fallbackText: "Unauthorized" });
1416
+ }
1417
+ return;
1418
+ }
1419
+ const role = getUserRole(ctx);
1420
+ const permission = getRequiredPermission(ctx);
1421
+ if (!hasTelegramPermission(config.telegramRolePolicies, role, permission)) {
1422
+ const message = `Access denied: ${permission} permission required.`;
1423
+ if (ctx.callbackQuery) {
1424
+ await ctx.answerCallbackQuery({ text: message }).catch(() => { });
1425
+ }
1426
+ else {
1427
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
1428
+ }
1429
+ return;
1430
+ }
1431
+ await next();
1432
+ });
1433
+ bot.command("start", async (ctx) => {
1434
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1435
+ if (!contextSession) {
1436
+ return;
1437
+ }
1438
+ const { contextKey, session } = contextSession;
1439
+ const info = session.getInfo();
1440
+ const authStatus = capabilitiesOf(info).auth ? await checkAuthStatus(config.codexApiKey) : null;
1441
+ const authWarning = authStatus && !authStatus.authenticated
1442
+ ? "Not authenticated. Use /login or set CODEX_API_KEY."
1443
+ : undefined;
1444
+ const isReturning = registry.hasMetadata(contextKey);
1445
+ if (isReturning) {
1446
+ const welcome = renderWelcomeReturning(renderSessionInfoHTML(info), renderSessionInfoPlain(info), isTopicContext(contextKey), authWarning);
1447
+ await safeReply(ctx, welcome.html, { fallbackText: welcome.plain });
1448
+ }
1449
+ else {
1450
+ const welcome = renderWelcomeFirstTime(authWarning);
1451
+ await safeReply(ctx, [welcome.html, "", renderLaunchSummaryHTML(info)].join("\n"), {
1452
+ fallbackText: [welcome.plain, "", renderLaunchSummaryPlain(info)].join("\n"),
1453
+ });
1454
+ }
1455
+ });
1456
+ bot.command("help", async (ctx) => {
1457
+ const help = renderHelpMessage();
1458
+ await safeReply(ctx, help.html, { fallbackText: help.plain });
1459
+ });
1460
+ bot.command("agent", async (ctx) => {
1461
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1462
+ if (!contextSession) {
1463
+ return;
1464
+ }
1465
+ const { contextKey, session } = contextSession;
1466
+ if (!capabilitiesOf(session.getInfo()).modelSelection) {
1467
+ const text = `Model selection is not supported for ${labelOf(session.getInfo())}.`;
1468
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1469
+ return;
1470
+ }
1471
+ if (isBusy(contextKey)) {
1472
+ await safeReply(ctx, escapeHTML("Cannot switch agent while a prompt is running."), {
1473
+ fallbackText: "Cannot switch agent while a prompt is running.",
1474
+ });
1475
+ return;
1476
+ }
1477
+ const availableAgents = enabledAgents(config);
1478
+ const currentAgent = idOf(session.getInfo());
1479
+ if (availableAgents.length <= 1) {
1480
+ const only = agentLabel(availableAgents[0] ?? currentAgent);
1481
+ await safeReply(ctx, `<b>Current agent:</b> <code>${escapeHTML(only)}</code>\nNo other agents are enabled.`, {
1482
+ fallbackText: `Current agent: ${only}\nNo other agents are enabled.`,
1483
+ });
1484
+ return;
1485
+ }
1486
+ pendingAgentPicks.set(contextKey, availableAgents);
1487
+ const keyboard = new InlineKeyboard();
1488
+ for (const availableAgent of availableAgents) {
1489
+ keyboard.text(`${agentLabel(availableAgent)}${availableAgent === currentAgent ? " ✓" : ""}`, `agent_${availableAgent}`).row();
1490
+ }
1491
+ await safeReply(ctx, `<b>Current agent:</b> <code>${escapeHTML(agentLabel(currentAgent))}</code>\nSelect agent for this Telegram context:`, {
1492
+ fallbackText: `Current agent: ${agentLabel(currentAgent)}\nSelect agent for this Telegram context:`,
1493
+ replyMarkup: keyboard,
1494
+ });
1495
+ });
1496
+ bot.command("auth", async (ctx) => {
1497
+ if (!ctx.chat) {
1498
+ return;
1499
+ }
1500
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1501
+ const info = contextSession?.session.getInfo();
1502
+ if (info && !capabilitiesOf(info).auth) {
1503
+ const text = `${labelOf(info)} uses its local CLI authentication. Run its login flow on the host if needed.`;
1504
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1505
+ return;
1506
+ }
1507
+ const authStatus = await checkAuthStatus(config.codexApiKey);
1508
+ const icon = authStatus.authenticated ? "✅" : "❌";
1509
+ const html = [
1510
+ `<b>${icon} Auth status:</b> ${authStatus.authenticated ? "authenticated" : "not authenticated"}`,
1511
+ `<b>Method:</b> <code>${escapeHTML(authStatus.method)}</code>`,
1512
+ `<b>Detail:</b> <code>${escapeHTML(authStatus.detail)}</code>`,
1513
+ ].join("\n");
1514
+ const plain = [
1515
+ `${icon} Auth status: ${authStatus.authenticated ? "authenticated" : "not authenticated"}`,
1516
+ `Method: ${authStatus.method}`,
1517
+ `Detail: ${authStatus.detail}`,
1518
+ ].join("\n");
1519
+ await safeReply(ctx, html, { fallbackText: plain });
1520
+ });
1521
+ bot.command("login", async (ctx) => {
1522
+ if (!ctx.chat) {
1523
+ return;
1524
+ }
1525
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1526
+ const info = contextSession?.session.getInfo();
1527
+ if (info && !capabilitiesOf(info).login) {
1528
+ const text = `${labelOf(info)} login is not managed by NordRelay. Run the CLI login flow on the host.`;
1529
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1530
+ return;
1531
+ }
1532
+ const authStatus = await checkAuthStatus(config.codexApiKey);
1533
+ if (authStatus.authenticated) {
1534
+ await safeReply(ctx, `<b>✅ Already authenticated</b> via <code>${escapeHTML(authStatus.method)}</code>.`, {
1535
+ fallbackText: `✅ Already authenticated via ${authStatus.method}.`,
1536
+ });
1537
+ return;
1538
+ }
1539
+ if (!config.enableTelegramLogin) {
1540
+ await safeReply(ctx, [
1541
+ "<b>Telegram-initiated login is disabled.</b>",
1542
+ "",
1543
+ "Run <code>codex login</code> on the host, or set CODEX_API_KEY in .env.",
1544
+ ].join("\n"), {
1545
+ fallbackText: [
1546
+ "Telegram-initiated login is disabled.",
1547
+ "",
1548
+ "Run 'codex login' on the host, or set CODEX_API_KEY in .env.",
1549
+ ].join("\n"),
1550
+ });
1551
+ return;
1552
+ }
1553
+ const result = await startLogin();
1554
+ if (result.success) {
1555
+ await safeReply(ctx, `<b>🔑 Login initiated.</b>\n\n<code>${escapeHTML(result.message)}</code>`, {
1556
+ fallbackText: `🔑 Login initiated.\n\n${result.message}`,
1557
+ });
1558
+ return;
1559
+ }
1560
+ await safeReply(ctx, `<b>❌ Login failed.</b>\n\n<code>${escapeHTML(result.message)}</code>`, {
1561
+ fallbackText: `❌ Login failed.\n\n${result.message}`,
1562
+ });
1563
+ });
1564
+ bot.command("logout", async (ctx) => {
1565
+ if (!ctx.chat) {
1566
+ return;
1567
+ }
1568
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1569
+ const info = contextSession?.session.getInfo();
1570
+ if (info && !capabilitiesOf(info).logout) {
1571
+ const text = `${labelOf(info)} logout is not managed by NordRelay. Run the CLI logout flow on the host.`;
1572
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1573
+ return;
1574
+ }
1575
+ const authStatus = await checkAuthStatus(config.codexApiKey);
1576
+ if (authStatus.method === "api-key") {
1577
+ await safeReply(ctx, [
1578
+ "<b>Cannot logout via Telegram when using CODEX_API_KEY.</b>",
1579
+ "",
1580
+ "Remove CODEX_API_KEY from .env to use CLI-based auth instead.",
1581
+ ].join("\n"), {
1582
+ fallbackText: [
1583
+ "Cannot logout via Telegram when using CODEX_API_KEY.",
1584
+ "",
1585
+ "Remove CODEX_API_KEY from .env to use CLI-based auth instead.",
1586
+ ].join("\n"),
1587
+ });
1588
+ return;
1589
+ }
1590
+ if (!config.enableTelegramLogin) {
1591
+ await safeReply(ctx, [
1592
+ "<b>Telegram-initiated auth management is disabled.</b>",
1593
+ "",
1594
+ "Run <code>codex logout</code> on the host.",
1595
+ ].join("\n"), {
1596
+ fallbackText: [
1597
+ "Telegram-initiated auth management is disabled.",
1598
+ "",
1599
+ "Run 'codex logout' on the host.",
1600
+ ].join("\n"),
1601
+ });
1602
+ return;
1603
+ }
1604
+ if (!authStatus.authenticated) {
1605
+ await safeReply(ctx, escapeHTML("Not currently authenticated."), {
1606
+ fallbackText: "Not currently authenticated.",
1607
+ });
1608
+ return;
1609
+ }
1610
+ const result = await startLogout();
1611
+ if (result.success) {
1612
+ await safeReply(ctx, `<b>🔓 Logged out.</b>\n\n${escapeHTML(result.message)}`, {
1613
+ fallbackText: `🔓 Logged out.\n\n${result.message}`,
1614
+ });
1615
+ return;
1616
+ }
1617
+ await safeReply(ctx, `<b>❌ Logout failed.</b>\n\n<code>${escapeHTML(result.message)}</code>`, {
1618
+ fallbackText: `❌ Logout failed.\n\n${result.message}`,
1619
+ });
1620
+ });
1621
+ bot.command("mirror", async (ctx) => {
1622
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1623
+ if (!contextSession) {
1624
+ return;
1625
+ }
1626
+ const { contextKey, session } = contextSession;
1627
+ if (!capabilitiesOf(session.getInfo()).cliMirror) {
1628
+ const text = `CLI mirroring is not supported for ${labelOf(session.getInfo())} yet.`;
1629
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1630
+ return;
1631
+ }
1632
+ const argument = (ctx.message?.text ?? "").replace(/^\/mirror(?:@\w+)?\s*/i, "").trim();
1633
+ if (argument) {
1634
+ const mode = parseMirrorMode(argument, getEffectiveMirrorMode(contextKey));
1635
+ if (!["off", "status", "final", "full"].includes(argument.toLowerCase())) {
1636
+ await safeReply(ctx, escapeHTML("Usage: /mirror [off|status|final|full]"), {
1637
+ fallbackText: "Usage: /mirror [off|status|final|full]",
1638
+ });
1639
+ return;
1640
+ }
1641
+ preferencesStore.update(contextKey, { mirrorMode: mode });
1642
+ }
1643
+ const mode = getEffectiveMirrorMode(contextKey);
1644
+ const plain = [
1645
+ `CLI mirroring: ${mode}`,
1646
+ `Minimum update interval: ${config.telegramMirrorMinUpdateMs} ms`,
1647
+ "Modes: off, status, final, full",
1648
+ ].join("\n");
1649
+ const html = [
1650
+ `<b>CLI mirroring:</b> <code>${escapeHTML(mode)}</code>`,
1651
+ `<b>Minimum update interval:</b> <code>${config.telegramMirrorMinUpdateMs} ms</code>`,
1652
+ "<b>Modes:</b> <code>off</code>, <code>status</code>, <code>final</code>, <code>full</code>",
1653
+ ].join("\n");
1654
+ await safeReply(ctx, html, { fallbackText: plain });
1655
+ });
1656
+ bot.command("notify", async (ctx) => {
1657
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1658
+ if (!contextSession) {
1659
+ return;
1660
+ }
1661
+ const { contextKey } = contextSession;
1662
+ const argument = (ctx.message?.text ?? "").replace(/^\/notify(?:@\w+)?\s*/i, "").trim();
1663
+ if (argument) {
1664
+ const quietMatch = argument.match(/^quiet\s+(.+)$/i);
1665
+ if (quietMatch) {
1666
+ let quietHours;
1667
+ try {
1668
+ quietHours = quietMatch[1].toLowerCase() === "off" ? null : parseQuietHours(quietMatch[1]);
1669
+ }
1670
+ catch (error) {
1671
+ await safeReply(ctx, escapeHTML(`Invalid quiet hours: ${friendlyErrorText(error)}`), {
1672
+ fallbackText: `Invalid quiet hours: ${friendlyErrorText(error)}`,
1673
+ });
1674
+ return;
1675
+ }
1676
+ preferencesStore.update(contextKey, { quietHours });
1677
+ }
1678
+ else {
1679
+ const mode = parseNotifyMode(argument, getEffectiveNotifyMode(contextKey));
1680
+ if (!["off", "minimal", "all"].includes(argument.toLowerCase())) {
1681
+ await safeReply(ctx, escapeHTML("Usage: /notify [off|minimal|all] or /notify quiet HH-HH"), {
1682
+ fallbackText: "Usage: /notify [off|minimal|all] or /notify quiet HH-HH",
1683
+ });
1684
+ return;
1685
+ }
1686
+ preferencesStore.update(contextKey, { notifyMode: mode });
1687
+ }
1688
+ }
1689
+ const mode = getEffectiveNotifyMode(contextKey);
1690
+ const quietHours = getEffectiveQuietHours(contextKey);
1691
+ const plain = [
1692
+ `Notifications: ${mode}`,
1693
+ `Quiet hours: ${formatQuietHours(quietHours)}`,
1694
+ `Currently quiet: ${isQuietNow(quietHours) ? "yes" : "no"}`,
1695
+ ].join("\n");
1696
+ const html = [
1697
+ `<b>Notifications:</b> <code>${escapeHTML(mode)}</code>`,
1698
+ `<b>Quiet hours:</b> <code>${escapeHTML(formatQuietHours(quietHours))}</code>`,
1699
+ `<b>Currently quiet:</b> <code>${isQuietNow(quietHours) ? "yes" : "no"}</code>`,
1700
+ ].join("\n");
1701
+ await safeReply(ctx, html, { fallbackText: plain });
1702
+ });
1703
+ bot.command("workspaces", async (ctx) => {
1704
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1705
+ if (!contextSession) {
1706
+ return;
1707
+ }
1708
+ const { session } = contextSession;
1709
+ const agentName = labelOf(session.getInfo());
1710
+ const workspaces = filterAllowedWorkspaces(session.listWorkspaces(), config);
1711
+ const currentWorkspace = session.getInfo().workspace;
1712
+ const lines = workspaces.slice(0, 20).map((workspace, index) => {
1713
+ const prefix = workspace === currentWorkspace ? "*" : `${index + 1}.`;
1714
+ const policy = renderWorkspacePolicyLine(workspace, config);
1715
+ return `${prefix} ${workspace}${policy ? ` (${policy})` : ""}`;
1716
+ });
1717
+ const currentPolicy = evaluateWorkspacePolicy(currentWorkspace, config);
1718
+ const header = [
1719
+ "Workspaces:",
1720
+ `Current: ${currentWorkspace}`,
1721
+ currentPolicy.warning ? `Current warning: ${currentPolicy.warning}` : undefined,
1722
+ config.workspaceAllowedRoots.length > 0 ? `Allowed roots: ${config.workspaceAllowedRoots.join(", ")}` : "Allowed roots: unrestricted",
1723
+ "",
1724
+ ].filter((line) => Boolean(line));
1725
+ const plain = [...header, ...(lines.length > 0 ? lines : [`No workspaces found in ${agentName} state.`])].join("\n");
1726
+ const html = [
1727
+ "<b>Workspaces:</b>",
1728
+ `<b>Current:</b> <code>${escapeHTML(currentWorkspace)}</code>`,
1729
+ currentPolicy.warning ? `<b>Current warning:</b> <code>${escapeHTML(currentPolicy.warning)}</code>` : undefined,
1730
+ `<b>Allowed roots:</b> <code>${escapeHTML(config.workspaceAllowedRoots.length > 0 ? config.workspaceAllowedRoots.join(", ") : "unrestricted")}</code>`,
1731
+ "",
1732
+ ...(lines.length > 0 ? lines.map((line) => `<code>${escapeHTML(line)}</code>`) : [`<code>No workspaces found in ${escapeHTML(agentName)} state.</code>`]),
1733
+ ].filter((line) => Boolean(line)).join("\n");
1734
+ await safeReply(ctx, html, { fallbackText: plain });
1735
+ });
1736
+ bot.command("voice", async (ctx) => {
1737
+ if (!ctx.chat) {
1738
+ return;
1739
+ }
1740
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1741
+ if (!contextSession) {
1742
+ return;
1743
+ }
1744
+ const { contextKey } = contextSession;
1745
+ const argument = (ctx.message?.text ?? "").replace(/^\/voice(?:@\w+)?\s*/i, "").trim();
1746
+ if (argument) {
1747
+ const parts = argument.split(/\s+/);
1748
+ const key = parts[0]?.toLowerCase();
1749
+ const value = parts.slice(1).join(" ").trim();
1750
+ if (key === "backend" && value) {
1751
+ preferencesStore.update(contextKey, { voiceBackend: parseVoiceBackendPreference(value) });
1752
+ }
1753
+ else if (key === "language") {
1754
+ preferencesStore.update(contextKey, { voiceLanguage: value && value.toLowerCase() !== "auto" ? value : null });
1755
+ }
1756
+ else if (key === "transcribe_only" || key === "transcribe-only") {
1757
+ const enabled = parseToggle(value);
1758
+ if (enabled === undefined) {
1759
+ await safeReply(ctx, escapeHTML("Usage: /voice transcribe_only on|off"), {
1760
+ fallbackText: "Usage: /voice transcribe_only on|off",
1761
+ });
1762
+ return;
1763
+ }
1764
+ preferencesStore.update(contextKey, { voiceTranscribeOnly: enabled });
1765
+ }
1766
+ else {
1767
+ await safeReply(ctx, escapeHTML("Usage: /voice, /voice backend auto|parakeet|faster-whisper|openai, /voice language auto|<code>, /voice transcribe_only on|off"), {
1768
+ fallbackText: "Usage: /voice, /voice backend auto|parakeet|faster-whisper|openai, /voice language auto|<code>, /voice transcribe_only on|off",
1769
+ });
1770
+ return;
1771
+ }
1772
+ }
1773
+ const backends = await getAvailableBackends().catch(() => []);
1774
+ if (backends.length === 0) {
1775
+ await safeReply(ctx, [
1776
+ "<b>Voice transcription is not available.</b>",
1777
+ "",
1778
+ "Install <code>faster-whisper</code> + ffmpeg, install <code>parakeet-coreml</code> on macOS Apple Silicon, or set <code>OPENAI_API_KEY</code>.",
1779
+ "<i>Cloud transcription uses OPENAI_API_KEY, not CODEX_API_KEY.</i>",
1780
+ ].join("\n"), {
1781
+ fallbackText: [
1782
+ "Voice transcription is not available.",
1783
+ "",
1784
+ "Install faster-whisper + ffmpeg, install parakeet-coreml on macOS Apple Silicon, or set OPENAI_API_KEY.",
1785
+ "Cloud transcription uses OPENAI_API_KEY, not CODEX_API_KEY.",
1786
+ ].join("\n"),
1787
+ });
1788
+ return;
1789
+ }
1790
+ const joined = backends.join(" + ");
1791
+ const backendPreference = getEffectiveVoiceBackend(contextKey);
1792
+ const language = getEffectiveVoiceLanguage(contextKey);
1793
+ const transcribeOnly = isVoiceTranscribeOnly(contextKey);
1794
+ const plain = [
1795
+ `Voice backends: ${joined}`,
1796
+ `Preferred backend: ${backendPreference}`,
1797
+ `Language: ${language ?? "auto"}`,
1798
+ `Transcribe only: ${transcribeOnly ? "on" : "off"}`,
1799
+ ].join("\n");
1800
+ const html = [
1801
+ `<b>Voice backends:</b> <code>${escapeHTML(joined)}</code>`,
1802
+ `<b>Preferred backend:</b> <code>${escapeHTML(backendPreference)}</code>`,
1803
+ `<b>Language:</b> <code>${escapeHTML(language ?? "auto")}</code>`,
1804
+ `<b>Transcribe only:</b> <code>${transcribeOnly ? "on" : "off"}</code>`,
1805
+ ].join("\n");
1806
+ await safeReply(ctx, html, {
1807
+ fallbackText: plain,
1808
+ });
1809
+ });
1810
+ bot.command(["status", "health"], async (ctx) => {
1811
+ const health = await getConnectorHealth();
1812
+ const authStatus = await checkAuthStatus(config.codexApiKey);
1813
+ const html = renderHealthHTML(health, authStatus.authenticated, getUserRole(ctx));
1814
+ const plain = renderHealthPlain(health, authStatus.authenticated, getUserRole(ctx));
1815
+ await safeReply(ctx, html, { fallbackText: plain });
1816
+ });
1817
+ bot.command("version", async (ctx) => {
1818
+ const health = await getConnectorHealth();
1819
+ const state = await readConnectorState();
1820
+ const plain = [
1821
+ `NordRelay ${health.version}`,
1822
+ `Runtime status: ${state.status ?? "unknown"}`,
1823
+ `Codex CLI: ${health.codexCli}`,
1824
+ `Pi CLI: ${health.piCli}`,
1825
+ ].join("\n");
1826
+ const html = [
1827
+ `<b>NordRelay</b> <code>${escapeHTML(health.version)}</code>`,
1828
+ `<b>Runtime status:</b> <code>${escapeHTML(state.status ?? "unknown")}</code>`,
1829
+ `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
1830
+ `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
1831
+ ].join("\n");
1832
+ await safeReply(ctx, html, { fallbackText: plain });
1833
+ });
1834
+ bot.command(["tasks", "progress"], async (ctx) => {
1835
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1836
+ if (!contextSession) {
1837
+ return;
1838
+ }
1839
+ const progress = turnProgress.get(contextSession.contextKey);
1840
+ const queue = promptStore.list(contextSession.contextKey);
1841
+ const externalActivity = getExternalActivity(contextSession.session);
1842
+ const busyState = {
1843
+ ...getBusyState(contextSession.contextKey),
1844
+ external: Boolean(externalActivity?.active),
1845
+ };
1846
+ const info = contextSession.session.getInfo();
1847
+ const plain = renderProgressPlain(progress, queue.length, busyState, info);
1848
+ const html = renderProgressHTML(progress, queue.length, busyState, info);
1849
+ await safeReply(ctx, html, { fallbackText: plain });
1850
+ });
1851
+ bot.command("activity", async (ctx) => {
1852
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1853
+ if (!contextSession) {
1854
+ return;
1855
+ }
1856
+ const info = contextSession.session.getInfo();
1857
+ if (!capabilitiesOf(info).activityLog) {
1858
+ const text = `${labelOf(info)} activity timelines are not available yet.`;
1859
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1860
+ return;
1861
+ }
1862
+ const threadId = contextSession.session.getActiveThreadId();
1863
+ if (!threadId) {
1864
+ await safeReply(ctx, escapeHTML("No active thread yet."), { fallbackText: "No active thread yet." });
1865
+ return;
1866
+ }
1867
+ const options = parseActivityOptions((ctx.message?.text ?? "").replace(/^\/activity(?:@\w+)?\s*/i, "").trim());
1868
+ const events = filterActivityEvents(getThreadActivityLog(threadId, options.exportFile ? 200 : options.limit), options);
1869
+ const rendered = renderActivityTimeline(threadId, events, options);
1870
+ if (options.exportFile && ctx.chat) {
1871
+ const exportPath = path.join(tmpdir(), `codex-activity-${threadId}-${randomUUID().slice(0, 8)}.txt`);
1872
+ await writeFile(exportPath, rendered.plain, "utf8");
1873
+ try {
1874
+ await telegramRateLimiter.run(chatBucket(ctx.chat.id), "sendDocument", () => ctx.api.sendDocument(ctx.chat.id, new InputFile(exportPath, path.basename(exportPath)), {
1875
+ ...(ctx.message?.message_thread_id ? { message_thread_id: ctx.message.message_thread_id } : {}),
1876
+ }));
1877
+ }
1878
+ finally {
1879
+ await unlink(exportPath).catch(() => { });
1880
+ }
1881
+ return;
1882
+ }
1883
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
1884
+ });
1885
+ bot.command("diagnostics", async (ctx) => {
1886
+ const health = await getConnectorHealth();
1887
+ const authStatus = await checkAuthStatus(config.codexApiKey);
1888
+ const contextKey = contextKeyFromCtx(ctx);
1889
+ const queueLength = contextKey ? promptStore.list(contextKey).length : 0;
1890
+ const progress = contextKey ? turnProgress.get(contextKey) : undefined;
1891
+ const contextSession = contextKey ? await getContextSession(ctx, { deferThreadStart: true }) : null;
1892
+ const rolloutDiagnostics = contextSession && capabilitiesOf(contextSession.session.getInfo()).externalActivity
1893
+ ? renderRolloutDiagnostics(contextSession.session.getActiveThreadId(), config.codexExternalBusyStaleMs)
1894
+ : { plain: "Rollout: no context", html: "<b>Rollout:</b> <code>no context</code>" };
1895
+ const runtime = {
1896
+ rateLimit: getTelegramRateLimitMetrics(),
1897
+ externalMirrors: externalMirrors.size,
1898
+ externalQueueTimers: externalQueueTimers.size,
1899
+ queueStatusMessages: queueStatusMessages.size,
1900
+ mirrorMode: contextKey ? getEffectiveMirrorMode(contextKey) : config.telegramMirrorMode,
1901
+ notifyMode: contextKey ? getEffectiveNotifyMode(contextKey) : config.telegramNotifyMode,
1902
+ quietHours: formatQuietHours(contextKey ? getEffectiveQuietHours(contextKey) : config.telegramQuietHours),
1903
+ voiceBackend: contextKey ? getEffectiveVoiceBackend(contextKey) : config.voicePreferredBackend,
1904
+ voiceLanguage: contextKey ? getEffectiveVoiceLanguage(contextKey) ?? "auto" : config.voiceDefaultLanguage ?? "auto",
1905
+ voiceTranscribeOnly: contextKey ? isVoiceTranscribeOnly(contextKey) : config.voiceTranscribeOnly,
1906
+ };
1907
+ const plain = `${renderDiagnosticsPlain(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${rolloutDiagnostics.plain}`;
1908
+ const html = `${renderDiagnosticsHTML(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${rolloutDiagnostics.html}`;
1909
+ await safeReply(ctx, html, { fallbackText: plain });
1910
+ });
1911
+ bot.command("sync", async (ctx) => {
1912
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1913
+ if (!contextSession) {
1914
+ return;
1915
+ }
1916
+ const sessionInfo = contextSession.session.getInfo();
1917
+ if (!capabilitiesOf(sessionInfo).externalActivity) {
1918
+ const plain = [`${labelOf(sessionInfo)} has no external CLI state watcher to sync.`, "", renderSessionInfoPlain(sessionInfo)].join("\n");
1919
+ const html = [`<b>${escapeHTML(labelOf(sessionInfo))} has no external CLI state watcher to sync.</b>`, "", renderSessionInfoHTML(sessionInfo)].join("\n");
1920
+ await safeReply(ctx, html, { fallbackText: plain });
1921
+ return;
1922
+ }
1923
+ const result = contextSession.session.syncFromCodexState({ reattach: true });
1924
+ if (result.changed) {
1925
+ updateSessionMetadata(contextSession.contextKey, contextSession.session);
1926
+ }
1927
+ const fields = result.changedFields.length > 0 ? result.changedFields.join(", ") : "none";
1928
+ const plain = [
1929
+ result.changed ? `Synced from ${labelOf(sessionInfo)} state.` : "Already in sync.",
1930
+ `Changed: ${fields}`,
1931
+ `Reattached: ${result.reattached ? "yes" : "no"}`,
1932
+ "",
1933
+ renderSessionInfoPlain(result.info),
1934
+ ].join("\n");
1935
+ const html = [
1936
+ result.changed ? `<b>Synced from ${escapeHTML(labelOf(sessionInfo))} state.</b>` : "<b>Already in sync.</b>",
1937
+ `<b>Changed:</b> <code>${escapeHTML(fields)}</code>`,
1938
+ `<b>Reattached:</b> <code>${result.reattached ? "yes" : "no"}</code>`,
1939
+ "",
1940
+ renderSessionInfoHTML(result.info),
1941
+ ].join("\n");
1942
+ await safeReply(ctx, html, { fallbackText: plain });
1943
+ });
1944
+ bot.command("logs", async (ctx) => {
1945
+ const rawText = ctx.message?.text ?? "";
1946
+ const argument = rawText.replace(/^\/logs(?:@\w+)?\s*/i, "").trim();
1947
+ const lines = Number.parseInt(argument || "80", 10);
1948
+ const logTail = await readLogTail(Number.isNaN(lines) ? 80 : lines);
1949
+ const plain = `Connector log tail:\n\n${logTail || "(empty)"}`;
1950
+ const html = `<b>Connector log tail:</b>\n\n<pre>${escapeHTML(logTail || "(empty)")}</pre>`;
1951
+ await safeReply(ctx, html, { fallbackText: plain });
1952
+ });
1953
+ bot.command("restart", async (ctx) => {
1954
+ await safeReply(ctx, escapeHTML("Restarting connector..."), {
1955
+ fallbackText: "Restarting connector...",
1956
+ });
1957
+ setTimeout(() => {
1958
+ spawnConnectorRestart();
1959
+ }, 300);
1960
+ });
1961
+ bot.command("update", async (ctx) => {
1962
+ const updateLog = spawnSelfUpdate();
1963
+ const plain = [
1964
+ "Update started.",
1965
+ "The connector will pull main, install dependencies, run check, tests, build, and restart only if all steps pass.",
1966
+ `Log: ${updateLog}`,
1967
+ "Use /logs after the restart or inspect update.log on the host.",
1968
+ ].join("\n");
1969
+ const html = [
1970
+ "<b>Update started.</b>",
1971
+ "The connector will pull <code>main</code>, install dependencies, run check, tests, build, and restart only if all steps pass.",
1972
+ `<b>Log:</b> <code>${escapeHTML(updateLog)}</code>`,
1973
+ `Use <code>/logs</code> after the restart or inspect <code>${escapeHTML(getUpdateLogPath())}</code> on the host.`,
1974
+ ].join("\n");
1975
+ await safeReply(ctx, html, { fallbackText: plain });
1976
+ });
1977
+ bot.command("new", async (ctx) => {
1978
+ const chatId = ctx.chat?.id;
1979
+ if (!chatId) {
1980
+ return;
1981
+ }
1982
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1983
+ if (!contextSession) {
1984
+ return;
1985
+ }
1986
+ const { contextKey, session } = contextSession;
1987
+ if (isBusy(contextKey)) {
1988
+ await safeReply(ctx, escapeHTML("Cannot create a new thread while a prompt is running."), {
1989
+ fallbackText: "Cannot create a new thread while a prompt is running.",
1990
+ });
1991
+ return;
1992
+ }
1993
+ const currentPolicy = evaluateWorkspacePolicy(session.getCurrentWorkspace(), config);
1994
+ if (!currentPolicy.allowed) {
1995
+ await safeReply(ctx, escapeHTML(currentPolicy.warning ?? "Current workspace is blocked by workspace policy."), {
1996
+ fallbackText: currentPolicy.warning ?? "Current workspace is blocked by workspace policy.",
1997
+ });
1998
+ return;
1999
+ }
2000
+ const workspaces = filterAllowedWorkspaces(session.listWorkspaces(), config);
2001
+ if (workspaces.length <= 1) {
2002
+ try {
2003
+ const info = await session.newThread();
2004
+ updateSessionMetadata(contextKey, session);
2005
+ const label = isTopicContext(contextKey) ? "New thread created for this topic." : "New thread created.";
2006
+ const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2007
+ const plainText = [label, policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
2008
+ const html = [`<b>${escapeHTML(label)}</b>`, policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
2009
+ await safeReply(ctx, html, { fallbackText: plainText });
2010
+ }
2011
+ catch (error) {
2012
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
2013
+ fallbackText: `Failed: ${friendlyErrorText(error)}`,
2014
+ });
2015
+ }
2016
+ return;
2017
+ }
2018
+ pendingWorkspacePicks.set(contextKey, workspaces);
2019
+ const currentWorkspace = session.getCurrentWorkspace();
2020
+ const workspaceButtons = workspaces.map((workspace, index) => ({
2021
+ label: `${workspace === currentWorkspace ? "📂" : "📁"} ${getWorkspaceShortName(workspace)}`,
2022
+ callbackData: `ws_${index}`,
2023
+ }));
2024
+ pendingWorkspaceButtons.set(contextKey, workspaceButtons);
2025
+ const keyboard = paginateKeyboard(workspaceButtons, 0, "ws");
2026
+ await safeReply(ctx, "<b>Select workspace for new thread:</b>", {
2027
+ fallbackText: "Select workspace for new thread:",
2028
+ replyMarkup: keyboard,
2029
+ });
2030
+ });
2031
+ bot.command(["abort", "stop"], async (ctx) => {
2032
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2033
+ if (!contextSession) {
2034
+ return;
2035
+ }
2036
+ const { session } = contextSession;
2037
+ try {
2038
+ await session.abort();
2039
+ await safeReply(ctx, escapeHTML("Aborted current operation"), {
2040
+ fallbackText: "Aborted current operation",
2041
+ });
2042
+ }
2043
+ catch (error) {
2044
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
2045
+ fallbackText: `Failed: ${friendlyErrorText(error)}`,
2046
+ });
2047
+ }
2048
+ });
2049
+ bot.command("retry", async (ctx) => {
2050
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2051
+ if (!contextSession) {
2052
+ return;
2053
+ }
2054
+ const { contextKey, session } = contextSession;
2055
+ const chatId = ctx.chat?.id;
2056
+ if (!chatId) {
2057
+ return;
2058
+ }
2059
+ if (isBusy(contextKey)) {
2060
+ await sendBusyReply(ctx);
2061
+ return;
2062
+ }
2063
+ const cached = promptStore.getLastPrompt(contextKey);
2064
+ if (!cached) {
2065
+ await safeReply(ctx, escapeHTML("Nothing to retry. Send a message first."), {
2066
+ fallbackText: "Nothing to retry. Send a message first.",
2067
+ });
2068
+ return;
2069
+ }
2070
+ await setReaction(ctx, "👀");
2071
+ try {
2072
+ await handleUserPrompt(ctx, contextKey, chatId, session, cached);
2073
+ await setReaction(ctx, "👍");
2074
+ }
2075
+ catch {
2076
+ await clearReaction(ctx);
2077
+ }
2078
+ });
2079
+ bot.command("queue", async (ctx) => {
2080
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2081
+ if (!contextSession) {
2082
+ return;
2083
+ }
2084
+ const chatId = ctx.chat?.id;
2085
+ const { contextKey, session } = contextSession;
2086
+ const rawText = ctx.message?.text ?? "";
2087
+ const argument = rawText.replace(/^\/queue(?:@\w+)?\s*/i, "").trim();
2088
+ if (/^pause$/i.test(argument)) {
2089
+ promptStore.pause(contextKey);
2090
+ const message = `Queue paused. ${promptStore.list(contextKey).length} queued.`;
2091
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
2092
+ await updateQueueStatusMessage(contextKey, message);
2093
+ return;
2094
+ }
2095
+ if (/^resume$/i.test(argument)) {
2096
+ promptStore.resume(contextKey);
2097
+ const message = `Queue resumed. ${promptStore.list(contextKey).length} queued.`;
2098
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
2099
+ if (chatId) {
2100
+ void drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
2101
+ console.error("Failed to drain queue after resume:", error);
2102
+ });
2103
+ }
2104
+ return;
2105
+ }
2106
+ const moveMatch = argument.match(/^move\s+([a-z0-9]+)\s+(top|up|down)$/i);
2107
+ if (moveMatch) {
2108
+ const direction = moveMatch[2].toLowerCase();
2109
+ const item = direction === "top"
2110
+ ? promptStore.moveToTop(contextKey, moveMatch[1])
2111
+ : direction === "up"
2112
+ ? promptStore.moveUp(contextKey, moveMatch[1])
2113
+ : promptStore.moveDown(contextKey, moveMatch[1]);
2114
+ if (!item) {
2115
+ await safeReply(ctx, escapeHTML(`No queued prompt found with id ${moveMatch[1]}.`), {
2116
+ fallbackText: `No queued prompt found with id ${moveMatch[1]}.`,
2117
+ });
2118
+ return;
2119
+ }
2120
+ const message = `Moved queued prompt ${item.id} ${direction}.`;
2121
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
2122
+ return;
2123
+ }
2124
+ const runMatch = argument.match(/^run\s+([a-z0-9]+)$/i);
2125
+ if (runMatch) {
2126
+ const item = promptStore.remove(contextKey, runMatch[1]);
2127
+ if (!item) {
2128
+ await safeReply(ctx, escapeHTML(`No queued prompt found with id ${runMatch[1]}.`), {
2129
+ fallbackText: `No queued prompt found with id ${runMatch[1]}.`,
2130
+ });
2131
+ return;
2132
+ }
2133
+ promptStore.enqueueFront(contextKey, item);
2134
+ promptStore.resume(contextKey);
2135
+ if (!chatId) {
2136
+ return;
2137
+ }
2138
+ const busy = getBusyReason(contextKey);
2139
+ if (busy.busy) {
2140
+ const message = `Queued prompt ${item.id} moved to top and will run when the current task finishes.`;
2141
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
2142
+ if (busy.kind === "external") {
2143
+ scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
2144
+ }
2145
+ return;
2146
+ }
2147
+ const next = promptStore.dequeue(contextKey);
2148
+ if (next) {
2149
+ await handleUserPrompt(ctx, contextKey, chatId, session, next, { fromQueue: true });
2150
+ }
2151
+ return;
2152
+ }
2153
+ if (argument) {
2154
+ await safeReply(ctx, escapeHTML("Usage: /queue, /queue pause, /queue resume, /queue move <id> top|up|down, /queue run <id>"), {
2155
+ fallbackText: "Usage: /queue, /queue pause, /queue resume, /queue move <id> top|up|down, /queue run <id>",
2156
+ });
2157
+ return;
2158
+ }
2159
+ const queue = promptStore.list(contextKey);
2160
+ if (queue.length === 0) {
2161
+ const rendered = renderQueueList(contextKey, queue);
2162
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2163
+ return;
2164
+ }
2165
+ const rendered = renderQueueList(contextKey, queue);
2166
+ await safeReply(ctx, rendered.html, {
2167
+ fallbackText: rendered.plain,
2168
+ replyMarkup: rendered.keyboard,
2169
+ });
2170
+ });
2171
+ bot.command("clearqueue", async (ctx) => {
2172
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2173
+ if (!contextSession) {
2174
+ return;
2175
+ }
2176
+ const count = promptStore.clear(contextSession.contextKey);
2177
+ const message = `Cleared ${count} queued prompt${count === 1 ? "" : "s"}.`;
2178
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
2179
+ });
2180
+ bot.command("cancel", async (ctx) => {
2181
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2182
+ if (!contextSession) {
2183
+ return;
2184
+ }
2185
+ const rawText = ctx.message?.text ?? "";
2186
+ const id = rawText.replace(/^\/cancel(?:@\w+)?\s*/i, "").trim();
2187
+ if (!id) {
2188
+ await safeReply(ctx, escapeHTML("Usage: /cancel <queue-id>"), {
2189
+ fallbackText: "Usage: /cancel <queue-id>",
2190
+ });
2191
+ return;
2192
+ }
2193
+ const removed = promptStore.remove(contextSession.contextKey, id);
2194
+ if (!removed) {
2195
+ await safeReply(ctx, escapeHTML(`No queued prompt found with id ${id}.`), {
2196
+ fallbackText: `No queued prompt found with id ${id}.`,
2197
+ });
2198
+ return;
2199
+ }
2200
+ await safeReply(ctx, escapeHTML(`Cancelled queued prompt ${removed.id}.`), {
2201
+ fallbackText: `Cancelled queued prompt ${removed.id}.`,
2202
+ });
2203
+ });
2204
+ bot.command("artifacts", async (ctx) => {
2205
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2206
+ if (!contextSession || !ctx.chat) {
2207
+ return;
2208
+ }
2209
+ const workspace = contextSession.session.getInfo().workspace;
2210
+ const rawText = ctx.message?.text ?? "";
2211
+ const argument = rawText.replace(/^\/artifacts(?:@\w+)?\s*/i, "").trim();
2212
+ const reports = await listRecentArtifactReports(workspace, 10, config.maxFileSize);
2213
+ if (reports.length === 0) {
2214
+ await safeReply(ctx, escapeHTML("No generated artifacts found for this workspace."), {
2215
+ fallbackText: "No generated artifacts found for this workspace.",
2216
+ });
2217
+ return;
2218
+ }
2219
+ if (argument) {
2220
+ const parts = argument.split(/\s+/).filter(Boolean);
2221
+ const shouldZip = parts[0]?.toLowerCase() === "zip";
2222
+ const requestedTurn = shouldZip ? parts[1] : parts[0];
2223
+ const selected = !requestedTurn || requestedTurn.toLowerCase() === "latest"
2224
+ ? reports[0]
2225
+ : reports.find((report) => report.turnId === requestedTurn || report.turnId.startsWith(requestedTurn));
2226
+ if (!selected) {
2227
+ await safeReply(ctx, escapeHTML(`No artifact turn found for "${argument}".`), {
2228
+ fallbackText: `No artifact turn found for "${argument}".`,
2229
+ });
2230
+ return;
2231
+ }
2232
+ if (shouldZip) {
2233
+ await deliverArtifactReportZip(ctx, ctx.chat.id, selected, ctx.message?.message_thread_id);
2234
+ }
2235
+ else {
2236
+ await deliverArtifactReport(ctx, ctx.chat.id, selected, ctx.message?.message_thread_id);
2237
+ }
2238
+ return;
2239
+ }
2240
+ const { html, plain } = renderArtifactReports(reports);
2241
+ await safeReply(ctx, html, {
2242
+ fallbackText: plain,
2243
+ replyMarkup: buildArtifactActionsKeyboard(reports),
2244
+ });
2245
+ });
2246
+ bot.command("session", async (ctx) => {
2247
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2248
+ if (!contextSession) {
2249
+ return;
2250
+ }
2251
+ const { contextKey, session } = contextSession;
2252
+ const info = session.getInfo();
2253
+ const contextLabel = isTopicContext(contextKey) ? "Topic session" : "Chat session";
2254
+ const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2255
+ const plainLines = [`${contextLabel}:`, policyLine, renderSessionInfoPlain(info)].filter((line) => line !== undefined);
2256
+ const htmlLines = [`<b>${escapeHTML(contextLabel)}:</b>`, policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, renderSessionInfoHTML(info)].filter((line) => line !== undefined);
2257
+ await safeReply(ctx, htmlLines.join("\n"), { fallbackText: plainLines.join("\n") });
2258
+ });
2259
+ const openLaunchProfilesPicker = async (ctx) => {
2260
+ const chatId = ctx.chat?.id;
2261
+ if (!chatId) {
2262
+ return;
2263
+ }
2264
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2265
+ if (!contextSession) {
2266
+ return;
2267
+ }
2268
+ const { contextKey, session } = contextSession;
2269
+ const info = session.getInfo();
2270
+ if (!capabilitiesOf(info).launchProfiles) {
2271
+ const text = `Launch profiles are not supported for ${labelOf(info)}.`;
2272
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2273
+ return;
2274
+ }
2275
+ if (isBusy(contextKey)) {
2276
+ await safeReply(ctx, escapeHTML("Cannot change launch profile while a prompt is running."), {
2277
+ fallbackText: "Cannot change launch profile while a prompt is running.",
2278
+ });
2279
+ return;
2280
+ }
2281
+ const selectedLaunchProfile = session.getSelectedLaunchProfile();
2282
+ const launchButtons = config.launchProfiles.map((profile, index) => ({
2283
+ label: formatLaunchProfileLabel(profile, profile.id === selectedLaunchProfile.id),
2284
+ callbackData: `launch_${index}`,
2285
+ }));
2286
+ pendingLaunchPicks.set(contextKey, config.launchProfiles.map((profile) => profile.id));
2287
+ pendingLaunchButtons.set(contextKey, launchButtons);
2288
+ pendingUnsafeLaunchConfirmations.delete(contextKey);
2289
+ const keyboard = paginateKeyboard(launchButtons, 0, "launch");
2290
+ const htmlLines = [
2291
+ `<b>Selected launch profile:</b> <code>${escapeHTML(selectedLaunchProfile.label)}</code>`,
2292
+ `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(selectedLaunchProfile))}</code>`,
2293
+ "",
2294
+ "Select a profile for new or reattached threads:",
2295
+ ];
2296
+ const plainLines = [
2297
+ `Selected launch profile: ${selectedLaunchProfile.label}`,
2298
+ `Behavior: ${formatLaunchProfileBehavior(selectedLaunchProfile)}`,
2299
+ "",
2300
+ "Select a profile for new or reattached threads:",
2301
+ ];
2302
+ if (selectedLaunchProfile.unsafe) {
2303
+ htmlLines.splice(2, 0, "⚠️ <i>Selected profile uses danger-full-access.</i>");
2304
+ plainLines.splice(2, 0, "⚠️ Selected profile uses danger-full-access.");
2305
+ }
2306
+ if (info.nextLaunchProfileId) {
2307
+ htmlLines.splice(2, 0, `<b>Active thread still uses:</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`);
2308
+ plainLines.splice(2, 0, `Active thread still uses: ${info.launchProfileLabel}`);
2309
+ }
2310
+ await safeReply(ctx, htmlLines.join("\n"), {
2311
+ fallbackText: plainLines.join("\n"),
2312
+ replyMarkup: keyboard,
2313
+ });
2314
+ };
2315
+ bot.command(["launch", "launch_profiles"], openLaunchProfilesPicker);
2316
+ bot.hears(/^\/launch-profiles(?:@\w+)?$/i, openLaunchProfilesPicker);
2317
+ bot.command("handback", async (ctx) => {
2318
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2319
+ if (!contextSession) {
2320
+ return;
2321
+ }
2322
+ const { contextKey, session } = contextSession;
2323
+ if (isBusy(contextKey)) {
2324
+ await safeReply(ctx, escapeHTML("Cannot hand back while a prompt is running. Use /abort first."), {
2325
+ fallbackText: "Cannot hand back while a prompt is running. Use /abort first.",
2326
+ });
2327
+ return;
2328
+ }
2329
+ if (!session.hasActiveThread()) {
2330
+ await safeReply(ctx, escapeHTML("No active thread to hand back."), {
2331
+ fallbackText: "No active thread to hand back.",
2332
+ });
2333
+ return;
2334
+ }
2335
+ try {
2336
+ const info = session.handback();
2337
+ updateSessionMetadata(contextKey, session);
2338
+ if (!info.threadId) {
2339
+ await safeReply(ctx, escapeHTML("This thread has not started yet, so there is no resumable thread ID. Send a message to create one, or use /new to start fresh."), {
2340
+ fallbackText: "This thread has not started yet, so there is no resumable thread ID. Send a message to create one, or use /new to start fresh.",
2341
+ });
2342
+ return;
2343
+ }
2344
+ const shellEscape = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
2345
+ const resumeCommand = info.command ?? `cd ${shellEscape(info.workspace)} && codex resume ${shellEscape(info.threadId)}`;
2346
+ const handbackLabel = info.label ?? "Codex CLI";
2347
+ let copiedToClipboard = false;
2348
+ if (process.platform === "darwin") {
2349
+ try {
2350
+ const { spawnSync } = await import("node:child_process");
2351
+ const result = spawnSync("pbcopy", [], {
2352
+ input: resumeCommand,
2353
+ timeout: 2000,
2354
+ stdio: ["pipe", "ignore", "ignore"],
2355
+ });
2356
+ copiedToClipboard = result.status === 0;
2357
+ }
2358
+ catch {
2359
+ // Ignore clipboard failures.
2360
+ }
2361
+ }
2362
+ const plainText = [
2363
+ `🔄 Thread handed back to ${handbackLabel}.`,
2364
+ "",
2365
+ "Run this in your terminal:",
2366
+ resumeCommand,
2367
+ copiedToClipboard ? "" : undefined,
2368
+ copiedToClipboard ? "📋 Command copied to clipboard!" : undefined,
2369
+ "",
2370
+ "Send any message here to start a new NordRelay thread.",
2371
+ ]
2372
+ .filter((line) => line !== undefined)
2373
+ .join("\n");
2374
+ const html = [
2375
+ `<b>🔄 Thread handed back to ${escapeHTML(handbackLabel)}.</b>`,
2376
+ "",
2377
+ "Run this in your terminal:",
2378
+ `<pre>${escapeHTML(resumeCommand)}</pre>`,
2379
+ copiedToClipboard ? "" : undefined,
2380
+ copiedToClipboard ? "📋 <i>Command copied to clipboard!</i>" : undefined,
2381
+ "",
2382
+ "Send any message here to start a new NordRelay thread.",
2383
+ ]
2384
+ .filter((line) => line !== undefined)
2385
+ .join("\n");
2386
+ await safeReply(ctx, html, { fallbackText: plainText });
2387
+ }
2388
+ catch (error) {
2389
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
2390
+ fallbackText: `Failed: ${friendlyErrorText(error)}`,
2391
+ });
2392
+ }
2393
+ });
2394
+ bot.command("attach", async (ctx) => {
2395
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2396
+ if (!contextSession) {
2397
+ return;
2398
+ }
2399
+ const { contextKey, session } = contextSession;
2400
+ if (isBusy(contextKey)) {
2401
+ await safeReply(ctx, escapeHTML("Cannot attach while a prompt is running."), {
2402
+ fallbackText: "Cannot attach while a prompt is running.",
2403
+ });
2404
+ return;
2405
+ }
2406
+ const rawText = ctx.message?.text ?? "";
2407
+ const threadId = rawText.replace(/^\/attach(?:@\w+)?\s*/, "").trim();
2408
+ if (!threadId) {
2409
+ await safeReply(ctx, escapeHTML("Usage: /attach <thread-id>"), {
2410
+ fallbackText: "Usage: /attach <thread-id>",
2411
+ });
2412
+ return;
2413
+ }
2414
+ const requestedThread = session.getSessionRecord(threadId);
2415
+ if (!requestedThread) {
2416
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(`Unknown ${labelOf(session.getInfo())} session: ${threadId}`)}`, {
2417
+ fallbackText: `Failed: Unknown ${labelOf(session.getInfo())} session: ${threadId}`,
2418
+ });
2419
+ return;
2420
+ }
2421
+ const workspacePolicy = evaluateWorkspacePolicy(requestedThread.cwd, config);
2422
+ if (!workspacePolicy.allowed) {
2423
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(workspacePolicy.warning ?? "Thread workspace blocked by policy.")}`, {
2424
+ fallbackText: `Failed: ${workspacePolicy.warning ?? "Thread workspace blocked by policy."}`,
2425
+ });
2426
+ return;
2427
+ }
2428
+ const busyState = getBusyState(contextKey);
2429
+ busyState.switching = true;
2430
+ try {
2431
+ const info = await session.switchSession(threadId);
2432
+ updateSessionMetadata(contextKey, session);
2433
+ const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2434
+ const html = ["<b>Attached to thread.</b>", policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
2435
+ const plain = ["Attached to thread.", policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
2436
+ await safeReply(ctx, html, { fallbackText: plain });
2437
+ }
2438
+ catch (error) {
2439
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
2440
+ fallbackText: `Failed: ${friendlyErrorText(error)}`,
2441
+ });
2442
+ }
2443
+ finally {
2444
+ busyState.switching = false;
2445
+ }
2446
+ });
2447
+ bot.command(["sessions", "switch"], async (ctx) => {
2448
+ const chatId = ctx.chat?.id;
2449
+ if (!chatId) {
2450
+ return;
2451
+ }
2452
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2453
+ if (!contextSession) {
2454
+ return;
2455
+ }
2456
+ const { contextKey, session } = contextSession;
2457
+ if (isBusy(contextKey)) {
2458
+ await safeReply(ctx, escapeHTML("Cannot switch sessions while a prompt is running."), {
2459
+ fallbackText: "Cannot switch sessions while a prompt is running.",
2460
+ });
2461
+ return;
2462
+ }
2463
+ const rawText = ctx.message?.text ?? "";
2464
+ const threadId = rawText.replace(/^\/(?:sessions|switch)(?:@\w+)?\s*/, "").trim();
2465
+ const requestedThread = threadId ? session.getSessionRecord(threadId) : null;
2466
+ if (threadId && requestedThread) {
2467
+ const workspacePolicy = evaluateWorkspacePolicy(requestedThread.cwd, config);
2468
+ if (!workspacePolicy.allowed) {
2469
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(workspacePolicy.warning ?? "Thread workspace blocked by policy.")}`, {
2470
+ fallbackText: `Failed: ${workspacePolicy.warning ?? "Thread workspace blocked by policy."}`,
2471
+ });
2472
+ return;
2473
+ }
2474
+ const busyState = getBusyState(contextKey);
2475
+ busyState.switching = true;
2476
+ try {
2477
+ const info = await session.switchSession(threadId);
2478
+ updateSessionMetadata(contextKey, session);
2479
+ const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2480
+ const html = ["<b>Switched thread.</b>", policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
2481
+ const plain = ["Switched thread.", policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
2482
+ await safeReply(ctx, html, { fallbackText: plain });
2483
+ }
2484
+ catch (error) {
2485
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
2486
+ fallbackText: `Failed: ${friendlyErrorText(error)}`,
2487
+ });
2488
+ }
2489
+ finally {
2490
+ busyState.switching = false;
2491
+ }
2492
+ return;
2493
+ }
2494
+ const query = threadId;
2495
+ const pinnedThreadIds = registry.listPinnedThreadIds(contextKey);
2496
+ const pinnedSet = new Set(pinnedThreadIds);
2497
+ const sessions = orderPinnedSessions(filterSessions(session.listAllSessions(100), query)
2498
+ .filter((listedSession) => evaluateWorkspacePolicy(listedSession.cwd, config).allowed), pinnedThreadIds).slice(0, 50);
2499
+ if (sessions.length === 0) {
2500
+ const message = query ? `No threads found matching "${query}".` : "No recent threads found.";
2501
+ await safeReply(ctx, escapeHTML(message), {
2502
+ fallbackText: message,
2503
+ });
2504
+ return;
2505
+ }
2506
+ const groupedSessions = new Map();
2507
+ for (const listedSession of sessions) {
2508
+ const workspaceSessions = groupedSessions.get(listedSession.cwd);
2509
+ if (workspaceSessions) {
2510
+ workspaceSessions.push(listedSession);
2511
+ }
2512
+ else {
2513
+ groupedSessions.set(listedSession.cwd, [listedSession]);
2514
+ }
2515
+ }
2516
+ const orderedSessions = [];
2517
+ for (const workspaceSessions of groupedSessions.values()) {
2518
+ orderedSessions.push(...workspaceSessions);
2519
+ }
2520
+ pendingSessionPicks.set(contextKey, orderedSessions.map((listedSession) => listedSession.id));
2521
+ const activeThreadId = session.getInfo().threadId;
2522
+ const sessionButtons = orderedSessions.map((listedSession, index) => {
2523
+ return {
2524
+ label: formatSessionLabel({
2525
+ workspace: listedSession.cwd,
2526
+ title: listedSession.title || listedSession.firstUserMessage || "",
2527
+ relativeTime: formatRelativeTime(listedSession.updatedAt),
2528
+ model: listedSession.model || undefined,
2529
+ isActive: listedSession.id === activeThreadId,
2530
+ isPinned: pinnedSet.has(listedSession.id),
2531
+ }),
2532
+ callbackData: `sess_${index}`,
2533
+ };
2534
+ });
2535
+ pendingSessionButtons.set(contextKey, sessionButtons);
2536
+ const keyboard = paginateKeyboard(sessionButtons, 0, "sess");
2537
+ const heading = query ? `Matching threads (${orderedSessions.length})` : `Recent threads (${orderedSessions.length})`;
2538
+ await safeReply(ctx, `<b>${escapeHTML(heading)}</b>:\nTap to switch.`, {
2539
+ fallbackText: `${heading}:\nTap to switch.`,
2540
+ replyMarkup: keyboard,
2541
+ });
2542
+ });
2543
+ bot.command("pin", async (ctx) => {
2544
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2545
+ if (!contextSession) {
2546
+ return;
2547
+ }
2548
+ const { contextKey, session } = contextSession;
2549
+ const rawText = ctx.message?.text ?? "";
2550
+ const requestedThreadId = rawText.replace(/^\/pin(?:@\w+)?\s*/i, "").trim();
2551
+ const threadId = requestedThreadId || session.getInfo().threadId;
2552
+ if (!threadId) {
2553
+ await safeReply(ctx, escapeHTML("No active thread to pin. Use /pin <thread-id>."), {
2554
+ fallbackText: "No active thread to pin. Use /pin <thread-id>.",
2555
+ });
2556
+ return;
2557
+ }
2558
+ if (!session.getSessionRecord(threadId)) {
2559
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(`Unknown ${labelOf(session.getInfo())} session: ${threadId}`)}`, {
2560
+ fallbackText: `Failed: Unknown ${labelOf(session.getInfo())} session: ${threadId}`,
2561
+ });
2562
+ return;
2563
+ }
2564
+ const pinned = registry.pinThread(contextKey, threadId);
2565
+ await safeReply(ctx, `<b>Pinned thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Total pinned:</b> <code>${pinned.length}</code>`, {
2566
+ fallbackText: `Pinned thread: ${threadId}\nTotal pinned: ${pinned.length}`,
2567
+ });
2568
+ });
2569
+ bot.command("unpin", async (ctx) => {
2570
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2571
+ if (!contextSession) {
2572
+ return;
2573
+ }
2574
+ const { contextKey, session } = contextSession;
2575
+ const rawText = ctx.message?.text ?? "";
2576
+ const requestedThreadId = rawText.replace(/^\/unpin(?:@\w+)?\s*/i, "").trim();
2577
+ const threadId = requestedThreadId || session.getInfo().threadId;
2578
+ if (!threadId) {
2579
+ await safeReply(ctx, escapeHTML("No active thread to unpin. Use /unpin <thread-id>."), {
2580
+ fallbackText: "No active thread to unpin. Use /unpin <thread-id>.",
2581
+ });
2582
+ return;
2583
+ }
2584
+ const pinned = registry.unpinThread(contextKey, threadId);
2585
+ await safeReply(ctx, `<b>Unpinned thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Total pinned:</b> <code>${pinned.length}</code>`, {
2586
+ fallbackText: `Unpinned thread: ${threadId}\nTotal pinned: ${pinned.length}`,
2587
+ });
2588
+ });
2589
+ bot.command("pinned", async (ctx) => {
2590
+ const chatId = ctx.chat?.id;
2591
+ if (!chatId) {
2592
+ return;
2593
+ }
2594
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2595
+ if (!contextSession) {
2596
+ return;
2597
+ }
2598
+ const { contextKey, session } = contextSession;
2599
+ const pinnedThreadIds = registry.listPinnedThreadIds(contextKey);
2600
+ const pinnedSessions = pinnedThreadIds
2601
+ .map((threadId) => session.getSessionRecord(threadId))
2602
+ .filter((record) => Boolean(record));
2603
+ if (pinnedSessions.length === 0) {
2604
+ await safeReply(ctx, escapeHTML("No pinned threads."), { fallbackText: "No pinned threads." });
2605
+ return;
2606
+ }
2607
+ const activeThreadId = session.getInfo().threadId;
2608
+ pendingSessionPicks.set(contextKey, pinnedSessions.map((record) => record.id));
2609
+ const sessionButtons = pinnedSessions.map((record, index) => ({
2610
+ label: formatSessionLabel({
2611
+ workspace: record.cwd,
2612
+ title: record.title || record.firstUserMessage || "",
2613
+ relativeTime: formatRelativeTime(record.updatedAt),
2614
+ model: record.model || undefined,
2615
+ isActive: record.id === activeThreadId,
2616
+ isPinned: true,
2617
+ }),
2618
+ callbackData: `sess_${index}`,
2619
+ }));
2620
+ pendingSessionButtons.set(contextKey, sessionButtons);
2621
+ await safeReply(ctx, `<b>Pinned threads</b> (${pinnedSessions.length}):\nTap to switch.`, {
2622
+ fallbackText: `Pinned threads (${pinnedSessions.length}):\nTap to switch.`,
2623
+ replyMarkup: paginateKeyboard(sessionButtons, 0, "sess"),
2624
+ });
2625
+ });
2626
+ bot.command("model", async (ctx) => {
2627
+ const chatId = ctx.chat?.id;
2628
+ if (!chatId) {
2629
+ return;
2630
+ }
2631
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2632
+ if (!contextSession) {
2633
+ return;
2634
+ }
2635
+ const { contextKey, session } = contextSession;
2636
+ if (isBusy(contextKey)) {
2637
+ await safeReply(ctx, escapeHTML("Cannot change model while a prompt is running."), {
2638
+ fallbackText: "Cannot change model while a prompt is running.",
2639
+ });
2640
+ return;
2641
+ }
2642
+ const models = session.listModels();
2643
+ if (models.length === 0) {
2644
+ await safeReply(ctx, escapeHTML("No models available."), {
2645
+ fallbackText: "No models available.",
2646
+ });
2647
+ return;
2648
+ }
2649
+ const currentModel = session.getInfo().model ?? "(default)";
2650
+ const modelButtons = models.map((model) => ({
2651
+ label: `${model.displayName}${model.slug === currentModel ? " ✓" : ""}`,
2652
+ callbackData: `model_${model.slug}`,
2653
+ }));
2654
+ pendingModelButtons.set(contextKey, modelButtons);
2655
+ const keyboard = paginateKeyboard(modelButtons, 0, "model");
2656
+ await safeReply(ctx, [`<b>Current model:</b> <code>${escapeHTML(currentModel)}</code>`, "", "Select a model for new threads:"].join("\n"), {
2657
+ fallbackText: [`Current model: ${currentModel}`, "", "Select a model for new threads:"].join("\n"),
2658
+ replyMarkup: keyboard,
2659
+ });
2660
+ });
2661
+ bot.command("fast", async (ctx) => {
2662
+ const chatId = ctx.chat?.id;
2663
+ if (!chatId) {
2664
+ return;
2665
+ }
2666
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2667
+ if (!contextSession) {
2668
+ return;
2669
+ }
2670
+ const { contextKey, session } = contextSession;
2671
+ if (!capabilitiesOf(session.getInfo()).fastMode) {
2672
+ const text = `Fast mode is not supported for ${labelOf(session.getInfo())}.`;
2673
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2674
+ return;
2675
+ }
2676
+ if (isBusy(contextKey)) {
2677
+ await safeReply(ctx, escapeHTML("Cannot change fast mode while a prompt is running."), {
2678
+ fallbackText: "Cannot change fast mode while a prompt is running.",
2679
+ });
2680
+ return;
2681
+ }
2682
+ const rawText = ctx.message?.text ?? "";
2683
+ const argument = rawText.replace(/^\/fast(?:@\w+)?\s*/i, "").trim();
2684
+ const currentFastMode = session.getInfo().fastMode;
2685
+ const nextFastMode = parseFastModeArgument(argument, currentFastMode);
2686
+ if (nextFastMode === undefined) {
2687
+ await safeReply(ctx, escapeHTML("Usage: /fast [on|off]"), {
2688
+ fallbackText: "Usage: /fast [on|off]",
2689
+ });
2690
+ return;
2691
+ }
2692
+ try {
2693
+ const result = session.setFastMode(nextFastMode);
2694
+ updateSessionMetadata(contextKey, session);
2695
+ const info = session.getInfo();
2696
+ const plain = [
2697
+ `Fast mode: ${result.enabled ? "on" : "off"}`,
2698
+ `Launch profile: ${result.profile.label} (${formatLaunchProfileBehavior(result.profile)})`,
2699
+ result.appliedToActiveThread
2700
+ ? "Applied to the current idle thread and future threads."
2701
+ : "Applies to the next thread in this Telegram context.",
2702
+ "",
2703
+ renderSessionInfoPlain(info),
2704
+ ].join("\n");
2705
+ const html = [
2706
+ `<b>Fast mode:</b> <code>${result.enabled ? "on" : "off"}</code>`,
2707
+ `<b>Launch profile:</b> <code>${escapeHTML(result.profile.label)}</code> <i>(${escapeHTML(formatLaunchProfileBehavior(result.profile))})</i>`,
2708
+ result.appliedToActiveThread
2709
+ ? "Applied to the current idle thread and future threads."
2710
+ : "Applies to the next thread in this Telegram context.",
2711
+ "",
2712
+ renderSessionInfoHTML(info),
2713
+ ].join("\n");
2714
+ await safeReply(ctx, html, { fallbackText: plain });
2715
+ }
2716
+ catch (error) {
2717
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
2718
+ fallbackText: `Failed: ${friendlyErrorText(error)}`,
2719
+ });
2720
+ }
2721
+ });
2722
+ const openReasoningPicker = async (ctx) => {
2723
+ const chatId = ctx.chat?.id;
2724
+ if (!chatId) {
2725
+ return;
2726
+ }
2727
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2728
+ if (!contextSession) {
2729
+ return;
2730
+ }
2731
+ const { contextKey, session } = contextSession;
2732
+ const info = session.getInfo();
2733
+ if (!capabilitiesOf(info).reasoningSelection) {
2734
+ const text = `${agentReasoningLabel(idOf(info))} selection is not supported for ${labelOf(info)}.`;
2735
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2736
+ return;
2737
+ }
2738
+ const efforts = idOf(info) === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
2739
+ const current = info.reasoningEffort;
2740
+ const effortButtons = efforts.map((effort) => ({
2741
+ label: effort === current ? `${effort} ✓` : effort,
2742
+ callbackData: `effort_${effort}`,
2743
+ }));
2744
+ pendingEffortButtons.set(contextKey, effortButtons);
2745
+ const keyboard = paginateKeyboard(effortButtons, 0, "effort");
2746
+ const label = agentReasoningLabel(idOf(info));
2747
+ const text = current
2748
+ ? `<b>${escapeHTML(label)}:</b> <code>${escapeHTML(current)}</code>\n\nSelect for new threads:`
2749
+ : `<b>${escapeHTML(label)}:</b> not set (model default)\n\nSelect for new threads:`;
2750
+ await safeReply(ctx, text, {
2751
+ fallbackText: text.replace(/<[^>]+>/g, ""),
2752
+ replyMarkup: keyboard,
2753
+ });
2754
+ };
2755
+ bot.command(["effort", "reasoning"], openReasoningPicker);
2756
+ bot.callbackQuery(/^agent_(codex|pi)$/, async (ctx) => {
2757
+ const chatId = ctx.chat?.id;
2758
+ const messageId = ctx.callbackQuery.message?.message_id;
2759
+ const selectedAgent = ctx.match?.[1];
2760
+ const contextKey = contextKeyFromCtx(ctx);
2761
+ if (!chatId || !contextKey || !selectedAgent) {
2762
+ await ctx.answerCallbackQuery();
2763
+ return;
2764
+ }
2765
+ const picks = pendingAgentPicks.get(contextKey);
2766
+ if (!picks?.includes(selectedAgent)) {
2767
+ await ctx.answerCallbackQuery({ text: "Expired, run /agent again" });
2768
+ return;
2769
+ }
2770
+ if (isBusy(contextKey)) {
2771
+ await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
2772
+ return;
2773
+ }
2774
+ await ctx.answerCallbackQuery({ text: `Switching to ${agentLabel(selectedAgent)}...` });
2775
+ pendingAgentPicks.delete(contextKey);
2776
+ try {
2777
+ const session = await registry.switchAgent(contextKey, selectedAgent);
2778
+ const info = session.getInfo();
2779
+ const html = [`<b>Agent switched to ${escapeHTML(labelOf(info))}.</b>`, "", renderSessionInfoHTML(info)].join("\n");
2780
+ const plain = [`Agent switched to ${labelOf(info)}.`, "", renderSessionInfoPlain(info)].join("\n");
2781
+ if (messageId) {
2782
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain });
2783
+ }
2784
+ else {
2785
+ await safeReply(ctx, html, { fallbackText: plain });
2786
+ }
2787
+ }
2788
+ catch (error) {
2789
+ const html = `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`;
2790
+ const plain = `Failed: ${friendlyErrorText(error)}`;
2791
+ if (messageId) {
2792
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain });
2793
+ }
2794
+ else {
2795
+ await safeReply(ctx, html, { fallbackText: plain });
2796
+ }
2797
+ }
2798
+ });
2799
+ bot.callbackQuery(NOOP_PAGE_CALLBACK_DATA, async (ctx) => {
2800
+ await ctx.answerCallbackQuery();
2801
+ });
2802
+ handlePageCallback(/^sess_page_(\d+)$/, "sess", pendingSessionButtons, "Expired, run /sessions again");
2803
+ handlePageCallback(/^ws_page_(\d+)$/, "ws", pendingWorkspaceButtons, "Expired, run /new again");
2804
+ handlePageCallback(/^launch_page_(\d+)$/, "launch", pendingLaunchButtons, `Expired, run ${LAUNCH_PROFILES_COMMAND} again`);
2805
+ handlePageCallback(/^model_page_(\d+)$/, "model", pendingModelButtons, "Expired, run /model again");
2806
+ handlePageCallback(/^effort_page_(\d+)$/, "effort", pendingEffortButtons, "Expired, run /reasoning again");
2807
+ bot.callbackQuery(/^(?:codex_abort|agent_abort):(.+)$/, async (ctx) => {
2808
+ const contextKey = ctx.match?.[1];
2809
+ if (!contextKey) {
2810
+ await ctx.answerCallbackQuery();
2811
+ return;
2812
+ }
2813
+ const session = registry.get(contextKey);
2814
+ if (!session) {
2815
+ await ctx.answerCallbackQuery({ text: "Nothing to abort" });
2816
+ return;
2817
+ }
2818
+ await ctx.answerCallbackQuery({ text: "Aborting..." });
2819
+ await session.abort();
2820
+ });
2821
+ bot.callbackQuery(/^queue_(cancel|remove|top|up|down|run):(-?\d+(?::\d+)?):([a-z0-9]+)$/, async (ctx) => {
2822
+ const action = ctx.match?.[1];
2823
+ const contextKey = ctx.match?.[2];
2824
+ const queueId = ctx.match?.[3];
2825
+ if (!action || !contextKey || !queueId) {
2826
+ await ctx.answerCallbackQuery();
2827
+ return;
2828
+ }
2829
+ const currentContextKey = contextKeyFromCtx(ctx);
2830
+ if (currentContextKey && currentContextKey !== contextKey) {
2831
+ await ctx.answerCallbackQuery({ text: "This queue button belongs to another chat or topic." });
2832
+ return;
2833
+ }
2834
+ const chatId = ctx.chat?.id;
2835
+ const messageId = ctx.callbackQuery.message?.message_id;
2836
+ if (action === "top" || action === "up" || action === "down") {
2837
+ const item = action === "top"
2838
+ ? promptStore.moveToTop(contextKey, queueId)
2839
+ : action === "up"
2840
+ ? promptStore.moveUp(contextKey, queueId)
2841
+ : promptStore.moveDown(contextKey, queueId);
2842
+ await ctx.answerCallbackQuery({ text: item ? `Moved ${queueId} ${action}.` : "Queued prompt not found." });
2843
+ if (chatId && messageId) {
2844
+ const rendered = renderQueueList(contextKey, promptStore.list(contextKey));
2845
+ await safeEditMessage(bot, chatId, messageId, rendered.html, {
2846
+ fallbackText: rendered.plain,
2847
+ replyMarkup: rendered.keyboard,
2848
+ });
2849
+ }
2850
+ return;
2851
+ }
2852
+ if (action === "run") {
2853
+ const item = promptStore.remove(contextKey, queueId);
2854
+ if (!item) {
2855
+ await ctx.answerCallbackQuery({ text: "Queued prompt already started or was cancelled." });
2856
+ return;
2857
+ }
2858
+ promptStore.enqueueFront(contextKey, item);
2859
+ promptStore.resume(contextKey);
2860
+ await ctx.answerCallbackQuery({ text: `Queued prompt ${queueId} moved to next.` });
2861
+ if (chatId && messageId) {
2862
+ const rendered = renderQueueList(contextKey, promptStore.list(contextKey));
2863
+ await safeEditMessage(bot, chatId, messageId, rendered.html, {
2864
+ fallbackText: rendered.plain,
2865
+ replyMarkup: rendered.keyboard,
2866
+ });
2867
+ }
2868
+ const session = registry.get(contextKey);
2869
+ if (chatId && session && !getBusyReason(contextKey).busy) {
2870
+ void drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
2871
+ console.error("Failed to drain queue after run-now callback:", error);
2872
+ });
2873
+ }
2874
+ return;
2875
+ }
2876
+ const removed = promptStore.remove(contextKey, queueId);
2877
+ if (!removed) {
2878
+ await ctx.answerCallbackQuery({ text: "Queued prompt already started or was cancelled." });
2879
+ if (chatId && messageId) {
2880
+ if (action === "remove") {
2881
+ const rendered = renderQueueList(contextKey, promptStore.list(contextKey));
2882
+ await safeEditMessage(bot, chatId, messageId, rendered.html, {
2883
+ fallbackText: rendered.plain,
2884
+ replyMarkup: rendered.keyboard,
2885
+ });
2886
+ }
2887
+ else {
2888
+ const message = `Queued prompt ${queueId} is no longer queued.`;
2889
+ await safeEditMessage(bot, chatId, messageId, escapeHTML(message), { fallbackText: message });
2890
+ }
2891
+ }
2892
+ return;
2893
+ }
2894
+ const message = `Cancelled queued prompt ${removed.id}.`;
2895
+ await ctx.answerCallbackQuery({ text: message });
2896
+ if (!chatId || !messageId) {
2897
+ return;
2898
+ }
2899
+ if (action === "remove") {
2900
+ const rendered = renderQueueList(contextKey, promptStore.list(contextKey));
2901
+ await safeEditMessage(bot, chatId, messageId, rendered.html, {
2902
+ fallbackText: rendered.plain,
2903
+ replyMarkup: rendered.keyboard,
2904
+ });
2905
+ return;
2906
+ }
2907
+ await safeEditMessage(bot, chatId, messageId, escapeHTML(message), { fallbackText: message });
2908
+ });
2909
+ bot.callbackQuery(/^approval_(yes|no):([a-z0-9]+)$/, async (ctx) => {
2910
+ const action = ctx.match?.[1];
2911
+ const approvalId = ctx.match?.[2];
2912
+ if (!action || !approvalId) {
2913
+ await ctx.answerCallbackQuery();
2914
+ return;
2915
+ }
2916
+ const pending = pendingApprovals.get(approvalId);
2917
+ if (!pending) {
2918
+ await ctx.answerCallbackQuery({ text: "Approval expired" });
2919
+ return;
2920
+ }
2921
+ const role = getUserRole(ctx);
2922
+ if (pending.requestedBy !== undefined && ctx.from?.id !== pending.requestedBy && role !== "admin") {
2923
+ await ctx.answerCallbackQuery({ text: "Only the requester or an admin can approve" });
2924
+ return;
2925
+ }
2926
+ clearTimeout(pending.timeout);
2927
+ pendingApprovals.delete(approvalId);
2928
+ getBusyState(pending.contextKey).approving = false;
2929
+ const chatId = ctx.chat?.id;
2930
+ const messageId = ctx.callbackQuery.message?.message_id;
2931
+ if (action === "no") {
2932
+ await ctx.answerCallbackQuery({ text: "Denied" });
2933
+ const text = `<b>Denied prompt</b> <code>${escapeHTML(approvalId)}</code>.`;
2934
+ if (chatId && messageId) {
2935
+ await safeEditMessage(bot, chatId, messageId, text, {
2936
+ fallbackText: `Denied prompt ${approvalId}.`,
2937
+ });
2938
+ }
2939
+ const session = registry.get(pending.contextKey);
2940
+ if (chatId && session) {
2941
+ void drainQueuedPrompts(ctx, pending.contextKey, chatId, session).catch((error) => {
2942
+ console.error("Failed to drain queue after approval denial:", error);
2943
+ });
2944
+ }
2945
+ return;
2946
+ }
2947
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2948
+ if (!contextSession) {
2949
+ await ctx.answerCallbackQuery({ text: "No context" });
2950
+ return;
2951
+ }
2952
+ await ctx.answerCallbackQuery({ text: "Approved" });
2953
+ if (chatId && messageId) {
2954
+ await safeEditMessage(bot, chatId, messageId, `<b>Approved prompt</b> <code>${escapeHTML(approvalId)}</code>.`, {
2955
+ fallbackText: `Approved prompt ${approvalId}.`,
2956
+ });
2957
+ }
2958
+ await handleUserPrompt(ctx, pending.contextKey, chatId ?? parseContextKey(pending.contextKey).chatId, contextSession.session, pending.prompt, {
2959
+ approved: true,
2960
+ });
2961
+ });
2962
+ bot.callbackQuery(/^sess_(\d+)$/, async (ctx) => {
2963
+ const chatId = ctx.chat?.id;
2964
+ const messageId = ctx.callbackQuery.message?.message_id;
2965
+ const index = Number.parseInt(ctx.match?.[1] ?? "", 10);
2966
+ if (!chatId || Number.isNaN(index)) {
2967
+ return;
2968
+ }
2969
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2970
+ if (!contextSession) {
2971
+ return;
2972
+ }
2973
+ const { contextKey, session } = contextSession;
2974
+ const threadIds = pendingSessionPicks.get(contextKey);
2975
+ const threadId = threadIds?.[index];
2976
+ if (!threadId) {
2977
+ await ctx.answerCallbackQuery({ text: "Session expired, run /sessions again" });
2978
+ return;
2979
+ }
2980
+ const threadRecord = session.getSessionRecord(threadId);
2981
+ const workspacePolicy = evaluateWorkspacePolicy(threadRecord?.cwd ?? session.getCurrentWorkspace(), config);
2982
+ if (!workspacePolicy.allowed) {
2983
+ await ctx.answerCallbackQuery({ text: "Workspace blocked" });
2984
+ await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(workspacePolicy.warning ?? "Thread workspace blocked by policy.")}`, {
2985
+ fallbackText: `Failed: ${workspacePolicy.warning ?? "Thread workspace blocked by policy."}`,
2986
+ });
2987
+ return;
2988
+ }
2989
+ if (isBusy(contextKey)) {
2990
+ await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
2991
+ return;
2992
+ }
2993
+ await ctx.answerCallbackQuery({ text: "Switching..." });
2994
+ pendingSessionPicks.delete(contextKey);
2995
+ pendingSessionButtons.delete(contextKey);
2996
+ const busyState = getBusyState(contextKey);
2997
+ busyState.switching = true;
2998
+ try {
2999
+ const info = await session.switchSession(threadId);
3000
+ updateSessionMetadata(contextKey, session);
3001
+ const policyLine = renderWorkspacePolicyLine(info.workspace, config);
3002
+ const plainText = ["Switched session.", policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
3003
+ const html = ["<b>Switched session.</b>", policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
3004
+ if (messageId) {
3005
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plainText });
3006
+ }
3007
+ else {
3008
+ await safeReply(ctx, html, { fallbackText: plainText });
3009
+ }
3010
+ }
3011
+ catch (error) {
3012
+ const errHtml = `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`;
3013
+ const errPlain = `Failed: ${friendlyErrorText(error)}`;
3014
+ if (messageId) {
3015
+ await safeEditMessage(bot, chatId, messageId, errHtml, { fallbackText: errPlain });
3016
+ }
3017
+ else {
3018
+ await safeReply(ctx, errHtml, { fallbackText: errPlain });
3019
+ }
3020
+ }
3021
+ finally {
3022
+ busyState.switching = false;
3023
+ }
3024
+ });
3025
+ bot.callbackQuery(/^ws_(\d+)$/, async (ctx) => {
3026
+ const chatId = ctx.chat?.id;
3027
+ const messageId = ctx.callbackQuery.message?.message_id;
3028
+ const index = Number.parseInt(ctx.match?.[1] ?? "", 10);
3029
+ if (!chatId || Number.isNaN(index)) {
3030
+ return;
3031
+ }
3032
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
3033
+ if (!contextSession) {
3034
+ return;
3035
+ }
3036
+ const { contextKey, session } = contextSession;
3037
+ const workspaces = pendingWorkspacePicks.get(contextKey);
3038
+ const workspace = workspaces?.[index];
3039
+ if (!workspace) {
3040
+ await ctx.answerCallbackQuery({ text: "Expired, run /new again" });
3041
+ return;
3042
+ }
3043
+ const workspacePolicy = evaluateWorkspacePolicy(workspace, config);
3044
+ if (!workspacePolicy.allowed) {
3045
+ await ctx.answerCallbackQuery({ text: "Workspace blocked" });
3046
+ await safeReply(ctx, escapeHTML(workspacePolicy.warning ?? "Workspace blocked by policy."), {
3047
+ fallbackText: workspacePolicy.warning ?? "Workspace blocked by policy.",
3048
+ });
3049
+ return;
3050
+ }
3051
+ if (isBusy(contextKey)) {
3052
+ await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3053
+ return;
3054
+ }
3055
+ await ctx.answerCallbackQuery({ text: "Creating thread..." });
3056
+ pendingWorkspacePicks.delete(contextKey);
3057
+ pendingWorkspaceButtons.delete(contextKey);
3058
+ const busyState = getBusyState(contextKey);
3059
+ busyState.switching = true;
3060
+ try {
3061
+ const info = await session.newThread(workspace);
3062
+ updateSessionMetadata(contextKey, session);
3063
+ const label = isTopicContext(contextKey) ? "New thread created for this topic." : "New thread created.";
3064
+ const policyLine = renderWorkspacePolicyLine(info.workspace, config);
3065
+ const plainText = [label, policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
3066
+ const html = [`<b>${escapeHTML(label)}</b>`, policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
3067
+ if (messageId) {
3068
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plainText });
3069
+ }
3070
+ else {
3071
+ await safeReply(ctx, html, { fallbackText: plainText });
3072
+ }
3073
+ }
3074
+ catch (error) {
3075
+ const errHtml = `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`;
3076
+ const errPlain = `Failed: ${friendlyErrorText(error)}`;
3077
+ if (messageId) {
3078
+ await safeEditMessage(bot, chatId, messageId, errHtml, { fallbackText: errPlain });
3079
+ }
3080
+ else {
3081
+ await safeReply(ctx, errHtml, { fallbackText: errPlain });
3082
+ }
3083
+ }
3084
+ finally {
3085
+ busyState.switching = false;
3086
+ }
3087
+ });
3088
+ bot.callbackQuery(/^launch_(\d+)$/, async (ctx) => {
3089
+ const chatId = ctx.chat?.id;
3090
+ const messageId = ctx.callbackQuery.message?.message_id;
3091
+ const index = Number.parseInt(ctx.match?.[1] ?? "", 10);
3092
+ if (!chatId || Number.isNaN(index)) {
3093
+ return;
3094
+ }
3095
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
3096
+ if (!contextSession) {
3097
+ return;
3098
+ }
3099
+ const { contextKey, session } = contextSession;
3100
+ const launchProfileIds = pendingLaunchPicks.get(contextKey);
3101
+ const profileId = launchProfileIds?.[index];
3102
+ if (!profileId) {
3103
+ await ctx.answerCallbackQuery({ text: `Expired, run ${LAUNCH_PROFILES_COMMAND} again` });
3104
+ return;
3105
+ }
3106
+ if (isBusy(contextKey)) {
3107
+ await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3108
+ return;
3109
+ }
3110
+ const profile = findLaunchProfile(config.launchProfiles, profileId);
3111
+ if (!profile) {
3112
+ clearLaunchSelectionState(contextKey);
3113
+ await ctx.answerCallbackQuery({ text: "Launch profile no longer exists" });
3114
+ return;
3115
+ }
3116
+ if (profile.unsafe) {
3117
+ pendingUnsafeLaunchConfirmations.set(contextKey, profile.id);
3118
+ pendingLaunchPicks.delete(contextKey);
3119
+ pendingLaunchButtons.delete(contextKey);
3120
+ await ctx.answerCallbackQuery({ text: "Confirm danger-full-access" });
3121
+ const confirmKeyboard = new InlineKeyboard()
3122
+ .text("Enable danger-full-access", `launchconfirm_yes:${profile.id}`)
3123
+ .row()
3124
+ .text("Cancel", `launchconfirm_no:${profile.id}`);
3125
+ const html = [
3126
+ `<b>Confirm launch profile:</b> <code>${escapeHTML(profile.label)}</code>`,
3127
+ `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(profile))}</code>`,
3128
+ "",
3129
+ "⚠️ <b>This profile uses danger-full-access.</b>",
3130
+ "It will apply to new or reattached threads in this Telegram context.",
3131
+ ].join("\n");
3132
+ const plain = [
3133
+ `Confirm launch profile: ${profile.label}`,
3134
+ `Behavior: ${formatLaunchProfileBehavior(profile)}`,
3135
+ "",
3136
+ "WARNING: This profile uses danger-full-access.",
3137
+ "It will apply to new or reattached threads in this Telegram context.",
3138
+ ].join("\n");
3139
+ if (messageId) {
3140
+ await safeEditMessage(bot, chatId, messageId, html, {
3141
+ fallbackText: plain,
3142
+ replyMarkup: confirmKeyboard,
3143
+ });
3144
+ }
3145
+ else {
3146
+ await safeReply(ctx, html, {
3147
+ fallbackText: plain,
3148
+ replyMarkup: confirmKeyboard,
3149
+ });
3150
+ }
3151
+ return;
3152
+ }
3153
+ await ctx.answerCallbackQuery({ text: `Launch set to ${profile.label}` });
3154
+ clearLaunchSelectionState(contextKey);
3155
+ const selectedProfile = session.setLaunchProfile(profile.id);
3156
+ updateSessionMetadata(contextKey, session);
3157
+ const html = [
3158
+ `<b>Launch profile set to</b> <code>${escapeHTML(selectedProfile.label)}</code>`,
3159
+ `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(selectedProfile))}</code>`,
3160
+ "",
3161
+ "Applies to new or reattached threads.",
3162
+ ].join("\n");
3163
+ const plain = [
3164
+ `Launch profile set to ${selectedProfile.label}`,
3165
+ `Behavior: ${formatLaunchProfileBehavior(selectedProfile)}`,
3166
+ "",
3167
+ "Applies to new or reattached threads.",
3168
+ ].join("\n");
3169
+ if (messageId) {
3170
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain });
3171
+ }
3172
+ else {
3173
+ await safeReply(ctx, html, { fallbackText: plain });
3174
+ }
3175
+ });
3176
+ bot.callbackQuery(/^launchconfirm_(yes|no):([a-z0-9_-]+)$/, async (ctx) => {
3177
+ const chatId = ctx.chat?.id;
3178
+ const messageId = ctx.callbackQuery.message?.message_id;
3179
+ const action = ctx.match?.[1];
3180
+ const confirmedProfileId = ctx.match?.[2];
3181
+ if (!chatId || !messageId || !action || !confirmedProfileId) {
3182
+ return;
3183
+ }
3184
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
3185
+ if (!contextSession) {
3186
+ return;
3187
+ }
3188
+ const { contextKey, session } = contextSession;
3189
+ const profileId = pendingUnsafeLaunchConfirmations.get(contextKey);
3190
+ if (!profileId || profileId !== confirmedProfileId) {
3191
+ await ctx.answerCallbackQuery({ text: `Expired, run ${LAUNCH_PROFILES_COMMAND} again` });
3192
+ return;
3193
+ }
3194
+ if (action === "no") {
3195
+ clearLaunchSelectionState(contextKey);
3196
+ await ctx.answerCallbackQuery({ text: "Cancelled" });
3197
+ await safeEditMessage(bot, chatId, messageId, `<b>Launch change cancelled.</b>\n\nRun ${LAUNCH_PROFILES_COMMAND} again to pick another profile.`, {
3198
+ fallbackText: `Launch change cancelled.\n\nRun ${LAUNCH_PROFILES_COMMAND} again to pick another profile.`,
3199
+ });
3200
+ return;
3201
+ }
3202
+ if (isBusy(contextKey)) {
3203
+ await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3204
+ return;
3205
+ }
3206
+ const profile = findLaunchProfile(config.launchProfiles, profileId);
3207
+ if (!profile) {
3208
+ clearLaunchSelectionState(contextKey);
3209
+ await ctx.answerCallbackQuery({ text: "Launch profile no longer exists" });
3210
+ await safeEditMessage(bot, chatId, messageId, `<b>Launch profile expired.</b>\n\nRun ${LAUNCH_PROFILES_COMMAND} again.`, {
3211
+ fallbackText: `Launch profile expired.\n\nRun ${LAUNCH_PROFILES_COMMAND} again.`,
3212
+ });
3213
+ return;
3214
+ }
3215
+ clearLaunchSelectionState(contextKey);
3216
+ const selectedProfile = session.setLaunchProfile(profile.id);
3217
+ updateSessionMetadata(contextKey, session);
3218
+ await ctx.answerCallbackQuery({ text: `Launch set to ${selectedProfile.label}` });
3219
+ const html = [
3220
+ `<b>Launch profile set to</b> <code>${escapeHTML(selectedProfile.label)}</code>`,
3221
+ `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(selectedProfile))}</code>`,
3222
+ "",
3223
+ "⚠️ <i>danger-full-access confirmed for new or reattached threads.</i>",
3224
+ ].join("\n");
3225
+ const plain = [
3226
+ `Launch profile set to ${selectedProfile.label}`,
3227
+ `Behavior: ${formatLaunchProfileBehavior(selectedProfile)}`,
3228
+ "",
3229
+ "danger-full-access confirmed for new or reattached threads.",
3230
+ ].join("\n");
3231
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain });
3232
+ });
3233
+ bot.callbackQuery(/^model_(.+)$/, async (ctx) => {
3234
+ const chatId = ctx.chat?.id;
3235
+ const messageId = ctx.callbackQuery.message?.message_id;
3236
+ const slug = ctx.match?.[1];
3237
+ if (!chatId || !slug) {
3238
+ return;
3239
+ }
3240
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
3241
+ if (!contextSession) {
3242
+ return;
3243
+ }
3244
+ const { contextKey, session } = contextSession;
3245
+ const buttons = pendingModelButtons.get(contextKey);
3246
+ if (!buttons) {
3247
+ await ctx.answerCallbackQuery({ text: "Expired, run /model again" });
3248
+ return;
3249
+ }
3250
+ const modelExists = buttons.some((button) => button.callbackData === `model_${slug}`);
3251
+ if (!modelExists) {
3252
+ await ctx.answerCallbackQuery({ text: "Expired, run /model again" });
3253
+ return;
3254
+ }
3255
+ if (isBusy(contextKey)) {
3256
+ await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3257
+ return;
3258
+ }
3259
+ await ctx.answerCallbackQuery({ text: "Setting model..." });
3260
+ pendingModelButtons.delete(contextKey);
3261
+ try {
3262
+ const result = await session.setModelForCurrentSession(slug);
3263
+ updateSessionMetadata(contextKey, session);
3264
+ const scope = result.appliedToActiveThread
3265
+ ? "applied to the current idle thread and future threads"
3266
+ : "applies to new threads";
3267
+ const html = `<b>Model set to</b> <code>${escapeHTML(result.value)}</code> — ${escapeHTML(scope)}.`;
3268
+ const plainText = `Model set to ${result.value} — ${scope}.`;
3269
+ if (messageId) {
3270
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plainText });
3271
+ }
3272
+ else {
3273
+ await safeReply(ctx, html, { fallbackText: plainText });
3274
+ }
3275
+ }
3276
+ catch (error) {
3277
+ const errHtml = `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`;
3278
+ const errPlain = `Failed: ${friendlyErrorText(error)}`;
3279
+ if (messageId) {
3280
+ await safeEditMessage(bot, chatId, messageId, errHtml, { fallbackText: errPlain });
3281
+ }
3282
+ else {
3283
+ await safeReply(ctx, errHtml, { fallbackText: errPlain });
3284
+ }
3285
+ }
3286
+ });
3287
+ bot.callbackQuery(/^effort_(off|minimal|low|medium|high|xhigh)$/, async (ctx) => {
3288
+ const chatId = ctx.chat?.id;
3289
+ const messageId = ctx.callbackQuery.message?.message_id;
3290
+ const effort = ctx.match?.[1];
3291
+ if (!chatId || !messageId || !effort) {
3292
+ return;
3293
+ }
3294
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
3295
+ if (!contextSession) {
3296
+ return;
3297
+ }
3298
+ const { contextKey, session } = contextSession;
3299
+ const buttons = pendingEffortButtons.get(contextKey);
3300
+ if (!buttons || !buttons.some((button) => button.callbackData === `effort_${effort}`)) {
3301
+ await ctx.answerCallbackQuery({ text: "Expired, run /reasoning again" });
3302
+ return;
3303
+ }
3304
+ if (isBusy(contextKey)) {
3305
+ await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3306
+ return;
3307
+ }
3308
+ await ctx.answerCallbackQuery({ text: `Effort set to ${effort}` });
3309
+ pendingEffortButtons.delete(contextKey);
3310
+ const result = await session.setReasoningEffortForCurrentSession(effort);
3311
+ updateSessionMetadata(contextKey, session);
3312
+ const label = agentReasoningLabel(idOf(session.getInfo()));
3313
+ const scope = result.appliedToActiveThread
3314
+ ? "applied to the current idle thread and future threads"
3315
+ : "applies to new threads";
3316
+ const html = `⚡ ${escapeHTML(label)} set to <code>${escapeHTML(effort)}</code> — ${escapeHTML(scope)}.`;
3317
+ await safeEditMessage(bot, chatId, messageId, html, {
3318
+ fallbackText: `⚡ ${label} set to ${effort} — ${scope}.`,
3319
+ });
3320
+ });
3321
+ bot.callbackQuery(/^artifact_(send|zip|delete|delete_confirm):([a-zA-Z0-9._-]+)$/, async (ctx) => {
3322
+ const action = ctx.match?.[1];
3323
+ const turnId = ctx.match?.[2];
3324
+ const chatId = ctx.chat?.id;
3325
+ const messageId = ctx.callbackQuery.message?.message_id;
3326
+ if (!action || !turnId || !chatId) {
3327
+ await ctx.answerCallbackQuery();
3328
+ return;
3329
+ }
3330
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
3331
+ if (!contextSession) {
3332
+ await ctx.answerCallbackQuery({ text: "No context" });
3333
+ return;
3334
+ }
3335
+ const workspace = contextSession.session.getInfo().workspace;
3336
+ if (action === "delete") {
3337
+ await ctx.answerCallbackQuery({ text: "Confirm deletion" });
3338
+ const keyboard = new InlineKeyboard()
3339
+ .text("Delete artifacts", `artifact_delete_confirm:${turnId}`)
3340
+ .row()
3341
+ .text("Cancel", NOOP_PAGE_CALLBACK_DATA);
3342
+ const html = `<b>Delete artifact turn?</b>\n<code>${escapeHTML(turnId)}</code>`;
3343
+ const plain = `Delete artifact turn?\n${turnId}`;
3344
+ if (messageId) {
3345
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain, replyMarkup: keyboard });
3346
+ }
3347
+ else {
3348
+ await safeReply(ctx, html, { fallbackText: plain, replyMarkup: keyboard });
3349
+ }
3350
+ return;
3351
+ }
3352
+ if (action === "delete_confirm") {
3353
+ const removed = await removeArtifactTurn(workspace, turnId);
3354
+ await ctx.answerCallbackQuery({ text: removed ? "Deleted" : "Already gone" });
3355
+ const html = removed
3356
+ ? `<b>Deleted artifact turn:</b> <code>${escapeHTML(turnId)}</code>`
3357
+ : `<b>Artifact turn not found:</b> <code>${escapeHTML(turnId)}</code>`;
3358
+ const plain = removed ? `Deleted artifact turn: ${turnId}` : `Artifact turn not found: ${turnId}`;
3359
+ if (messageId) {
3360
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain });
3361
+ }
3362
+ else {
3363
+ await safeReply(ctx, html, { fallbackText: plain });
3364
+ }
3365
+ return;
3366
+ }
3367
+ const report = await getArtifactTurnReport(workspace, turnId, config.maxFileSize);
3368
+ if (!report) {
3369
+ await ctx.answerCallbackQuery({ text: "Artifact turn not found" });
3370
+ return;
3371
+ }
3372
+ await ctx.answerCallbackQuery({ text: action === "zip" ? "Sending ZIP..." : "Sending artifacts..." });
3373
+ if (action === "zip") {
3374
+ await deliverArtifactReportZip(ctx, chatId, report, ctx.callbackQuery.message?.message_thread_id);
3375
+ }
3376
+ else {
3377
+ await deliverArtifactReport(ctx, chatId, report, ctx.callbackQuery.message?.message_thread_id);
3378
+ }
3379
+ });
3380
+ bot.on("message:text", async (ctx) => {
3381
+ const contextSession = await getContextSession(ctx);
3382
+ if (!contextSession) {
3383
+ return;
3384
+ }
3385
+ const userText = ctx.message.text.trim();
3386
+ if (!userText || userText.startsWith("/")) {
3387
+ return;
3388
+ }
3389
+ const { contextKey, session } = contextSession;
3390
+ await setReaction(ctx, "👀");
3391
+ try {
3392
+ await handleUserPrompt(ctx, contextKey, ctx.chat.id, session, userText);
3393
+ await setReaction(ctx, "👍");
3394
+ }
3395
+ catch {
3396
+ await clearReaction(ctx);
3397
+ }
3398
+ });
3399
+ bot.on(["message:voice", "message:audio"], async (ctx) => {
3400
+ const contextSession = await getContextSession(ctx);
3401
+ if (!contextSession) {
3402
+ return;
3403
+ }
3404
+ const { contextKey, session } = contextSession;
3405
+ const chatId = ctx.chat.id;
3406
+ const fileId = ctx.message.voice?.file_id ?? ctx.message.audio?.file_id;
3407
+ if (!fileId) {
3408
+ return;
3409
+ }
3410
+ const busyState = getBusyState(contextKey);
3411
+ busyState.transcribing = true;
3412
+ let tempFilePath;
3413
+ let transcript;
3414
+ try {
3415
+ await sendChatActionSafe(ctx.api, chatId, "typing", ctx.message?.message_thread_id);
3416
+ tempFilePath = await downloadTelegramFile(ctx.api, config.telegramBotToken, fileId);
3417
+ const backendPreference = getEffectiveVoiceBackend(contextKey);
3418
+ const language = getEffectiveVoiceLanguage(contextKey);
3419
+ const result = await transcribeAudio(tempFilePath, {
3420
+ preferredBackend: backendPreference === "auto" ? undefined : backendPreference,
3421
+ language,
3422
+ });
3423
+ transcript = result.text.trim();
3424
+ if (!transcript) {
3425
+ await safeReply(ctx, escapeHTML("Transcription was empty. Please try again or send text instead."), {
3426
+ fallbackText: "Transcription was empty. Please try again or send text instead.",
3427
+ });
3428
+ return;
3429
+ }
3430
+ const preview = trimLine(transcript.replace(/\s+/g, " "), 100);
3431
+ await safeReply(ctx, `🎙️ <b>Transcribed:</b> ${escapeHTML(preview)} <i>(via ${escapeHTML(result.backend)}, ${formatDurationSeconds(result.durationMs / 1000)})</i>`, { fallbackText: `🎙️ Transcribed: ${preview} (via ${result.backend}, ${formatDurationSeconds(result.durationMs / 1000)})` });
3432
+ }
3433
+ catch (error) {
3434
+ const note = "Voice uses faster-whisper/parakeet locally or OPENAI_API_KEY for cloud transcription, not CODEX_API_KEY.";
3435
+ await safeReply(ctx, `<b>Transcription failed:</b>\n${escapeHTML(friendlyErrorText(error))}\n\n<i>${escapeHTML(note)}</i>`, {
3436
+ fallbackText: `Transcription failed:\n${friendlyErrorText(error)}\n\n${note}`,
3437
+ });
3438
+ return;
3439
+ }
3440
+ finally {
3441
+ busyState.transcribing = false;
3442
+ if (tempFilePath) {
3443
+ await unlink(tempFilePath).catch(() => { });
3444
+ }
3445
+ }
3446
+ if (!transcript) {
3447
+ return;
3448
+ }
3449
+ if (isVoiceTranscribeOnly(contextKey)) {
3450
+ return;
3451
+ }
3452
+ await setReaction(ctx, "👀");
3453
+ try {
3454
+ await handleUserPrompt(ctx, contextKey, chatId, session, transcript);
3455
+ await setReaction(ctx, "👍");
3456
+ }
3457
+ catch {
3458
+ await clearReaction(ctx);
3459
+ }
3460
+ });
3461
+ bot.on("message:photo", async (ctx) => {
3462
+ const contextSession = await getContextSession(ctx);
3463
+ if (!contextSession) {
3464
+ return;
3465
+ }
3466
+ const { contextKey, session } = contextSession;
3467
+ const chatId = ctx.chat.id;
3468
+ const photos = ctx.message.photo;
3469
+ const photo = photos[photos.length - 1];
3470
+ if (!photo) {
3471
+ return;
3472
+ }
3473
+ if (ctx.message.media_group_id) {
3474
+ enqueueMediaGroupPart(ctx, contextKey, chatId, session, ctx.message.media_group_id, {
3475
+ kind: "photo",
3476
+ fileId: photo.file_id,
3477
+ fileName: `photo-${photo.file_unique_id}.jpg`,
3478
+ mimeType: "image/jpeg",
3479
+ caption: ctx.message.caption?.trim(),
3480
+ });
3481
+ return;
3482
+ }
3483
+ const busyState = getBusyState(contextKey);
3484
+ busyState.transcribing = true;
3485
+ let tempFilePath;
3486
+ try {
3487
+ await sendChatActionSafe(ctx.api, chatId, "upload_photo", ctx.message?.message_thread_id);
3488
+ tempFilePath = await downloadTelegramFile(ctx.api, config.telegramBotToken, photo.file_id, 20 * 1024 * 1024);
3489
+ }
3490
+ catch (error) {
3491
+ await safeReply(ctx, `<b>Failed to download photo:</b> ${escapeHTML(friendlyErrorText(error))}`, {
3492
+ fallbackText: `Failed to download photo: ${friendlyErrorText(error)}`,
3493
+ });
3494
+ return;
3495
+ }
3496
+ finally {
3497
+ busyState.transcribing = false;
3498
+ if (!tempFilePath) {
3499
+ // Download failed — nothing to clean up further
3500
+ }
3501
+ }
3502
+ const turnId = randomUUID().slice(0, 12);
3503
+ const workspace = session.getCurrentWorkspace();
3504
+ const outDir = outboxPath(workspace, turnId);
3505
+ await ensureOutDir(outDir);
3506
+ let stagedPhoto;
3507
+ try {
3508
+ const buffer = await readFile(tempFilePath);
3509
+ stagedPhoto = await stageFile(buffer, `photo-${turnId}.jpg`, "image/jpeg", {
3510
+ workspace,
3511
+ turnId,
3512
+ maxFileSize: config.maxFileSize,
3513
+ });
3514
+ }
3515
+ catch (error) {
3516
+ await safeReply(ctx, `<b>Failed to stage photo:</b> ${escapeHTML(friendlyErrorText(error))}`, {
3517
+ fallbackText: `Failed to stage photo: ${friendlyErrorText(error)}`,
3518
+ });
3519
+ return;
3520
+ }
3521
+ finally {
3522
+ await unlink(tempFilePath).catch(() => { });
3523
+ }
3524
+ const caption = ctx.message.caption?.trim();
3525
+ const promptInput = {
3526
+ imagePaths: [stagedPhoto.localPath],
3527
+ stagedFileInstructions: buildFileInstructions([stagedPhoto], outDir),
3528
+ };
3529
+ if (caption) {
3530
+ promptInput.text = caption;
3531
+ }
3532
+ await setReaction(ctx, "👀");
3533
+ try {
3534
+ await handleUserPrompt(ctx, contextKey, chatId, session, toPromptEnvelope(promptInput, outDir));
3535
+ await setReaction(ctx, "👍");
3536
+ }
3537
+ catch {
3538
+ await clearReaction(ctx);
3539
+ }
3540
+ });
3541
+ bot.on("message:document", async (ctx) => {
3542
+ const contextSession = await getContextSession(ctx);
3543
+ if (!contextSession) {
3544
+ return;
3545
+ }
3546
+ const { contextKey, session } = contextSession;
3547
+ const chatId = ctx.chat.id;
3548
+ const doc = ctx.message.document;
3549
+ if (!doc) {
3550
+ return;
3551
+ }
3552
+ if (ctx.message.media_group_id) {
3553
+ enqueueMediaGroupPart(ctx, contextKey, chatId, session, ctx.message.media_group_id, {
3554
+ kind: "document",
3555
+ fileId: doc.file_id,
3556
+ fileName: doc.file_name ?? "document",
3557
+ mimeType: doc.mime_type ?? "application/octet-stream",
3558
+ fileSize: doc.file_size,
3559
+ caption: ctx.message.caption?.trim(),
3560
+ });
3561
+ return;
3562
+ }
3563
+ if (doc.file_size && doc.file_size > config.maxFileSize) {
3564
+ const sizeMB = Math.round(doc.file_size / 1024 / 1024);
3565
+ const maxMB = Math.round(config.maxFileSize / 1024 / 1024);
3566
+ await safeReply(ctx, `<b>File too large</b> (${sizeMB} MB, max ${maxMB} MB)`, {
3567
+ fallbackText: `File too large (${sizeMB} MB, max ${maxMB} MB)`,
3568
+ });
3569
+ return;
3570
+ }
3571
+ const busyState = getBusyState(contextKey);
3572
+ busyState.transcribing = true;
3573
+ let tempFilePath;
3574
+ try {
3575
+ await sendChatActionSafe(ctx.api, chatId, "typing", ctx.message?.message_thread_id);
3576
+ tempFilePath = await downloadTelegramFile(ctx.api, config.telegramBotToken, doc.file_id, config.maxFileSize);
3577
+ }
3578
+ catch (error) {
3579
+ await safeReply(ctx, `<b>Failed to download file:</b> ${escapeHTML(friendlyErrorText(error))}`, {
3580
+ fallbackText: `Failed to download file: ${friendlyErrorText(error)}`,
3581
+ });
3582
+ return;
3583
+ }
3584
+ finally {
3585
+ busyState.transcribing = false;
3586
+ }
3587
+ const turnId = randomUUID().slice(0, 12);
3588
+ const workspace = session.getCurrentWorkspace();
3589
+ const originalName = doc.file_name ?? "document";
3590
+ const mimeType = doc.mime_type ?? "application/octet-stream";
3591
+ let stagedFile;
3592
+ try {
3593
+ const buffer = await readFile(tempFilePath);
3594
+ stagedFile = await stageFile(buffer, originalName, mimeType, {
3595
+ workspace,
3596
+ turnId,
3597
+ maxFileSize: config.maxFileSize,
3598
+ });
3599
+ }
3600
+ catch (error) {
3601
+ await safeReply(ctx, `<b>Failed to stage file:</b> ${escapeHTML(friendlyErrorText(error))}`, {
3602
+ fallbackText: `Failed to stage file: ${friendlyErrorText(error)}`,
3603
+ });
3604
+ return;
3605
+ }
3606
+ finally {
3607
+ if (tempFilePath) {
3608
+ await unlink(tempFilePath).catch(() => { });
3609
+ }
3610
+ }
3611
+ await safeReply(ctx, `📎 <b>Received:</b> <code>${escapeHTML(stagedFile.safeName)}</code>`, {
3612
+ fallbackText: `📎 Received: ${stagedFile.safeName}`,
3613
+ });
3614
+ // Keep typing visible during the gap between staging and prompt execution
3615
+ await sendChatActionSafe(ctx.api, chatId, "typing", ctx.message?.message_thread_id).catch(() => { });
3616
+ const outDir = outboxPath(workspace, turnId);
3617
+ await ensureOutDir(outDir);
3618
+ const promptInput = {
3619
+ stagedFileInstructions: buildFileInstructions([stagedFile], outDir),
3620
+ };
3621
+ const caption = ctx.message.caption?.trim();
3622
+ if (caption) {
3623
+ promptInput.text = caption;
3624
+ }
3625
+ await setReaction(ctx, "👀");
3626
+ try {
3627
+ await handleUserPrompt(ctx, contextKey, chatId, session, toPromptEnvelope(promptInput, outDir));
3628
+ await setReaction(ctx, "👍");
3629
+ }
3630
+ catch {
3631
+ await clearReaction(ctx);
3632
+ }
3633
+ });
3634
+ bot.catch((error) => {
3635
+ const message = error.error instanceof Error ? error.error.message : String(error.error);
3636
+ console.error("Telegram bot error:", message);
3637
+ });
3638
+ return bot;
3639
+ }
3640
+ export async function registerCommands(bot) {
3641
+ await bot.api.setMyCommands([
3642
+ { command: "start", description: "Welcome & status" },
3643
+ { command: "help", description: "Command reference" },
3644
+ { command: "agent", description: "Select Codex or Pi" },
3645
+ { command: "new", description: "Start a new thread" },
3646
+ { command: "session", description: "Current thread details" },
3647
+ { command: "sessions", description: "Browse & switch threads" },
3648
+ { command: "sync", description: "Sync active session from CLI state" },
3649
+ { command: "pinned", description: "Show pinned threads" },
3650
+ { command: "pin", description: "Pin current or given thread" },
3651
+ { command: "unpin", description: "Unpin current or given thread" },
3652
+ { command: "retry", description: "Resend the last prompt" },
3653
+ { command: "queue", description: "Show queued prompts" },
3654
+ { command: "cancel", description: "Cancel a queued prompt" },
3655
+ { command: "clearqueue", description: "Clear queued prompts" },
3656
+ { command: "artifacts", description: "List or resend generated files" },
3657
+ { command: "workspaces", description: "List allowed workspaces" },
3658
+ { command: "abort", description: "Cancel current operation" },
3659
+ { command: "stop", description: "Cancel current operation" },
3660
+ { command: "launch_profiles", description: "Select launch profile" },
3661
+ { command: "fast", description: "Toggle fast mode" },
3662
+ { command: "model", description: "View & change model" },
3663
+ { command: "reasoning", description: "Set reasoning effort" },
3664
+ { command: "mirror", description: "Control CLI mirroring" },
3665
+ { command: "notify", description: "Control notifications" },
3666
+ { command: "auth", description: "Check auth status" },
3667
+ { command: "login", description: "Start authentication" },
3668
+ { command: "logout", description: "Sign out" },
3669
+ { command: "voice", description: "Voice transcription status" },
3670
+ { command: "tasks", description: "Current turn progress" },
3671
+ { command: "progress", description: "Current turn progress" },
3672
+ { command: "activity", description: "Thread activity timeline" },
3673
+ { command: "status", description: "Connector runtime status" },
3674
+ { command: "health", description: "Connector health report" },
3675
+ { command: "version", description: "Connector version" },
3676
+ { command: "logs", description: "Admin: show connector logs" },
3677
+ { command: "diagnostics", description: "Admin: connector diagnostics" },
3678
+ { command: "restart", description: "Admin: restart connector" },
3679
+ { command: "update", description: "Admin: update connector" },
3680
+ { command: "handback", description: "Hand session back to CLI" },
3681
+ { command: "attach", description: "Bind a session to this topic" },
3682
+ { command: "switch", description: "Switch to a thread by ID" },
3683
+ ]);
3684
+ }
3685
+ function renderArtifactReports(reports) {
3686
+ const lines = reports.slice(0, 5).map((report, index) => {
3687
+ const size = formatFileSize(totalArtifactSize(report.artifacts));
3688
+ const skipped = report.skippedCount > 0 ? `, ${report.skippedCount} skipped` : "";
3689
+ return `${index + 1}. ${report.turnId} · ${formatRelativeTime(report.updatedAt)} · ${report.artifacts.length} file${report.artifacts.length === 1 ? "" : "s"} · ${size}${skipped}`;
3690
+ });
3691
+ const usage = "Tap an action below, or use /artifacts latest, /artifacts zip latest, or /artifacts <turn-id>.";
3692
+ const plain = ["Recent artifacts:", ...lines, "", usage].join("\n");
3693
+ const html = ["<b>Recent artifacts:</b>", ...lines.map(escapeHTML), "", escapeHTML(usage)].join("\n");
3694
+ return { html, plain };
3695
+ }
3696
+ function buildArtifactActionsKeyboard(reports) {
3697
+ const keyboard = new InlineKeyboard();
3698
+ for (const [index, report] of reports.slice(0, 5).entries()) {
3699
+ const label = `${index + 1}`;
3700
+ keyboard
3701
+ .text(`${label} Send`, `artifact_send:${report.turnId}`)
3702
+ .text(`${label} ZIP`, `artifact_zip:${report.turnId}`)
3703
+ .text(`${label} Delete`, `artifact_delete:${report.turnId}`)
3704
+ .row();
3705
+ }
3706
+ return keyboard;
3707
+ }
3708
+ function renderProgressPlain(progress, queueLength, busyState, info) {
3709
+ const busyFlags = formatBusyFlags(busyState);
3710
+ if (!progress) {
3711
+ return [
3712
+ "Progress:",
3713
+ "Status: idle",
3714
+ `Thread: ${info.threadId ?? "(not started yet)"}`,
3715
+ `Queue: ${queueLength}`,
3716
+ `Busy: ${busyFlags || "no"}`,
3717
+ ].join("\n");
3718
+ }
3719
+ const lines = [
3720
+ "Progress:",
3721
+ `Status: ${progress.status}`,
3722
+ `Prompt: ${progress.promptDescription}`,
3723
+ `Elapsed: ${formatDurationSeconds(((progress.completedAt ?? Date.now()) - progress.startedAt) / 1000)}`,
3724
+ `Current tool: ${progress.currentTool ?? "-"}`,
3725
+ `Last tool: ${progress.lastTool ?? "-"}`,
3726
+ `Tools: ${formatToolSummaryLine(progress.toolCounts) || "-"}`,
3727
+ `Output chars: ${progress.textCharacters}`,
3728
+ `Queue: ${queueLength}`,
3729
+ `Busy: ${busyFlags || "no"}`,
3730
+ ];
3731
+ if (progress.error) {
3732
+ lines.push(`Error: ${progress.error}`);
3733
+ }
3734
+ return lines.join("\n");
3735
+ }
3736
+ function renderProgressHTML(progress, queueLength, busyState, info) {
3737
+ const busyFlags = formatBusyFlags(busyState);
3738
+ if (!progress) {
3739
+ return [
3740
+ "<b>Progress:</b>",
3741
+ "<b>Status:</b> <code>idle</code>",
3742
+ `<b>Thread:</b> <code>${escapeHTML(info.threadId ?? "(not started yet)")}</code>`,
3743
+ `<b>Queue:</b> <code>${queueLength}</code>`,
3744
+ `<b>Busy:</b> <code>${escapeHTML(busyFlags || "no")}</code>`,
3745
+ ].join("\n");
3746
+ }
3747
+ const lines = [
3748
+ "<b>Progress:</b>",
3749
+ `<b>Status:</b> <code>${escapeHTML(progress.status)}</code>`,
3750
+ `<b>Prompt:</b> <code>${escapeHTML(progress.promptDescription)}</code>`,
3751
+ `<b>Elapsed:</b> <code>${escapeHTML(formatDurationSeconds(((progress.completedAt ?? Date.now()) - progress.startedAt) / 1000))}</code>`,
3752
+ `<b>Current tool:</b> <code>${escapeHTML(progress.currentTool ?? "-")}</code>`,
3753
+ `<b>Last tool:</b> <code>${escapeHTML(progress.lastTool ?? "-")}</code>`,
3754
+ `<b>Tools:</b> <code>${escapeHTML(formatToolSummaryLine(progress.toolCounts) || "-")}</code>`,
3755
+ `<b>Output chars:</b> <code>${progress.textCharacters}</code>`,
3756
+ `<b>Queue:</b> <code>${queueLength}</code>`,
3757
+ `<b>Busy:</b> <code>${escapeHTML(busyFlags || "no")}</code>`,
3758
+ ];
3759
+ if (progress.error) {
3760
+ lines.push(`<b>Error:</b> <code>${escapeHTML(progress.error)}</code>`);
3761
+ }
3762
+ return lines.join("\n");
3763
+ }
3764
+ function renderExternalMirrorStatus(snapshot, queueLength) {
3765
+ const prompt = trimLine(snapshot.latestUserMessage ?? "-", 180);
3766
+ const elapsed = snapshot.activity.startedAt
3767
+ ? formatDurationSeconds((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
3768
+ : "-";
3769
+ const lines = [
3770
+ "Codex CLI task running.",
3771
+ `Thread: ${snapshot.threadId}`,
3772
+ `Elapsed: ${elapsed}`,
3773
+ `Prompt: ${prompt}`,
3774
+ `Last tool: ${snapshot.latestToolName ?? "-"}`,
3775
+ `Queue: ${queueLength}`,
3776
+ ];
3777
+ return {
3778
+ plain: lines.join("\n"),
3779
+ html: [
3780
+ "<b>Codex CLI task running.</b>",
3781
+ `<b>Thread:</b> <code>${escapeHTML(snapshot.threadId)}</code>`,
3782
+ `<b>Elapsed:</b> <code>${escapeHTML(elapsed)}</code>`,
3783
+ `<b>Prompt:</b> <code>${escapeHTML(prompt)}</code>`,
3784
+ `<b>Last tool:</b> <code>${escapeHTML(snapshot.latestToolName ?? "-")}</code>`,
3785
+ `<b>Queue:</b> <code>${queueLength}</code>`,
3786
+ ].join("\n"),
3787
+ };
3788
+ }
3789
+ function renderExternalMirrorEvent(event) {
3790
+ if (event.kind === "task") {
3791
+ const status = event.status ?? event.type;
3792
+ const plain = `CLI task: ${status}`;
3793
+ return {
3794
+ plain,
3795
+ html: `<b>CLI task:</b> <code>${escapeHTML(status)}</code>`,
3796
+ };
3797
+ }
3798
+ if (event.kind !== "tool") {
3799
+ return null;
3800
+ }
3801
+ const status = event.status ?? event.type;
3802
+ const tool = event.toolName ?? "tool";
3803
+ const detail = event.text ? `\n${trimLine(event.text.replace(/\s+/g, " "), 180)}` : "";
3804
+ const plain = `CLI tool ${status}: ${tool}${detail}`;
3805
+ return {
3806
+ plain,
3807
+ html: `<b>CLI tool ${escapeHTML(status)}:</b> <code>${escapeHTML(tool)}</code>${detail ? `\n<code>${escapeHTML(detail.trim())}</code>` : ""}`,
3808
+ };
3809
+ }
3810
+ function renderActivityTimeline(threadId, events, options = { limit: 16, filter: "all", exportFile: false }) {
3811
+ if (events.length === 0) {
3812
+ return {
3813
+ plain: `Activity:\nThread: ${threadId}\nFilter: ${options.filter}\nNo rollout events found.`,
3814
+ html: `<b>Activity:</b>\n<b>Thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Filter:</b> <code>${escapeHTML(options.filter)}</code>\n<code>No rollout events found.</code>`,
3815
+ };
3816
+ }
3817
+ const lines = events.map((event) => {
3818
+ const time = event.timestamp ? event.timestamp.toISOString().slice(11, 19) : "--:--:--";
3819
+ const label = activityEventLabel(event);
3820
+ const detail = event.text ? ` · ${trimLine(event.text.replace(/\s+/g, " ").trim(), 120)}` : "";
3821
+ const tool = event.toolName ? ` · ${event.toolName}` : "";
3822
+ return `${time} · ${label}${tool}${detail}`;
3823
+ });
3824
+ return {
3825
+ plain: ["Activity:", `Thread: ${threadId}`, `Filter: ${options.filter}`, `Events: ${events.length}`, ...lines].join("\n"),
3826
+ html: [
3827
+ "<b>Activity:</b>",
3828
+ `<b>Thread:</b> <code>${escapeHTML(threadId)}</code>`,
3829
+ `<b>Filter:</b> <code>${escapeHTML(options.filter)}</code>`,
3830
+ `<b>Events:</b> <code>${events.length}</code>`,
3831
+ ...lines.map((line) => `<code>${escapeHTML(line)}</code>`),
3832
+ ].join("\n"),
3833
+ };
3834
+ }
3835
+ function parseActivityOptions(argument) {
3836
+ const options = {
3837
+ limit: 16,
3838
+ filter: "all",
3839
+ exportFile: false,
3840
+ };
3841
+ const parts = argument.split(/\s+/).filter(Boolean);
3842
+ for (let index = 0; index < parts.length; index += 1) {
3843
+ const part = parts[index].toLowerCase();
3844
+ if (/^\d+$/.test(part)) {
3845
+ options.limit = Math.min(200, Math.max(1, Number(part)));
3846
+ continue;
3847
+ }
3848
+ if (part === "export") {
3849
+ options.exportFile = true;
3850
+ continue;
3851
+ }
3852
+ if (isActivityFilter(part)) {
3853
+ options.filter = part;
3854
+ continue;
3855
+ }
3856
+ if (part === "since" && parts[index + 1]) {
3857
+ options.sinceMs = parseDurationToMs(parts[index + 1]);
3858
+ index += 1;
3859
+ }
3860
+ }
3861
+ return options;
3862
+ }
3863
+ function filterActivityEvents(events, options) {
3864
+ const cutoff = options.sinceMs ? Date.now() - options.sinceMs : undefined;
3865
+ return events
3866
+ .filter((event) => {
3867
+ if (cutoff && event.timestamp && event.timestamp.getTime() < cutoff) {
3868
+ return false;
3869
+ }
3870
+ switch (options.filter) {
3871
+ case "tools":
3872
+ return event.kind === "tool";
3873
+ case "errors":
3874
+ return event.status === "failed" || event.status === "error" || /error|failed/i.test(event.text ?? "");
3875
+ case "user":
3876
+ return event.kind === "user";
3877
+ case "agent":
3878
+ return event.kind === "agent";
3879
+ case "tasks":
3880
+ return event.kind === "task";
3881
+ default:
3882
+ return true;
3883
+ }
3884
+ })
3885
+ .slice(-options.limit);
3886
+ }
3887
+ function isActivityFilter(value) {
3888
+ return value === "all" || value === "tools" || value === "errors" || value === "user" || value === "agent" || value === "tasks";
3889
+ }
3890
+ function renderRolloutDiagnostics(threadId, staleAfterMs) {
3891
+ if (!threadId) {
3892
+ return {
3893
+ plain: "Rollout: no active thread",
3894
+ html: "<b>Rollout:</b> <code>no active thread</code>",
3895
+ };
3896
+ }
3897
+ const snapshot = getThreadRolloutSnapshot(threadId, { staleAfterMs, maxEvents: 0 });
3898
+ if (!snapshot) {
3899
+ return {
3900
+ plain: `Rollout:\nThread: ${threadId}\nStatus: unavailable`,
3901
+ html: [
3902
+ "<b>Rollout:</b>",
3903
+ `<b>Thread:</b> <code>${escapeHTML(threadId)}</code>`,
3904
+ "<b>Status:</b> <code>unavailable</code>",
3905
+ ].join("\n"),
3906
+ };
3907
+ }
3908
+ const activity = snapshot.activity;
3909
+ const status = activity.active ? "active" : activity.stale ? "stale" : "idle";
3910
+ const reason = activity.active
3911
+ ? "open task without terminal event"
3912
+ : activity.stale
3913
+ ? "open task exceeded stale timeout"
3914
+ : "no open task";
3915
+ const lines = [
3916
+ "Rollout:",
3917
+ `Path: ${snapshot.rolloutPath}`,
3918
+ `Status: ${status}`,
3919
+ `Reason: ${reason}`,
3920
+ `Turn: ${activity.turnId ?? "-"}`,
3921
+ `Lines: ${snapshot.lineCount}`,
3922
+ `Updated: ${activity.updatedAt?.toISOString() ?? "-"}`,
3923
+ ];
3924
+ return {
3925
+ plain: lines.join("\n"),
3926
+ html: [
3927
+ "<b>Rollout:</b>",
3928
+ `<b>Path:</b> <code>${escapeHTML(snapshot.rolloutPath)}</code>`,
3929
+ `<b>Status:</b> <code>${escapeHTML(status)}</code>`,
3930
+ `<b>Reason:</b> <code>${escapeHTML(reason)}</code>`,
3931
+ `<b>Turn:</b> <code>${escapeHTML(activity.turnId ?? "-")}</code>`,
3932
+ `<b>Lines:</b> <code>${snapshot.lineCount}</code>`,
3933
+ `<b>Updated:</b> <code>${escapeHTML(activity.updatedAt?.toISOString() ?? "-")}</code>`,
3934
+ ].join("\n"),
3935
+ };
3936
+ }
3937
+ function activityEventLabel(event) {
3938
+ if (event.kind === "task") {
3939
+ return `task ${event.status ?? event.type}`;
3940
+ }
3941
+ if (event.kind === "user") {
3942
+ return "user";
3943
+ }
3944
+ if (event.kind === "agent") {
3945
+ return event.phase ? `agent ${event.phase}` : "agent";
3946
+ }
3947
+ return event.status ? `tool ${event.status}` : "tool";
3948
+ }
3949
+ function isEmptyArtifactReport(report) {
3950
+ return report.artifacts.length === 0 && report.skippedCount === 0 && !(report.omittedCount && report.omittedCount > 0);
3951
+ }
3952
+ function formatBusyFlags(state) {
3953
+ return Object.entries(state)
3954
+ .filter(([, enabled]) => enabled)
3955
+ .map(([name]) => name)
3956
+ .join(", ");
3957
+ }
3958
+ function renderDiagnosticsPlain(config, registry, health, authenticated, role, queueLength, progress, runtime) {
3959
+ const contexts = registry.listContexts();
3960
+ return [
3961
+ "Diagnostics:",
3962
+ `Status: ${health.state.status ?? "unknown"}`,
3963
+ `Version: ${health.version}`,
3964
+ `Role: ${role}`,
3965
+ `Auth: ${authenticated ? "yes" : "no"} (${health.state.authMethod ?? "-"})`,
3966
+ `PID: ${health.state.pid ?? "-"} (${health.pidRunning ? "running" : "not running"})`,
3967
+ `App PID: ${health.state.appPid ?? "-"} (${health.appPidRunning ? "running" : "not running"})`,
3968
+ `Workspace: ${config.workspace}`,
3969
+ `Codex CLI: ${health.codexCli}`,
3970
+ `Pi CLI: ${health.piCli}`,
3971
+ `Enabled agents/default: ${enabledAgents(config).join(", ")} / ${config.defaultAgent}`,
3972
+ `State DB: ${health.databasePath ?? "-"}`,
3973
+ `Log file: ${health.logFile}`,
3974
+ `Log format: ${config.logFormat}`,
3975
+ `Tool verbosity: ${config.toolVerbosity}`,
3976
+ `Telegram rate limit queued/running/retries/429: ${runtime.rateLimit.queued}/${runtime.rateLimit.running}/${runtime.rateLimit.retries}/${runtime.rateLimit.rateLimitHits}`,
3977
+ `Telegram last retry_after: ${runtime.rateLimit.lastRetryAfterSeconds ?? "-"}s`,
3978
+ `CLI mirror mode/update: ${runtime.mirrorMode} / ${config.telegramMirrorMinUpdateMs} ms`,
3979
+ `Notify/quiet: ${runtime.notifyMode} / ${runtime.quietHours}`,
3980
+ `Voice: ${runtime.voiceBackend} / ${runtime.voiceLanguage} / transcribe-only ${runtime.voiceTranscribeOnly ? "on" : "off"}`,
3981
+ `Sync interval: ${config.codexSyncIntervalMs} ms`,
3982
+ `External busy check/stale: ${config.codexExternalBusyCheckMs} ms / ${config.codexExternalBusyStaleMs} ms`,
3983
+ `External mirrors/timers/status messages: ${runtime.externalMirrors}/${runtime.externalQueueTimers}/${runtime.queueStatusMessages}`,
3984
+ `Auto-send artifacts: ${config.telegramAutoSendArtifacts ? "yes" : "no"}`,
3985
+ `Artifact ignore dirs/globs: ${config.artifactIgnoreDirs.length}/${config.artifactIgnoreGlobs.length}`,
3986
+ `Artifact retention: ${config.artifactRetentionDays}d / ${config.artifactMaxTurnDirs} turns / ${config.artifactMaxInboxDirs} inbox dirs`,
3987
+ `Workspace allowed/warn roots: ${config.workspaceAllowedRoots.length}/${config.workspaceWarnRoots.length}`,
3988
+ `Allowed users/chats/admins/readonly: ${config.telegramAllowedUserIds.length}/${config.telegramAllowedChatIds.length}/${config.telegramAdminUserIds.length}/${config.telegramReadOnlyUserIds.length}`,
3989
+ `Loaded sessions: ${contexts.length}`,
3990
+ `Current queue: ${queueLength}`,
3991
+ `Current progress: ${progress?.status ?? "idle"}`,
3992
+ ].join("\n");
3993
+ }
3994
+ function renderDiagnosticsHTML(config, registry, health, authenticated, role, queueLength, progress, runtime) {
3995
+ const contexts = registry.listContexts();
3996
+ return [
3997
+ "<b>Diagnostics:</b>",
3998
+ `<b>Status:</b> <code>${escapeHTML(health.state.status ?? "unknown")}</code>`,
3999
+ `<b>Version:</b> <code>${escapeHTML(health.version)}</code>`,
4000
+ `<b>Role:</b> <code>${escapeHTML(role)}</code>`,
4001
+ `<b>Auth:</b> <code>${authenticated ? "yes" : "no"} (${escapeHTML(health.state.authMethod ?? "-")})</code>`,
4002
+ `<b>PID:</b> <code>${escapeHTML(String(health.state.pid ?? "-"))} (${health.pidRunning ? "running" : "not running"})</code>`,
4003
+ `<b>App PID:</b> <code>${escapeHTML(String(health.state.appPid ?? "-"))} (${health.appPidRunning ? "running" : "not running"})</code>`,
4004
+ `<b>Workspace:</b> <code>${escapeHTML(config.workspace)}</code>`,
4005
+ `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4006
+ `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
4007
+ `<b>Enabled agents/default:</b> <code>${escapeHTML(`${enabledAgents(config).join(", ")} / ${config.defaultAgent}`)}</code>`,
4008
+ `<b>State DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
4009
+ `<b>Log file:</b> <code>${escapeHTML(health.logFile)}</code>`,
4010
+ `<b>Log format:</b> <code>${escapeHTML(config.logFormat)}</code>`,
4011
+ `<b>Tool verbosity:</b> <code>${escapeHTML(config.toolVerbosity)}</code>`,
4012
+ `<b>Telegram rate limit queued/running/retries/429:</b> <code>${runtime.rateLimit.queued}/${runtime.rateLimit.running}/${runtime.rateLimit.retries}/${runtime.rateLimit.rateLimitHits}</code>`,
4013
+ `<b>Telegram last retry_after:</b> <code>${escapeHTML(String(runtime.rateLimit.lastRetryAfterSeconds ?? "-"))}s</code>`,
4014
+ `<b>CLI mirror mode/update:</b> <code>${escapeHTML(runtime.mirrorMode)} / ${config.telegramMirrorMinUpdateMs} ms</code>`,
4015
+ `<b>Notify/quiet:</b> <code>${escapeHTML(runtime.notifyMode)} / ${escapeHTML(runtime.quietHours)}</code>`,
4016
+ `<b>Voice:</b> <code>${escapeHTML(runtime.voiceBackend)} / ${escapeHTML(runtime.voiceLanguage)} / transcribe-only ${runtime.voiceTranscribeOnly ? "on" : "off"}</code>`,
4017
+ `<b>Sync interval:</b> <code>${config.codexSyncIntervalMs} ms</code>`,
4018
+ `<b>External busy check/stale:</b> <code>${config.codexExternalBusyCheckMs} ms / ${config.codexExternalBusyStaleMs} ms</code>`,
4019
+ `<b>External mirrors/timers/status messages:</b> <code>${runtime.externalMirrors}/${runtime.externalQueueTimers}/${runtime.queueStatusMessages}</code>`,
4020
+ `<b>Auto-send artifacts:</b> <code>${config.telegramAutoSendArtifacts ? "yes" : "no"}</code>`,
4021
+ `<b>Artifact ignore dirs/globs:</b> <code>${config.artifactIgnoreDirs.length}/${config.artifactIgnoreGlobs.length}</code>`,
4022
+ `<b>Artifact retention:</b> <code>${config.artifactRetentionDays}d / ${config.artifactMaxTurnDirs} turns / ${config.artifactMaxInboxDirs} inbox dirs</code>`,
4023
+ `<b>Workspace allowed/warn roots:</b> <code>${config.workspaceAllowedRoots.length}/${config.workspaceWarnRoots.length}</code>`,
4024
+ `<b>Allowed users/chats/admins/readonly:</b> <code>${config.telegramAllowedUserIds.length}/${config.telegramAllowedChatIds.length}/${config.telegramAdminUserIds.length}/${config.telegramReadOnlyUserIds.length}</code>`,
4025
+ `<b>Loaded sessions:</b> <code>${contexts.length}</code>`,
4026
+ `<b>Current queue:</b> <code>${queueLength}</code>`,
4027
+ `<b>Current progress:</b> <code>${escapeHTML(progress?.status ?? "idle")}</code>`,
4028
+ ].join("\n");
4029
+ }
4030
+ function renderHealthPlain(health, authenticated, role) {
4031
+ return [
4032
+ `Status: ${health.state.status ?? "unknown"}`,
4033
+ `Version: ${health.version}`,
4034
+ `Role: ${role}`,
4035
+ `Auth: ${authenticated ? "yes" : "no"}`,
4036
+ `PID: ${health.state.pid ?? "-"} (${health.pidRunning ? "running" : "not running"})`,
4037
+ `App PID: ${health.state.appPid ?? "-"} (${health.appPidRunning ? "running" : "not running"})`,
4038
+ `Uptime: ${formatDuration(health.uptimeSeconds)}`,
4039
+ `Workspace: ${health.state.workspace ?? "-"}`,
4040
+ `Codex CLI: ${health.codexCli}`,
4041
+ `Pi CLI: ${health.piCli}`,
4042
+ `Codex state DB: ${health.databasePath ?? "-"}`,
4043
+ `Log: ${health.logFile}`,
4044
+ ].join("\n");
4045
+ }
4046
+ function renderHealthHTML(health, authenticated, role) {
4047
+ return [
4048
+ `<b>Status:</b> <code>${escapeHTML(health.state.status ?? "unknown")}</code>`,
4049
+ `<b>Version:</b> <code>${escapeHTML(health.version)}</code>`,
4050
+ `<b>Role:</b> <code>${escapeHTML(role)}</code>`,
4051
+ `<b>Auth:</b> <code>${authenticated ? "yes" : "no"}</code>`,
4052
+ `<b>PID:</b> <code>${escapeHTML(String(health.state.pid ?? "-"))} (${health.pidRunning ? "running" : "not running"})</code>`,
4053
+ `<b>App PID:</b> <code>${escapeHTML(String(health.state.appPid ?? "-"))} (${health.appPidRunning ? "running" : "not running"})</code>`,
4054
+ `<b>Uptime:</b> <code>${escapeHTML(formatDuration(health.uptimeSeconds))}</code>`,
4055
+ `<b>Workspace:</b> <code>${escapeHTML(health.state.workspace ?? "-")}</code>`,
4056
+ `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4057
+ `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
4058
+ `<b>Codex state DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
4059
+ `<b>Log:</b> <code>${escapeHTML(health.logFile)}</code>`,
4060
+ ].join("\n");
4061
+ }
4062
+ function parseFastModeArgument(argument, currentValue) {
4063
+ if (!argument) {
4064
+ return !currentValue;
4065
+ }
4066
+ const normalized = argument.toLowerCase();
4067
+ if (["on", "enable", "enabled", "true", "1"].includes(normalized)) {
4068
+ return true;
4069
+ }
4070
+ if (["off", "disable", "disabled", "false", "0"].includes(normalized)) {
4071
+ return false;
4072
+ }
4073
+ return undefined;
4074
+ }
4075
+ function parseToggle(argument) {
4076
+ const normalized = argument.trim().toLowerCase();
4077
+ if (["on", "enable", "enabled", "true", "1", "yes"].includes(normalized)) {
4078
+ return true;
4079
+ }
4080
+ if (["off", "disable", "disabled", "false", "0", "no"].includes(normalized)) {
4081
+ return false;
4082
+ }
4083
+ return undefined;
4084
+ }
4085
+ function parseDurationToMs(value) {
4086
+ const match = value.trim().match(/^(\d+)(s|m|h|d)?$/i);
4087
+ if (!match) {
4088
+ return undefined;
4089
+ }
4090
+ const amount = Number(match[1]);
4091
+ const unit = (match[2] ?? "m").toLowerCase();
4092
+ const multiplier = unit === "s"
4093
+ ? 1000
4094
+ : unit === "h"
4095
+ ? 60 * 60 * 1000
4096
+ : unit === "d"
4097
+ ? 24 * 60 * 60 * 1000
4098
+ : 60 * 1000;
4099
+ return amount * multiplier;
4100
+ }
4101
+ function extractCommandName(text) {
4102
+ const match = text.trim().match(/^\/([a-zA-Z0-9_-]+)(?:@\w+)?(?:\s|$)/);
4103
+ return match?.[1]?.toLowerCase();
4104
+ }
4105
+ function isPromptEnvelopeLike(value) {
4106
+ return typeof value === "object" && value !== null && "input" in value && "description" in value;
4107
+ }
4108
+ function isQueuedPromptLike(value) {
4109
+ return "id" in value &&
4110
+ "contextKey" in value &&
4111
+ "createdAt" in value &&
4112
+ typeof value.id === "string" &&
4113
+ typeof value.contextKey === "string" &&
4114
+ typeof value.createdAt === "number";
4115
+ }
4116
+ function capabilitiesOf(info) {
4117
+ return info.capabilities ?? CODEX_AGENT_CAPABILITIES;
4118
+ }
4119
+ function labelOf(info) {
4120
+ return info.agentLabel ?? agentLabel(info.agentId ?? "codex");
4121
+ }
4122
+ function idOf(info) {
4123
+ return info.agentId ?? "codex";
4124
+ }
4125
+ function requiresTurnApproval(info) {
4126
+ return info.unsafeLaunch || info.approvalPolicy !== "never";
4127
+ }
4128
+ function formatDuration(totalSeconds) {
4129
+ const seconds = Math.max(0, Math.floor(totalSeconds));
4130
+ const days = Math.floor(seconds / 86400);
4131
+ const hours = Math.floor((seconds % 86400) / 3600);
4132
+ const minutes = Math.floor((seconds % 3600) / 60);
4133
+ if (days > 0) {
4134
+ return `${days}d ${hours}h`;
4135
+ }
4136
+ if (hours > 0) {
4137
+ return `${hours}h ${minutes}m`;
4138
+ }
4139
+ return `${minutes}m`;
4140
+ }
4141
+ function formatDurationSeconds(totalSeconds) {
4142
+ const seconds = Math.max(0, Math.floor(totalSeconds));
4143
+ if (seconds < 60) {
4144
+ return `${seconds}s`;
4145
+ }
4146
+ const minutes = Math.floor(seconds / 60);
4147
+ const remainingSeconds = seconds % 60;
4148
+ if (minutes < 60) {
4149
+ return `${minutes}m ${remainingSeconds}s`;
4150
+ }
4151
+ const hours = Math.floor(minutes / 60);
4152
+ return `${hours}h ${minutes % 60}m`;
4153
+ }
4154
+ function renderToolStartMessage(toolName) {
4155
+ return {
4156
+ text: `<b>🔧 Running:</b> <code>${escapeHTML(toolName)}</code>`,
4157
+ fallbackText: `🔧 Running: ${toolName}`,
4158
+ parseMode: "HTML",
4159
+ };
4160
+ }
4161
+ function renderToolEndMessage(toolName, partialResult, isError) {
4162
+ const preview = summarizeToolOutput(partialResult);
4163
+ const icon = isError ? "❌" : "✅";
4164
+ const htmlLines = [`<b>${icon}</b> <code>${escapeHTML(toolName)}</code>`];
4165
+ const plainLines = [`${icon} ${toolName}`];
4166
+ if (preview) {
4167
+ htmlLines.push(`<pre>${escapeHTML(preview)}</pre>`);
4168
+ plainLines.push(preview);
4169
+ }
4170
+ return {
4171
+ text: htmlLines.join("\n"),
4172
+ fallbackText: plainLines.join("\n"),
4173
+ parseMode: "HTML",
4174
+ };
4175
+ }
4176
+ export function formatToolSummaryLine(toolCounts) {
4177
+ if (toolCounts.size === 0) {
4178
+ return "";
4179
+ }
4180
+ const summarizedCounts = new Map();
4181
+ for (const [toolName, count] of toolCounts.entries()) {
4182
+ const summaryName = summarizeToolName(toolName);
4183
+ summarizedCounts.set(summaryName, (summarizedCounts.get(summaryName) ?? 0) + count);
4184
+ }
4185
+ const entries = [...summarizedCounts.entries()].sort((left, right) => {
4186
+ const countDelta = right[1] - left[1];
4187
+ return countDelta !== 0 ? countDelta : left[0].localeCompare(right[0]);
4188
+ });
4189
+ const tools = entries
4190
+ .map(([name, count]) => formatSummaryEntry(name, count))
4191
+ .join(", ");
4192
+ return `Tools used: ${tools}`;
4193
+ }
4194
+ function renderTodoList(items) {
4195
+ const lines = items.map((item) => {
4196
+ const icon = item.completed ? "✅" : "⬜";
4197
+ return `${icon} ${escapeHTML(item.text)}`;
4198
+ });
4199
+ return `📋 <b>Plan</b>\n${lines.join("\n")}`;
4200
+ }
4201
+ export function formatTurnUsageLine(usage) {
4202
+ return `🪙 in: ${usage.inputTokens} · cached: ${usage.cachedInputTokens} · out: ${usage.outputTokens}`;
4203
+ }
4204
+ export function summarizeToolName(toolName) {
4205
+ if (toolName.startsWith("🔍 ")) {
4206
+ return "web_fetch";
4207
+ }
4208
+ if (toolName === "file_change") {
4209
+ return "file_change";
4210
+ }
4211
+ if (toolName === "⚠️ error") {
4212
+ return "error";
4213
+ }
4214
+ if (toolName.startsWith("mcp:")) {
4215
+ const tool = toolName.split("/").at(-1) ?? toolName;
4216
+ if (SUBAGENT_TOOL_NAMES.has(tool)) {
4217
+ return "subagent";
4218
+ }
4219
+ return tool;
4220
+ }
4221
+ return "bash";
4222
+ }
4223
+ function formatSummaryEntry(name, count) {
4224
+ if (count <= 1) {
4225
+ return name;
4226
+ }
4227
+ const label = name === "subagent" ? "subagents" : name;
4228
+ return `${count}x ${label}`;
4229
+ }
4230
+ const SUBAGENT_TOOL_NAMES = new Set(["spawn_agent", "send_input", "wait_agent", "close_agent", "resume_agent"]);
4231
+ async function safeReply(ctx, text, options = {}) {
4232
+ const chatId = ctx.chat?.id;
4233
+ if (!chatId) {
4234
+ return;
4235
+ }
4236
+ const parseMode = options.parseMode !== undefined ? options.parseMode : "HTML";
4237
+ const messageThreadId = options.messageThreadId ?? ctx.message?.message_thread_id ?? ctx.callbackQuery?.message?.message_thread_id;
4238
+ const chunks = splitTelegramText(redactText(text));
4239
+ const fallbackChunks = options.fallbackText ? splitTelegramText(redactText(options.fallbackText)) : [];
4240
+ for (const [index, chunk] of chunks.entries()) {
4241
+ await sendTextMessage(ctx.api, chatId, chunk, {
4242
+ parseMode,
4243
+ fallbackText: fallbackChunks[index] ?? chunk,
4244
+ replyMarkup: index === 0 ? options.replyMarkup : undefined,
4245
+ messageThreadId,
4246
+ });
4247
+ }
4248
+ }
4249
+ async function sendTextMessage(api, chatId, text, options = {}) {
4250
+ const parseMode = Object.prototype.hasOwnProperty.call(options, "parseMode") ? options.parseMode : "HTML";
4251
+ const safeText = redactText(text);
4252
+ const safeFallbackText = options.fallbackText === undefined ? undefined : redactText(options.fallbackText);
4253
+ const bucket = chatBucket(chatId);
4254
+ try {
4255
+ return await telegramRateLimiter.run(bucket, "sendMessage", () => api.sendMessage(chatId, safeText, {
4256
+ ...(parseMode ? { parse_mode: parseMode } : {}),
4257
+ ...(options.messageThreadId ? { message_thread_id: options.messageThreadId } : {}),
4258
+ reply_markup: options.replyMarkup,
4259
+ }));
4260
+ }
4261
+ catch (error) {
4262
+ if (parseMode && safeFallbackText !== undefined && isTelegramParseError(error)) {
4263
+ return await telegramRateLimiter.run(bucket, "sendMessage", () => api.sendMessage(chatId, safeFallbackText, {
4264
+ ...(options.messageThreadId ? { message_thread_id: options.messageThreadId } : {}),
4265
+ reply_markup: options.replyMarkup,
4266
+ }));
4267
+ }
4268
+ throw error;
4269
+ }
4270
+ }
4271
+ async function safeEditMessage(bot, chatId, messageId, text, options = {}) {
4272
+ const parseMode = Object.prototype.hasOwnProperty.call(options, "parseMode") ? options.parseMode : "HTML";
4273
+ const safeText = redactText(text);
4274
+ const safeFallbackText = options.fallbackText === undefined ? undefined : redactText(options.fallbackText);
4275
+ const bucket = `${chatBucket(chatId)}:${messageId}`;
4276
+ try {
4277
+ await telegramRateLimiter.run(bucket, "editMessageText", () => bot.api.editMessageText(chatId, messageId, safeText, {
4278
+ ...(parseMode ? { parse_mode: parseMode } : {}),
4279
+ reply_markup: options.replyMarkup,
4280
+ }));
4281
+ }
4282
+ catch (error) {
4283
+ if (isMessageNotModifiedError(error)) {
4284
+ return;
4285
+ }
4286
+ if (parseMode && safeFallbackText !== undefined && isTelegramParseError(error)) {
4287
+ await telegramRateLimiter.run(bucket, "editMessageText", () => bot.api.editMessageText(chatId, messageId, safeFallbackText, {
4288
+ reply_markup: options.replyMarkup,
4289
+ }));
4290
+ return;
4291
+ }
4292
+ throw error;
4293
+ }
4294
+ }
4295
+ async function safeEditReplyMarkup(bot, chatId, messageId, replyMarkup) {
4296
+ try {
4297
+ await telegramRateLimiter.run(`${chatBucket(chatId)}:${messageId}`, "editMessageReplyMarkup", () => bot.api.editMessageReplyMarkup(chatId, messageId, {
4298
+ reply_markup: replyMarkup ?? new InlineKeyboard(),
4299
+ }));
4300
+ }
4301
+ catch (error) {
4302
+ if (!isMessageNotModifiedError(error)) {
4303
+ throw error;
4304
+ }
4305
+ }
4306
+ }
4307
+ async function sendChatActionSafe(api, chatId, action, messageThreadId) {
4308
+ await telegramRateLimiter.run(chatBucket(chatId), "sendChatAction", () => api.sendChatAction(chatId, action, {
4309
+ ...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
4310
+ }));
4311
+ }
4312
+ function chatBucket(chatId) {
4313
+ return `chat:${String(chatId)}`;
4314
+ }
4315
+ async function downloadTelegramFile(api, token, fileId, maxBytes = MAX_AUDIO_FILE_SIZE) {
4316
+ const file = await api.getFile(fileId);
4317
+ if (!file.file_path) {
4318
+ throw new Error("Telegram did not return a file path");
4319
+ }
4320
+ if (file.file_size && file.file_size > maxBytes) {
4321
+ throw new Error(`Telegram file too large (${Math.round(file.file_size / 1024 / 1024)} MB, max ${Math.round(maxBytes / 1024 / 1024)} MB)`);
4322
+ }
4323
+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
4324
+ const response = await fetch(url);
4325
+ if (!response.ok) {
4326
+ throw new Error(`Failed to download Telegram file: ${response.status}`);
4327
+ }
4328
+ const buffer = Buffer.from(await response.arrayBuffer());
4329
+ const extension = path.extname(file.file_path) || ".bin";
4330
+ const tempPath = path.join(tmpdir(), `nordrelay-file-${randomUUID()}${extension}`);
4331
+ await writeFile(tempPath, buffer);
4332
+ return tempPath;
4333
+ }
4334
+ function splitTelegramText(text) {
4335
+ if (text.length <= TELEGRAM_MESSAGE_LIMIT) {
4336
+ return [text];
4337
+ }
4338
+ const chunks = [];
4339
+ let remaining = text;
4340
+ while (remaining.length > TELEGRAM_MESSAGE_LIMIT) {
4341
+ let cut = remaining.lastIndexOf("\n", TELEGRAM_MESSAGE_LIMIT);
4342
+ if (cut < TELEGRAM_MESSAGE_LIMIT * 0.5) {
4343
+ cut = remaining.lastIndexOf(" ", TELEGRAM_MESSAGE_LIMIT);
4344
+ }
4345
+ if (cut < TELEGRAM_MESSAGE_LIMIT * 0.5) {
4346
+ cut = TELEGRAM_MESSAGE_LIMIT;
4347
+ }
4348
+ chunks.push(remaining.slice(0, cut).trimEnd());
4349
+ remaining = remaining.slice(cut).trimStart();
4350
+ }
4351
+ if (remaining) {
4352
+ chunks.push(remaining);
4353
+ }
4354
+ return chunks.length > 0 ? chunks : [""];
4355
+ }
4356
+ function splitMarkdownForTelegram(markdown) {
4357
+ if (!markdown) {
4358
+ return [];
4359
+ }
4360
+ const chunks = [];
4361
+ let remaining = markdown;
4362
+ while (remaining) {
4363
+ const maxLength = Math.min(remaining.length, FORMATTED_CHUNK_TARGET);
4364
+ const initialCut = findPreferredSplitIndex(remaining, maxLength);
4365
+ const candidate = remaining.slice(0, initialCut) || remaining.slice(0, 1);
4366
+ const rendered = renderMarkdownChunkWithinLimit(candidate);
4367
+ chunks.push(rendered);
4368
+ remaining = remaining.slice(rendered.sourceText.length).trimStart();
4369
+ }
4370
+ return chunks;
4371
+ }
4372
+ function renderMarkdownChunkWithinLimit(markdown) {
4373
+ if (!markdown) {
4374
+ return {
4375
+ text: "",
4376
+ fallbackText: "",
4377
+ parseMode: "HTML",
4378
+ sourceText: "",
4379
+ };
4380
+ }
4381
+ let sourceText = markdown;
4382
+ let rendered = formatMarkdownMessage(sourceText);
4383
+ while (rendered.text.length > TELEGRAM_MESSAGE_LIMIT && sourceText.length > 1) {
4384
+ const nextLength = Math.max(1, sourceText.length - Math.max(100, Math.ceil(sourceText.length * 0.1)));
4385
+ sourceText = sourceText.slice(0, nextLength).trimEnd() || sourceText.slice(0, nextLength);
4386
+ rendered = formatMarkdownMessage(sourceText);
4387
+ }
4388
+ return {
4389
+ ...rendered,
4390
+ sourceText,
4391
+ };
4392
+ }
4393
+ function formatMarkdownMessage(markdown) {
4394
+ try {
4395
+ return {
4396
+ text: formatTelegramHTML(markdown),
4397
+ fallbackText: markdown,
4398
+ parseMode: "HTML",
4399
+ };
4400
+ }
4401
+ catch (error) {
4402
+ console.error("Failed to format Telegram HTML, falling back to plain text", error);
4403
+ return {
4404
+ text: markdown,
4405
+ fallbackText: markdown,
4406
+ parseMode: undefined,
4407
+ };
4408
+ }
4409
+ }
4410
+ function findPreferredSplitIndex(text, maxLength) {
4411
+ if (text.length <= maxLength) {
4412
+ return Math.max(1, text.length);
4413
+ }
4414
+ const newlineIndex = text.lastIndexOf("\n", maxLength);
4415
+ if (newlineIndex >= maxLength * 0.5) {
4416
+ return Math.max(1, newlineIndex);
4417
+ }
4418
+ const spaceIndex = text.lastIndexOf(" ", maxLength);
4419
+ if (spaceIndex >= maxLength * 0.5) {
4420
+ return Math.max(1, spaceIndex);
4421
+ }
4422
+ return Math.max(1, maxLength);
4423
+ }
4424
+ function buildStreamingPreview(text) {
4425
+ if (text.length <= STREAMING_PREVIEW_LIMIT) {
4426
+ return text;
4427
+ }
4428
+ return `${text.slice(0, STREAMING_PREVIEW_LIMIT)}\n\n… streaming (preview truncated)`;
4429
+ }
4430
+ function appendWithCap(base, addition, cap) {
4431
+ const combined = `${base}${addition}`;
4432
+ return combined.length <= cap ? combined : combined.slice(-cap);
4433
+ }
4434
+ function summarizeToolOutput(text) {
4435
+ const trimmed = text.trim();
4436
+ if (!trimmed) {
4437
+ return "";
4438
+ }
4439
+ return trimmed.length <= TOOL_OUTPUT_PREVIEW_LIMIT ? trimmed : `${trimmed.slice(-TOOL_OUTPUT_PREVIEW_LIMIT)}\n…`;
4440
+ }
4441
+ function trimLine(text, maxLength) {
4442
+ const singleLine = text.replace(/\s+/g, " ").trim();
4443
+ if (singleLine.length <= maxLength) {
4444
+ return singleLine;
4445
+ }
4446
+ return `${singleLine.slice(0, maxLength - 1)}…`;
4447
+ }
4448
+ function getWorkspaceShortName(workspace) {
4449
+ return workspace.split(/[\\/]/).filter(Boolean).pop() ?? workspace;
4450
+ }
4451
+ function formatRelativeTime(date) {
4452
+ const deltaMs = Date.now() - date.getTime();
4453
+ const deltaSeconds = Math.max(0, Math.floor(deltaMs / 1000));
4454
+ if (deltaSeconds < 60) {
4455
+ return "just now";
4456
+ }
4457
+ const deltaMinutes = Math.floor(deltaSeconds / 60);
4458
+ if (deltaMinutes < 60) {
4459
+ return `${deltaMinutes}m ago`;
4460
+ }
4461
+ const deltaHours = Math.floor(deltaMinutes / 60);
4462
+ if (deltaHours < 48) {
4463
+ return `${deltaHours}h ago`;
4464
+ }
4465
+ const deltaDays = Math.floor(deltaHours / 24);
4466
+ if (deltaDays < 14) {
4467
+ return `${deltaDays}d ago`;
4468
+ }
4469
+ const deltaWeeks = Math.floor(deltaDays / 7);
4470
+ return `${deltaWeeks}w ago`;
4471
+ }
4472
+ function filterSessions(sessions, query) {
4473
+ const normalized = query.trim().toLowerCase();
4474
+ if (!normalized) {
4475
+ return sessions;
4476
+ }
4477
+ return sessions.filter((session) => [
4478
+ session.id,
4479
+ session.title ?? "",
4480
+ session.cwd,
4481
+ session.model ?? "",
4482
+ session.firstUserMessage ?? "",
4483
+ ].some((value) => value.toLowerCase().includes(normalized)));
4484
+ }
4485
+ function orderPinnedSessions(sessions, pinnedThreadIds) {
4486
+ const pinnedIndex = new Map(pinnedThreadIds.map((threadId, index) => [threadId, index]));
4487
+ return [...sessions].sort((left, right) => {
4488
+ const leftPinned = pinnedIndex.get(left.id);
4489
+ const rightPinned = pinnedIndex.get(right.id);
4490
+ if (leftPinned !== undefined && rightPinned !== undefined) {
4491
+ return leftPinned - rightPinned;
4492
+ }
4493
+ if (leftPinned !== undefined) {
4494
+ return -1;
4495
+ }
4496
+ if (rightPinned !== undefined) {
4497
+ return 1;
4498
+ }
4499
+ return 0;
4500
+ });
4501
+ }
4502
+ function isMessageNotModifiedError(error) {
4503
+ const message = error instanceof Error ? error.message : String(error);
4504
+ return message.includes("message is not modified");
4505
+ }
4506
+ function isTelegramParseError(error) {
4507
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
4508
+ return (message.includes("can't parse entities") ||
4509
+ message.includes("unsupported start tag") ||
4510
+ message.includes("unexpected end tag") ||
4511
+ message.includes("entity name") ||
4512
+ message.includes("parse entities"));
4513
+ }
4514
+ function renderPromptFailure(accumulatedText, error) {
4515
+ const message = friendlyErrorText(error);
4516
+ return accumulatedText.trim() ? `${accumulatedText.trim()}\n\n⚠️ ${message}` : `⚠️ ${message}`;
4517
+ }
4518
+ function formatError(error) {
4519
+ return error instanceof Error ? error.message : String(error);
4520
+ }