@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/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
7
7
  [![Follow updates](https://img.shields.io/badge/-Follow%20updates-333333?logo=x)](https://x.com/grin_rus)
8
+ [![Community](https://img.shields.io/badge/Community-Telegram-26A5E4?logo=telegram&logoColor=white)](https://t.me/+Fj_IyKRi6-41MGUy)
8
9
 
9
10
  OpenCode Telegram Bot is a secure Telegram client for [OpenCode](https://opencode.ai) CLI that runs on your local machine.
10
11
 
@@ -26,6 +27,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
26
27
 
27
28
  - **Remote coding** — send prompts to OpenCode from anywhere, receive complete results with code sent as files
28
29
  - **Session management** — create new sessions or continue existing ones, just like in the TUI
30
+ - **Track live session** — follow a live OpenCode CLI session; see [Track Existing Session](#track-existing-session)
29
31
  - **Live status** — pinned message with current project/worktree, model, context usage, and changed files list, updated in real time
30
32
  - **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
31
33
  - **Agent modes** — switch between Plan and Build modes on the fly
@@ -158,6 +160,18 @@ Scheduled tasks let you prepare prompts in advance and run them automatically la
158
160
  - By default, the bot waits up to 120 minutes for one scheduled task run; change this with `SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES` if needed
159
161
  - Up to 10 scheduled tasks can exist at once by default; change this with `TASK_LIMIT` in your `.env`
160
162
 
163
+ ## Track Existing Session
164
+
165
+ After you create a new session, select an existing one, or let the bot auto-create one from your first prompt, the bot automatically starts tracking that session. It follows live events from the same OpenCode CLI session, shows external text input sent from another TUI client, and lets you continue the same session from Telegram.
166
+
167
+ For this to work, the console OpenCode instance must be started on the same port the bot connects to. By default, OpenCode starts on a random port, so use one of the setups below.
168
+
169
+ - **Single TUI, simplest setup** — start OpenCode on a fixed port: `opencode --port 4096`
170
+ - Point the bot to `http://127.0.0.1:4096`, then select or create the same session in Telegram
171
+ - **Multiple TUI clients, shared backend** — start one backend: `opencode serve --port 4096`
172
+ - In each terminal client, connect with: `opencode attach http://127.0.0.1:4096`
173
+ - In the bot, select or create the same session to start tracking it automatically
174
+
161
175
  ## Configuration
162
176
 
163
177
  ### Localization
@@ -330,7 +344,7 @@ Please follow commit and release note conventions in [CONTRIBUTING.md](CONTRIBUT
330
344
 
331
345
  ## Community
332
346
 
333
- Have questions, want to share your experience using the bot, or have an idea for a feature? Join the conversation in [GitHub Discussions](https://github.com/grinev/opencode-telegram-bot/discussions).
347
+ Have questions, want to share your experience using the bot, or have an idea for a feature? Join the [Telegram group](https://t.me/+Fj_IyKRi6-41MGUy) for announcements and discussions, or start a thread in [GitHub Discussions](https://github.com/grinev/opencode-telegram-bot/discussions).
334
348
 
335
349
  ## License
336
350
 
@@ -0,0 +1,66 @@
1
+ import { logger } from "../utils/logger.js";
2
+ class AttachManager {
3
+ state = null;
4
+ attach(sessionId, directory) {
5
+ this.state = {
6
+ sessionId,
7
+ directory,
8
+ busy: false,
9
+ };
10
+ logger.info(`[Attach] Attached to session: session=${sessionId}, directory=${directory}`);
11
+ }
12
+ clear(reason) {
13
+ if (!this.state) {
14
+ return;
15
+ }
16
+ logger.info(`[Attach] Cleared attached session: reason=${reason}, session=${this.state.sessionId}, directory=${this.state.directory}`);
17
+ this.state = null;
18
+ }
19
+ getSnapshot() {
20
+ return this.state ? { ...this.state } : null;
21
+ }
22
+ isAttached() {
23
+ return this.state !== null;
24
+ }
25
+ isAttachedSession(sessionId, directory) {
26
+ if (!this.state || !sessionId) {
27
+ return false;
28
+ }
29
+ if (this.state.sessionId !== sessionId) {
30
+ return false;
31
+ }
32
+ if (directory && this.state.directory !== directory) {
33
+ return false;
34
+ }
35
+ return true;
36
+ }
37
+ isBusy() {
38
+ return this.state?.busy === true;
39
+ }
40
+ markBusy(sessionId) {
41
+ if (!this.state || this.state.sessionId !== sessionId) {
42
+ return false;
43
+ }
44
+ if (this.state.busy) {
45
+ return false;
46
+ }
47
+ this.state.busy = true;
48
+ logger.info(`[Attach] Marked attached session busy: session=${sessionId}`);
49
+ return true;
50
+ }
51
+ markIdle(sessionId) {
52
+ if (!this.state || this.state.sessionId !== sessionId) {
53
+ return false;
54
+ }
55
+ if (!this.state.busy) {
56
+ return false;
57
+ }
58
+ this.state.busy = false;
59
+ logger.info(`[Attach] Marked attached session idle: session=${sessionId}`);
60
+ return true;
61
+ }
62
+ __resetForTests() {
63
+ this.state = null;
64
+ }
65
+ }
66
+ export const attachManager = new AttachManager();
@@ -0,0 +1,166 @@
1
+ import { opencodeClient } from "../opencode/client.js";
2
+ import { stopEventListening } from "../opencode/events.js";
3
+ import { summaryAggregator } from "../summary/aggregator.js";
4
+ import { pinnedMessageManager } from "../pinned/manager.js";
5
+ import { keyboardManager } from "../keyboard/manager.js";
6
+ import { questionManager } from "../question/manager.js";
7
+ import { permissionManager } from "../permission/manager.js";
8
+ import { showCurrentQuestion } from "../bot/handlers/question.js";
9
+ import { showPermissionRequest } from "../bot/handlers/permission.js";
10
+ import { getCurrentSession } from "../session/manager.js";
11
+ import { getCurrentProject } from "../settings/manager.js";
12
+ import { attachManager } from "./manager.js";
13
+ import { logger } from "../utils/logger.js";
14
+ function getAttachBusyStatus(sessionId, statuses) {
15
+ if (!statuses || typeof statuses !== "object") {
16
+ return false;
17
+ }
18
+ const sessionStatus = statuses[sessionId];
19
+ return sessionStatus?.type === "busy";
20
+ }
21
+ async function ensureAttachPinnedSession({ api, chatId, session, }) {
22
+ if (!pinnedMessageManager.isInitialized()) {
23
+ pinnedMessageManager.initialize(api, chatId);
24
+ }
25
+ keyboardManager.initialize(api, chatId);
26
+ const pinnedState = pinnedMessageManager.getState();
27
+ if (pinnedState.sessionId === session.id && pinnedState.messageId) {
28
+ return;
29
+ }
30
+ await pinnedMessageManager.onSessionChange(session.id, session.title);
31
+ await pinnedMessageManager.loadContextFromHistory(session.id, session.directory);
32
+ const contextInfo = pinnedMessageManager.getContextInfo();
33
+ if (contextInfo) {
34
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
35
+ }
36
+ }
37
+ async function syncPinnedAttachState() {
38
+ if (!pinnedMessageManager.isInitialized()) {
39
+ return;
40
+ }
41
+ const attached = attachManager.getSnapshot();
42
+ await pinnedMessageManager.setAttachState(attached !== null, attached?.busy ?? false);
43
+ }
44
+ async function restorePendingQuestion(bot, chatId, sessionId, directory) {
45
+ const { data, error } = await opencodeClient.question.list({
46
+ directory,
47
+ });
48
+ if (error || !data) {
49
+ logger.warn("[Attach] Failed to load pending questions during attach:", error);
50
+ return false;
51
+ }
52
+ const pendingQuestion = data.find((request) => request.sessionID === sessionId);
53
+ if (!pendingQuestion) {
54
+ return false;
55
+ }
56
+ questionManager.startQuestions(pendingQuestion.questions, pendingQuestion.id);
57
+ await showCurrentQuestion(bot.api, chatId);
58
+ return true;
59
+ }
60
+ async function restorePendingPermissions(bot, chatId, sessionId, directory) {
61
+ const { data, error } = await opencodeClient.permission.list({
62
+ directory,
63
+ });
64
+ if (error || !data) {
65
+ logger.warn("[Attach] Failed to load pending permissions during attach:", error);
66
+ return 0;
67
+ }
68
+ const pendingPermissions = data.filter((request) => request.sessionID === sessionId);
69
+ for (const request of pendingPermissions) {
70
+ await showPermissionRequest(bot.api, chatId, request);
71
+ }
72
+ return pendingPermissions.length;
73
+ }
74
+ export async function attachToSession(deps) {
75
+ const { bot, chatId, session, ensureEventSubscription } = deps;
76
+ const alreadyAttached = attachManager.isAttachedSession(session.id, session.directory);
77
+ await ensureAttachPinnedSession({
78
+ api: bot.api,
79
+ chatId,
80
+ session,
81
+ });
82
+ if (!alreadyAttached) {
83
+ await ensureEventSubscription(session.directory);
84
+ summaryAggregator.setSession(session.id);
85
+ summaryAggregator.setBotAndChatId(bot, chatId);
86
+ attachManager.attach(session.id, session.directory);
87
+ }
88
+ else {
89
+ summaryAggregator.setSession(session.id);
90
+ summaryAggregator.setBotAndChatId(bot, chatId);
91
+ }
92
+ const { data: statuses, error: statusesError } = await opencodeClient.session.status({
93
+ directory: session.directory,
94
+ });
95
+ if (statusesError) {
96
+ logger.warn("[Attach] Failed to load session status during attach:", statusesError);
97
+ }
98
+ const busy = getAttachBusyStatus(session.id, statuses);
99
+ if (busy) {
100
+ attachManager.markBusy(session.id);
101
+ }
102
+ else {
103
+ attachManager.markIdle(session.id);
104
+ }
105
+ await syncPinnedAttachState();
106
+ let restoredQuestion = false;
107
+ let restoredPermissions = 0;
108
+ if (!alreadyAttached && !questionManager.isActive() && !permissionManager.isActive()) {
109
+ restoredQuestion = await restorePendingQuestion(bot, chatId, session.id, session.directory);
110
+ if (!restoredQuestion) {
111
+ restoredPermissions = await restorePendingPermissions(bot, chatId, session.id, session.directory);
112
+ }
113
+ }
114
+ return {
115
+ busy,
116
+ alreadyAttached,
117
+ restoredQuestion,
118
+ restoredPermissions,
119
+ };
120
+ }
121
+ export async function restoreAttachedCurrentSession(deps) {
122
+ const currentProject = getCurrentProject();
123
+ const currentSession = getCurrentSession();
124
+ if (!currentProject || !currentSession) {
125
+ return false;
126
+ }
127
+ if (currentSession.directory !== currentProject.worktree) {
128
+ logger.warn(`[Attach] Skipping auto-restore because project/session mismatch: sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}`);
129
+ return false;
130
+ }
131
+ try {
132
+ await attachToSession({
133
+ bot: deps.bot,
134
+ chatId: deps.chatId,
135
+ session: currentSession,
136
+ ensureEventSubscription: deps.ensureEventSubscription,
137
+ });
138
+ logger.info(`[Attach] Restored followed session on startup: session=${currentSession.id}, directory=${currentSession.directory}`);
139
+ return true;
140
+ }
141
+ catch (error) {
142
+ logger.error("[Attach] Failed to restore followed session on startup:", error);
143
+ return false;
144
+ }
145
+ }
146
+ export function detachAttachedSession(reason) {
147
+ if (!attachManager.isAttached()) {
148
+ return;
149
+ }
150
+ stopEventListening();
151
+ summaryAggregator.clear();
152
+ attachManager.clear(reason);
153
+ void syncPinnedAttachState();
154
+ }
155
+ export async function markAttachedSessionBusy(sessionId) {
156
+ if (!attachManager.markBusy(sessionId)) {
157
+ return;
158
+ }
159
+ await syncPinnedAttachState();
160
+ }
161
+ export async function markAttachedSessionIdle(sessionId) {
162
+ if (!attachManager.markIdle(sessionId)) {
163
+ return;
164
+ }
165
+ await syncPinnedAttachState();
166
+ }
@@ -1,16 +1,12 @@
1
1
  import { opencodeClient } from "../../opencode/client.js";
2
- import { stopEventListening } from "../../opencode/events.js";
3
2
  import { getCurrentSession } from "../../session/manager.js";
4
3
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
5
- import { summaryAggregator } from "../../summary/aggregator.js";
6
4
  import { logger } from "../../utils/logger.js";
7
5
  import { t } from "../../i18n/index.js";
8
6
  import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
9
7
  import { assistantRunState } from "../assistant-run-state.js";
10
8
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
9
  function abortLocalStreaming() {
12
- stopEventListening();
13
- summaryAggregator.clear();
14
10
  clearAllInteractionState("abort_command");
15
11
  }
16
12
  async function pollSessionStatus(sessionId, directory, maxWaitMs = 5000) {
@@ -13,6 +13,8 @@ import { t } from "../../i18n/index.js";
13
13
  import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
14
14
  import { config } from "../../config.js";
15
15
  import { assistantRunState } from "../assistant-run-state.js";
16
+ import { attachToSession, detachAttachedSession, markAttachedSessionBusy, markAttachedSessionIdle, } from "../../attach/service.js";
17
+ import { externalUserInputSuppressionManager } from "../../external-input/suppression.js";
16
18
  const COMMANDS_CALLBACK_PREFIX = "commands:";
17
19
  const COMMANDS_CALLBACK_SELECT_PREFIX = `${COMMANDS_CALLBACK_PREFIX}select:`;
18
20
  const COMMANDS_CALLBACK_PAGE_PREFIX = `${COMMANDS_CALLBACK_PREFIX}page:`;
@@ -234,6 +236,7 @@ async function ensureSessionForProject(ctx, projectDirectory) {
234
236
  let currentSession = getCurrentSession();
235
237
  if (currentSession && currentSession.directory !== projectDirectory) {
236
238
  logger.warn(`[Commands] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${projectDirectory}. Resetting session context.`);
239
+ detachAttachedSession("session_mismatch_reset");
237
240
  clearSession();
238
241
  summaryAggregator.clear();
239
242
  foregroundSessionState.clearAll("session_mismatch_reset");
@@ -273,9 +276,12 @@ async function executeCommand(ctx, deps, params) {
273
276
  if (!session) {
274
277
  return;
275
278
  }
276
- await deps.ensureEventSubscription(session.directory);
277
- summaryAggregator.setSession(session.id);
278
- summaryAggregator.setBotAndChatId(deps.bot, ctx.chat.id);
279
+ await attachToSession({
280
+ bot: deps.bot,
281
+ chatId: ctx.chat.id,
282
+ session,
283
+ ensureEventSubscription: deps.ensureEventSubscription,
284
+ });
279
285
  const sessionIsBusy = await isSessionBusy(session.id, session.directory);
280
286
  if (sessionIsBusy) {
281
287
  await ctx.reply(t("bot.session_busy"));
@@ -287,12 +293,14 @@ async function executeCommand(ctx, deps, params) {
287
293
  ? `${storedModel.providerID}/${storedModel.modelID}`
288
294
  : undefined;
289
295
  foregroundSessionState.markBusy(session.id);
296
+ await markAttachedSessionBusy(session.id);
290
297
  assistantRunState.startRun(session.id, {
291
298
  startedAt: Date.now(),
292
299
  configuredAgent: currentAgent,
293
300
  configuredProviderID: storedModel.providerID,
294
301
  configuredModelID: storedModel.modelID,
295
302
  });
303
+ externalUserInputSuppressionManager.register(session.id, args ? `/${params.commandName} ${args}` : `/${params.commandName}`);
296
304
  safeBackgroundTask({
297
305
  taskName: "session.command",
298
306
  task: () => opencodeClient.session.command({
@@ -307,6 +315,7 @@ async function executeCommand(ctx, deps, params) {
307
315
  onSuccess: ({ error }) => {
308
316
  if (error) {
309
317
  foregroundSessionState.markIdle(session.id);
318
+ void markAttachedSessionIdle(session.id);
310
319
  assistantRunState.clearRun(session.id, "session_command_api_error");
311
320
  logger.error("[Commands] OpenCode API returned an error for session.command", {
312
321
  sessionId: session.id,
@@ -321,6 +330,7 @@ async function executeCommand(ctx, deps, params) {
321
330
  },
322
331
  onError: (error) => {
323
332
  foregroundSessionState.markIdle(session.id);
333
+ void markAttachedSessionIdle(session.id);
324
334
  assistantRunState.clearRun(session.id, "session_command_background_error");
325
335
  logger.error("[Commands] session.command background task failed", {
326
336
  sessionId: session.id,
@@ -3,8 +3,6 @@ import { setCurrentSession } from "../../session/manager.js";
3
3
  import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
5
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
- import { summaryAggregator } from "../../summary/aggregator.js";
7
- import { pinnedMessageManager } from "../../pinned/manager.js";
8
6
  import { keyboardManager } from "../../keyboard/manager.js";
9
7
  import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
10
8
  import { getStoredModel } from "../../model/manager.js";
@@ -13,7 +11,8 @@ import { createMainKeyboard } from "../utils/keyboard.js";
13
11
  import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
14
12
  import { logger } from "../../utils/logger.js";
15
13
  import { t } from "../../i18n/index.js";
16
- export async function newCommand(ctx) {
14
+ import { attachToSession } from "../../attach/service.js";
15
+ export async function newCommand(ctx, deps) {
17
16
  try {
18
17
  if (isForegroundBusy()) {
19
18
  await replyBusyBlocked(ctx);
@@ -38,27 +37,20 @@ export async function newCommand(ctx) {
38
37
  directory: currentProject.worktree,
39
38
  };
40
39
  setCurrentSession(sessionInfo);
41
- summaryAggregator.clear();
42
40
  clearAllInteractionState("session_created");
43
41
  await ingestSessionInfoForCache(session);
44
- // Initialize pinned message manager and create pinned message
45
- if (!pinnedMessageManager.isInitialized()) {
46
- pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
47
- }
48
- // Initialize keyboard manager if not already
49
- keyboardManager.initialize(ctx.api, ctx.chat.id);
50
- try {
51
- await pinnedMessageManager.onSessionChange(session.id, session.title);
52
- }
53
- catch (err) {
54
- logger.error("[Bot] Error creating pinned message:", err);
55
- }
42
+ await attachToSession({
43
+ bot: deps.bot,
44
+ chatId: ctx.chat.id,
45
+ session: sessionInfo,
46
+ ensureEventSubscription: deps.ensureEventSubscription,
47
+ });
56
48
  // Get current state for keyboard
57
49
  const currentAgent = await resolveProjectAgent(getStoredAgent());
58
50
  const currentModel = getStoredModel();
59
- const contextInfo = pinnedMessageManager.getContextInfo();
60
- const variantName = formatVariantForButton(currentModel.variant || "default");
61
51
  keyboardManager.updateAgent(currentAgent);
52
+ const contextInfo = keyboardManager.getContextInfo();
53
+ const variantName = formatVariantForButton(currentModel.variant || "default");
62
54
  const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
63
55
  await ctx.reply(t("new.created", { title: session.title }), {
64
56
  reply_markup: keyboard,
@@ -4,8 +4,6 @@ import { resolveProjectAgent } from "../../agent/manager.js";
4
4
  import { setCurrentSession } from "../../session/manager.js";
5
5
  import { getCurrentProject } from "../../settings/manager.js";
6
6
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
7
- import { summaryAggregator } from "../../summary/aggregator.js";
8
- import { pinnedMessageManager } from "../../pinned/manager.js";
9
7
  import { keyboardManager } from "../../keyboard/manager.js";
10
8
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
11
9
  import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
@@ -13,6 +11,7 @@ import { logger } from "../../utils/logger.js";
13
11
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
14
12
  import { config } from "../../config.js";
15
13
  import { getDateLocale, t } from "../../i18n/index.js";
14
+ import { attachToSession } from "../../attach/service.js";
16
15
  const SESSION_CALLBACK_PREFIX = "session:";
17
16
  const SESSION_PAGE_CALLBACK_PREFIX = "session:page:";
18
17
  const SESSION_FETCH_EXTRA_COUNT = 1;
@@ -120,7 +119,7 @@ export async function sessionsCommand(ctx) {
120
119
  await ctx.reply(t("sessions.fetch_error"));
121
120
  }
122
121
  }
123
- export async function handleSessionSelect(ctx) {
122
+ export async function handleSessionSelect(ctx, deps) {
124
123
  const callbackQuery = ctx.callbackQuery;
125
124
  if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
126
125
  return false;
@@ -182,7 +181,6 @@ export async function handleSessionSelect(ctx) {
182
181
  directory: currentProject.worktree,
183
182
  };
184
183
  setCurrentSession(sessionInfo);
185
- summaryAggregator.clear();
186
184
  clearAllInteractionState("session_switched");
187
185
  await ctx.answerCallbackQuery();
188
186
  let loadingMessageId = null;
@@ -195,30 +193,31 @@ export async function handleSessionSelect(ctx) {
195
193
  logger.error("[Sessions] Failed to send loading message:", err);
196
194
  }
197
195
  }
198
- // Initialize pinned message manager if not already
199
- if (!pinnedMessageManager.isInitialized() && ctx.chat) {
200
- pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
201
- }
202
- // Initialize keyboard manager if not already
203
- if (ctx.chat) {
204
- keyboardManager.initialize(ctx.api, ctx.chat.id);
205
- }
206
196
  try {
207
- // Create new pinned message for this session
208
- await pinnedMessageManager.onSessionChange(session.id, session.title);
209
- // Load context from session history (for existing sessions)
210
- // Wait for it to complete so keyboard has correct context
211
- await pinnedMessageManager.loadContextFromHistory(session.id, currentProject.worktree);
197
+ await attachToSession({
198
+ bot: deps.bot,
199
+ chatId: ctx.chat.id,
200
+ session: sessionInfo,
201
+ ensureEventSubscription: deps.ensureEventSubscription,
202
+ });
212
203
  }
213
204
  catch (err) {
214
- logger.error("[Bot] Error initializing pinned message:", err);
205
+ if (loadingMessageId) {
206
+ try {
207
+ await ctx.api.deleteMessage(ctx.chat.id, loadingMessageId);
208
+ }
209
+ catch (deleteError) {
210
+ logger.debug("[Sessions] Failed to delete loading message after follow error:", deleteError);
211
+ }
212
+ }
213
+ logger.error("[Sessions] Error following selected session:", err);
214
+ throw err;
215
215
  }
216
216
  if (ctx.chat) {
217
217
  const chatId = ctx.chat.id;
218
218
  const currentAgent = await resolveProjectAgent();
219
219
  keyboardManager.updateAgent(currentAgent);
220
- // Update keyboard with loaded context (callback executes async via setImmediate, so update manually)
221
- const contextInfo = pinnedMessageManager.getContextInfo();
220
+ const contextInfo = keyboardManager.getContextInfo();
222
221
  if (contextInfo) {
223
222
  keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
224
223
  }
@@ -10,6 +10,7 @@ import { foregroundSessionState } from "../../scheduled-task/foreground-state.js
10
10
  import { abortCurrentOperation } from "./abort.js";
11
11
  import { t } from "../../i18n/index.js";
12
12
  import { assistantRunState } from "../assistant-run-state.js";
13
+ import { detachAttachedSession } from "../../attach/service.js";
13
14
  export async function startCommand(ctx) {
14
15
  if (ctx.chat) {
15
16
  if (!pinnedMessageManager.isInitialized()) {
@@ -18,6 +19,7 @@ export async function startCommand(ctx) {
18
19
  keyboardManager.initialize(ctx.api, ctx.chat.id);
19
20
  }
20
21
  await abortCurrentOperation(ctx, { notifyUser: false });
22
+ detachAttachedSession("start_command_reset");
21
23
  foregroundSessionState.clearAll("start_command_reset");
22
24
  assistantRunState.clearAll("start_command_reset");
23
25
  clearSession();
@@ -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);