@grinev/opencode-telegram-bot 0.17.0 → 0.19.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 (39) hide show
  1. package/.env.example +22 -1
  2. package/README.md +35 -6
  3. package/dist/app/start-bot-app.js +4 -0
  4. package/dist/attach/manager.js +66 -0
  5. package/dist/attach/service.js +171 -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 +1 -0
  9. package/dist/bot/commands/mcps.js +394 -0
  10. package/dist/bot/commands/new.js +10 -18
  11. package/dist/bot/commands/opencode-start.js +2 -10
  12. package/dist/bot/commands/sessions.js +19 -20
  13. package/dist/bot/commands/start.js +2 -0
  14. package/dist/bot/handlers/prompt.js +26 -31
  15. package/dist/bot/index.js +82 -10
  16. package/dist/bot/middleware/interaction-guard.js +1 -1
  17. package/dist/bot/utils/busy-guard.js +3 -2
  18. package/dist/bot/utils/external-user-input.js +46 -0
  19. package/dist/bot/utils/switch-project.js +2 -0
  20. package/dist/config.js +25 -6
  21. package/dist/external-input/suppression.js +54 -0
  22. package/dist/i18n/de.js +34 -0
  23. package/dist/i18n/en.js +34 -0
  24. package/dist/i18n/es.js +34 -0
  25. package/dist/i18n/fr.js +34 -0
  26. package/dist/i18n/ru.js +34 -0
  27. package/dist/i18n/zh.js +34 -0
  28. package/dist/interaction/guard.js +2 -1
  29. package/dist/opencode/auto-restart.js +92 -0
  30. package/dist/opencode/events.js +13 -1
  31. package/dist/opencode/process.js +18 -1
  32. package/dist/pinned/manager.js +39 -0
  33. package/dist/summary/aggregator.js +89 -9
  34. package/dist/summary/formatter.js +2 -2
  35. package/dist/summary/markdown-to-telegram-v2.js +99 -0
  36. package/dist/summary/subagent-formatter.js +23 -2
  37. package/dist/telegram/render/inline-renderer.js +18 -0
  38. package/dist/tts/client.js +94 -19
  39. package/package.json +2 -2
package/.env.example CHANGED
@@ -15,6 +15,13 @@ TELEGRAM_ALLOWED_USER_ID=
15
15
  # OpenCode API URL (optional, default: http://localhost:4096)
16
16
  # OPENCODE_API_URL=http://localhost:4096
17
17
 
18
+ # Automatically restart a local OpenCode server when health-checks fail (default: false)
19
+ # Only works when OPENCODE_API_URL points to localhost / 127.0.0.1 / ::1 / 0.0.0.0.
20
+ # OPENCODE_AUTO_RESTART_ENABLED=false
21
+
22
+ # OpenCode health monitor interval in seconds when auto-restart is enabled (default: 300)
23
+ # OPENCODE_MONITOR_INTERVAL_SEC=300
24
+
18
25
  # OpenCode Server Authentication (optional)
19
26
  # OPENCODE_SERVER_USERNAME=opencode
20
27
  # OPENCODE_SERVER_PASSWORD=
@@ -105,8 +112,22 @@ OPENCODE_MODEL_ID=big-pickle
105
112
 
106
113
  # Text-to-Speech credentials (optional)
107
114
  # TTS reply behavior is controlled globally with /tts and persisted in settings.json.
108
- # Set both TTS_API_URL and TTS_API_KEY to enable audio replies.
115
+ # Provider: "openai" (default) or "google".
116
+ #
117
+ # --- OpenAI-compatible (default) ---
118
+ # Set TTS_API_URL and TTS_API_KEY to any OpenAI-compatible TTS endpoint.
109
119
  # TTS_API_URL=
110
120
  # TTS_API_KEY=
111
121
  # TTS_MODEL=gpt-4o-mini-tts
112
122
  # TTS_VOICE=alloy
123
+ #
124
+ # --- Google Cloud TTS ---
125
+ # 1. Create a project at https://console.cloud.google.com
126
+ # 2. Enable "Cloud Text-to-Speech API"
127
+ # 3. Create a Service Account (IAM & Admin > Service Accounts)
128
+ # with role "Cloud Text-to-Speech API User"
129
+ # 4. Download the JSON key file
130
+ # Free tier: 1M characters/month (WaveNet/Neural2), 4M/month (Standard voices)
131
+ # TTS_PROVIDER=google
132
+ # TTS_VOICE=en-US-Studio-O
133
+ # GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
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
@@ -136,6 +138,7 @@ opencode-telegram config
136
138
  | `/rename` | Rename the current session |
137
139
  | `/commands` | Browse and run custom commands |
138
140
  | `/skills` | Browse and run OpenCode skills |
141
+ | `/mcps` | Browse and toggle MCP servers |
139
142
  | `/task` | Create a scheduled task |
140
143
  | `/tasklist` | Browse and delete scheduled tasks |
141
144
  | `/opencode_start` | Start the local OpenCode server on the bot machine |
@@ -158,6 +161,18 @@ Scheduled tasks let you prepare prompts in advance and run them automatically la
158
161
  - 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
162
  - Up to 10 scheduled tasks can exist at once by default; change this with `TASK_LIMIT` in your `.env`
160
163
 
164
+ ## Track Existing Session
165
+
166
+ 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.
167
+
168
+ 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.
169
+
170
+ - **Single TUI, simplest setup** — start OpenCode on a fixed port: `opencode --port 4096`
171
+ - Point the bot to `http://127.0.0.1:4096`, then select or create the same session in Telegram
172
+ - **Multiple TUI clients, shared backend** — start one backend: `opencode serve --port 4096`
173
+ - In each terminal client, connect with: `opencode attach http://127.0.0.1:4096`
174
+ - In the bot, select or create the same session to start tracking it automatically
175
+
161
176
  ## Configuration
162
177
 
163
178
  ### Localization
@@ -180,6 +195,8 @@ When installed via npm, the configuration wizard handles the initial setup. The
180
195
  | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
181
196
  | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
182
197
  | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
198
+ | `OPENCODE_AUTO_RESTART_ENABLED` | Automatically restart a local OpenCode server when health-checks fail | No | `false` |
199
+ | `OPENCODE_MONITOR_INTERVAL_SEC` | Health monitor interval in seconds when OpenCode auto-restart is enabled | No | `300` |
183
200
  | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
184
201
  | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
185
202
  | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
@@ -204,10 +221,12 @@ When installed via npm, the configuration wizard handles the initial setup. The
204
221
  | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
205
222
  | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
206
223
  | `STT_NOTE_PROMPT` | Optional note prepended to the LLM prompt as `[Note: ...]` for voice transcriptions; empty / `false` / `0` disable it | No | — |
207
- | `TTS_API_URL` | TTS API base URL | No | |
208
- | `TTS_API_KEY` | TTS API key | No | — |
209
- | `TTS_MODEL` | TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
210
- | `TTS_VOICE` | OpenAI-compatible TTS voice name | No | `alloy` |
224
+ | `TTS_PROVIDER` | TTS provider: `openai` for OpenAI-compatible APIs or `google` for Google Cloud TTS | No | `openai` |
225
+ | `TTS_API_URL` | OpenAI-compatible TTS API base URL | No | — |
226
+ | `TTS_API_KEY` | OpenAI-compatible TTS API key | No | |
227
+ | `TTS_MODEL` | OpenAI-compatible TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
228
+ | `TTS_VOICE` | TTS voice name. Defaults to `alloy` for OpenAI-compatible APIs and `en-US-Studio-O` for Google Cloud TTS | No | provider-specific |
229
+ | `GOOGLE_APPLICATION_CREDENTIALS` | Path to a Google Cloud service account JSON key file for `TTS_PROVIDER=google` | No | — |
211
230
  | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
212
231
  | `LOG_RETENTION` | Number of log files to keep: launch files in `sources`, daily files in `installed` | No | `10` |
213
232
 
@@ -228,15 +247,24 @@ If `STT_NOTE_PROMPT` is set to a non-empty value other than `false` or `0`, the
228
247
 
229
248
  If TTS credentials are configured, you can toggle spoken replies globally with `/tts`. The preference is stored in `settings.json` and persists across restarts.
230
249
 
231
- TTS configuration example:
250
+ OpenAI-compatible TTS configuration example:
232
251
 
233
252
  ```env
253
+ TTS_PROVIDER=openai
234
254
  TTS_API_URL=https://api.openai.com/v1
235
255
  TTS_API_KEY=your-tts-api-key
236
256
  TTS_MODEL=gpt-4o-mini-tts
237
257
  TTS_VOICE=alloy
238
258
  ```
239
259
 
260
+ Google Cloud TTS configuration example:
261
+
262
+ ```env
263
+ TTS_PROVIDER=google
264
+ TTS_VOICE=en-US-Studio-O
265
+ GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
266
+ ```
267
+
240
268
  Supported provider examples (Whisper-compatible):
241
269
 
242
270
  - **OpenAI**
@@ -312,6 +340,7 @@ npm run dev
312
340
 
313
341
  - Ensure an OpenCode server is running at the configured `OPENCODE_API_URL` (default: `http://localhost:4096`)
314
342
  - For a local setup, you can start it with `opencode serve` or use `/opencode_start` in Telegram
343
+ - For VPS/systemd setups with scheduled tasks, enable `OPENCODE_AUTO_RESTART_ENABLED=true` to let the bot restart a local OpenCode server when health-checks fail
315
344
  - If `OPENCODE_API_URL` points to a remote server, verify that the address is reachable from the bot machine and that the remote server is healthy
316
345
 
317
346
  **No models in model picker**
@@ -330,7 +359,7 @@ Please follow commit and release note conventions in [CONTRIBUTING.md](CONTRIBUT
330
359
 
331
360
  ## Community
332
361
 
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).
362
+ 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
363
 
335
364
  ## License
336
365
 
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { cleanupBotRuntime, createBot } from "../bot/index.js";
4
4
  import { config } from "../config.js";
5
+ import { opencodeAutoRestartService } from "../opencode/auto-restart.js";
5
6
  import { loadSettings } from "../settings/manager.js";
6
7
  import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
7
8
  import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
@@ -39,6 +40,7 @@ export async function startBotApp() {
39
40
  logger.debug(`[Runtime] Application start mode: ${mode}`);
40
41
  await loadSettings();
41
42
  await reconcileStoredModelSelection();
43
+ await opencodeAutoRestartService.start();
42
44
  await warmupSessionDirectoryCache();
43
45
  const bot = createBot();
44
46
  await scheduledTaskRuntime.initialize(bot);
@@ -73,6 +75,7 @@ export async function startBotApp() {
73
75
  shutdownStarted = true;
74
76
  logger.info(`[App] Received ${signal}, shutting down...`);
75
77
  cleanupBotRuntime(`app_shutdown_${signal.toLowerCase()}`);
78
+ opencodeAutoRestartService.stop();
76
79
  scheduledTaskRuntime.shutdown();
77
80
  shutdownTimeout = setTimeout(() => {
78
81
  logger.warn(`[App] Shutdown did not finish in ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit.`);
@@ -114,6 +117,7 @@ export async function startBotApp() {
114
117
  shutdownTimeout = null;
115
118
  }
116
119
  cleanupBotRuntime("app_shutdown_complete");
120
+ opencodeAutoRestartService.stop();
117
121
  scheduledTaskRuntime.shutdown();
118
122
  await clearManagedServiceState().catch((error) => {
119
123
  logger.warn("[App] Failed to clear managed service state", error);
@@ -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,171 @@
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
+ if (pinnedState.messageId && pinnedState.sessionId === null) {
31
+ await pinnedMessageManager.restoreExistingSession(session.id, session.title);
32
+ }
33
+ else {
34
+ await pinnedMessageManager.onSessionChange(session.id, session.title);
35
+ }
36
+ await pinnedMessageManager.loadContextFromHistory(session.id, session.directory);
37
+ const contextInfo = pinnedMessageManager.getContextInfo();
38
+ if (contextInfo) {
39
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
40
+ }
41
+ }
42
+ async function syncPinnedAttachState() {
43
+ if (!pinnedMessageManager.isInitialized()) {
44
+ return;
45
+ }
46
+ const attached = attachManager.getSnapshot();
47
+ await pinnedMessageManager.setAttachState(attached !== null, attached?.busy ?? false);
48
+ }
49
+ async function restorePendingQuestion(bot, chatId, sessionId, directory) {
50
+ const { data, error } = await opencodeClient.question.list({
51
+ directory,
52
+ });
53
+ if (error || !data) {
54
+ logger.warn("[Attach] Failed to load pending questions during attach:", error);
55
+ return false;
56
+ }
57
+ const pendingQuestion = data.find((request) => request.sessionID === sessionId);
58
+ if (!pendingQuestion) {
59
+ return false;
60
+ }
61
+ questionManager.startQuestions(pendingQuestion.questions, pendingQuestion.id);
62
+ await showCurrentQuestion(bot.api, chatId);
63
+ return true;
64
+ }
65
+ async function restorePendingPermissions(bot, chatId, sessionId, directory) {
66
+ const { data, error } = await opencodeClient.permission.list({
67
+ directory,
68
+ });
69
+ if (error || !data) {
70
+ logger.warn("[Attach] Failed to load pending permissions during attach:", error);
71
+ return 0;
72
+ }
73
+ const pendingPermissions = data.filter((request) => request.sessionID === sessionId);
74
+ for (const request of pendingPermissions) {
75
+ await showPermissionRequest(bot.api, chatId, request);
76
+ }
77
+ return pendingPermissions.length;
78
+ }
79
+ export async function attachToSession(deps) {
80
+ const { bot, chatId, session, ensureEventSubscription } = deps;
81
+ const alreadyAttached = attachManager.isAttachedSession(session.id, session.directory);
82
+ await ensureAttachPinnedSession({
83
+ api: bot.api,
84
+ chatId,
85
+ session,
86
+ });
87
+ if (!alreadyAttached) {
88
+ await ensureEventSubscription(session.directory);
89
+ summaryAggregator.setSession(session.id);
90
+ summaryAggregator.setBotAndChatId(bot, chatId);
91
+ attachManager.attach(session.id, session.directory);
92
+ }
93
+ else {
94
+ summaryAggregator.setSession(session.id);
95
+ summaryAggregator.setBotAndChatId(bot, chatId);
96
+ }
97
+ const { data: statuses, error: statusesError } = await opencodeClient.session.status({
98
+ directory: session.directory,
99
+ });
100
+ if (statusesError) {
101
+ logger.warn("[Attach] Failed to load session status during attach:", statusesError);
102
+ }
103
+ const busy = getAttachBusyStatus(session.id, statuses);
104
+ if (busy) {
105
+ attachManager.markBusy(session.id);
106
+ }
107
+ else {
108
+ attachManager.markIdle(session.id);
109
+ }
110
+ await syncPinnedAttachState();
111
+ let restoredQuestion = false;
112
+ let restoredPermissions = 0;
113
+ if (!alreadyAttached && !questionManager.isActive() && !permissionManager.isActive()) {
114
+ restoredQuestion = await restorePendingQuestion(bot, chatId, session.id, session.directory);
115
+ if (!restoredQuestion) {
116
+ restoredPermissions = await restorePendingPermissions(bot, chatId, session.id, session.directory);
117
+ }
118
+ }
119
+ return {
120
+ busy,
121
+ alreadyAttached,
122
+ restoredQuestion,
123
+ restoredPermissions,
124
+ };
125
+ }
126
+ export async function restoreAttachedCurrentSession(deps) {
127
+ const currentProject = getCurrentProject();
128
+ const currentSession = getCurrentSession();
129
+ if (!currentProject || !currentSession) {
130
+ return false;
131
+ }
132
+ if (currentSession.directory !== currentProject.worktree) {
133
+ logger.warn(`[Attach] Skipping auto-restore because project/session mismatch: sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}`);
134
+ return false;
135
+ }
136
+ try {
137
+ await attachToSession({
138
+ bot: deps.bot,
139
+ chatId: deps.chatId,
140
+ session: currentSession,
141
+ ensureEventSubscription: deps.ensureEventSubscription,
142
+ });
143
+ logger.info(`[Attach] Restored followed session on startup: session=${currentSession.id}, directory=${currentSession.directory}`);
144
+ return true;
145
+ }
146
+ catch (error) {
147
+ logger.error("[Attach] Failed to restore followed session on startup:", error);
148
+ return false;
149
+ }
150
+ }
151
+ export function detachAttachedSession(reason) {
152
+ if (!attachManager.isAttached()) {
153
+ return;
154
+ }
155
+ stopEventListening();
156
+ summaryAggregator.clear();
157
+ attachManager.clear(reason);
158
+ void syncPinnedAttachState();
159
+ }
160
+ export async function markAttachedSessionBusy(sessionId) {
161
+ if (!attachManager.markBusy(sessionId)) {
162
+ return;
163
+ }
164
+ await syncPinnedAttachState();
165
+ }
166
+ export async function markAttachedSessionIdle(sessionId) {
167
+ if (!attachManager.markIdle(sessionId)) {
168
+ return;
169
+ }
170
+ await syncPinnedAttachState();
171
+ }
@@ -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,
@@ -16,6 +16,7 @@ const COMMAND_DEFINITIONS = [
16
16
  { command: "rename", descriptionKey: "cmd.description.rename" },
17
17
  { command: "commands", descriptionKey: "cmd.description.commands" },
18
18
  { command: "skills", descriptionKey: "cmd.description.skills" },
19
+ { command: "mcps", descriptionKey: "cmd.description.mcps" },
19
20
  { command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
20
21
  { command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
21
22
  { command: "open", descriptionKey: "cmd.description.open" },