@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.
- package/.env.example +22 -1
- package/README.md +35 -6
- package/dist/app/start-bot-app.js +4 -0
- package/dist/attach/manager.js +66 -0
- package/dist/attach/service.js +171 -0
- package/dist/bot/commands/abort.js +0 -4
- package/dist/bot/commands/commands.js +13 -3
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/mcps.js +394 -0
- package/dist/bot/commands/new.js +10 -18
- package/dist/bot/commands/opencode-start.js +2 -10
- 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 +82 -10
- 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/config.js +25 -6
- package/dist/external-input/suppression.js +54 -0
- package/dist/i18n/de.js +34 -0
- package/dist/i18n/en.js +34 -0
- package/dist/i18n/es.js +34 -0
- package/dist/i18n/fr.js +34 -0
- package/dist/i18n/ru.js +34 -0
- package/dist/i18n/zh.js +34 -0
- package/dist/interaction/guard.js +2 -1
- package/dist/opencode/auto-restart.js +92 -0
- package/dist/opencode/events.js +13 -1
- package/dist/opencode/process.js +18 -1
- package/dist/pinned/manager.js +39 -0
- package/dist/summary/aggregator.js +89 -9
- package/dist/summary/formatter.js +2 -2
- package/dist/summary/markdown-to-telegram-v2.js +99 -0
- package/dist/summary/subagent-formatter.js +23 -2
- package/dist/telegram/render/inline-renderer.js +18 -0
- package/dist/tts/client.js +94 -19
- 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
|
-
#
|
|
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)
|
|
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
|
|
@@ -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
|
-
| `
|
|
208
|
-
| `
|
|
209
|
-
| `
|
|
210
|
-
| `
|
|
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
|
|
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
|
|
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,
|
|
@@ -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" },
|