@grinev/opencode-telegram-bot 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bot/index.js CHANGED
@@ -66,7 +66,11 @@ import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
66
66
  import { assistantRunState } from "./assistant-run-state.js";
67
67
  import { ResponseStreamer } from "./streaming/response-streamer.js";
68
68
  import { ToolCallStreamer } from "./streaming/tool-call-streamer.js";
69
+ import { attachManager } from "../attach/manager.js";
70
+ import { markAttachedSessionBusy, markAttachedSessionIdle, restoreAttachedCurrentSession, } from "../attach/service.js";
71
+ import { externalUserInputSuppressionManager } from "../external-input/suppression.js";
69
72
  import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload, renderAssistantFinalPartsSafe, } from "./utils/assistant-rendering.js";
73
+ import { deliverExternalUserInputNotification } from "./utils/external-user-input.js";
70
74
  let botInstance = null;
71
75
  let chatIdInstance = null;
72
76
  let commandsInitialized = false;
@@ -263,6 +267,27 @@ function getToolStreamKey(tool) {
263
267
  }
264
268
  return "default";
265
269
  }
270
+ function getEventSessionId(event) {
271
+ const properties = event.properties;
272
+ return properties.sessionID || properties.info?.sessionID || properties.part?.sessionID || null;
273
+ }
274
+ function shouldMarkAttachedBusyFromEvent(event) {
275
+ switch (event.type) {
276
+ case "session.status":
277
+ return event.properties.status?.type === "busy";
278
+ case "message.updated": {
279
+ const info = event.properties.info;
280
+ return info?.role === "assistant" && !info.time?.completed;
281
+ }
282
+ case "message.part.updated":
283
+ case "message.part.delta":
284
+ case "question.asked":
285
+ case "permission.asked":
286
+ return true;
287
+ default:
288
+ return false;
289
+ }
290
+ }
266
291
  async function ensureCommandsInitialized(ctx, next) {
267
292
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
268
293
  await next();
@@ -388,6 +413,26 @@ async function ensureEventSubscription(directory) {
388
413
  }
389
414
  });
390
415
  });
416
+ summaryAggregator.setOnExternalUserInput(async (sessionId, _messageId, messageText) => {
417
+ void enqueueSessionCompletionTask(sessionId, async () => {
418
+ if (!botInstance || !chatIdInstance) {
419
+ return;
420
+ }
421
+ try {
422
+ await deliverExternalUserInputNotification({
423
+ api: botInstance.api,
424
+ chatId: chatIdInstance,
425
+ currentSessionId: getCurrentSession()?.id ?? null,
426
+ sessionId,
427
+ text: messageText,
428
+ consumeSuppressedInput: (incomingSessionId, incomingText) => externalUserInputSuppressionManager.consume(incomingSessionId, incomingText),
429
+ });
430
+ }
431
+ catch (err) {
432
+ logger.error("[Bot] Failed to deliver external user input to Telegram:", err);
433
+ }
434
+ });
435
+ });
391
436
  summaryAggregator.setOnTool(async (toolInfo) => {
392
437
  if (!botInstance || !chatIdInstance) {
393
438
  logger.error("Bot or chat ID not available for sending tool notification");
@@ -461,18 +506,19 @@ async function ensureEventSubscription(directory) {
461
506
  logger.error("Failed to send file to Telegram:", err);
462
507
  }
463
508
  });
464
- summaryAggregator.setOnQuestion(async (questions, requestID) => {
509
+ summaryAggregator.setOnQuestion(async (questions, requestID, sessionId) => {
465
510
  if (!botInstance || !chatIdInstance) {
466
511
  logger.error("Bot or chat ID not available for showing questions");
467
512
  return;
468
513
  }
469
514
  const currentSession = getCurrentSession();
470
- if (currentSession) {
471
- await Promise.all([
472
- toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
473
- toolCallStreamer.flushSession(currentSession.id, "question_asked"),
474
- ]);
515
+ if (!currentSession || currentSession.id !== sessionId) {
516
+ return;
475
517
  }
518
+ await Promise.all([
519
+ toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
520
+ toolCallStreamer.flushSession(currentSession.id, "question_asked"),
521
+ ]);
476
522
  if (questionManager.isActive()) {
477
523
  logger.warn("[Bot] Replacing active poll with a new one");
478
524
  const previousMessageIds = questionManager.getMessageIds();
@@ -503,6 +549,10 @@ async function ensureEventSubscription(directory) {
503
549
  logger.error("Bot or chat ID not available for showing permission request");
504
550
  return;
505
551
  }
552
+ const currentSession = getCurrentSession();
553
+ if (!currentSession || currentSession.id !== request.sessionID) {
554
+ return;
555
+ }
506
556
  await Promise.all([
507
557
  toolMessageBatcher.flushSession(request.sessionID, "permission_asked"),
508
558
  toolCallStreamer.flushSession(request.sessionID, "permission_asked"),
@@ -583,6 +633,7 @@ async function ensureEventSubscription(directory) {
583
633
  }
584
634
  });
585
635
  summaryAggregator.setOnSessionIdle(async (sessionId) => {
636
+ await markAttachedSessionIdle(sessionId);
586
637
  await sessionCompletionTasks.get(sessionId)?.catch(() => undefined);
587
638
  const completedRun = assistantRunState.finishRun(sessionId, "session_idle");
588
639
  clearPromptResponseMode(sessionId);
@@ -627,6 +678,7 @@ async function ensureEventSubscription(directory) {
627
678
  }
628
679
  });
629
680
  summaryAggregator.setOnSessionError(async (sessionId, message) => {
681
+ await markAttachedSessionIdle(sessionId);
630
682
  if (!botInstance || !chatIdInstance) {
631
683
  clearPromptResponseMode(sessionId);
632
684
  assistantRunState.clearRun(sessionId, "session_error_no_bot_context");
@@ -706,6 +758,11 @@ async function ensureEventSubscription(directory) {
706
758
  });
707
759
  logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
708
760
  subscribeToEvents(directory, (event) => {
761
+ const attached = attachManager.getSnapshot();
762
+ const eventSessionId = getEventSessionId(event);
763
+ if (attached && eventSessionId === attached.sessionId && shouldMarkAttachedBusyFromEvent(event)) {
764
+ void markAttachedSessionBusy(attached.sessionId);
765
+ }
709
766
  if (event.type === "session.created" || event.type === "session.updated") {
710
767
  const info = event.properties.info;
711
768
  if (info?.directory) {
@@ -723,6 +780,7 @@ async function ensureEventSubscription(directory) {
723
780
  export function createBot() {
724
781
  clearAllInteractionState("bot_startup");
725
782
  sessionCompletionTasks.clear();
783
+ attachManager.clear("bot_startup");
726
784
  assistantRunState.clearAll("bot_startup");
727
785
  if (heartbeatTimer) {
728
786
  clearInterval(heartbeatTimer);
@@ -748,6 +806,8 @@ export function createBot() {
748
806
  };
749
807
  }
750
808
  const bot = new Bot(config.telegram.token, botOptions);
809
+ botInstance = bot;
810
+ chatIdInstance = config.telegram.allowedUserId;
751
811
  // Heartbeat for diagnostics: verify the event loop is not blocked
752
812
  let heartbeatCounter = 0;
753
813
  heartbeatTimer = setInterval(() => {
@@ -806,7 +866,7 @@ export function createBot() {
806
866
  bot.command("worktree", worktreeCommand);
807
867
  bot.command("open", openCommand);
808
868
  bot.command("sessions", sessionsCommand);
809
- bot.command("new", newCommand);
869
+ bot.command("new", (ctx) => newCommand(ctx, { bot, ensureEventSubscription }));
810
870
  bot.command("abort", abortCommand);
811
871
  bot.command("task", taskCommand);
812
872
  bot.command("tasklist", taskListCommand);
@@ -827,7 +887,7 @@ export function createBot() {
827
887
  // Clean up path index when the open-directory menu is cancelled
828
888
  clearOpenPathIndex();
829
889
  }
830
- const handledSession = await handleSessionSelect(ctx);
890
+ const handledSession = await handleSessionSelect(ctx, { bot, ensureEventSubscription });
831
891
  const handledProject = await handleProjectSelect(ctx);
832
892
  const handledWorktree = await handleWorktreeCallback(ctx);
833
893
  const handledOpen = await handleOpenCallback(ctx);
@@ -960,6 +1020,14 @@ export function createBot() {
960
1020
  });
961
1021
  // Voice and audio message handlers (STT transcription -> prompt)
962
1022
  const voicePromptDeps = { bot, ensureEventSubscription };
1023
+ safeBackgroundTask({
1024
+ taskName: "bot.restoreFollowedSession",
1025
+ task: () => restoreAttachedCurrentSession({
1026
+ bot,
1027
+ chatId: config.telegram.allowedUserId,
1028
+ ensureEventSubscription,
1029
+ }),
1030
+ });
963
1031
  bot.on("message:voice", async (ctx) => {
964
1032
  logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
965
1033
  botInstance = bot;
@@ -80,7 +80,7 @@ export async function interactionGuardMiddleware(ctx, next) {
80
80
  const message = decision.busy
81
81
  ? decision.state?.kind === "question" || decision.state?.kind === "permission"
82
82
  ? getInteractionBlockedMessage(decision.reason, decision.state.kind)
83
- : t("interaction.blocked.finish_current")
83
+ : t("bot.session_busy")
84
84
  : getInteractionBlockedMessage(decision.reason, decision.state?.kind);
85
85
  logger.debug(`[InteractionGuard] Blocked input: interactionKind=${decision.state?.kind || "none"}, inputType=${decision.inputType}, reason=${decision.reason || "unknown"}, command=${decision.command || "-"}, busy=${decision.busy ? "yes" : "no"}`);
86
86
  if (ctx.callbackQuery) {
@@ -1,10 +1,11 @@
1
1
  import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
2
+ import { attachManager } from "../../attach/manager.js";
2
3
  import { t } from "../../i18n/index.js";
3
4
  export function isForegroundBusy() {
4
- return foregroundSessionState.isBusy();
5
+ return foregroundSessionState.isBusy() || attachManager.isBusy();
5
6
  }
6
7
  export async function replyBusyBlocked(ctx) {
7
- const message = t("interaction.blocked.finish_current");
8
+ const message = t("bot.session_busy");
8
9
  if (ctx.callbackQuery) {
9
10
  await ctx.answerCallbackQuery({ text: message }).catch(() => { });
10
11
  return;
@@ -0,0 +1,46 @@
1
+ import { t } from "../../i18n/index.js";
2
+ import { escapePlainTextForTelegramMarkdownV2 } from "../../summary/formatter.js";
3
+ import { sendBotText } from "./telegram-text.js";
4
+ function normalizeExternalUserInputText(text) {
5
+ return text.replace(/\r\n/g, "\n").trim();
6
+ }
7
+ function buildQuotedPlainText(text) {
8
+ return text
9
+ .split("\n")
10
+ .map((line) => (line.length > 0 ? `> ${line}` : ">"))
11
+ .join("\n");
12
+ }
13
+ function buildQuotedMarkdownText(text) {
14
+ return text
15
+ .split("\n")
16
+ .map((line) => line.length > 0 ? `> ${escapePlainTextForTelegramMarkdownV2(line)}` : ">")
17
+ .join("\n");
18
+ }
19
+ export function buildExternalUserInputNotification(text) {
20
+ const normalizedText = normalizeExternalUserInputText(text);
21
+ if (!normalizedText) {
22
+ return null;
23
+ }
24
+ const title = `👤 ${t("bot.external_user_input")}`;
25
+ return {
26
+ text: `${escapePlainTextForTelegramMarkdownV2(title)}\n\n${buildQuotedMarkdownText(normalizedText)}`,
27
+ rawFallbackText: `${title}\n\n${buildQuotedPlainText(normalizedText)}`,
28
+ };
29
+ }
30
+ export async function deliverExternalUserInputNotification({ api, chatId, currentSessionId, sessionId, text, consumeSuppressedInput, }) {
31
+ const notification = buildExternalUserInputNotification(text);
32
+ if (!notification || currentSessionId !== sessionId) {
33
+ return false;
34
+ }
35
+ if (consumeSuppressedInput(sessionId, text)) {
36
+ return false;
37
+ }
38
+ await sendBotText({
39
+ api,
40
+ chatId,
41
+ text: notification.text,
42
+ rawFallbackText: notification.rawFallbackText,
43
+ format: "markdown_v2",
44
+ });
45
+ return true;
46
+ }
@@ -3,6 +3,7 @@ import { clearSession } from "../../session/manager.js";
3
3
  import { summaryAggregator } from "../../summary/aggregator.js";
4
4
  import { pinnedMessageManager } from "../../pinned/manager.js";
5
5
  import { keyboardManager } from "../../keyboard/manager.js";
6
+ import { detachAttachedSession } from "../../attach/service.js";
6
7
  import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
7
8
  import { getStoredModel } from "../../model/manager.js";
8
9
  import { formatVariantForButton } from "../../variant/manager.js";
@@ -23,6 +24,7 @@ import { logger } from "../../utils/logger.js";
23
24
  * @param reason short tag for `clearAllInteractionState` (e.g. "project_switched")
24
25
  */
25
26
  export async function switchToProject(ctx, project, reason) {
27
+ detachAttachedSession(reason);
26
28
  setCurrentProject(project);
27
29
  clearSession();
28
30
  summaryAggregator.clear();
@@ -0,0 +1,54 @@
1
+ const SUPPRESSION_TTL_MS = 60_000;
2
+ function normalizeExternalUserInputText(text) {
3
+ return text.replace(/\r\n/g, "\n").trim();
4
+ }
5
+ class ExternalUserInputSuppressionManager {
6
+ entriesBySession = new Map();
7
+ register(sessionId, text, now = Date.now()) {
8
+ const normalizedText = normalizeExternalUserInputText(text);
9
+ if (!sessionId || !normalizedText) {
10
+ return;
11
+ }
12
+ this.prune(now);
13
+ const sessionEntries = this.entriesBySession.get(sessionId) ?? [];
14
+ sessionEntries.push({ text: normalizedText, createdAt: now });
15
+ this.entriesBySession.set(sessionId, sessionEntries);
16
+ }
17
+ consume(sessionId, text, now = Date.now()) {
18
+ const normalizedText = normalizeExternalUserInputText(text);
19
+ if (!sessionId || !normalizedText) {
20
+ return false;
21
+ }
22
+ this.prune(now);
23
+ const sessionEntries = this.entriesBySession.get(sessionId);
24
+ if (!sessionEntries?.length) {
25
+ return false;
26
+ }
27
+ const entryIndex = sessionEntries.findIndex((entry) => entry.text === normalizedText);
28
+ if (entryIndex < 0) {
29
+ return false;
30
+ }
31
+ sessionEntries.splice(entryIndex, 1);
32
+ if (sessionEntries.length === 0) {
33
+ this.entriesBySession.delete(sessionId);
34
+ }
35
+ return true;
36
+ }
37
+ clearAll() {
38
+ this.entriesBySession.clear();
39
+ }
40
+ __resetForTests() {
41
+ this.clearAll();
42
+ }
43
+ prune(now) {
44
+ for (const [sessionId, sessionEntries] of this.entriesBySession.entries()) {
45
+ const activeEntries = sessionEntries.filter((entry) => now - entry.createdAt <= SUPPRESSION_TTL_MS);
46
+ if (activeEntries.length === 0) {
47
+ this.entriesBySession.delete(sessionId);
48
+ continue;
49
+ }
50
+ this.entriesBySession.set(sessionId, activeEntries);
51
+ }
52
+ }
53
+ }
54
+ export const externalUserInputSuppressionManager = new ExternalUserInputSuppressionManager();
package/dist/i18n/de.js CHANGED
@@ -48,6 +48,7 @@ export const de = {
48
48
  "bot.prompt_send_error": "Anfrage konnte nicht an OpenCode gesendet werden.",
49
49
  "bot.session_error": "🔴 OpenCode meldete einen Fehler: {message}",
50
50
  "bot.session_retry": "🔁 {message}\n\nDer Provider liefert bei wiederholten Versuchen immer wieder denselben Fehler. Mit /abort abbrechen.",
51
+ "bot.external_user_input": "Externe Benutzereingabe",
51
52
  "bot.unknown_command": "⚠️ Unbekannter Befehl: {command}. Nutze /help, um verfügbare Befehle zu sehen.",
52
53
  "bot.photo_downloading": "⏳ Lade Foto herunter...",
53
54
  "bot.photo_too_large": "⚠️ Foto ist zu groß (max. {maxSizeMb}MB)",
@@ -113,6 +114,17 @@ export const de = {
113
114
  "sessions.preview.title": "Letzte Nachrichten:",
114
115
  "sessions.preview.you": "Du:",
115
116
  "sessions.preview.agent": "Agent:",
117
+ "attach.project_not_selected": "🏗 Projekt ist nicht ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.",
118
+ "attach.session_not_selected": "💬 Keine Sitzung ausgewählt.\n\nWähle zuerst mit /sessions eine Sitzung aus.",
119
+ "attach.session_project_mismatch": "⚠️ Die ausgewählte Sitzung passt nicht zum aktuellen Projekt. Wähle sie über /sessions erneut aus.",
120
+ "attach.connected": "✅ Mit Sitzung verbunden: {title}",
121
+ "attach.already_connected": "ℹ️ Bereits mit Sitzung verbunden: {title}",
122
+ "attach.status.idle_message": "Status: idle. Warte auf neue Ereignisse.",
123
+ "attach.status.busy_message": "Status: busy. Neue Prompts sind vorübergehend blockiert.",
124
+ "attach.restored_question": "Eine ausstehende Frage für diese Sitzung wurde wiederhergestellt.",
125
+ "attach.restored_permissions": "Ausstehende Berechtigungsanfragen wiederhergestellt: {count}.",
126
+ "attach.disconnect_hint": "Zum Trennen einfach zu einer anderen Sitzung oder einem anderen Projekt wechseln.",
127
+ "attach.error": "🔴 Verbindung mit der aktuellen Sitzung fehlgeschlagen.",
116
128
  "new.project_not_selected": "🏗 Projekt ist nicht ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.",
117
129
  "new.created": "✅ Neue Sitzung erstellt: {title}",
118
130
  "new.create_error": "🔴 OpenCode-Server ist nicht verfügbar oder beim Erstellen der Sitzung ist ein Fehler aufgetreten.",
@@ -232,6 +244,9 @@ export const de = {
232
244
  "pinned.line.project": "Projekt: {project}",
233
245
  "pinned.line.worktree": "Worktree: {worktree}",
234
246
  "pinned.line.model": "Modell: {model}",
247
+ "pinned.line.attach": "Tracking: {status}",
248
+ "pinned.attach.status.idle": "aktiv, idle",
249
+ "pinned.attach.status.busy": "aktiv, busy",
235
250
  "pinned.line.context": "Kontext: {used} / {limit} ({percent}%)",
236
251
  "pinned.line.cost": "Kosten: {cost} ausgegeben",
237
252
  "subagent.header": "Subagent {agent}: {description}",
package/dist/i18n/en.js CHANGED
@@ -48,6 +48,7 @@ export const en = {
48
48
  "bot.prompt_send_error": "Failed to send request to OpenCode.",
49
49
  "bot.session_error": "🔴 OpenCode returned an error: {message}",
50
50
  "bot.session_retry": "🔁 {message}\n\nProvider keeps returning the same error on repeated retries. Use /abort to abort.",
51
+ "bot.external_user_input": "External user input",
51
52
  "bot.unknown_command": "⚠️ Unknown command: {command}. Use /help to see available commands.",
52
53
  "bot.photo_downloading": "⏳ Downloading photo...",
53
54
  "bot.photo_too_large": "⚠️ Photo is too large (max {maxSizeMb}MB)",
@@ -113,6 +114,17 @@ export const en = {
113
114
  "sessions.preview.title": "Recent messages:",
114
115
  "sessions.preview.you": "You:",
115
116
  "sessions.preview.agent": "Agent:",
117
+ "attach.project_not_selected": "🏗 Project is not selected.\n\nFirst select a project with /projects.",
118
+ "attach.session_not_selected": "💬 Session is not selected.\n\nFirst choose a session with /sessions.",
119
+ "attach.session_project_mismatch": "⚠️ The selected session does not match the current project. Choose the session again via /sessions.",
120
+ "attach.connected": "✅ Connected to session: {title}",
121
+ "attach.already_connected": "ℹ️ Already connected to session: {title}",
122
+ "attach.status.idle_message": "Status: idle. Waiting for new events.",
123
+ "attach.status.busy_message": "Status: busy. New prompts are temporarily blocked.",
124
+ "attach.restored_question": "Recovered a pending question for this session.",
125
+ "attach.restored_permissions": "Recovered pending permission requests: {count}.",
126
+ "attach.disconnect_hint": "To disconnect, switch to another session or project.",
127
+ "attach.error": "🔴 Failed to attach to the current session.",
116
128
  "new.project_not_selected": "🏗 Project is not selected.\n\nFirst select a project with /projects.",
117
129
  "new.created": "✅ New session created: {title}",
118
130
  "new.create_error": "🔴 OpenCode Server is unavailable or an error occurred while creating session.",
@@ -232,6 +244,9 @@ export const en = {
232
244
  "pinned.line.project": "Project: {project}",
233
245
  "pinned.line.worktree": "Worktree: {worktree}",
234
246
  "pinned.line.model": "Model: {model}",
247
+ "pinned.line.attach": "Tracking: {status}",
248
+ "pinned.attach.status.idle": "active, idle",
249
+ "pinned.attach.status.busy": "active, busy",
235
250
  "pinned.line.context": "Context: {used} / {limit} ({percent}%)",
236
251
  "pinned.line.cost": "Cost: {cost} spent",
237
252
  "subagent.header": "Subagent {agent}: {description}",
package/dist/i18n/es.js CHANGED
@@ -48,6 +48,7 @@ export const es = {
48
48
  "bot.prompt_send_error": "No se pudo enviar la solicitud a OpenCode.",
49
49
  "bot.session_error": "🔴 OpenCode devolvió un error: {message}",
50
50
  "bot.session_retry": "🔁 {message}\n\nEl proveedor devuelve el mismo error en intentos repetidos. Usa /abort para detenerlo.",
51
+ "bot.external_user_input": "Entrada externa del usuario",
51
52
  "bot.unknown_command": "⚠️ Comando desconocido: {command}. Usa /help para ver los comandos disponibles.",
52
53
  "bot.photo_downloading": "⏳ Descargando foto...",
53
54
  "bot.photo_too_large": "⚠️ La foto es demasiado grande (max {maxSizeMb}MB)",
@@ -113,6 +114,17 @@ export const es = {
113
114
  "sessions.preview.title": "Mensajes recientes:",
114
115
  "sessions.preview.you": "Tú:",
115
116
  "sessions.preview.agent": "Agente:",
117
+ "attach.project_not_selected": "🏗 No hay un proyecto seleccionado.\n\nPrimero selecciona un proyecto con /projects.",
118
+ "attach.session_not_selected": "💬 No hay una sesión seleccionada.\n\nPrimero selecciona una sesión con /sessions.",
119
+ "attach.session_project_mismatch": "⚠️ La sesión seleccionada no coincide con el proyecto actual. Vuelve a seleccionarla con /sessions.",
120
+ "attach.connected": "✅ Conectado a la sesión: {title}",
121
+ "attach.already_connected": "ℹ️ Ya estás conectado a la sesión: {title}",
122
+ "attach.status.idle_message": "Estado: idle. Esperando nuevos eventos.",
123
+ "attach.status.busy_message": "Estado: busy. Los nuevos prompts están bloqueados temporalmente.",
124
+ "attach.restored_question": "Se restauró una pregunta pendiente para esta sesión.",
125
+ "attach.restored_permissions": "Se restauraron solicitudes de permiso pendientes: {count}.",
126
+ "attach.disconnect_hint": "Para desconectarte, cambia a otra sesión o proyecto.",
127
+ "attach.error": "🔴 No se pudo conectar a la sesión actual.",
116
128
  "new.project_not_selected": "🏗 No hay un proyecto seleccionado.\n\nPrimero selecciona un proyecto con /projects.",
117
129
  "new.created": "✅ Sesión nueva creada: {title}",
118
130
  "new.create_error": "🔴 OpenCode Server no está disponible u ocurrió un error al crear la sesión.",
@@ -232,6 +244,9 @@ export const es = {
232
244
  "pinned.line.project": "Proyecto: {project}",
233
245
  "pinned.line.worktree": "Worktree: {worktree}",
234
246
  "pinned.line.model": "Modelo: {model}",
247
+ "pinned.line.attach": "Tracking: {status}",
248
+ "pinned.attach.status.idle": "activo, idle",
249
+ "pinned.attach.status.busy": "activo, busy",
235
250
  "pinned.line.context": "Contexto: {used} / {limit} ({percent}%)",
236
251
  "pinned.line.cost": "Costo: {cost} gastado",
237
252
  "subagent.header": "Subagente {agent}: {description}",
package/dist/i18n/fr.js CHANGED
@@ -48,6 +48,7 @@ export const fr = {
48
48
  "bot.prompt_send_error": "Impossible d'envoyer la requête à OpenCode.",
49
49
  "bot.session_error": "🔴 OpenCode a renvoyé une erreur : {message}",
50
50
  "bot.session_retry": "🔁 {message}\n\nLe fournisseur renvoie la même erreur à chaque nouvelle tentative. Utilisez /abort pour arrêter.",
51
+ "bot.external_user_input": "Entrée utilisateur externe",
51
52
  "bot.unknown_command": "⚠️ Commande inconnue : {command}. Utilisez /help pour voir les commandes disponibles.",
52
53
  "bot.photo_downloading": "⏳ Téléchargement de la photo...",
53
54
  "bot.photo_too_large": "⚠️ La photo est trop volumineuse (max {maxSizeMb}MB)",
@@ -113,6 +114,17 @@ export const fr = {
113
114
  "sessions.preview.title": "Messages récents :",
114
115
  "sessions.preview.you": "Vous :",
115
116
  "sessions.preview.agent": "Agent :",
117
+ "attach.project_not_selected": "🏗 Aucun projet sélectionné.\n\nSélectionnez d'abord un projet avec /projects.",
118
+ "attach.session_not_selected": "💬 Aucune session sélectionnée.\n\nSélectionnez d'abord une session avec /sessions.",
119
+ "attach.session_project_mismatch": "⚠️ La session sélectionnée ne correspond pas au projet actuel. Sélectionnez-la de nouveau via /sessions.",
120
+ "attach.connected": "✅ Connecté à la session : {title}",
121
+ "attach.already_connected": "ℹ️ Déjà connecté à la session : {title}",
122
+ "attach.status.idle_message": "Statut : idle. En attente de nouveaux événements.",
123
+ "attach.status.busy_message": "Statut : busy. Les nouveaux prompts sont temporairement bloqués.",
124
+ "attach.restored_question": "Une question en attente a été restaurée pour cette session.",
125
+ "attach.restored_permissions": "Demandes de permission en attente restaurées : {count}.",
126
+ "attach.disconnect_hint": "Pour vous déconnecter, passez simplement à une autre session ou à un autre projet.",
127
+ "attach.error": "🔴 Impossible de se connecter à la session actuelle.",
116
128
  "new.project_not_selected": "🏗 Aucun projet n'est sélectionné.\n\nSélectionnez d'abord un projet avec /projects.",
117
129
  "new.created": "✅ Nouvelle session créée : {title}",
118
130
  "new.create_error": "🔴 Le serveur OpenCode est indisponible ou une erreur s'est produite lors de la création de la session.",
@@ -232,6 +244,9 @@ export const fr = {
232
244
  "pinned.line.project": "Projet : {project}",
233
245
  "pinned.line.worktree": "Worktree : {worktree}",
234
246
  "pinned.line.model": "Modèle : {model}",
247
+ "pinned.line.attach": "Tracking : {status}",
248
+ "pinned.attach.status.idle": "actif, idle",
249
+ "pinned.attach.status.busy": "actif, busy",
235
250
  "pinned.line.context": "Contexte : {used} / {limit} ({percent}%)",
236
251
  "pinned.line.cost": "Coût : {cost} dépensé",
237
252
  "subagent.header": "Sous-agent {agent} : {description}",
package/dist/i18n/ru.js CHANGED
@@ -48,6 +48,7 @@ export const ru = {
48
48
  "bot.prompt_send_error": "Не удалось отправить запрос в OpenCode.",
49
49
  "bot.session_error": "🔴 OpenCode вернул ошибку: {message}",
50
50
  "bot.session_retry": "🔁 {message}\n\nПровайдер возвращает одну и ту же ошибку при повторных запросах. Используйте /abort для остановки.",
51
+ "bot.external_user_input": "Внешний ввод пользователя",
51
52
  "bot.unknown_command": "⚠️ Неизвестная команда: {command}. Используйте /help для списка команд.",
52
53
  "bot.photo_downloading": "⏳ Скачиваю фото...",
53
54
  "bot.photo_too_large": "⚠️ Фото слишком большое (макс. {maxSizeMb}МБ)",
@@ -113,6 +114,17 @@ export const ru = {
113
114
  "sessions.preview.title": "Последние сообщения:",
114
115
  "sessions.preview.you": "Вы:",
115
116
  "sessions.preview.agent": "Агент:",
117
+ "attach.project_not_selected": "🏗 Проект не выбран.\n\nСначала выберите проект командой /projects.",
118
+ "attach.session_not_selected": "💬 Сессия не выбрана.\n\nСначала выберите сессию через /sessions.",
119
+ "attach.session_project_mismatch": "⚠️ Выбранная сессия не соответствует текущему проекту. Повторно выберите сессию через /sessions.",
120
+ "attach.connected": "✅ Подключился к сессии: {title}",
121
+ "attach.already_connected": "ℹ️ Уже подключен к сессии: {title}",
122
+ "attach.status.idle_message": "Статус: idle. Жду новые события.",
123
+ "attach.status.busy_message": "Статус: busy. Новые промты временно заблокированы.",
124
+ "attach.restored_question": "Восстановил ожидающий вопрос для этой сессии.",
125
+ "attach.restored_permissions": "Восстановил ожидающие запросы разрешений: {count}.",
126
+ "attach.disconnect_hint": "Чтобы отключиться, переключитесь на другую сессию или проект.",
127
+ "attach.error": "🔴 Не удалось подключиться к текущей сессии.",
116
128
  "new.project_not_selected": "🏗 Проект не выбран.\n\nСначала выберите проект командой /projects.",
117
129
  "new.created": "✅ Создана новая сессия: {title}",
118
130
  "new.create_error": "🔴 OpenCode Server недоступен или произошла ошибка при создании сессии.",
@@ -232,6 +244,9 @@ export const ru = {
232
244
  "pinned.line.project": "Проект: {project}",
233
245
  "pinned.line.worktree": "Worktree: {worktree}",
234
246
  "pinned.line.model": "Модель: {model}",
247
+ "pinned.line.attach": "Tracking: {status}",
248
+ "pinned.attach.status.idle": "активен, idle",
249
+ "pinned.attach.status.busy": "активен, busy",
235
250
  "pinned.line.context": "Контекст: {used} / {limit} ({percent}%)",
236
251
  "pinned.line.cost": "Стоимость: {cost} потрачено",
237
252
  "subagent.header": "Сабагент {agent}: {description}",
package/dist/i18n/zh.js CHANGED
@@ -48,6 +48,7 @@ export const zh = {
48
48
  "bot.prompt_send_error": "向 OpenCode 发送请求失败。",
49
49
  "bot.session_error": "🔴 OpenCode 返回错误:{message}",
50
50
  "bot.session_retry": "🔁 {message}\n\n提供方在重复重试时持续返回同一错误。使用 /abort 可停止。",
51
+ "bot.external_user_input": "外部用户输入",
51
52
  "bot.unknown_command": "⚠️ 未知命令:{command}。使用 /help 查看可用命令。",
52
53
  "bot.photo_downloading": "⏳ 正在下载照片...",
53
54
  "bot.photo_too_large": "⚠️ 照片过大(最大 {maxSizeMb}MB)",
@@ -113,6 +114,17 @@ export const zh = {
113
114
  "sessions.preview.title": "最近消息:",
114
115
  "sessions.preview.you": "你:",
115
116
  "sessions.preview.agent": "代理:",
117
+ "attach.project_not_selected": "🏗 未选择项目。\n\n请先使用 /projects 选择一个项目。",
118
+ "attach.session_not_selected": "💬 未选择会话。\n\n请先使用 /sessions 选择一个会话。",
119
+ "attach.session_project_mismatch": "⚠️ 选中的会话与当前项目不匹配。请通过 /sessions 重新选择。",
120
+ "attach.connected": "✅ 已连接到会话:{title}",
121
+ "attach.already_connected": "ℹ️ 已经连接到会话:{title}",
122
+ "attach.status.idle_message": "状态:idle。正在等待新事件。",
123
+ "attach.status.busy_message": "状态:busy。新的提示词暂时被阻止。",
124
+ "attach.restored_question": "已恢复该会话的待处理问题。",
125
+ "attach.restored_permissions": "已恢复待处理权限请求:{count}。",
126
+ "attach.disconnect_hint": "如需断开连接,只需切换到其他会话或项目。",
127
+ "attach.error": "🔴 无法连接到当前会话。",
116
128
  "new.project_not_selected": "🏗 未选择项目。\n\n请先使用 /projects 选择一个项目。",
117
129
  "new.created": "✅ 新会话已创建:{title}",
118
130
  "new.create_error": "🔴 OpenCode 服务器不可用,或创建会话时发生错误。",
@@ -232,6 +244,9 @@ export const zh = {
232
244
  "pinned.line.project": "项目: {project}",
233
245
  "pinned.line.worktree": "Worktree: {worktree}",
234
246
  "pinned.line.model": "模型: {model}",
247
+ "pinned.line.attach": "Tracking: {status}",
248
+ "pinned.attach.status.idle": "active, idle",
249
+ "pinned.attach.status.busy": "active, busy",
235
250
  "pinned.line.context": "上下文: {used} / {limit} ({percent}%)",
236
251
  "pinned.line.cost": "费用: {cost}",
237
252
  "subagent.header": "子代理 {agent}: {description}",
@@ -1,6 +1,7 @@
1
1
  import { interactionManager } from "./manager.js";
2
2
  import { allowsBusyInteraction, isBusyAllowedCommand } from "./busy.js";
3
3
  import { foregroundSessionState } from "../scheduled-task/foreground-state.js";
4
+ import { attachManager } from "../attach/manager.js";
4
5
  function normalizeIncomingCommand(text) {
5
6
  const trimmed = text.trim();
6
7
  if (!trimmed.startsWith("/")) {
@@ -83,7 +84,7 @@ function isAllowedTaskCallback(ctx, state) {
83
84
  export function resolveInteractionGuardDecision(ctx) {
84
85
  const state = interactionManager.getSnapshot();
85
86
  const { inputType, command } = classifyIncomingInput(ctx);
86
- const isBusy = foregroundSessionState.isBusy();
87
+ const isBusy = foregroundSessionState.isBusy() || attachManager.isBusy();
87
88
  if (state && interactionManager.isExpired()) {
88
89
  interactionManager.clear("expired");
89
90
  return createBlockDecision(inputType, state, "expired", command, isBusy);
@@ -8,6 +8,7 @@ let eventCallback = null;
8
8
  let isListening = false;
9
9
  let activeDirectory = null;
10
10
  let streamAbortController = null;
11
+ let listenerGeneration = 0;
11
12
  function getReconnectDelayMs(attempt) {
12
13
  const exponentialDelay = RECONNECT_BASE_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
13
14
  return Math.min(exponentialDelay, RECONNECT_MAX_DELAY_MS);
@@ -44,6 +45,7 @@ export async function subscribeToEvents(directory, callback) {
44
45
  activeDirectory = null;
45
46
  }
46
47
  const controller = new AbortController();
48
+ const generation = ++listenerGeneration;
47
49
  activeDirectory = directory;
48
50
  eventCallback = callback;
49
51
  isListening = true;
@@ -70,7 +72,16 @@ export async function subscribeToEvents(directory, callback) {
70
72
  // Use setImmediate to avoid blocking the event loop
71
73
  // and let grammY process incoming Telegram updates
72
74
  const callbackSnapshot = eventCallback;
73
- setImmediate(() => callbackSnapshot(event));
75
+ setImmediate(() => {
76
+ if (streamAbortController !== controller ||
77
+ controller.signal.aborted ||
78
+ !isListening ||
79
+ activeDirectory !== directory ||
80
+ listenerGeneration !== generation) {
81
+ return;
82
+ }
83
+ callbackSnapshot(event);
84
+ });
74
85
  }
75
86
  }
76
87
  eventStream = null;
@@ -130,6 +141,7 @@ export async function subscribeToEvents(directory, callback) {
130
141
  }
131
142
  }
132
143
  export function stopEventListening() {
144
+ listenerGeneration++;
133
145
  streamAbortController?.abort();
134
146
  streamAbortController = null;
135
147
  isListening = false;