iosm-cli 0.2.13 → 0.2.15

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 (78) hide show
  1. package/.npmignore +2 -0
  2. package/CHANGELOG.md +49 -0
  3. package/README.md +12 -2
  4. package/dist/cli/args.d.ts +1 -1
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +9 -2
  7. package/dist/cli/args.js.map +1 -1
  8. package/dist/core/auth-storage.d.ts.map +1 -1
  9. package/dist/core/auth-storage.js +17 -2
  10. package/dist/core/auth-storage.js.map +1 -1
  11. package/dist/core/command-dispatcher.d.ts +16 -0
  12. package/dist/core/command-dispatcher.d.ts.map +1 -0
  13. package/dist/core/command-dispatcher.js +678 -0
  14. package/dist/core/command-dispatcher.js.map +1 -0
  15. package/dist/core/model-registry.d.ts.map +1 -1
  16. package/dist/core/model-registry.js +13 -1
  17. package/dist/core/model-registry.js.map +1 -1
  18. package/dist/core/model-resolver.d.ts +2 -2
  19. package/dist/core/model-resolver.d.ts.map +1 -1
  20. package/dist/core/model-resolver.js +1 -2
  21. package/dist/core/model-resolver.js.map +1 -1
  22. package/dist/core/provider-policy.d.ts +7 -0
  23. package/dist/core/provider-policy.d.ts.map +1 -0
  24. package/dist/core/provider-policy.js +19 -0
  25. package/dist/core/provider-policy.js.map +1 -0
  26. package/dist/core/settings-manager.d.ts +25 -0
  27. package/dist/core/settings-manager.d.ts.map +1 -1
  28. package/dist/core/settings-manager.js +32 -0
  29. package/dist/core/settings-manager.js.map +1 -1
  30. package/dist/core/slash-commands.d.ts.map +1 -1
  31. package/dist/core/slash-commands.js +4 -1
  32. package/dist/core/slash-commands.js.map +1 -1
  33. package/dist/core/subagent-background-runs.d.ts +56 -0
  34. package/dist/core/subagent-background-runs.d.ts.map +1 -0
  35. package/dist/core/subagent-background-runs.js +275 -0
  36. package/dist/core/subagent-background-runs.js.map +1 -0
  37. package/dist/core/tools/task.d.ts.map +1 -1
  38. package/dist/core/tools/task.js +39 -35
  39. package/dist/core/tools/task.js.map +1 -1
  40. package/dist/main.d.ts.map +1 -1
  41. package/dist/main.js +16 -2
  42. package/dist/main.js.map +1 -1
  43. package/dist/modes/index.d.ts +1 -0
  44. package/dist/modes/index.d.ts.map +1 -1
  45. package/dist/modes/index.js +1 -0
  46. package/dist/modes/index.js.map +1 -1
  47. package/dist/modes/interactive/components/login-dialog.d.ts +1 -1
  48. package/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
  49. package/dist/modes/interactive/components/login-dialog.js +1 -4
  50. package/dist/modes/interactive/components/login-dialog.js.map +1 -1
  51. package/dist/modes/interactive/components/oauth-selector.d.ts.map +1 -1
  52. package/dist/modes/interactive/components/oauth-selector.js +1 -2
  53. package/dist/modes/interactive/components/oauth-selector.js.map +1 -1
  54. package/dist/modes/interactive/interactive-mode.d.ts +7 -0
  55. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  56. package/dist/modes/interactive/interactive-mode.js +253 -10
  57. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  58. package/dist/modes/rpc/rpc-client.d.ts +11 -1
  59. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  60. package/dist/modes/rpc/rpc-client.js +54 -0
  61. package/dist/modes/rpc/rpc-client.js.map +1 -1
  62. package/dist/modes/rpc/rpc-mode.d.ts +1 -1
  63. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  64. package/dist/modes/rpc/rpc-mode.js +87 -3
  65. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  66. package/dist/modes/rpc/rpc-types.d.ts +69 -0
  67. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  68. package/dist/modes/rpc/rpc-types.js.map +1 -1
  69. package/dist/modes/telegram/telegram-bridge-mode.d.ts +15 -0
  70. package/dist/modes/telegram/telegram-bridge-mode.d.ts.map +1 -0
  71. package/dist/modes/telegram/telegram-bridge-mode.js +2164 -0
  72. package/dist/modes/telegram/telegram-bridge-mode.js.map +1 -0
  73. package/docs/cli-reference.md +10 -1
  74. package/docs/configuration.md +21 -0
  75. package/docs/rpc-json-sdk.md +23 -0
  76. package/examples/extensions/README.md +1 -2
  77. package/package.json +4 -3
  78. package/examples/extensions/antigravity-image-gen.ts +0 -415
@@ -0,0 +1,2164 @@
1
+ import { marked } from "marked";
2
+ import { setTimeout as sleepTimeout } from "node:timers/promises";
3
+ import { APP_NAME, VERSION } from "../../config.js";
4
+ import { RpcClient } from "../rpc/rpc-client.js";
5
+ const COMMANDS_PAGE_SIZE = 8;
6
+ const COMMAND_MENU_TTL_MS = 5 * 60 * 1000;
7
+ const MODELS_PAGE_SIZE = 8;
8
+ const MODEL_MENU_TTL_MS = 2 * 60 * 1000;
9
+ const LIVE_STATUS_ANIMATION_MAX_THROTTLE_MS = 850;
10
+ const INPUT_BUTTON_HUB = "🧭 Hub";
11
+ const INPUT_BUTTON_START = "▶️ Start";
12
+ const INPUT_BUTTON_NEW = "🆕 New";
13
+ const INPUT_BUTTON_COMMANDS = "⚡ Cmd";
14
+ const INPUT_BUTTON_HELP = "❓ Help";
15
+ const INPUT_BUTTON_ABORT = "⛔ Abort";
16
+ const INPUT_BUTTON_STOP = "🛑 Stop";
17
+ const MAIN_COMMAND_MENU = [
18
+ {
19
+ name: "model",
20
+ description: "Choose active model",
21
+ source: "builtin",
22
+ commandText: "/model",
23
+ },
24
+ {
25
+ name: "status",
26
+ description: "Show control hub status",
27
+ source: "builtin",
28
+ commandText: "/status",
29
+ },
30
+ {
31
+ name: "new",
32
+ description: "Start a new session",
33
+ source: "builtin",
34
+ commandText: "/new",
35
+ },
36
+ {
37
+ name: "abort",
38
+ description: "Abort active task",
39
+ source: "builtin",
40
+ commandText: "/abort",
41
+ },
42
+ {
43
+ name: "permissions",
44
+ description: "Show permission mode",
45
+ source: "builtin",
46
+ commandText: "/permissions status",
47
+ },
48
+ {
49
+ name: "yolo",
50
+ description: "Show YOLO mode",
51
+ source: "builtin",
52
+ commandText: "/yolo status",
53
+ },
54
+ {
55
+ name: "help",
56
+ description: "Show command help",
57
+ source: "builtin",
58
+ commandText: "/help",
59
+ },
60
+ {
61
+ name: "stop",
62
+ description: "Stop bridge and cancel active work",
63
+ source: "builtin",
64
+ commandText: "/stop",
65
+ },
66
+ ];
67
+ class TelegramBotApi {
68
+ constructor(token) {
69
+ this.token = token;
70
+ }
71
+ get endpoint() {
72
+ return `https://api.telegram.org/bot${this.token}`;
73
+ }
74
+ async getMe() {
75
+ return this.call("getMe", {});
76
+ }
77
+ async getUpdates(offset, timeoutSeconds) {
78
+ return this.call("getUpdates", {
79
+ offset,
80
+ timeout: timeoutSeconds,
81
+ allowed_updates: ["message", "callback_query"],
82
+ });
83
+ }
84
+ async sendMessage(chatId, text, options) {
85
+ return this.call("sendMessage", {
86
+ chat_id: chatId,
87
+ text,
88
+ reply_markup: options?.replyMarkup,
89
+ disable_notification: options?.disableNotification ?? false,
90
+ parse_mode: options?.parseMode,
91
+ });
92
+ }
93
+ async editMessageText(chatId, messageId, text, options) {
94
+ return this.call("editMessageText", {
95
+ chat_id: chatId,
96
+ message_id: messageId,
97
+ text,
98
+ reply_markup: options?.replyMarkup,
99
+ });
100
+ }
101
+ async editMessageReplyMarkup(chatId, messageId, replyMarkup) {
102
+ return this.call("editMessageReplyMarkup", {
103
+ chat_id: chatId,
104
+ message_id: messageId,
105
+ reply_markup: replyMarkup,
106
+ });
107
+ }
108
+ async deleteMessage(chatId, messageId) {
109
+ return this.call("deleteMessage", {
110
+ chat_id: chatId,
111
+ message_id: messageId,
112
+ });
113
+ }
114
+ async answerCallbackQuery(callbackQueryId, text) {
115
+ return this.call("answerCallbackQuery", {
116
+ callback_query_id: callbackQueryId,
117
+ text,
118
+ show_alert: false,
119
+ });
120
+ }
121
+ async sendTextDocument(chatId, filename, content, caption) {
122
+ const form = new FormData();
123
+ form.set("chat_id", String(chatId));
124
+ if (caption) {
125
+ form.set("caption", caption);
126
+ }
127
+ form.set("document", new Blob([content], { type: "text/plain" }), filename);
128
+ return this.callMultipart("sendDocument", form);
129
+ }
130
+ async call(method, payload) {
131
+ const response = await fetch(`${this.endpoint}/${method}`, {
132
+ method: "POST",
133
+ headers: {
134
+ "content-type": "application/json",
135
+ },
136
+ body: JSON.stringify(payload),
137
+ });
138
+ const envelope = (await response.json());
139
+ if (!response.ok || !envelope.ok || envelope.result === undefined) {
140
+ throw new Error(`Telegram API ${method} failed: ${envelope.description ?? response.statusText} (${envelope.error_code ?? response.status})`);
141
+ }
142
+ return envelope.result;
143
+ }
144
+ async callMultipart(method, form) {
145
+ const response = await fetch(`${this.endpoint}/${method}`, {
146
+ method: "POST",
147
+ body: form,
148
+ });
149
+ const envelope = (await response.json());
150
+ if (!response.ok || !envelope.ok || envelope.result === undefined) {
151
+ throw new Error(`Telegram API ${method} failed: ${envelope.description ?? response.statusText} (${envelope.error_code ?? response.status})`);
152
+ }
153
+ return envelope.result;
154
+ }
155
+ }
156
+ export async function runTelegramBridgeMode(options) {
157
+ const mode = new TelegramBridgeRuntime(options);
158
+ await mode.run();
159
+ // run() loops forever unless it throws; keep Promise<never> contract explicit.
160
+ return new Promise(() => { });
161
+ }
162
+ class TelegramBridgeRuntime {
163
+ constructor(options) {
164
+ this.options = options;
165
+ this.queue = [];
166
+ this.pendingConfirmations = new Map();
167
+ this.pendingConfirmationGroupIdsByKey = new Map();
168
+ this.commandCatalogByChat = new Map();
169
+ this.modelCatalogByChat = new Map();
170
+ this.hubMessageIdByChat = new Map();
171
+ this.hubViewByChat = new Map();
172
+ this.inputMenuEnabledByChat = new Set();
173
+ this.nextUpdateOffset = 0;
174
+ this.nextTurnId = 1;
175
+ this.rpcConnected = false;
176
+ this.bridgeStopped = false;
177
+ this.settingsManager = options.settingsManager;
178
+ const telegramSettings = this.settingsManager.getTelegramSettings();
179
+ if (!telegramSettings.enabled) {
180
+ throw new Error("Telegram bridge is disabled in settings (telegram.enabled=false).");
181
+ }
182
+ if (!telegramSettings.botToken) {
183
+ throw new Error("Telegram bridge requires settings.telegram.botToken.");
184
+ }
185
+ if (telegramSettings.allowedUserIds.length === 0) {
186
+ throw new Error("Telegram bridge requires at least one allowed user ID in settings.telegram.allowedUserIds.");
187
+ }
188
+ if (telegramSettings.transport !== "long-polling") {
189
+ throw new Error(`Unsupported telegram transport: ${telegramSettings.transport}`);
190
+ }
191
+ this.bot = new TelegramBotApi(telegramSettings.botToken);
192
+ this.allowedUserIds = new Set(telegramSettings.allowedUserIds);
193
+ this.statusEditThrottleMs = telegramSettings.chatDefaults.statusEditThrottleMs;
194
+ this.maxSummaryChars = telegramSettings.chatDefaults.maxSummaryChars;
195
+ this.rpcClient = new RpcClient({
196
+ cliPath: options.cliPath,
197
+ cwd: options.cwd,
198
+ args: this.buildRpcForwardedArgs(options.rawArgs),
199
+ });
200
+ }
201
+ async run() {
202
+ const me = await this.bot.getMe();
203
+ await this.startRpc();
204
+ console.log(`[telegram] bridge online as @${me.username ?? "unknown"} (${me.id})`);
205
+ const shutdown = async (signal) => {
206
+ console.log(`[telegram] received ${signal}, stopping bridge...`);
207
+ try {
208
+ await this.rpcClient.stop();
209
+ }
210
+ finally {
211
+ process.exit(0);
212
+ }
213
+ };
214
+ process.once("SIGINT", () => {
215
+ void shutdown("SIGINT");
216
+ });
217
+ process.once("SIGTERM", () => {
218
+ void shutdown("SIGTERM");
219
+ });
220
+ this.rpcClient.onEvent((event) => {
221
+ void this.onRpcEvent(event);
222
+ });
223
+ this.rpcClient.onExtensionUIRequest((request) => {
224
+ void this.onRpcExtensionUiRequest(request);
225
+ });
226
+ this.rpcClient.onRequiresConfirmation((event) => {
227
+ void this.onRpcConfirmationRequest(event);
228
+ });
229
+ // Keep polling forever.
230
+ for (;;) {
231
+ try {
232
+ const updates = await this.bot.getUpdates(this.nextUpdateOffset, 25);
233
+ for (const update of updates) {
234
+ this.nextUpdateOffset = Math.max(this.nextUpdateOffset, update.update_id + 1);
235
+ await this.handleUpdate(update);
236
+ }
237
+ }
238
+ catch (error) {
239
+ console.error(`[telegram] polling error: ${error instanceof Error ? error.message : String(error)}`);
240
+ await sleepTimeout(2_000);
241
+ }
242
+ }
243
+ }
244
+ buildRpcForwardedArgs(rawArgs) {
245
+ const forwarded = [];
246
+ for (let i = 0; i < rawArgs.length; i++) {
247
+ const arg = rawArgs[i];
248
+ if (i === 0 && arg === "telegram")
249
+ continue;
250
+ if (arg === "--mode") {
251
+ // RPC mode is enforced by RpcClient; ignore any caller-provided mode.
252
+ i += 1;
253
+ continue;
254
+ }
255
+ forwarded.push(arg);
256
+ }
257
+ return forwarded;
258
+ }
259
+ async startRpc() {
260
+ try {
261
+ await this.rpcClient.start();
262
+ this.activeSessionState = await this.rpcClient.getState();
263
+ this.rpcConnected = true;
264
+ }
265
+ catch (error) {
266
+ this.rpcConnected = false;
267
+ throw new Error(`Failed to start RPC child: ${error instanceof Error ? error.message : String(error)}`);
268
+ }
269
+ }
270
+ async restartRpc() {
271
+ await this.rpcClient.stop();
272
+ await this.startRpc();
273
+ }
274
+ async ensureRpcConnected() {
275
+ if (this.rpcConnected)
276
+ return;
277
+ await this.restartRpc();
278
+ }
279
+ isAuthorizedUser(userId) {
280
+ return typeof userId === "number" && this.allowedUserIds.has(userId);
281
+ }
282
+ buildInputMenuKeyboard(stopped = this.bridgeStopped) {
283
+ if (stopped) {
284
+ return {
285
+ keyboard: [[{ text: INPUT_BUTTON_HUB }, { text: INPUT_BUTTON_START }], [{ text: INPUT_BUTTON_HELP }]],
286
+ resize_keyboard: true,
287
+ is_persistent: true,
288
+ input_field_placeholder: "Bridge stopped. Tap Start to resume",
289
+ };
290
+ }
291
+ return {
292
+ keyboard: [
293
+ [{ text: INPUT_BUTTON_HUB }, { text: INPUT_BUTTON_NEW }, { text: INPUT_BUTTON_COMMANDS }],
294
+ [{ text: INPUT_BUTTON_HELP }, { text: INPUT_BUTTON_ABORT }, { text: INPUT_BUTTON_STOP }],
295
+ ],
296
+ resize_keyboard: true,
297
+ is_persistent: true,
298
+ input_field_placeholder: "Write a task or pick an action",
299
+ };
300
+ }
301
+ async ensureInputMenu(chatId, force = false) {
302
+ if (!force && this.inputMenuEnabledByChat.has(chatId))
303
+ return;
304
+ const notice = force ? "Quick actions updated ↓" : "Quick actions ready ↓";
305
+ await this.bot.sendMessage(chatId, notice, {
306
+ replyMarkup: this.buildInputMenuKeyboard(),
307
+ disableNotification: true,
308
+ });
309
+ this.inputMenuEnabledByChat.add(chatId);
310
+ }
311
+ normalizeInputActionText(text) {
312
+ return text
313
+ .normalize("NFKC")
314
+ .replace(/\uFE0F/g, "")
315
+ .toLowerCase()
316
+ .replace(/[^\p{Letter}\p{Number} ]+/gu, " ")
317
+ .replace(/\s+/g, " ")
318
+ .trim();
319
+ }
320
+ mapInputMenuAction(text) {
321
+ const normalized = this.normalizeInputActionText(text);
322
+ switch (normalized) {
323
+ case "start":
324
+ case "resume":
325
+ case "старт":
326
+ case "запуск":
327
+ return "/start";
328
+ case "hub":
329
+ case "status":
330
+ case "хаб":
331
+ case "статус":
332
+ return "/status";
333
+ case "new":
334
+ case "new session":
335
+ case "новая":
336
+ case "новая сессия":
337
+ case "новый":
338
+ return "/new";
339
+ case "cmd":
340
+ case "command":
341
+ case "commands":
342
+ case "команда":
343
+ case "команды":
344
+ return "/commands";
345
+ case "help":
346
+ case "помощь":
347
+ case "хелп":
348
+ return "/help";
349
+ case "abort":
350
+ case "cancel":
351
+ case "прервать":
352
+ case "отмена":
353
+ return "/abort";
354
+ case "stop":
355
+ case "стоп":
356
+ return "/stop";
357
+ }
358
+ if (normalized === this.normalizeInputActionText(INPUT_BUTTON_START))
359
+ return "/start";
360
+ if (normalized === this.normalizeInputActionText(INPUT_BUTTON_HUB))
361
+ return "/status";
362
+ if (normalized === this.normalizeInputActionText(INPUT_BUTTON_NEW))
363
+ return "/new";
364
+ if (normalized === this.normalizeInputActionText(INPUT_BUTTON_COMMANDS))
365
+ return "/commands";
366
+ if (normalized === this.normalizeInputActionText(INPUT_BUTTON_HELP))
367
+ return "/help";
368
+ if (normalized === this.normalizeInputActionText(INPUT_BUTTON_ABORT))
369
+ return "/abort";
370
+ if (normalized === this.normalizeInputActionText(INPUT_BUTTON_STOP))
371
+ return "/stop";
372
+ return undefined;
373
+ }
374
+ async handleUpdate(update) {
375
+ if (update.callback_query) {
376
+ await this.handleCallbackQuery(update.callback_query);
377
+ return;
378
+ }
379
+ if (!update.message?.text)
380
+ return;
381
+ const message = update.message;
382
+ const userId = message.from?.id;
383
+ if (!this.isAuthorizedUser(userId)) {
384
+ console.warn(`[telegram] unauthorized message ignored (user=${userId ?? "unknown"}, chat=${message.chat.id})`);
385
+ return;
386
+ }
387
+ this.lastAuthorizedChatId = message.chat.id;
388
+ const text = (message.text ?? "").trim();
389
+ if (text.length === 0)
390
+ return;
391
+ if (!this.inputMenuEnabledByChat.has(message.chat.id)) {
392
+ await this.ensureInputMenu(message.chat.id);
393
+ }
394
+ const quickActionCommand = this.mapInputMenuAction(text);
395
+ if (quickActionCommand) {
396
+ await this.handleCommandMessage(message.chat.id, quickActionCommand);
397
+ return;
398
+ }
399
+ if (text.startsWith("/")) {
400
+ await this.handleCommandMessage(message.chat.id, text);
401
+ return;
402
+ }
403
+ if (this.bridgeStopped) {
404
+ await this.bot.sendMessage(message.chat.id, "Bridge is stopped. Use /start to resume.");
405
+ return;
406
+ }
407
+ await this.enqueuePrompt(message.chat.id, text);
408
+ }
409
+ async handleCallbackQuery(callback) {
410
+ const userId = callback.from?.id;
411
+ if (!this.isAuthorizedUser(userId)) {
412
+ await this.bot.answerCallbackQuery(callback.id, "Unauthorized");
413
+ console.warn(`[telegram] unauthorized callback ignored (user=${userId ?? "unknown"})`);
414
+ return;
415
+ }
416
+ const data = callback.data?.trim();
417
+ if (!data) {
418
+ await this.bot.answerCallbackQuery(callback.id);
419
+ return;
420
+ }
421
+ if (data.startsWith("confirm:")) {
422
+ const [, requestId, decision] = data.split(":");
423
+ if (!requestId || (decision !== "yes" && decision !== "no")) {
424
+ await this.bot.answerCallbackQuery(callback.id, "Invalid confirmation payload");
425
+ return;
426
+ }
427
+ const confirmed = decision === "yes";
428
+ const pending = this.pendingConfirmations.get(requestId);
429
+ await this.respondConfirmation(requestId, confirmed);
430
+ const confirmChatId = callback.message?.chat.id;
431
+ const confirmMessageId = callback.message?.message_id;
432
+ if (confirmChatId && typeof confirmMessageId === "number") {
433
+ await this.updateConfirmationDecisionMessage(confirmChatId, confirmMessageId, confirmed, pending?.label);
434
+ await this.moveLiveStatusToBottom(confirmChatId);
435
+ }
436
+ await this.bot.answerCallbackQuery(callback.id, confirmed ? "Approved" : "Denied");
437
+ return;
438
+ }
439
+ if (data.startsWith("hub:")) {
440
+ await this.bot.answerCallbackQuery(callback.id);
441
+ await this.handleHubAction(callback.message?.chat.id, data.slice(4));
442
+ return;
443
+ }
444
+ if (data.startsWith("live:")) {
445
+ await this.handleLiveAction(callback, data);
446
+ return;
447
+ }
448
+ if (data.startsWith("cmd:")) {
449
+ await this.handleCommandMenuCallback(callback, data);
450
+ return;
451
+ }
452
+ if (data.startsWith("model:")) {
453
+ await this.handleModelMenuCallback(callback, data);
454
+ return;
455
+ }
456
+ await this.bot.answerCallbackQuery(callback.id);
457
+ }
458
+ async handleHubAction(chatId, action) {
459
+ if (!chatId)
460
+ return;
461
+ const stoppedSafeActions = new Set(["status", "refresh", "details", "compact", "start", "help"]);
462
+ if (this.bridgeStopped && !stoppedSafeActions.has(action)) {
463
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
464
+ return;
465
+ }
466
+ switch (action) {
467
+ case "start":
468
+ await this.handleCommandMessage(chatId, "/start");
469
+ return;
470
+ case "help":
471
+ await this.sendHelp(chatId);
472
+ return;
473
+ case "prompt":
474
+ await this.bot.sendMessage(chatId, "Send any text to start a task.");
475
+ return;
476
+ case "commands":
477
+ await this.sendCommandMenu(chatId, 0, { view: "main", refreshCatalog: false });
478
+ return;
479
+ case "new": {
480
+ await this.runNewSession(chatId);
481
+ return;
482
+ }
483
+ case "permissions":
484
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true, view: "details" });
485
+ return;
486
+ case "toggle_mode":
487
+ await this.togglePermissionMode(chatId);
488
+ return;
489
+ case "model":
490
+ await this.sendModelMenu(chatId, 0, { refreshCatalog: true });
491
+ return;
492
+ case "status":
493
+ case "refresh":
494
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
495
+ return;
496
+ case "details":
497
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true, view: "details" });
498
+ return;
499
+ case "compact":
500
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true, view: "compact" });
501
+ return;
502
+ case "abort":
503
+ await this.abortActiveTurn(chatId);
504
+ return;
505
+ case "stop":
506
+ await this.stopBridge(chatId);
507
+ return;
508
+ default:
509
+ await this.bot.sendMessage(chatId, "Action is not implemented yet in Telegram bridge.");
510
+ }
511
+ }
512
+ async runNewSession(chatId) {
513
+ const handled = await this.runBuiltinCommand(chatId, "/new");
514
+ if (!handled) {
515
+ await this.enqueuePrompt(chatId, "/new");
516
+ }
517
+ }
518
+ async switchMenuMessageToHub(chatId, messageId) {
519
+ if (messageId) {
520
+ this.hubMessageIdByChat.set(chatId, messageId);
521
+ }
522
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
523
+ }
524
+ async clearInlineKeyboard(chatId, messageId) {
525
+ try {
526
+ await this.bot.editMessageReplyMarkup(chatId, messageId, { inline_keyboard: [] });
527
+ }
528
+ catch {
529
+ // Best-effort cleanup of stale buttons.
530
+ }
531
+ }
532
+ buildConfirmationResolvedText(confirmed, label) {
533
+ const status = confirmed ? "✅ Allowed" : "❌ Denied";
534
+ if (!label)
535
+ return status;
536
+ const lines = label
537
+ .split("\n")
538
+ .map((line) => line.trim())
539
+ .filter((line) => line.length > 0);
540
+ const detail = lines.find((line) => !/^permission required$/i.test(line)) ?? lines[0];
541
+ if (!detail)
542
+ return status;
543
+ const clipped = detail.length > 120 ? `${detail.slice(0, 119)}…` : detail;
544
+ return `${status}\n${clipped}`;
545
+ }
546
+ async updateConfirmationDecisionMessage(chatId, messageId, confirmed, label) {
547
+ const text = this.buildConfirmationResolvedText(confirmed, label);
548
+ try {
549
+ await this.bot.editMessageText(chatId, messageId, text, {
550
+ replyMarkup: { inline_keyboard: [] },
551
+ });
552
+ }
553
+ catch {
554
+ await this.clearInlineKeyboard(chatId, messageId);
555
+ }
556
+ }
557
+ async moveLiveStatusToBottom(chatId) {
558
+ const turn = this.activeTurn;
559
+ if (!turn || turn.chatId !== chatId)
560
+ return;
561
+ const previousStatusId = turn.statusMessageId;
562
+ try {
563
+ const fresh = await this.bot.sendMessage(chatId, this.formatLiveStatus());
564
+ turn.statusMessageId = fresh.message_id;
565
+ turn.lastStatusEditAt = Date.now();
566
+ if (previousStatusId !== fresh.message_id) {
567
+ await this.bot.deleteMessage(chatId, previousStatusId).catch(() => { });
568
+ }
569
+ }
570
+ catch {
571
+ // Best-effort relocation; keep existing status message on failure.
572
+ }
573
+ }
574
+ async handleLiveAction(callback, data) {
575
+ const chatId = callback.message?.chat.id;
576
+ if (!chatId) {
577
+ await this.bot.answerCallbackQuery(callback.id);
578
+ return;
579
+ }
580
+ const [, action] = data.split(":");
581
+ if (!action) {
582
+ await this.bot.answerCallbackQuery(callback.id);
583
+ return;
584
+ }
585
+ if (this.bridgeStopped && action !== "hub") {
586
+ await this.bot.answerCallbackQuery(callback.id, "Bridge is stopped");
587
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
588
+ return;
589
+ }
590
+ if (action === "hub") {
591
+ await this.bot.answerCallbackQuery(callback.id);
592
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
593
+ return;
594
+ }
595
+ if (action === "commands") {
596
+ await this.bot.answerCallbackQuery(callback.id);
597
+ await this.sendCommandMenu(chatId, 0, { view: "main", refreshCatalog: false });
598
+ return;
599
+ }
600
+ if (action === "model") {
601
+ await this.bot.answerCallbackQuery(callback.id);
602
+ await this.sendModelMenu(chatId, 0, { refreshCatalog: true });
603
+ return;
604
+ }
605
+ if (action === "new") {
606
+ await this.bot.answerCallbackQuery(callback.id, "New session");
607
+ await this.runNewSession(chatId);
608
+ return;
609
+ }
610
+ if (action === "abort") {
611
+ await this.bot.answerCallbackQuery(callback.id, "Aborting...");
612
+ await this.abortActiveTurn(chatId);
613
+ return;
614
+ }
615
+ if (action === "stop") {
616
+ await this.bot.answerCallbackQuery(callback.id, "Stopping...");
617
+ await this.stopBridge(chatId);
618
+ return;
619
+ }
620
+ await this.bot.answerCallbackQuery(callback.id);
621
+ }
622
+ async handleCommandMessage(chatId, text) {
623
+ const [commandRaw, ...rest] = text.split(/\s+/);
624
+ const command = (commandRaw.toLowerCase().split("@")[0] ?? commandRaw.toLowerCase()).trim();
625
+ if (command === "/start") {
626
+ this.bridgeStopped = false;
627
+ await this.sendStart(chatId);
628
+ return;
629
+ }
630
+ if (command === "/help") {
631
+ await this.sendHelp(chatId);
632
+ return;
633
+ }
634
+ if (command === "/menu") {
635
+ await this.ensureInputMenu(chatId, true);
636
+ return;
637
+ }
638
+ if (command === "/status") {
639
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
640
+ return;
641
+ }
642
+ if (command === "/stop") {
643
+ await this.stopBridge(chatId);
644
+ return;
645
+ }
646
+ if (this.bridgeStopped) {
647
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
648
+ await this.ensureInputMenu(chatId, true);
649
+ return;
650
+ }
651
+ if (command === "/commands") {
652
+ await this.sendCommandMenu(chatId, 0, { view: "main", refreshCatalog: false });
653
+ return;
654
+ }
655
+ if (command === "/abort") {
656
+ await this.abortActiveTurn(chatId);
657
+ return;
658
+ }
659
+ if (command === "/yolo") {
660
+ await this.handleYoloCommand(chatId, rest);
661
+ return;
662
+ }
663
+ if (command === "/model" && rest.join(" ").trim().length === 0) {
664
+ await this.sendModelMenu(chatId, 0, { refreshCatalog: true });
665
+ return;
666
+ }
667
+ const handled = await this.runBuiltinCommand(chatId, text);
668
+ if (handled) {
669
+ return;
670
+ }
671
+ // Fallback to raw slash prompt to support extension/prompt-template/skill slash commands.
672
+ await this.enqueuePrompt(chatId, text);
673
+ }
674
+ async runBuiltinCommand(chatId, commandText) {
675
+ try {
676
+ await this.ensureRpcConnected();
677
+ const result = await this.rpcClient.runBuiltinCommand(commandText);
678
+ this.rpcConnected = true;
679
+ if (!result.handled) {
680
+ return false;
681
+ }
682
+ await this.sendBuiltinCommandResult(chatId, result);
683
+ return true;
684
+ }
685
+ catch (error) {
686
+ this.rpcConnected = false;
687
+ await this.bot.sendMessage(chatId, `Command failed: ${error instanceof Error ? error.message : String(error)}`);
688
+ return true;
689
+ }
690
+ }
691
+ async sendBuiltinCommandResult(chatId, result) {
692
+ if (result.message) {
693
+ const prefix = result.level === "error" ? "Error: " : result.level === "warning" ? "Warning: " : "";
694
+ await this.bot.sendMessage(chatId, `${prefix}${result.message}`);
695
+ }
696
+ if (result.text) {
697
+ await this.sendFinalOutput(chatId, result.text);
698
+ }
699
+ if (result.filePath) {
700
+ await this.bot.sendMessage(chatId, `File: ${result.filePath}`);
701
+ }
702
+ }
703
+ async sendStart(chatId) {
704
+ await this.sendStatusCard(chatId, {
705
+ header: `Control Hub · ${APP_NAME} v${VERSION}`,
706
+ includeKeyboard: true,
707
+ preferEditHub: false,
708
+ });
709
+ await this.ensureInputMenu(chatId, true);
710
+ }
711
+ async sendHelp(chatId) {
712
+ const help = [
713
+ "IOSM Telegram · Quick Guide",
714
+ "",
715
+ "What the agent can do",
716
+ "- Analyze your codebase and explain behavior",
717
+ "- Create/edit files and refactor code",
718
+ "- Run shell commands, investigate failures, fix tests",
719
+ "- Work with git changes and repository context",
720
+ "- Prepare concise result summaries",
721
+ "",
722
+ "Tools the agent uses",
723
+ "- Shell tools: bash/zsh commands for diagnostics and automation",
724
+ "- Code tools: search (rg), read/edit files, refactor project code",
725
+ "- Validation tools: run tests, linters, build checks",
726
+ "- Git tools: inspect diffs/status, create safe code changes",
727
+ "- In ASK mode, dangerous tool actions require Allow/Deny",
728
+ "",
729
+ "How to ask",
730
+ "- Write a plain task: what to do + where + expected output",
731
+ "- Example: \"Find why tests fail and fix them\"",
732
+ "- Example: \"Find large files and propose safe cleanup\"",
733
+ "",
734
+ "Quick buttons",
735
+ "- 🧭 Hub: current status/model/mode",
736
+ "- ⚡ Cmd: command/action center",
737
+ "- 🆕 New: start a new session",
738
+ "- ⛔ Abort: stop current task",
739
+ "- 🛑 Stop: stop bridge",
740
+ "",
741
+ "Models and permissions",
742
+ "- /model: choose model",
743
+ "- ASK mode: dangerous actions require Allow/Deny",
744
+ "- /yolo on|off|status: confirmation mode control",
745
+ "",
746
+ "Useful commands",
747
+ "/status /commands /model /menu /help",
748
+ "",
749
+ "Tip: phrase tasks like \"do X, verify Y, report result\".",
750
+ ].join("\n");
751
+ await this.bot.sendMessage(chatId, help);
752
+ }
753
+ buildDefaultCommandText(name) {
754
+ const lower = name.toLowerCase();
755
+ switch (lower) {
756
+ case "permissions":
757
+ return "/permissions status";
758
+ case "yolo":
759
+ return "/yolo status";
760
+ case "checkpoint":
761
+ return "/checkpoint list";
762
+ case "rollback":
763
+ return "/rollback list";
764
+ case "tree":
765
+ return "/tree list";
766
+ default:
767
+ return `/${name}`;
768
+ }
769
+ }
770
+ mapBuiltinCommands(commands) {
771
+ return commands.map((command) => ({
772
+ name: command.name,
773
+ description: command.description,
774
+ source: "builtin",
775
+ commandText: this.buildDefaultCommandText(command.name),
776
+ }));
777
+ }
778
+ mapExternalCommands(commands) {
779
+ return commands.map((command) => ({
780
+ name: command.name,
781
+ description: command.description,
782
+ source: command.source,
783
+ commandText: `/${command.name}`,
784
+ }));
785
+ }
786
+ getLocalBridgeCommands() {
787
+ return [
788
+ {
789
+ name: "commands",
790
+ description: "Open paged command buttons",
791
+ source: "builtin",
792
+ commandText: "/commands",
793
+ },
794
+ {
795
+ name: "model",
796
+ description: "Open model picker",
797
+ source: "builtin",
798
+ commandText: "/model",
799
+ },
800
+ {
801
+ name: "status",
802
+ description: "Show runtime status",
803
+ source: "builtin",
804
+ commandText: "/status",
805
+ },
806
+ {
807
+ name: "abort",
808
+ description: "Abort active task",
809
+ source: "builtin",
810
+ commandText: "/abort",
811
+ },
812
+ {
813
+ name: "stop",
814
+ description: "Stop bridge and cancel active work",
815
+ source: "builtin",
816
+ commandText: "/stop",
817
+ },
818
+ ];
819
+ }
820
+ commandButtonLabel(entry, view) {
821
+ if (view === "all") {
822
+ const raw = entry.commandText.startsWith("/") ? entry.commandText : `/${entry.name}`;
823
+ return raw.length > 24 ? `${raw.slice(0, 23)}…` : raw;
824
+ }
825
+ const key = entry.commandText.toLowerCase();
826
+ switch (key) {
827
+ case "/model":
828
+ return "🤖 Model";
829
+ case "/status":
830
+ return "🔄 Status";
831
+ case "/new":
832
+ return "🆕 New";
833
+ case "/abort":
834
+ return "⛔ Abort";
835
+ case "/permissions status":
836
+ return "🛡 Mode";
837
+ case "/yolo status":
838
+ return "⚠️ YOLO";
839
+ case "/help":
840
+ return "❓ Help";
841
+ case "/stop":
842
+ return "🛑 Stop";
843
+ default:
844
+ return entry.commandText;
845
+ }
846
+ }
847
+ async getCommandCatalog(chatId, refreshCatalog) {
848
+ const cached = this.commandCatalogByChat.get(chatId);
849
+ const now = Date.now();
850
+ if (!refreshCatalog && cached && now - cached.updatedAt < COMMAND_MENU_TTL_MS) {
851
+ return cached.entries;
852
+ }
853
+ await this.ensureRpcConnected();
854
+ const [builtin, dynamic] = await Promise.all([
855
+ this.rpcClient.getBuiltinCommands(),
856
+ this.rpcClient.getCommands(),
857
+ ]);
858
+ this.rpcConnected = true;
859
+ const entries = [];
860
+ const seen = new Set();
861
+ for (const entry of this.getLocalBridgeCommands()) {
862
+ const key = entry.commandText;
863
+ if (seen.has(key))
864
+ continue;
865
+ seen.add(key);
866
+ entries.push(entry);
867
+ }
868
+ for (const entry of this.mapBuiltinCommands(builtin)) {
869
+ const key = entry.commandText;
870
+ if (seen.has(key))
871
+ continue;
872
+ seen.add(key);
873
+ entries.push(entry);
874
+ }
875
+ const dynamicEntries = this.mapExternalCommands(dynamic).sort((a, b) => a.name.localeCompare(b.name));
876
+ for (const entry of dynamicEntries) {
877
+ const key = entry.commandText;
878
+ if (seen.has(key))
879
+ continue;
880
+ seen.add(key);
881
+ entries.push(entry);
882
+ }
883
+ this.commandCatalogByChat.set(chatId, { updatedAt: now, entries });
884
+ return entries;
885
+ }
886
+ buildCommandMenuKeyboard(entries, page, view) {
887
+ const pageCount = Math.max(1, Math.ceil(entries.length / COMMANDS_PAGE_SIZE));
888
+ const normalizedPage = Math.max(0, Math.min(page, pageCount - 1));
889
+ const start = normalizedPage * COMMANDS_PAGE_SIZE;
890
+ const pageEntries = entries.slice(start, start + COMMANDS_PAGE_SIZE);
891
+ const commandRows = [];
892
+ for (let index = 0; index < pageEntries.length; index += 2) {
893
+ const left = pageEntries[index];
894
+ const right = pageEntries[index + 1];
895
+ const row = [];
896
+ if (left) {
897
+ row.push({
898
+ text: this.commandButtonLabel(left, view),
899
+ callback_data: `cmd:run:${view}:${start + index}`,
900
+ });
901
+ }
902
+ if (right) {
903
+ row.push({
904
+ text: this.commandButtonLabel(right, view),
905
+ callback_data: `cmd:run:${view}:${start + index + 1}`,
906
+ });
907
+ }
908
+ if (row.length > 0) {
909
+ commandRows.push(row);
910
+ }
911
+ }
912
+ const navRow = [];
913
+ navRow.push({
914
+ text: normalizedPage > 0 ? "◀ Prev" : "·",
915
+ callback_data: normalizedPage > 0 ? `cmd:page:${view}:${normalizedPage - 1}` : "cmd:noop",
916
+ });
917
+ navRow.push({
918
+ text: `${normalizedPage + 1}/${pageCount}`,
919
+ callback_data: "cmd:noop",
920
+ });
921
+ navRow.push({
922
+ text: normalizedPage < pageCount - 1 ? "Next ▶" : "·",
923
+ callback_data: normalizedPage < pageCount - 1 ? `cmd:page:${view}:${normalizedPage + 1}` : "cmd:noop",
924
+ });
925
+ return {
926
+ inline_keyboard: [
927
+ ...commandRows,
928
+ navRow,
929
+ [
930
+ {
931
+ text: view === "main" ? "All Commands" : "Main Commands",
932
+ callback_data: `cmd:view:${view === "main" ? "all" : "main"}:0`,
933
+ },
934
+ {
935
+ text: "Refresh",
936
+ callback_data: view === "all" ? `cmd:refresh:${view}:${normalizedPage}` : `cmd:view:${view}:0`,
937
+ },
938
+ ],
939
+ [
940
+ { text: "Hub", callback_data: "cmd:hub" },
941
+ { text: "Close", callback_data: "cmd:close" },
942
+ ],
943
+ ],
944
+ };
945
+ }
946
+ async sendCommandMenu(chatId, page, options) {
947
+ let entries = [];
948
+ const view = options?.view ?? "main";
949
+ try {
950
+ entries = view === "main" ? MAIN_COMMAND_MENU : await this.getCommandCatalog(chatId, options?.refreshCatalog === true);
951
+ }
952
+ catch (error) {
953
+ this.rpcConnected = false;
954
+ await this.bot.sendMessage(chatId, `Failed to load commands: ${error instanceof Error ? error.message : String(error)}`);
955
+ return;
956
+ }
957
+ const pageCount = Math.max(1, Math.ceil(entries.length / COMMANDS_PAGE_SIZE));
958
+ const normalizedPage = Math.max(0, Math.min(page, pageCount - 1));
959
+ const header = [
960
+ `Command Center · ${APP_NAME}`,
961
+ `${view === "main" ? "Main Actions" : "All Commands"} · ${normalizedPage + 1}/${pageCount}`,
962
+ `${entries.length} items`,
963
+ ].join("\n");
964
+ const keyboard = this.buildCommandMenuKeyboard(entries, normalizedPage, view);
965
+ if (options?.messageId) {
966
+ try {
967
+ await this.bot.editMessageText(chatId, options.messageId, header, { replyMarkup: keyboard });
968
+ return;
969
+ }
970
+ catch {
971
+ // Fallback below: send a new message if editing failed.
972
+ }
973
+ }
974
+ await this.bot.sendMessage(chatId, header, { replyMarkup: keyboard });
975
+ }
976
+ async handleCommandMenuCallback(callback, data) {
977
+ const chatId = callback.message?.chat.id;
978
+ const messageId = callback.message?.message_id;
979
+ if (!chatId) {
980
+ await this.bot.answerCallbackQuery(callback.id);
981
+ return;
982
+ }
983
+ const parts = data.split(":");
984
+ const action = parts[1] ?? "";
985
+ const rawView = parts[2];
986
+ let view = rawView === "all" ? "all" : "main";
987
+ let value = parts[3];
988
+ // Backward compatibility with older callback payloads: cmd:<action>:<value>.
989
+ if ((action === "page" || action === "refresh" || action === "run") && parts.length === 3) {
990
+ view = "main";
991
+ value = parts[2];
992
+ }
993
+ if (action === "noop") {
994
+ await this.bot.answerCallbackQuery(callback.id);
995
+ return;
996
+ }
997
+ if (action === "close") {
998
+ await this.bot.answerCallbackQuery(callback.id, "Hub");
999
+ await this.switchMenuMessageToHub(chatId, messageId);
1000
+ return;
1001
+ }
1002
+ if (action === "hub") {
1003
+ await this.bot.answerCallbackQuery(callback.id, "Hub");
1004
+ await this.switchMenuMessageToHub(chatId, messageId);
1005
+ return;
1006
+ }
1007
+ if (this.bridgeStopped) {
1008
+ await this.bot.answerCallbackQuery(callback.id, "Bridge is stopped");
1009
+ await this.switchMenuMessageToHub(chatId, messageId);
1010
+ return;
1011
+ }
1012
+ if (action === "view") {
1013
+ const page = Number.parseInt(value ?? "0", 10);
1014
+ await this.bot.answerCallbackQuery(callback.id);
1015
+ await this.sendCommandMenu(chatId, Number.isFinite(page) ? page : 0, {
1016
+ messageId,
1017
+ view,
1018
+ refreshCatalog: view === "all",
1019
+ });
1020
+ return;
1021
+ }
1022
+ if (action === "page" || action === "refresh") {
1023
+ const page = Number.parseInt(value ?? "0", 10);
1024
+ await this.bot.answerCallbackQuery(callback.id);
1025
+ await this.sendCommandMenu(chatId, Number.isFinite(page) ? page : 0, {
1026
+ messageId,
1027
+ view,
1028
+ refreshCatalog: action === "refresh",
1029
+ });
1030
+ return;
1031
+ }
1032
+ if (action === "run") {
1033
+ const index = Number.parseInt(value ?? "-1", 10);
1034
+ let entries = [];
1035
+ if (view === "main") {
1036
+ entries = MAIN_COMMAND_MENU;
1037
+ }
1038
+ else {
1039
+ try {
1040
+ entries = await this.getCommandCatalog(chatId, false);
1041
+ }
1042
+ catch (error) {
1043
+ this.rpcConnected = false;
1044
+ await this.bot.answerCallbackQuery(callback.id, "Failed to load command catalog");
1045
+ await this.bot.sendMessage(chatId, `Failed to load commands: ${error instanceof Error ? error.message : String(error)}`);
1046
+ return;
1047
+ }
1048
+ }
1049
+ const entry = Number.isFinite(index) ? entries[index] : undefined;
1050
+ if (!entry) {
1051
+ await this.bot.answerCallbackQuery(callback.id, "Command list expired. Refresh.");
1052
+ return;
1053
+ }
1054
+ if (entry.commandText === "/model") {
1055
+ await this.bot.answerCallbackQuery(callback.id, "Open model picker");
1056
+ await this.sendModelMenu(chatId, 0, { refreshCatalog: true });
1057
+ return;
1058
+ }
1059
+ if (entry.commandText === "/status") {
1060
+ await this.bot.answerCallbackQuery(callback.id);
1061
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
1062
+ return;
1063
+ }
1064
+ if (entry.commandText === "/help") {
1065
+ await this.bot.answerCallbackQuery(callback.id);
1066
+ await this.sendHelp(chatId);
1067
+ return;
1068
+ }
1069
+ if (entry.commandText === "/abort") {
1070
+ await this.bot.answerCallbackQuery(callback.id, "Abort signal sent");
1071
+ await this.abortActiveTurn(chatId);
1072
+ return;
1073
+ }
1074
+ if (entry.commandText.startsWith("/yolo")) {
1075
+ await this.bot.answerCallbackQuery(callback.id);
1076
+ const parts = entry.commandText.split(/\s+/).slice(1);
1077
+ await this.handleYoloCommand(chatId, parts);
1078
+ return;
1079
+ }
1080
+ if (entry.commandText === "/stop") {
1081
+ await this.bot.answerCallbackQuery(callback.id, "Stopping bridge");
1082
+ await this.stopBridge(chatId);
1083
+ return;
1084
+ }
1085
+ if (entry.commandText === "/commands") {
1086
+ await this.bot.answerCallbackQuery(callback.id);
1087
+ await this.sendCommandMenu(chatId, 0, { view: "main", refreshCatalog: false });
1088
+ return;
1089
+ }
1090
+ await this.bot.answerCallbackQuery(callback.id, `Run ${entry.commandText}`);
1091
+ const handled = await this.runBuiltinCommand(chatId, entry.commandText);
1092
+ if (!handled) {
1093
+ await this.enqueuePrompt(chatId, entry.commandText);
1094
+ }
1095
+ return;
1096
+ }
1097
+ await this.bot.answerCallbackQuery(callback.id);
1098
+ }
1099
+ modelKey(model) {
1100
+ if (!model)
1101
+ return undefined;
1102
+ return `${model.provider}/${model.id}`;
1103
+ }
1104
+ async getModelCatalog(chatId, refreshCatalog) {
1105
+ const cached = this.modelCatalogByChat.get(chatId);
1106
+ const now = Date.now();
1107
+ if (!refreshCatalog && cached && now - cached.updatedAt < MODEL_MENU_TTL_MS) {
1108
+ return cached.entries;
1109
+ }
1110
+ await this.ensureRpcConnected();
1111
+ const entries = (await this.rpcClient.getAvailableModels())
1112
+ .map((model) => ({
1113
+ provider: model.provider,
1114
+ id: model.id,
1115
+ contextWindow: model.contextWindow,
1116
+ reasoning: model.reasoning,
1117
+ }))
1118
+ .sort((a, b) => this.modelKey(a).localeCompare(this.modelKey(b)));
1119
+ this.rpcConnected = true;
1120
+ this.modelCatalogByChat.set(chatId, { updatedAt: now, entries });
1121
+ return entries;
1122
+ }
1123
+ buildModelMenuKeyboard(entries, page, currentModelKey) {
1124
+ const pageCount = Math.max(1, Math.ceil(entries.length / MODELS_PAGE_SIZE));
1125
+ const normalizedPage = Math.max(0, Math.min(page, pageCount - 1));
1126
+ const start = normalizedPage * MODELS_PAGE_SIZE;
1127
+ const pageEntries = entries.slice(start, start + MODELS_PAGE_SIZE);
1128
+ const modelRows = pageEntries.map((entry, index) => {
1129
+ const key = this.modelKey(entry);
1130
+ const selectedPrefix = key === currentModelKey ? "✅ " : "";
1131
+ return [
1132
+ {
1133
+ text: `${selectedPrefix}${entry.provider}/${entry.id}`,
1134
+ callback_data: `model:set:${start + index}`,
1135
+ },
1136
+ ];
1137
+ });
1138
+ const navRow = [];
1139
+ navRow.push({
1140
+ text: normalizedPage > 0 ? "◀ Prev" : "·",
1141
+ callback_data: normalizedPage > 0 ? `model:page:${normalizedPage - 1}` : "model:noop",
1142
+ });
1143
+ navRow.push({
1144
+ text: `${normalizedPage + 1}/${pageCount}`,
1145
+ callback_data: "model:noop",
1146
+ });
1147
+ navRow.push({
1148
+ text: normalizedPage < pageCount - 1 ? "Next ▶" : "·",
1149
+ callback_data: normalizedPage < pageCount - 1 ? `model:page:${normalizedPage + 1}` : "model:noop",
1150
+ });
1151
+ return {
1152
+ inline_keyboard: [
1153
+ ...modelRows,
1154
+ navRow,
1155
+ [
1156
+ { text: "Cycle", callback_data: "model:cycle" },
1157
+ { text: "Refresh", callback_data: `model:refresh:${normalizedPage}` },
1158
+ ],
1159
+ [
1160
+ { text: "Hub", callback_data: "model:hub" },
1161
+ { text: "Close", callback_data: "model:close" },
1162
+ ],
1163
+ ],
1164
+ };
1165
+ }
1166
+ async sendModelMenu(chatId, page, options) {
1167
+ if (this.bridgeStopped) {
1168
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
1169
+ return;
1170
+ }
1171
+ let entries = [];
1172
+ try {
1173
+ entries = await this.getModelCatalog(chatId, options?.refreshCatalog === true);
1174
+ this.activeSessionState = await this.rpcClient.getState();
1175
+ this.rpcConnected = true;
1176
+ }
1177
+ catch (error) {
1178
+ this.rpcConnected = false;
1179
+ await this.bot.sendMessage(chatId, `Failed to load models: ${error instanceof Error ? error.message : String(error)}`);
1180
+ return;
1181
+ }
1182
+ if (entries.length === 0) {
1183
+ await this.bot.sendMessage(chatId, "No models available. Configure models in settings and retry.");
1184
+ return;
1185
+ }
1186
+ const currentModelKey = this.modelKey(this.activeSessionState?.model);
1187
+ const pageCount = Math.max(1, Math.ceil(entries.length / MODELS_PAGE_SIZE));
1188
+ const normalizedPage = Math.max(0, Math.min(page, pageCount - 1));
1189
+ const header = [
1190
+ "Model Picker",
1191
+ `Current: ${currentModelKey ?? "not selected"}`,
1192
+ `Available: ${entries.length}`,
1193
+ `Page: ${normalizedPage + 1}/${pageCount}`,
1194
+ ].join("\n");
1195
+ const keyboard = this.buildModelMenuKeyboard(entries, normalizedPage, currentModelKey);
1196
+ if (options?.messageId) {
1197
+ try {
1198
+ await this.bot.editMessageText(chatId, options.messageId, header, { replyMarkup: keyboard });
1199
+ return;
1200
+ }
1201
+ catch {
1202
+ // Fallback to sending a fresh message.
1203
+ }
1204
+ }
1205
+ await this.bot.sendMessage(chatId, header, { replyMarkup: keyboard });
1206
+ }
1207
+ async handleModelMenuCallback(callback, data) {
1208
+ const chatId = callback.message?.chat.id;
1209
+ const messageId = callback.message?.message_id;
1210
+ if (!chatId) {
1211
+ await this.bot.answerCallbackQuery(callback.id);
1212
+ return;
1213
+ }
1214
+ const [, action, value] = data.split(":");
1215
+ if (action === "noop") {
1216
+ await this.bot.answerCallbackQuery(callback.id);
1217
+ return;
1218
+ }
1219
+ if (action === "close") {
1220
+ await this.bot.answerCallbackQuery(callback.id, "Hub");
1221
+ await this.switchMenuMessageToHub(chatId, messageId);
1222
+ return;
1223
+ }
1224
+ if (action === "hub") {
1225
+ await this.bot.answerCallbackQuery(callback.id, "Hub");
1226
+ await this.switchMenuMessageToHub(chatId, messageId);
1227
+ return;
1228
+ }
1229
+ if (this.bridgeStopped) {
1230
+ await this.bot.answerCallbackQuery(callback.id, "Bridge is stopped");
1231
+ await this.switchMenuMessageToHub(chatId, messageId);
1232
+ return;
1233
+ }
1234
+ if (action === "page" || action === "refresh") {
1235
+ const page = Number.parseInt(value ?? "0", 10);
1236
+ await this.bot.answerCallbackQuery(callback.id);
1237
+ await this.sendModelMenu(chatId, Number.isFinite(page) ? page : 0, {
1238
+ messageId,
1239
+ refreshCatalog: action === "refresh",
1240
+ });
1241
+ return;
1242
+ }
1243
+ if (action === "cycle") {
1244
+ try {
1245
+ await this.ensureRpcConnected();
1246
+ const cycled = await this.rpcClient.cycleModel();
1247
+ this.activeSessionState = await this.rpcClient.getState();
1248
+ this.rpcConnected = true;
1249
+ await this.bot.answerCallbackQuery(callback.id, cycled ? `Model: ${cycled.model.provider}/${cycled.model.id}` : "No model candidates");
1250
+ await this.sendModelMenu(chatId, 0, { messageId, refreshCatalog: false });
1251
+ }
1252
+ catch (error) {
1253
+ this.rpcConnected = false;
1254
+ await this.bot.answerCallbackQuery(callback.id, "Failed to cycle model");
1255
+ await this.bot.sendMessage(chatId, `Failed to cycle model: ${error instanceof Error ? error.message : String(error)}`);
1256
+ }
1257
+ return;
1258
+ }
1259
+ if (action === "set") {
1260
+ const index = Number.parseInt(value ?? "-1", 10);
1261
+ let entries = [];
1262
+ try {
1263
+ entries = await this.getModelCatalog(chatId, false);
1264
+ }
1265
+ catch (error) {
1266
+ this.rpcConnected = false;
1267
+ await this.bot.answerCallbackQuery(callback.id, "Model list expired. Refresh.");
1268
+ await this.bot.sendMessage(chatId, `Failed to load models: ${error instanceof Error ? error.message : String(error)}`);
1269
+ return;
1270
+ }
1271
+ const entry = Number.isFinite(index) ? entries[index] : undefined;
1272
+ if (!entry) {
1273
+ await this.bot.answerCallbackQuery(callback.id, "Model list expired. Refresh.");
1274
+ return;
1275
+ }
1276
+ try {
1277
+ await this.ensureRpcConnected();
1278
+ await this.rpcClient.setModel(entry.provider, entry.id);
1279
+ this.activeSessionState = await this.rpcClient.getState();
1280
+ this.rpcConnected = true;
1281
+ await this.bot.answerCallbackQuery(callback.id, `Selected ${entry.provider}/${entry.id}`);
1282
+ await this.sendModelMenu(chatId, Math.floor(index / MODELS_PAGE_SIZE), { messageId, refreshCatalog: false });
1283
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
1284
+ }
1285
+ catch (error) {
1286
+ this.rpcConnected = false;
1287
+ await this.bot.answerCallbackQuery(callback.id, "Failed to set model");
1288
+ await this.bot.sendMessage(chatId, `Failed to set model: ${error instanceof Error ? error.message : String(error)}`);
1289
+ }
1290
+ return;
1291
+ }
1292
+ await this.bot.answerCallbackQuery(callback.id);
1293
+ }
1294
+ async stopBridge(chatId) {
1295
+ if (this.bridgeStopped && !this.activeTurn && this.queue.length === 0 && !this.rpcConnected) {
1296
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
1297
+ await this.ensureInputMenu(chatId, true);
1298
+ return;
1299
+ }
1300
+ const droppedQueue = this.queue.length;
1301
+ this.queue.length = 0;
1302
+ this.bridgeStopped = true;
1303
+ this.commandCatalogByChat.clear();
1304
+ this.modelCatalogByChat.clear();
1305
+ if (this.rpcConnected) {
1306
+ for (const requestId of this.pendingConfirmations.keys()) {
1307
+ try {
1308
+ this.rpcClient.respondExtensionUi({ type: "extension_ui_response", id: requestId, confirmed: false });
1309
+ }
1310
+ catch {
1311
+ // Ignore; bridge is stopping.
1312
+ }
1313
+ }
1314
+ }
1315
+ this.pendingConfirmations.clear();
1316
+ this.pendingConfirmationGroupIdsByKey.clear();
1317
+ const active = this.activeTurn;
1318
+ if (active?.statusEditTimer) {
1319
+ clearTimeout(active.statusEditTimer);
1320
+ }
1321
+ this.activeTurn = undefined;
1322
+ if (active) {
1323
+ if (active.statusEditInFlight) {
1324
+ try {
1325
+ await active.statusEditInFlight;
1326
+ }
1327
+ catch {
1328
+ // Best-effort; proceed with stop status.
1329
+ }
1330
+ }
1331
+ try {
1332
+ await this.bot.editMessageText(active.chatId, active.statusMessageId, "⛔ done (stopped)");
1333
+ }
1334
+ catch {
1335
+ // Best-effort status close.
1336
+ }
1337
+ if (active.chatId !== chatId) {
1338
+ await this.bot.sendMessage(active.chatId, "Bridge stopped. Active task cancelled.");
1339
+ }
1340
+ }
1341
+ try {
1342
+ await this.rpcClient.stop();
1343
+ }
1344
+ catch (error) {
1345
+ console.warn(`[telegram] rpc stop failed: ${error instanceof Error ? error.message : String(error)}`);
1346
+ }
1347
+ finally {
1348
+ this.rpcConnected = false;
1349
+ }
1350
+ const parts = ["Bridge stopped."];
1351
+ if (active) {
1352
+ parts.push("Active task cancelled.");
1353
+ }
1354
+ if (droppedQueue > 0) {
1355
+ parts.push(`Dropped queued tasks: ${droppedQueue}.`);
1356
+ }
1357
+ parts.push("Use /start to resume.");
1358
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
1359
+ await this.ensureInputMenu(chatId, true);
1360
+ await this.bot.sendMessage(chatId, parts.join(" "));
1361
+ }
1362
+ formatConnectionStatus() {
1363
+ if (this.bridgeStopped)
1364
+ return "⛔ stopped";
1365
+ if (!this.rpcConnected)
1366
+ return "🟥 disconnected";
1367
+ if (this.activeTurn)
1368
+ return "🟩 connected";
1369
+ return "🟦 idle";
1370
+ }
1371
+ formatPermissionShort(mode) {
1372
+ if (mode === "yolo")
1373
+ return "YOLO";
1374
+ if (mode === "auto")
1375
+ return "AUTO";
1376
+ return "ASK";
1377
+ }
1378
+ formatPermissionButton(mode) {
1379
+ if (mode === "yolo")
1380
+ return "⚠️ YOLO";
1381
+ if (mode === "auto")
1382
+ return "🤖 AUTO";
1383
+ return "🛡 ASK";
1384
+ }
1385
+ formatModelShort(model, maxLen = 42) {
1386
+ if (model.length <= maxLen)
1387
+ return model;
1388
+ return `${model.slice(0, maxLen - 1)}…`;
1389
+ }
1390
+ formatSessionShort(sessionName, sessionId) {
1391
+ if (sessionName && sessionName.trim().length > 0)
1392
+ return this.formatModelShort(sessionName.trim(), 24);
1393
+ if (!sessionId || sessionId.trim().length === 0)
1394
+ return "unknown";
1395
+ const value = sessionId.trim();
1396
+ if (value.length <= 14)
1397
+ return value;
1398
+ return `${value.slice(0, 8)}…${value.slice(-4)}`;
1399
+ }
1400
+ formatCompactTurn() {
1401
+ if (!this.activeTurn)
1402
+ return "idle";
1403
+ const elapsed = Math.max(0, Math.floor((Date.now() - this.activeTurn.startedAt) / 1000));
1404
+ const phase = this.formatStatusPhase(this.activeTurn.phase);
1405
+ return `${phase} ${elapsed}s`;
1406
+ }
1407
+ buildHubKeyboard(mode, view) {
1408
+ if (this.bridgeStopped) {
1409
+ return {
1410
+ inline_keyboard: [
1411
+ [
1412
+ { text: "▶️ Start", callback_data: "hub:start" },
1413
+ { text: "🔄 Refresh", callback_data: "hub:refresh" },
1414
+ ],
1415
+ [{ text: "❓ Help", callback_data: "hub:help" }],
1416
+ ],
1417
+ };
1418
+ }
1419
+ const detailsToggle = view === "details"
1420
+ ? { text: "◀ Compact", callback_data: "hub:compact" }
1421
+ : { text: "ℹ️ Details", callback_data: "hub:details" };
1422
+ return {
1423
+ inline_keyboard: [
1424
+ [
1425
+ { text: "🆕 New", callback_data: "hub:new" },
1426
+ { text: "⚡ Cmd", callback_data: "hub:commands" },
1427
+ { text: "🤖 Model", callback_data: "hub:model" },
1428
+ ],
1429
+ [
1430
+ { text: this.formatPermissionButton(mode), callback_data: "hub:toggle_mode" },
1431
+ { text: "🔄 Refresh", callback_data: "hub:refresh" },
1432
+ detailsToggle,
1433
+ ],
1434
+ [
1435
+ { text: "⛔ Abort", callback_data: "hub:abort" },
1436
+ { text: "🛑 Stop", callback_data: "hub:stop" },
1437
+ ],
1438
+ ],
1439
+ };
1440
+ }
1441
+ async sendStatusCard(chatId, options) {
1442
+ if (!this.bridgeStopped) {
1443
+ try {
1444
+ await this.ensureRpcConnected();
1445
+ this.activeSessionState = await this.rpcClient.getState();
1446
+ this.rpcConnected = true;
1447
+ }
1448
+ catch (error) {
1449
+ this.rpcConnected = false;
1450
+ await this.bot.sendMessage(chatId, `Status unavailable: ${error instanceof Error ? error.message : String(error)}`);
1451
+ return;
1452
+ }
1453
+ }
1454
+ const state = this.activeSessionState;
1455
+ const model = state?.model ? `${state.model.provider}/${state.model.id}` : "not selected";
1456
+ const queueSize = this.queue.length;
1457
+ const mcpState = "RPC child";
1458
+ const permissionMode = state?.permissionMode ?? "ask";
1459
+ const resolvedView = options?.view ?? this.hubViewByChat.get(chatId) ?? "compact";
1460
+ this.hubViewByChat.set(chatId, resolvedView);
1461
+ const header = options?.header ?? "Control Hub";
1462
+ const compactText = [
1463
+ header,
1464
+ `${this.formatConnectionStatus()} · ${this.formatPermissionShort(permissionMode)} · q${queueSize} · ${this.formatCompactTurn()}`,
1465
+ `🤖 ${this.formatModelShort(model)}`,
1466
+ `💬 ${this.formatSessionShort(state?.sessionName, state?.sessionId)}`,
1467
+ ].join("\n");
1468
+ const detailsText = [
1469
+ `${header} · Details`,
1470
+ `Connection: ${this.formatConnectionStatus()}`,
1471
+ `Mode: ${permissionMode}`,
1472
+ `Model: ${model}`,
1473
+ `Session: ${state?.sessionName ?? state?.sessionId ?? "unknown"}`,
1474
+ `Turn: ${this.activeTurn ? `${this.activeTurn.phase} (${Math.floor((Date.now() - this.activeTurn.startedAt) / 1000)}s)` : "idle"}`,
1475
+ `Queue: ${queueSize}`,
1476
+ `MCP: ${mcpState}`,
1477
+ "Telegram: active",
1478
+ ].join("\n");
1479
+ const text = resolvedView === "details" ? detailsText : compactText;
1480
+ const keyboard = options?.includeKeyboard ? this.buildHubKeyboard(permissionMode, resolvedView) : undefined;
1481
+ if (options?.includeKeyboard && options.preferEditHub) {
1482
+ const hubMessageId = this.hubMessageIdByChat.get(chatId);
1483
+ if (hubMessageId) {
1484
+ try {
1485
+ await this.bot.editMessageText(chatId, hubMessageId, text, { replyMarkup: keyboard });
1486
+ return;
1487
+ }
1488
+ catch {
1489
+ // Fallback below: send a fresh hub card if original message is gone.
1490
+ }
1491
+ }
1492
+ }
1493
+ const message = await this.bot.sendMessage(chatId, text, { replyMarkup: keyboard });
1494
+ if (options?.includeKeyboard) {
1495
+ this.hubMessageIdByChat.set(chatId, message.message_id);
1496
+ }
1497
+ }
1498
+ async togglePermissionMode(chatId) {
1499
+ try {
1500
+ await this.ensureRpcConnected();
1501
+ const current = await this.rpcClient.getPermissionMode();
1502
+ const next = current === "yolo" ? "ask" : "yolo";
1503
+ await this.rpcClient.setPermissionMode(next);
1504
+ this.activeSessionState = await this.rpcClient.getState();
1505
+ this.rpcConnected = true;
1506
+ await this.sendStatusCard(chatId, { includeKeyboard: true, preferEditHub: true });
1507
+ }
1508
+ catch (error) {
1509
+ this.rpcConnected = false;
1510
+ await this.bot.sendMessage(chatId, `Failed to switch mode: ${error instanceof Error ? error.message : String(error)}`);
1511
+ }
1512
+ }
1513
+ async handleYoloCommand(chatId, args) {
1514
+ const desired = args[0]?.toLowerCase();
1515
+ try {
1516
+ await this.ensureRpcConnected();
1517
+ if (!desired || desired === "status") {
1518
+ const mode = await this.rpcClient.getPermissionMode();
1519
+ await this.bot.sendMessage(chatId, `YOLO mode: ${mode === "yolo" ? "ON" : "OFF"} (${mode})`);
1520
+ return;
1521
+ }
1522
+ if (desired === "on") {
1523
+ await this.rpcClient.setPermissionMode("yolo");
1524
+ await this.bot.sendMessage(chatId, "YOLO mode: ON (tool confirmations disabled).");
1525
+ return;
1526
+ }
1527
+ if (desired === "off") {
1528
+ await this.rpcClient.setPermissionMode("ask");
1529
+ await this.bot.sendMessage(chatId, "YOLO mode: OFF (tool confirmations enabled).");
1530
+ return;
1531
+ }
1532
+ await this.bot.sendMessage(chatId, "Usage: /yolo [on|off|status]");
1533
+ }
1534
+ catch (error) {
1535
+ this.rpcConnected = false;
1536
+ await this.bot.sendMessage(chatId, `Failed to update mode: ${error instanceof Error ? error.message : String(error)}`);
1537
+ }
1538
+ }
1539
+ async abortActiveTurn(chatId) {
1540
+ if (!this.activeTurn) {
1541
+ await this.bot.sendMessage(chatId, "No active task.");
1542
+ return;
1543
+ }
1544
+ try {
1545
+ await this.ensureRpcConnected();
1546
+ this.activeTurn.aborted = true;
1547
+ await this.rpcClient.abort();
1548
+ await this.bot.sendMessage(chatId, "Abort signal sent.");
1549
+ }
1550
+ catch (error) {
1551
+ this.rpcConnected = false;
1552
+ await this.bot.sendMessage(chatId, `Abort failed: ${error instanceof Error ? error.message : String(error)}`);
1553
+ }
1554
+ }
1555
+ async enqueuePrompt(chatId, text) {
1556
+ if (!this.activeTurn) {
1557
+ await this.startTurn(chatId, text);
1558
+ return;
1559
+ }
1560
+ this.queue.push({ chatId, text });
1561
+ await this.bot.sendMessage(chatId, `⏸ queued · ${this.queue.length}`);
1562
+ void this.editLiveStatus();
1563
+ }
1564
+ async startTurn(chatId, text) {
1565
+ const statusMessage = await this.bot.sendMessage(chatId, this.formatStartingStatus(text));
1566
+ this.activeTurn = {
1567
+ turnId: this.nextTurnId++,
1568
+ chatId,
1569
+ prompt: text,
1570
+ startedAt: Date.now(),
1571
+ statusMessageId: statusMessage.message_id,
1572
+ phase: "starting",
1573
+ aborted: false,
1574
+ statusEditPending: false,
1575
+ lastStatusEditAt: 0,
1576
+ };
1577
+ try {
1578
+ await this.ensureRpcConnected();
1579
+ await this.rpcClient.prompt(text);
1580
+ this.rpcConnected = true;
1581
+ await this.editLiveStatus(true);
1582
+ }
1583
+ catch (error) {
1584
+ this.rpcConnected = false;
1585
+ await this.finishTurn({
1586
+ error: error instanceof Error ? error.message : String(error),
1587
+ });
1588
+ }
1589
+ }
1590
+ async onRpcEvent(event) {
1591
+ if (!this.activeTurn)
1592
+ return;
1593
+ switch (event.type) {
1594
+ case "turn_start":
1595
+ this.activeTurn.phase = "running";
1596
+ break;
1597
+ case "tool_execution_start": {
1598
+ const toolEvent = event;
1599
+ this.activeTurn.phase = "tool";
1600
+ this.activeTurn.lastTool = toolEvent.toolName;
1601
+ break;
1602
+ }
1603
+ case "tool_execution_update": {
1604
+ const toolEvent = event;
1605
+ this.activeTurn.phase = "tool";
1606
+ this.activeTurn.lastTool = toolEvent.toolName ?? this.activeTurn.lastTool;
1607
+ break;
1608
+ }
1609
+ case "turn_end": {
1610
+ const turnEnd = event;
1611
+ const directText = this.extractAssistantTextFromTurnEnd(turnEnd);
1612
+ if (directText) {
1613
+ // Keep as fallback in case getLastAssistantText fails.
1614
+ this.activeTurn.phase = "finalizing";
1615
+ }
1616
+ break;
1617
+ }
1618
+ case "agent_end":
1619
+ await this.finishTurn();
1620
+ return;
1621
+ default:
1622
+ break;
1623
+ }
1624
+ await this.editLiveStatus();
1625
+ }
1626
+ async onRpcExtensionUiRequest(request) {
1627
+ if (request.method === "confirm_permission" || request.method === "confirm") {
1628
+ const chatId = this.activeTurn?.chatId ?? this.lastAuthorizedChatId;
1629
+ if (!chatId) {
1630
+ this.rpcClient.respondExtensionUi({ type: "extension_ui_response", id: request.id, confirmed: false });
1631
+ return;
1632
+ }
1633
+ const label = `${request.title}\n${request.message}`;
1634
+ const groupKey = request.method === "confirm_permission"
1635
+ ? this.buildPermissionGroupKey(chatId, request.request)
1636
+ : this.buildGenericConfirmationGroupKey(chatId, label);
1637
+ await this.queueConfirmationPrompt(chatId, request.id, label, groupKey);
1638
+ return;
1639
+ }
1640
+ if (request.method === "notify") {
1641
+ const chatId = this.activeTurn?.chatId ?? this.lastAuthorizedChatId;
1642
+ if (chatId) {
1643
+ await this.bot.sendMessage(chatId, request.message);
1644
+ }
1645
+ return;
1646
+ }
1647
+ // Unsupported interactive extension methods in telegram v1: cancel by default.
1648
+ this.rpcClient.respondExtensionUi({ type: "extension_ui_response", id: request.id, cancelled: true });
1649
+ }
1650
+ async onRpcConfirmationRequest(event) {
1651
+ const chatId = this.activeTurn?.chatId ?? this.lastAuthorizedChatId;
1652
+ if (!chatId) {
1653
+ this.rpcClient.respondExtensionUi({ type: "extension_ui_response", id: event.id, confirmed: false });
1654
+ return;
1655
+ }
1656
+ const groupKey = this.buildPermissionGroupKey(chatId, event.request);
1657
+ await this.queueConfirmationPrompt(chatId, event.id, event.message, groupKey);
1658
+ }
1659
+ buildPermissionGroupKey(chatId, request) {
1660
+ const requiredPermission = request.requiredPermission ?? "unknown";
1661
+ const toolSource = request.toolSource ?? "unknown";
1662
+ const inputJson = JSON.stringify(request.input ?? {});
1663
+ return `${chatId}|perm|${request.toolName}|${requiredPermission}|${toolSource}|${request.summary}|${request.cwd}|${inputJson}`;
1664
+ }
1665
+ buildGenericConfirmationGroupKey(chatId, label) {
1666
+ const normalized = label.replace(/\s+/g, " ").trim();
1667
+ return `${chatId}|confirm|${normalized}`;
1668
+ }
1669
+ async queueConfirmationPrompt(chatId, requestId, label, groupKey) {
1670
+ if (this.pendingConfirmations.has(requestId)) {
1671
+ return;
1672
+ }
1673
+ const existing = this.pendingConfirmationGroupIdsByKey.get(groupKey);
1674
+ if (existing) {
1675
+ existing.add(requestId);
1676
+ this.pendingConfirmations.set(requestId, { chatId, requestId, label, groupKey });
1677
+ if (this.activeTurn) {
1678
+ this.activeTurn.phase = "awaiting confirmation";
1679
+ await this.editLiveStatus(true);
1680
+ }
1681
+ return;
1682
+ }
1683
+ const ids = new Set([requestId]);
1684
+ this.pendingConfirmationGroupIdsByKey.set(groupKey, ids);
1685
+ this.pendingConfirmations.set(requestId, { chatId, requestId, label, groupKey });
1686
+ await this.bot.sendMessage(chatId, label, {
1687
+ replyMarkup: {
1688
+ inline_keyboard: [
1689
+ [
1690
+ { text: "Allow", callback_data: `confirm:${requestId}:yes` },
1691
+ { text: "Deny", callback_data: `confirm:${requestId}:no` },
1692
+ ],
1693
+ ],
1694
+ },
1695
+ });
1696
+ if (this.activeTurn) {
1697
+ this.activeTurn.phase = "awaiting confirmation";
1698
+ await this.editLiveStatus(true);
1699
+ }
1700
+ }
1701
+ async respondConfirmation(requestId, confirmed) {
1702
+ const pending = this.pendingConfirmations.get(requestId);
1703
+ if (!pending)
1704
+ return;
1705
+ const groupedIds = this.pendingConfirmationGroupIdsByKey.get(pending.groupKey);
1706
+ const ids = groupedIds && groupedIds.size > 0 ? Array.from(groupedIds) : [requestId];
1707
+ for (const id of ids) {
1708
+ this.pendingConfirmations.delete(id);
1709
+ try {
1710
+ this.rpcClient.respondExtensionUi({ type: "extension_ui_response", id, confirmed });
1711
+ }
1712
+ catch {
1713
+ // Best-effort for duplicated request IDs from different streams.
1714
+ }
1715
+ }
1716
+ this.pendingConfirmationGroupIdsByKey.delete(pending.groupKey);
1717
+ if (this.activeTurn) {
1718
+ this.activeTurn.phase = confirmed ? "running" : "permission denied";
1719
+ await this.editLiveStatus(true);
1720
+ }
1721
+ }
1722
+ extractAssistantTextFromTurnEnd(event) {
1723
+ const messageEvent = event;
1724
+ const message = messageEvent.message;
1725
+ if (!message || message.role !== "assistant")
1726
+ return undefined;
1727
+ const content = Array.isArray(message.content) ? message.content : [];
1728
+ const textParts = content
1729
+ .filter((part) => part.type === "text" && typeof part.text === "string")
1730
+ .map((part) => part.text.trim())
1731
+ .filter((part) => part.length > 0);
1732
+ if (textParts.length === 0)
1733
+ return undefined;
1734
+ return textParts.join("\n\n");
1735
+ }
1736
+ escapeTelegramHtml(text) {
1737
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1738
+ }
1739
+ escapeTelegramHtmlAttribute(text) {
1740
+ return this.escapeTelegramHtml(text).replace(/"/g, "&quot;");
1741
+ }
1742
+ stripHtmlTags(text) {
1743
+ return text.replace(/<[^>]*>/g, "");
1744
+ }
1745
+ normalizeTelegramText(text) {
1746
+ return text.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
1747
+ }
1748
+ renderMarkdownInlineTokens(tokens) {
1749
+ if (!tokens || tokens.length === 0)
1750
+ return "";
1751
+ let output = "";
1752
+ for (const token of tokens) {
1753
+ switch (token.type) {
1754
+ case "strong":
1755
+ output += `<b>${this.renderMarkdownInlineTokens(token.tokens)}</b>`;
1756
+ break;
1757
+ case "em":
1758
+ output += `<i>${this.renderMarkdownInlineTokens(token.tokens)}</i>`;
1759
+ break;
1760
+ case "del":
1761
+ output += `<s>${this.renderMarkdownInlineTokens(token.tokens)}</s>`;
1762
+ break;
1763
+ case "codespan":
1764
+ output += `<code>${this.escapeTelegramHtml(token.text)}</code>`;
1765
+ break;
1766
+ case "br":
1767
+ output += "\n";
1768
+ break;
1769
+ case "link": {
1770
+ const labelRaw = this.renderMarkdownInlineTokens(token.tokens);
1771
+ const label = labelRaw.length > 0 ? labelRaw : this.escapeTelegramHtml(token.text ?? token.href);
1772
+ const href = token.href?.trim();
1773
+ if (href && /^(https?:\/\/|mailto:|tg:\/\/)/i.test(href)) {
1774
+ output += `<a href="${this.escapeTelegramHtmlAttribute(href)}">${label}</a>`;
1775
+ }
1776
+ else if (href) {
1777
+ output += `${label} (${this.escapeTelegramHtml(href)})`;
1778
+ }
1779
+ else {
1780
+ output += label;
1781
+ }
1782
+ break;
1783
+ }
1784
+ case "image": {
1785
+ const captionRaw = token.text?.trim();
1786
+ const caption = captionRaw && captionRaw.length > 0 ? captionRaw : "image";
1787
+ const href = token.href?.trim();
1788
+ if (href && /^https?:\/\//i.test(href)) {
1789
+ output += `🖼 <a href="${this.escapeTelegramHtmlAttribute(href)}">${this.escapeTelegramHtml(caption)}</a>`;
1790
+ }
1791
+ else {
1792
+ output += `🖼 ${this.escapeTelegramHtml(caption)}`;
1793
+ }
1794
+ break;
1795
+ }
1796
+ case "text":
1797
+ if (Array.isArray(token.tokens) && token.tokens.length > 0) {
1798
+ output += this.renderMarkdownInlineTokens(token.tokens);
1799
+ }
1800
+ else {
1801
+ output += this.escapeTelegramHtml(token.text);
1802
+ }
1803
+ break;
1804
+ case "escape":
1805
+ output += this.escapeTelegramHtml(token.text);
1806
+ break;
1807
+ case "html": {
1808
+ const htmlText = this.stripHtmlTags(token.text ?? token.raw ?? "");
1809
+ if (htmlText.length > 0) {
1810
+ output += this.escapeTelegramHtml(htmlText);
1811
+ }
1812
+ break;
1813
+ }
1814
+ default:
1815
+ if ("tokens" in token && Array.isArray(token.tokens)) {
1816
+ output += this.renderMarkdownInlineTokens(token.tokens);
1817
+ }
1818
+ else if ("text" in token && typeof token.text === "string") {
1819
+ output += this.escapeTelegramHtml(token.text);
1820
+ }
1821
+ else if ("raw" in token && typeof token.raw === "string") {
1822
+ output += this.escapeTelegramHtml(token.raw);
1823
+ }
1824
+ }
1825
+ }
1826
+ return output;
1827
+ }
1828
+ renderMarkdownTableCell(cell) {
1829
+ const rendered = this.renderMarkdownInlineTokens(cell.tokens);
1830
+ return rendered.replace(/\s+/g, " ").trim();
1831
+ }
1832
+ renderMarkdownBlockTokens(tokens, listDepth = 0) {
1833
+ if (!tokens || tokens.length === 0)
1834
+ return "";
1835
+ let output = "";
1836
+ for (const token of tokens) {
1837
+ switch (token.type) {
1838
+ case "space":
1839
+ output += "\n";
1840
+ break;
1841
+ case "heading":
1842
+ output += `<b>${this.renderMarkdownInlineTokens(token.tokens)}</b>\n`;
1843
+ break;
1844
+ case "paragraph":
1845
+ output += `${this.renderMarkdownInlineTokens(token.tokens)}\n`;
1846
+ break;
1847
+ case "text":
1848
+ if (Array.isArray(token.tokens) && token.tokens.length > 0) {
1849
+ output += `${this.renderMarkdownInlineTokens(token.tokens)}\n`;
1850
+ }
1851
+ else {
1852
+ output += `${this.escapeTelegramHtml(token.text)}\n`;
1853
+ }
1854
+ break;
1855
+ case "code":
1856
+ output += `<pre>${this.escapeTelegramHtml(token.text.replace(/\r\n/g, "\n"))}</pre>\n`;
1857
+ break;
1858
+ case "blockquote": {
1859
+ const rawQuote = this.normalizeTelegramText(this.renderMarkdownBlockTokens(token.tokens, listDepth));
1860
+ if (rawQuote.length > 0) {
1861
+ const quoted = rawQuote
1862
+ .split("\n")
1863
+ .map((line) => (line.trim().length > 0 ? `> ${line}` : ">"))
1864
+ .join("\n");
1865
+ output += `${quoted}\n`;
1866
+ }
1867
+ break;
1868
+ }
1869
+ case "list": {
1870
+ const baseIndent = " ".repeat(listDepth);
1871
+ const start = token.ordered && typeof token.start === "number" ? token.start : 1;
1872
+ token.items.forEach((item, index) => {
1873
+ const marker = token.ordered ? `${start + index}.` : "•";
1874
+ const renderedItem = this.normalizeTelegramText(this.renderMarkdownBlockTokens(item.tokens, listDepth + 1));
1875
+ const fallback = this.escapeTelegramHtml(item.text ?? "");
1876
+ const body = renderedItem.length > 0 ? renderedItem : fallback;
1877
+ const lines = body.split("\n");
1878
+ const firstLine = lines.shift() ?? "";
1879
+ output += `${baseIndent}${marker} ${firstLine}\n`;
1880
+ for (const line of lines) {
1881
+ if (line.trim().length === 0) {
1882
+ output += "\n";
1883
+ }
1884
+ else {
1885
+ output += `${baseIndent} ${line}\n`;
1886
+ }
1887
+ }
1888
+ });
1889
+ output += "\n";
1890
+ break;
1891
+ }
1892
+ case "hr":
1893
+ output += "────────\n";
1894
+ break;
1895
+ case "table": {
1896
+ const headerLine = token.header
1897
+ .map((cell) => this.renderMarkdownTableCell(cell))
1898
+ .join(" | ")
1899
+ .trim();
1900
+ if (headerLine.length > 0) {
1901
+ output += `${headerLine}\n`;
1902
+ }
1903
+ for (const row of token.rows) {
1904
+ const rowLine = row
1905
+ .map((cell) => this.renderMarkdownTableCell(cell))
1906
+ .join(" | ")
1907
+ .trim();
1908
+ if (rowLine.length > 0) {
1909
+ output += `${rowLine}\n`;
1910
+ }
1911
+ }
1912
+ output += "\n";
1913
+ break;
1914
+ }
1915
+ case "html": {
1916
+ const htmlText = this.stripHtmlTags(token.text ?? token.raw ?? "").trim();
1917
+ if (htmlText.length > 0) {
1918
+ output += `${this.escapeTelegramHtml(htmlText)}\n`;
1919
+ }
1920
+ break;
1921
+ }
1922
+ default:
1923
+ if ("tokens" in token && Array.isArray(token.tokens)) {
1924
+ output += `${this.renderMarkdownBlockTokens(token.tokens, listDepth)}\n`;
1925
+ }
1926
+ else if ("text" in token && typeof token.text === "string") {
1927
+ output += `${this.escapeTelegramHtml(token.text)}\n`;
1928
+ }
1929
+ else if ("raw" in token && typeof token.raw === "string") {
1930
+ output += `${this.escapeTelegramHtml(token.raw)}\n`;
1931
+ }
1932
+ }
1933
+ }
1934
+ return output;
1935
+ }
1936
+ markdownToTelegramHtml(markdown) {
1937
+ if (!markdown || markdown.trim().length === 0)
1938
+ return "";
1939
+ const tokens = marked.lexer(markdown, { gfm: true, breaks: true });
1940
+ const rendered = this.renderMarkdownBlockTokens(tokens);
1941
+ return this.normalizeTelegramText(rendered);
1942
+ }
1943
+ markdownToTelegramPlainText(markdown) {
1944
+ const html = this.markdownToTelegramHtml(markdown);
1945
+ return this.normalizeTelegramText(html
1946
+ .replace(/<a [^>]*>([\s\S]*?)<\/a>/gi, "$1")
1947
+ .replace(/<\/?(b|i|s|u|code|pre)>/gi, "")
1948
+ .replace(/&quot;/g, "\"")
1949
+ .replace(/&lt;/g, "<")
1950
+ .replace(/&gt;/g, ">")
1951
+ .replace(/&amp;/g, "&"));
1952
+ }
1953
+ async sendRichMessage(chatId, text) {
1954
+ const html = this.markdownToTelegramHtml(text);
1955
+ if (!html || html.length === 0) {
1956
+ await this.bot.sendMessage(chatId, text);
1957
+ return;
1958
+ }
1959
+ try {
1960
+ await this.bot.sendMessage(chatId, html, { parseMode: "HTML" });
1961
+ }
1962
+ catch {
1963
+ const plain = this.markdownToTelegramPlainText(text);
1964
+ await this.bot.sendMessage(chatId, plain.length > 0 ? plain : text);
1965
+ }
1966
+ }
1967
+ formatStatusPhase(phase) {
1968
+ const lower = phase.toLowerCase();
1969
+ if (lower.includes("awaiting"))
1970
+ return "confirm";
1971
+ if (lower.includes("tool"))
1972
+ return "tool";
1973
+ if (lower.includes("final"))
1974
+ return "final";
1975
+ if (lower.includes("start"))
1976
+ return "start";
1977
+ if (lower.includes("permission denied"))
1978
+ return "denied";
1979
+ if (lower.includes("running"))
1980
+ return "run";
1981
+ return lower.replace(/\s+/g, "-");
1982
+ }
1983
+ formatPromptPreview(prompt, limit = 100) {
1984
+ const cleaned = prompt.replace(/\s+/g, " ").trim();
1985
+ if (cleaned.length <= limit)
1986
+ return cleaned;
1987
+ return `${cleaned.slice(0, limit - 1)}…`;
1988
+ }
1989
+ statusSpinnerFrame(elapsedMs) {
1990
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1991
+ return frames[Math.floor(elapsedMs / 250) % frames.length] ?? "⠋";
1992
+ }
1993
+ statusPulseFrame(elapsedMs) {
1994
+ const frames = ["◼···", "◼•··", "◼••·", "◼•••", "◼••·", "◼•··"];
1995
+ return frames[Math.floor(elapsedMs / 280) % frames.length] ?? "◼···";
1996
+ }
1997
+ statusDotsFrame(elapsedMs) {
1998
+ const frames = [".", "..", "..."];
1999
+ return frames[Math.floor(elapsedMs / 420) % frames.length] ?? ".";
2000
+ }
2001
+ statusActivityLabel(turn) {
2002
+ if (!turn)
2003
+ return "idle";
2004
+ const phase = turn.phase.toLowerCase();
2005
+ if (phase.includes("awaiting"))
2006
+ return "awaiting confirm";
2007
+ if (phase.includes("tool")) {
2008
+ const tool = turn.lastTool?.trim();
2009
+ if (tool && tool.length > 0) {
2010
+ const shortTool = tool.length > 18 ? `${tool.slice(0, 17)}…` : tool;
2011
+ return `tool:${shortTool}`;
2012
+ }
2013
+ return "tool:running";
2014
+ }
2015
+ if (phase.includes("final"))
2016
+ return "finalizing";
2017
+ if (phase.includes("start"))
2018
+ return "starting";
2019
+ if (phase.includes("denied"))
2020
+ return "permission denied";
2021
+ return "working";
2022
+ }
2023
+ formatToolBadge(toolName) {
2024
+ if (!toolName)
2025
+ return "";
2026
+ const compact = toolName.trim().replace(/\s+/g, " ");
2027
+ const clipped = compact.length > 18 ? `${compact.slice(0, 17)}…` : compact;
2028
+ return ` · ${clipped}`;
2029
+ }
2030
+ formatStartingStatus(prompt) {
2031
+ return [`⏳ ${APP_NAME} · start · 0s · q${this.queue.length}`, `“${this.formatPromptPreview(prompt, 56)}”`].join("\n");
2032
+ }
2033
+ formatLiveStatus(turn = this.activeTurn) {
2034
+ if (!turn)
2035
+ return "No active task.";
2036
+ const elapsedMs = Date.now() - turn.startedAt;
2037
+ const elapsed = `${Math.floor(elapsedMs / 1000)}s`;
2038
+ const spinner = this.statusSpinnerFrame(elapsedMs);
2039
+ const pulse = this.statusPulseFrame(elapsedMs);
2040
+ const phaseLabel = this.formatStatusPhase(turn.phase);
2041
+ const toolSegment = this.formatToolBadge(turn.lastTool);
2042
+ const activity = this.statusActivityLabel(turn);
2043
+ return [
2044
+ `${spinner} ${APP_NAME} · ${phaseLabel}${toolSegment} · ${elapsed} · q${this.queue.length}`,
2045
+ `${pulse} ${activity}`,
2046
+ `“${this.formatPromptPreview(turn.prompt, 56)}”`,
2047
+ ].join("\n");
2048
+ }
2049
+ async editLiveStatus(force = false) {
2050
+ if (!this.activeTurn)
2051
+ return;
2052
+ if (this.activeTurn.statusEditPending)
2053
+ return;
2054
+ const turnId = this.activeTurn.turnId;
2055
+ const now = Date.now();
2056
+ const effectiveThrottleMs = Math.min(this.statusEditThrottleMs, LIVE_STATUS_ANIMATION_MAX_THROTTLE_MS);
2057
+ const waitMs = this.activeTurn.lastStatusEditAt + effectiveThrottleMs - now;
2058
+ if (!force && waitMs > 0) {
2059
+ this.activeTurn.statusEditPending = true;
2060
+ this.activeTurn.statusEditTimer = setTimeout(() => {
2061
+ if (this.activeTurn && this.activeTurn.turnId === turnId) {
2062
+ this.activeTurn.statusEditPending = false;
2063
+ void this.editLiveStatus(true);
2064
+ }
2065
+ }, waitMs);
2066
+ return;
2067
+ }
2068
+ const target = this.activeTurn;
2069
+ const statusText = this.formatLiveStatus(target);
2070
+ const editPromise = this.bot
2071
+ .editMessageText(target.chatId, target.statusMessageId, statusText, {
2072
+ replyMarkup: { inline_keyboard: [] },
2073
+ })
2074
+ .then(() => {
2075
+ target.lastStatusEditAt = Date.now();
2076
+ })
2077
+ .catch((error) => {
2078
+ const message = error instanceof Error ? error.message : String(error);
2079
+ // Ignore no-op edit errors and transient "message is not modified".
2080
+ if (!message.toLowerCase().includes("message is not modified")) {
2081
+ console.warn(`[telegram] status edit failed: ${message}`);
2082
+ }
2083
+ })
2084
+ .finally(() => {
2085
+ if (target.statusEditInFlight === editPromise) {
2086
+ target.statusEditInFlight = undefined;
2087
+ }
2088
+ });
2089
+ target.statusEditInFlight = editPromise;
2090
+ await editPromise;
2091
+ }
2092
+ async finishTurn(options) {
2093
+ const finishedTurn = this.activeTurn;
2094
+ if (!finishedTurn)
2095
+ return;
2096
+ this.activeTurn = undefined;
2097
+ if (finishedTurn.statusEditTimer) {
2098
+ clearTimeout(finishedTurn.statusEditTimer);
2099
+ }
2100
+ finishedTurn.statusEditPending = false;
2101
+ if (finishedTurn.statusEditInFlight) {
2102
+ try {
2103
+ await finishedTurn.statusEditInFlight;
2104
+ }
2105
+ catch {
2106
+ // Best-effort; still continue to final status update.
2107
+ }
2108
+ }
2109
+ let finalText = null;
2110
+ if (!options?.error) {
2111
+ try {
2112
+ finalText = await this.rpcClient.getLastAssistantText();
2113
+ this.rpcConnected = true;
2114
+ }
2115
+ catch (error) {
2116
+ this.rpcConnected = false;
2117
+ options = {
2118
+ error: error instanceof Error ? error.message : String(error),
2119
+ };
2120
+ }
2121
+ }
2122
+ const statusLabel = options?.error
2123
+ ? `❌ error · ${Math.floor((Date.now() - finishedTurn.startedAt) / 1000)}s\n${this.formatPromptPreview(options.error, 96)}`
2124
+ : finishedTurn.aborted
2125
+ ? `⛔ aborted · ${Math.floor((Date.now() - finishedTurn.startedAt) / 1000)}s`
2126
+ : `✅ done · ${Math.floor((Date.now() - finishedTurn.startedAt) / 1000)}s`;
2127
+ try {
2128
+ await this.bot.editMessageText(finishedTurn.chatId, finishedTurn.statusMessageId, statusLabel, {
2129
+ replyMarkup: { inline_keyboard: [] },
2130
+ });
2131
+ }
2132
+ catch {
2133
+ // Best-effort.
2134
+ }
2135
+ if (options?.error) {
2136
+ await this.bot.sendMessage(finishedTurn.chatId, `Task failed: ${options.error}`);
2137
+ }
2138
+ else if (finalText && finalText.trim().length > 0) {
2139
+ await this.sendFinalOutput(finishedTurn.chatId, finalText);
2140
+ }
2141
+ else if (!finishedTurn.aborted) {
2142
+ await this.bot.sendMessage(finishedTurn.chatId, "Task completed with no assistant text output.");
2143
+ }
2144
+ await this.drainQueue();
2145
+ }
2146
+ async sendFinalOutput(chatId, finalText) {
2147
+ if (finalText.length <= this.maxSummaryChars) {
2148
+ await this.sendRichMessage(chatId, finalText);
2149
+ return;
2150
+ }
2151
+ const summary = `${finalText.slice(0, this.maxSummaryChars).trimEnd()}\n\n[output truncated in chat]`;
2152
+ await this.sendRichMessage(chatId, summary);
2153
+ await this.bot.sendTextDocument(chatId, "iosm-output.txt", finalText, "Full output");
2154
+ }
2155
+ async drainQueue() {
2156
+ if (this.activeTurn)
2157
+ return;
2158
+ const next = this.queue.shift();
2159
+ if (!next)
2160
+ return;
2161
+ await this.startTurn(next.chatId, next.text);
2162
+ }
2163
+ }
2164
+ //# sourceMappingURL=telegram-bridge-mode.js.map