@grinev/opencode-telegram-bot 0.16.1 → 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.
Files changed (46) hide show
  1. package/.env.example +5 -0
  2. package/README.md +72 -43
  3. package/dist/app/start-bot-app.js +0 -2
  4. package/dist/attach/manager.js +66 -0
  5. package/dist/attach/service.js +166 -0
  6. package/dist/bot/commands/abort.js +0 -4
  7. package/dist/bot/commands/commands.js +13 -3
  8. package/dist/bot/commands/definitions.js +2 -0
  9. package/dist/bot/commands/new.js +10 -18
  10. package/dist/bot/commands/opencode-start.js +27 -24
  11. package/dist/bot/commands/opencode-stop.js +51 -23
  12. package/dist/bot/commands/projects.js +30 -6
  13. package/dist/bot/commands/sessions.js +19 -20
  14. package/dist/bot/commands/skills.js +376 -0
  15. package/dist/bot/commands/start.js +2 -0
  16. package/dist/bot/commands/status.js +21 -14
  17. package/dist/bot/commands/worktree.js +196 -0
  18. package/dist/bot/handlers/inline-menu.js +1 -0
  19. package/dist/bot/handlers/prompt.js +26 -31
  20. package/dist/bot/handlers/voice.js +8 -1
  21. package/dist/bot/index.js +90 -10
  22. package/dist/bot/middleware/interaction-guard.js +1 -1
  23. package/dist/bot/utils/busy-guard.js +3 -2
  24. package/dist/bot/utils/external-user-input.js +46 -0
  25. package/dist/bot/utils/switch-project.js +2 -0
  26. package/dist/config.js +2 -0
  27. package/dist/external-input/suppression.js +54 -0
  28. package/dist/git/worktree.js +158 -0
  29. package/dist/i18n/de.js +54 -1
  30. package/dist/i18n/en.js +54 -1
  31. package/dist/i18n/es.js +54 -1
  32. package/dist/i18n/fr.js +54 -1
  33. package/dist/i18n/ru.js +54 -1
  34. package/dist/i18n/zh.js +54 -1
  35. package/dist/interaction/guard.js +2 -1
  36. package/dist/opencode/events.js +13 -1
  37. package/dist/opencode/process.js +182 -0
  38. package/dist/pinned/manager.js +46 -61
  39. package/dist/project/manager.js +47 -32
  40. package/dist/runtime/bootstrap.js +119 -7
  41. package/dist/scheduled-task/executor.js +137 -7
  42. package/dist/settings/manager.js +9 -12
  43. package/dist/summary/aggregator.js +54 -9
  44. package/package.json +1 -1
  45. package/dist/process/manager.js +0 -273
  46. package/dist/process/types.js +0 -1
@@ -18,6 +18,8 @@ import { logger } from "../../utils/logger.js";
18
18
  import { t } from "../../i18n/index.js";
19
19
  import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
20
20
  import { assistantRunState } from "../assistant-run-state.js";
21
+ import { attachToSession, detachAttachedSession, markAttachedSessionBusy, markAttachedSessionIdle, } from "../../attach/service.js";
22
+ import { externalUserInputSuppressionManager } from "../../external-input/suppression.js";
21
23
  /** Module-level references for async callbacks that don't have ctx. */
22
24
  let botInstance = null;
23
25
  let chatIdInstance = null;
@@ -59,6 +61,7 @@ async function isSessionBusy(sessionId, directory) {
59
61
  }
60
62
  }
61
63
  async function resetMismatchedSessionContext() {
64
+ detachAttachedSession("session_mismatch_reset");
62
65
  stopEventListening();
63
66
  summaryAggregator.clear();
64
67
  foregroundSessionState.clearAll("session_mismatch_reset");
@@ -96,13 +99,8 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
96
99
  }
97
100
  botInstance = bot;
98
101
  chatIdInstance = ctx.chat.id;
99
- // Initialize pinned message manager if not already
100
- if (!pinnedMessageManager.isInitialized()) {
101
- pinnedMessageManager.initialize(bot.api, ctx.chat.id);
102
- }
103
- // Initialize keyboard manager if not already
104
- keyboardManager.initialize(bot.api, ctx.chat.id);
105
102
  let currentSession = getCurrentSession();
103
+ let createdNewSession = false;
106
104
  if (currentSession && currentSession.directory !== currentProject.worktree) {
107
105
  logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
108
106
  await resetMismatchedSessionContext();
@@ -126,38 +124,28 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
126
124
  };
127
125
  setCurrentSession(currentSession);
128
126
  await ingestSessionInfoForCache(session);
129
- // Create pinned message for new session
130
- try {
131
- await pinnedMessageManager.onSessionChange(session.id, session.title);
132
- }
133
- catch (err) {
134
- logger.error("[Bot] Error creating pinned message for new session:", err);
135
- }
127
+ createdNewSession = true;
128
+ }
129
+ else {
130
+ logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
131
+ }
132
+ await attachToSession({
133
+ bot,
134
+ chatId: ctx.chat.id,
135
+ session: currentSession,
136
+ ensureEventSubscription,
137
+ });
138
+ if (createdNewSession) {
136
139
  const currentAgent = await resolveProjectAgent(getStoredAgent());
137
140
  const currentModel = getStoredModel();
138
- const contextInfo = pinnedMessageManager.getContextInfo();
139
- const variantName = formatVariantForButton(currentModel.variant || "default");
140
141
  keyboardManager.updateAgent(currentAgent);
142
+ const contextInfo = keyboardManager.getContextInfo();
143
+ const variantName = formatVariantForButton(currentModel.variant || "default");
141
144
  const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
142
- await ctx.reply(t("bot.session_created", { title: session.title }), {
145
+ await ctx.reply(t("bot.session_created", { title: currentSession.title }), {
143
146
  reply_markup: keyboard,
144
147
  });
145
148
  }
146
- else {
147
- logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
148
- // Ensure pinned message exists for existing session
149
- if (!pinnedMessageManager.getState().messageId) {
150
- try {
151
- await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title);
152
- }
153
- catch (err) {
154
- logger.error("[Bot] Error creating pinned message for existing session:", err);
155
- }
156
- }
157
- }
158
- await ensureEventSubscription(currentSession.directory);
159
- summaryAggregator.setSession(currentSession.id);
160
- summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
161
149
  const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
162
150
  if (sessionIsBusy) {
163
151
  logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
@@ -211,6 +199,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
211
199
  };
212
200
  logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
213
201
  foregroundSessionState.markBusy(currentSession.id);
202
+ await markAttachedSessionBusy(currentSession.id);
214
203
  assistantRunState.startRun(currentSession.id, {
215
204
  startedAt: Date.now(),
216
205
  configuredAgent: currentAgent,
@@ -218,6 +207,9 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
218
207
  configuredModelID: storedModel.modelID,
219
208
  });
220
209
  setPromptResponseMode(currentSession.id, responseMode);
210
+ if (text.trim().length > 0) {
211
+ externalUserInputSuppressionManager.register(currentSession.id, text);
212
+ }
221
213
  // CRITICAL: DO NOT wait for session.prompt to complete.
222
214
  // If we wait, the handler will not finish and grammY will not call getUpdates,
223
215
  // which blocks receiving button callback_query updates.
@@ -228,6 +220,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
228
220
  onSuccess: ({ error }) => {
229
221
  if (error) {
230
222
  foregroundSessionState.markIdle(currentSession.id);
223
+ void markAttachedSessionIdle(currentSession.id);
231
224
  assistantRunState.clearRun(currentSession.id, "session_prompt_api_error");
232
225
  clearPromptResponseMode(currentSession.id);
233
226
  const details = formatErrorDetails(error, 6000);
@@ -242,6 +235,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
242
235
  },
243
236
  onError: (error) => {
244
237
  foregroundSessionState.markIdle(currentSession.id);
238
+ void markAttachedSessionIdle(currentSession.id);
245
239
  assistantRunState.clearRun(currentSession.id, "session_prompt_background_error");
246
240
  clearPromptResponseMode(currentSession.id);
247
241
  const details = formatErrorDetails(error, 6000);
@@ -256,6 +250,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
256
250
  catch (err) {
257
251
  if (currentSession) {
258
252
  foregroundSessionState.markIdle(currentSession.id);
253
+ await markAttachedSessionIdle(currentSession.id);
259
254
  assistantRunState.clearRun(currentSession.id, "session_prompt_handler_error");
260
255
  }
261
256
  logger.error("Error in prompt handler:", err);
@@ -156,8 +156,15 @@ export async function handleVoiceMessage(ctx, deps) {
156
156
  logger.warn("[Voice] Failed to edit status message with recognized text:", editError);
157
157
  }
158
158
  logger.info(`[Voice] Transcribed audio: ${recognizedText.length} chars`);
159
+ let textForLLM = recognizedText;
160
+ const notePrompt = config.stt.notePrompt.trim();
161
+ if (notePrompt && notePrompt.toLowerCase() !== "false" && notePrompt !== "0") {
162
+ const llmNote = `[Note: ${notePrompt}]`;
163
+ logger.debug(`[Voice] Added STT note to LLM prompt: ${llmNote}`);
164
+ textForLLM = `${llmNote}\n${recognizedText}`;
165
+ }
159
166
  // Process the recognized text as a prompt
160
- await processPrompt(ctx, recognizedText, deps);
167
+ await processPrompt(ctx, textForLLM, deps);
161
168
  }
162
169
  catch (err) {
163
170
  const errorMessage = err instanceof Error ? err.message : "unknown error";
package/dist/bot/index.js CHANGED
@@ -16,6 +16,7 @@ import { AGENT_MODE_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTT
16
16
  import { sessionsCommand, handleSessionSelect } from "./commands/sessions.js";
17
17
  import { newCommand } from "./commands/new.js";
18
18
  import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
19
+ import { worktreeCommand, handleWorktreeCallback } from "./commands/worktree.js";
19
20
  import { openCommand, handleOpenCallback, clearOpenPathIndex } from "./commands/open.js";
20
21
  import { abortCommand } from "./commands/abort.js";
21
22
  import { opencodeStartCommand } from "./commands/opencode-start.js";
@@ -24,6 +25,7 @@ import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./com
24
25
  import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js";
25
26
  import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
26
27
  import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
28
+ import { skillsCommand, handleSkillsCallback, handleSkillTextArguments, } from "./commands/skills.js";
27
29
  import { ttsCommand } from "./commands/tts.js";
28
30
  import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
29
31
  import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
@@ -64,7 +66,11 @@ import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
64
66
  import { assistantRunState } from "./assistant-run-state.js";
65
67
  import { ResponseStreamer } from "./streaming/response-streamer.js";
66
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";
67
72
  import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload, renderAssistantFinalPartsSafe, } from "./utils/assistant-rendering.js";
73
+ import { deliverExternalUserInputNotification } from "./utils/external-user-input.js";
68
74
  let botInstance = null;
69
75
  let chatIdInstance = null;
70
76
  let commandsInitialized = false;
@@ -261,6 +267,27 @@ function getToolStreamKey(tool) {
261
267
  }
262
268
  return "default";
263
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
+ }
264
291
  async function ensureCommandsInitialized(ctx, next) {
265
292
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
266
293
  await next();
@@ -386,6 +413,26 @@ async function ensureEventSubscription(directory) {
386
413
  }
387
414
  });
388
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
+ });
389
436
  summaryAggregator.setOnTool(async (toolInfo) => {
390
437
  if (!botInstance || !chatIdInstance) {
391
438
  logger.error("Bot or chat ID not available for sending tool notification");
@@ -459,18 +506,19 @@ async function ensureEventSubscription(directory) {
459
506
  logger.error("Failed to send file to Telegram:", err);
460
507
  }
461
508
  });
462
- summaryAggregator.setOnQuestion(async (questions, requestID) => {
509
+ summaryAggregator.setOnQuestion(async (questions, requestID, sessionId) => {
463
510
  if (!botInstance || !chatIdInstance) {
464
511
  logger.error("Bot or chat ID not available for showing questions");
465
512
  return;
466
513
  }
467
514
  const currentSession = getCurrentSession();
468
- if (currentSession) {
469
- await Promise.all([
470
- toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
471
- toolCallStreamer.flushSession(currentSession.id, "question_asked"),
472
- ]);
515
+ if (!currentSession || currentSession.id !== sessionId) {
516
+ return;
473
517
  }
518
+ await Promise.all([
519
+ toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
520
+ toolCallStreamer.flushSession(currentSession.id, "question_asked"),
521
+ ]);
474
522
  if (questionManager.isActive()) {
475
523
  logger.warn("[Bot] Replacing active poll with a new one");
476
524
  const previousMessageIds = questionManager.getMessageIds();
@@ -501,6 +549,10 @@ async function ensureEventSubscription(directory) {
501
549
  logger.error("Bot or chat ID not available for showing permission request");
502
550
  return;
503
551
  }
552
+ const currentSession = getCurrentSession();
553
+ if (!currentSession || currentSession.id !== request.sessionID) {
554
+ return;
555
+ }
504
556
  await Promise.all([
505
557
  toolMessageBatcher.flushSession(request.sessionID, "permission_asked"),
506
558
  toolCallStreamer.flushSession(request.sessionID, "permission_asked"),
@@ -581,6 +633,7 @@ async function ensureEventSubscription(directory) {
581
633
  }
582
634
  });
583
635
  summaryAggregator.setOnSessionIdle(async (sessionId) => {
636
+ await markAttachedSessionIdle(sessionId);
584
637
  await sessionCompletionTasks.get(sessionId)?.catch(() => undefined);
585
638
  const completedRun = assistantRunState.finishRun(sessionId, "session_idle");
586
639
  clearPromptResponseMode(sessionId);
@@ -625,6 +678,7 @@ async function ensureEventSubscription(directory) {
625
678
  }
626
679
  });
627
680
  summaryAggregator.setOnSessionError(async (sessionId, message) => {
681
+ await markAttachedSessionIdle(sessionId);
628
682
  if (!botInstance || !chatIdInstance) {
629
683
  clearPromptResponseMode(sessionId);
630
684
  assistantRunState.clearRun(sessionId, "session_error_no_bot_context");
@@ -704,6 +758,11 @@ async function ensureEventSubscription(directory) {
704
758
  });
705
759
  logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
706
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
+ }
707
766
  if (event.type === "session.created" || event.type === "session.updated") {
708
767
  const info = event.properties.info;
709
768
  if (info?.directory) {
@@ -721,6 +780,7 @@ async function ensureEventSubscription(directory) {
721
780
  export function createBot() {
722
781
  clearAllInteractionState("bot_startup");
723
782
  sessionCompletionTasks.clear();
783
+ attachManager.clear("bot_startup");
724
784
  assistantRunState.clearAll("bot_startup");
725
785
  if (heartbeatTimer) {
726
786
  clearInterval(heartbeatTimer);
@@ -746,6 +806,8 @@ export function createBot() {
746
806
  };
747
807
  }
748
808
  const bot = new Bot(config.telegram.token, botOptions);
809
+ botInstance = bot;
810
+ chatIdInstance = config.telegram.allowedUserId;
749
811
  // Heartbeat for diagnostics: verify the event loop is not blocked
750
812
  let heartbeatCounter = 0;
751
813
  heartbeatTimer = setInterval(() => {
@@ -801,14 +863,16 @@ export function createBot() {
801
863
  bot.command("opencode_start", opencodeStartCommand);
802
864
  bot.command("opencode_stop", opencodeStopCommand);
803
865
  bot.command("projects", projectsCommand);
866
+ bot.command("worktree", worktreeCommand);
804
867
  bot.command("open", openCommand);
805
868
  bot.command("sessions", sessionsCommand);
806
- bot.command("new", newCommand);
869
+ bot.command("new", (ctx) => newCommand(ctx, { bot, ensureEventSubscription }));
807
870
  bot.command("abort", abortCommand);
808
871
  bot.command("task", taskCommand);
809
872
  bot.command("tasklist", taskListCommand);
810
873
  bot.command("rename", renameCommand);
811
874
  bot.command("commands", commandsCommand);
875
+ bot.command("skills", skillsCommand);
812
876
  bot.on("message:text", unknownCommandMiddleware);
813
877
  bot.on("callback_query:data", async (ctx) => {
814
878
  logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
@@ -823,8 +887,9 @@ export function createBot() {
823
887
  // Clean up path index when the open-directory menu is cancelled
824
888
  clearOpenPathIndex();
825
889
  }
826
- const handledSession = await handleSessionSelect(ctx);
890
+ const handledSession = await handleSessionSelect(ctx, { bot, ensureEventSubscription });
827
891
  const handledProject = await handleProjectSelect(ctx);
892
+ const handledWorktree = await handleWorktreeCallback(ctx);
828
893
  const handledOpen = await handleOpenCallback(ctx);
829
894
  const handledQuestion = await handleQuestionCallback(ctx);
830
895
  const handledPermission = await handlePermissionCallback(ctx);
@@ -836,10 +901,12 @@ export function createBot() {
836
901
  const handledTaskList = await handleTaskListCallback(ctx);
837
902
  const handledRenameCancel = await handleRenameCancel(ctx);
838
903
  const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
839
- logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, open=${handledOpen}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}`);
904
+ const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
905
+ logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}`);
840
906
  if (!handledInlineCancel &&
841
907
  !handledSession &&
842
908
  !handledProject &&
909
+ !handledWorktree &&
843
910
  !handledOpen &&
844
911
  !handledQuestion &&
845
912
  !handledPermission &&
@@ -850,7 +917,8 @@ export function createBot() {
850
917
  !handledTask &&
851
918
  !handledTaskList &&
852
919
  !handledRenameCancel &&
853
- !handledCommands) {
920
+ !handledCommands &&
921
+ !handledSkills) {
854
922
  logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
855
923
  await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
856
924
  }
@@ -952,6 +1020,14 @@ export function createBot() {
952
1020
  });
953
1021
  // Voice and audio message handlers (STT transcription -> prompt)
954
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
+ });
955
1031
  bot.on("message:voice", async (ctx) => {
956
1032
  logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
957
1033
  botInstance = bot;
@@ -1049,6 +1125,10 @@ export function createBot() {
1049
1125
  if (handledCommandArgs) {
1050
1126
  return;
1051
1127
  }
1128
+ const handledSkillArgs = await handleSkillTextArguments(ctx, promptDeps);
1129
+ if (handledSkillArgs) {
1130
+ return;
1131
+ }
1052
1132
  await processUserPrompt(ctx, text, promptDeps);
1053
1133
  logger.debug("[Bot] message:text handler completed (prompt sent in background)");
1054
1134
  });
@@ -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();
package/dist/config.js CHANGED
@@ -73,6 +73,7 @@ export const config = {
73
73
  projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
74
74
  commandsListLimit: getOptionalPositiveIntEnvVar("COMMANDS_LIST_LIMIT", 10),
75
75
  taskLimit: getOptionalPositiveIntEnvVar("TASK_LIMIT", 10),
76
+ scheduledTaskExecutionTimeoutMinutes: getOptionalPositiveIntEnvVar("SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES", 120),
76
77
  responseStreamThrottleMs: getOptionalPositiveIntEnvVar("RESPONSE_STREAM_THROTTLE_MS", 500),
77
78
  bashToolDisplayMaxLength: getOptionalPositiveIntEnvVar("BASH_TOOL_DISPLAY_MAX_LENGTH", 128),
78
79
  locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
@@ -92,6 +93,7 @@ export const config = {
92
93
  apiKey: getEnvVar("STT_API_KEY", false),
93
94
  model: getEnvVar("STT_MODEL", false) || "whisper-large-v3-turbo",
94
95
  language: getEnvVar("STT_LANGUAGE", false),
96
+ notePrompt: getEnvVar("STT_NOTE_PROMPT", false),
95
97
  },
96
98
  tts: {
97
99
  apiUrl: getEnvVar("TTS_API_URL", false),
@@ -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();