@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 +15 -1
- package/dist/attach/manager.js +66 -0
- package/dist/attach/service.js +166 -0
- package/dist/bot/commands/abort.js +0 -4
- package/dist/bot/commands/commands.js +13 -3
- package/dist/bot/commands/new.js +10 -18
- package/dist/bot/commands/sessions.js +19 -20
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/handlers/prompt.js +26 -31
- package/dist/bot/index.js +76 -8
- package/dist/bot/middleware/interaction-guard.js +1 -1
- package/dist/bot/utils/busy-guard.js +3 -2
- package/dist/bot/utils/external-user-input.js +46 -0
- package/dist/bot/utils/switch-project.js +2 -0
- package/dist/external-input/suppression.js +54 -0
- package/dist/i18n/de.js +15 -0
- package/dist/i18n/en.js +15 -0
- package/dist/i18n/es.js +15 -0
- package/dist/i18n/fr.js +15 -0
- package/dist/i18n/ru.js +15 -0
- package/dist/i18n/zh.js +15 -0
- package/dist/interaction/guard.js +2 -1
- package/dist/opencode/events.js +13 -1
- package/dist/pinned/manager.js +18 -0
- package/dist/summary/aggregator.js +54 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
7
|
[](https://x.com/grin_rus)
|
|
8
|
+
[](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
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
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,
|
package/dist/bot/commands/new.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
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:
|
|
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);
|