@grinev/opencode-telegram-bot 0.16.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -0
- package/README.md +72 -43
- package/dist/app/start-bot-app.js +0 -2
- 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/definitions.js +2 -0
- package/dist/bot/commands/new.js +10 -18
- package/dist/bot/commands/opencode-start.js +27 -24
- package/dist/bot/commands/opencode-stop.js +51 -23
- package/dist/bot/commands/projects.js +30 -6
- package/dist/bot/commands/sessions.js +19 -20
- package/dist/bot/commands/skills.js +376 -0
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/commands/status.js +21 -14
- package/dist/bot/commands/worktree.js +196 -0
- package/dist/bot/handlers/inline-menu.js +1 -0
- package/dist/bot/handlers/prompt.js +26 -31
- package/dist/bot/handlers/voice.js +8 -1
- package/dist/bot/index.js +90 -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 +2 -0
- package/dist/external-input/suppression.js +54 -0
- package/dist/git/worktree.js +158 -0
- package/dist/i18n/de.js +54 -1
- package/dist/i18n/en.js +54 -1
- package/dist/i18n/es.js +54 -1
- package/dist/i18n/fr.js +54 -1
- package/dist/i18n/ru.js +54 -1
- package/dist/i18n/zh.js +54 -1
- package/dist/interaction/guard.js +2 -1
- package/dist/opencode/events.js +13 -1
- package/dist/opencode/process.js +182 -0
- package/dist/pinned/manager.js +46 -61
- package/dist/project/manager.js +47 -32
- package/dist/runtime/bootstrap.js +119 -7
- package/dist/scheduled-task/executor.js +137 -7
- package/dist/settings/manager.js +9 -12
- package/dist/summary/aggregator.js +54 -9
- package/package.json +1 -1
- package/dist/process/manager.js +0 -273
- package/dist/process/types.js +0 -1
package/.env.example
CHANGED
|
@@ -51,6 +51,10 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
51
51
|
# Maximum number of scheduled tasks allowed at once (default: 10)
|
|
52
52
|
# TASK_LIMIT=10
|
|
53
53
|
|
|
54
|
+
# Maximum execution time for a scheduled task in minutes (default: 120)
|
|
55
|
+
# If exceeded, the bot stops waiting for the result and marks the run as failed.
|
|
56
|
+
# SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES=120
|
|
57
|
+
|
|
54
58
|
# Stream update throttle in milliseconds for assistant/tool message edits (default: 500)
|
|
55
59
|
# Higher value = fewer Telegram edit requests, lower value = more real-time updates
|
|
56
60
|
# RESPONSE_STREAM_THROTTLE_MS=500
|
|
@@ -97,6 +101,7 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
97
101
|
# STT_API_KEY=
|
|
98
102
|
# STT_MODEL=
|
|
99
103
|
# STT_LANGUAGE=
|
|
104
|
+
# STT_NOTE_PROMPT="The following text is transcribed from voice. It may contain homophone or phonetic errors. Infer the intended meaning from context."
|
|
100
105
|
|
|
101
106
|
# Text-to-Speech credentials (optional)
|
|
102
107
|
# TTS reply behavior is controlled globally with /tts and persisted in settings.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,17 +27,20 @@ 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
|
|
29
|
-
- **
|
|
30
|
+
- **Track live session** — follow a live OpenCode CLI session; see [Track Existing Session](#track-existing-session)
|
|
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
|
|
32
34
|
- **Subagent activity** — watch live subagent progress in chat, including the current task, agent, model, and active tool step
|
|
33
35
|
- **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
|
|
36
|
+
- **Skills Catalog** — browse OpenCode skills from an inline menu and run them immediately or with arguments in the next message
|
|
34
37
|
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
|
|
35
38
|
- **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, and optionally enable spoken replies with `/tts`
|
|
36
39
|
- **File attachments** — send images, PDF documents, and any text-based files to OpenCode (code, logs, configs etc.)
|
|
37
40
|
- **Scheduled tasks** — schedule prompts to run later or on a recurring interval; see [Scheduled Tasks](#scheduled-tasks)
|
|
38
41
|
- **Context control** — compact context when it gets too large, right from the chat
|
|
39
42
|
- **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions
|
|
43
|
+
- **Git worktree switching** — browse and switch between existing git worktrees for the current repository with `/worktree`
|
|
40
44
|
- **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
|
|
41
45
|
- **Localization** — UI localization is supported for multiple languages (`BOT_LOCALE`)
|
|
42
46
|
|
|
@@ -60,13 +64,15 @@ You'll also need your **Telegram User ID** — send any message to [@userinfobot
|
|
|
60
64
|
|
|
61
65
|
### 2. Start OpenCode Server
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
Run the OpenCode server on the same machine where the bot runs:
|
|
64
68
|
|
|
65
69
|
```bash
|
|
66
70
|
opencode serve
|
|
67
71
|
```
|
|
68
72
|
|
|
69
|
-
> The bot connects to the OpenCode API at `http://localhost:4096` by default.
|
|
73
|
+
> The bot connects to the local OpenCode API at `http://localhost:4096` by default.
|
|
74
|
+
|
|
75
|
+
> After the bot is configured, you can also start and stop the local OpenCode server from Telegram with `/opencode_start` and `/opencode_stop`.
|
|
70
76
|
|
|
71
77
|
### 3. Install & Run
|
|
72
78
|
|
|
@@ -76,6 +82,8 @@ The fastest way — run directly with `npx`:
|
|
|
76
82
|
npx @grinev/opencode-telegram-bot@latest
|
|
77
83
|
```
|
|
78
84
|
|
|
85
|
+
> **Note:** This README tracks the `main` branch, which may include unreleased changes. The latest npm release may not include every feature described here yet. See [recent commits on `main`](https://github.com/grinev/opencode-telegram-bot/commits/main).
|
|
86
|
+
|
|
79
87
|
> Quick start is for npm usage. You do not need to clone this repository. If you run this command from the source directory (repository root), it may fail with `opencode-telegram: not found`. To run from sources, use the [Development](#development) section.
|
|
80
88
|
|
|
81
89
|
On first launch, an interactive wizard will guide you through the configuration — it asks for interface language first, then your bot token, user ID, OpenCode API URL, and optional OpenCode server credentials (username/password). After that, you're ready to go. Open your bot in Telegram and start sending tasks.
|
|
@@ -124,19 +132,21 @@ opencode-telegram config
|
|
|
124
132
|
| `/abort` | Abort the current task |
|
|
125
133
|
| `/sessions` | Browse and switch between recent sessions |
|
|
126
134
|
| `/projects` | Switch between OpenCode projects |
|
|
135
|
+
| `/worktree` | Switch between existing git worktrees |
|
|
127
136
|
| `/open` | Add a project by browsing directories |
|
|
128
137
|
| `/tts` | Toggle audio replies |
|
|
129
138
|
| `/rename` | Rename the current session |
|
|
130
139
|
| `/commands` | Browse and run custom commands |
|
|
140
|
+
| `/skills` | Browse and run OpenCode skills |
|
|
131
141
|
| `/task` | Create a scheduled task |
|
|
132
142
|
| `/tasklist` | Browse and delete scheduled tasks |
|
|
133
|
-
| `/opencode_start` | Start the OpenCode server
|
|
134
|
-
| `/opencode_stop` | Stop the OpenCode server
|
|
143
|
+
| `/opencode_start` | Start the local OpenCode server on the bot machine |
|
|
144
|
+
| `/opencode_stop` | Stop the local OpenCode server on the bot machine |
|
|
135
145
|
| `/help` | Show available commands |
|
|
136
146
|
|
|
137
147
|
Any regular text message is sent as a prompt to the coding agent only when no blocking interaction is active. Voice/audio messages are transcribed and then sent as prompts when STT is configured.
|
|
138
148
|
|
|
139
|
-
|
|
149
|
+
When the current project is a git repository, `/worktree` shows the existing worktrees for that repository. Status and pinned updates display the main project path with the active branch, and show a separate `Worktree` line when a linked worktree is selected.
|
|
140
150
|
|
|
141
151
|
## Scheduled Tasks
|
|
142
152
|
|
|
@@ -146,8 +156,22 @@ Scheduled tasks let you prepare prompts in advance and run them automatically la
|
|
|
146
156
|
- Scheduled executions currently always run with the `build` agent
|
|
147
157
|
- Tasks run outside your active chat session, so they do not interrupt or affect the current session flow
|
|
148
158
|
- The minimum recurring interval is 5 minutes
|
|
159
|
+
- If a recurring task is still running when its next interval arrives, the bot does not start a parallel copy of the same task and does not replay missed intervals later
|
|
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
|
|
149
161
|
- Up to 10 scheduled tasks can exist at once by default; change this with `TASK_LIMIT` in your `.env`
|
|
150
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
|
+
|
|
151
175
|
## Configuration
|
|
152
176
|
|
|
153
177
|
### Localization
|
|
@@ -164,40 +188,42 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
164
188
|
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
|
|
165
189
|
- **Linux:** `~/.config/opencode-telegram-bot/.env`
|
|
166
190
|
|
|
167
|
-
| Variable
|
|
168
|
-
|
|
|
169
|
-
| `TELEGRAM_BOT_TOKEN`
|
|
170
|
-
| `TELEGRAM_ALLOWED_USER_ID`
|
|
171
|
-
| `TELEGRAM_PROXY_URL`
|
|
172
|
-
| `OPENCODE_API_URL`
|
|
173
|
-
| `OPENCODE_SERVER_USERNAME`
|
|
174
|
-
| `OPENCODE_SERVER_PASSWORD`
|
|
175
|
-
| `OPENCODE_MODEL_PROVIDER`
|
|
176
|
-
| `OPENCODE_MODEL_ID`
|
|
177
|
-
| `BOT_LOCALE`
|
|
178
|
-
| `SESSIONS_LIST_LIMIT`
|
|
179
|
-
| `PROJECTS_LIST_LIMIT`
|
|
180
|
-
| `OPEN_BROWSER_ROOTS`
|
|
181
|
-
| `COMMANDS_LIST_LIMIT`
|
|
182
|
-
| `TASK_LIMIT`
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
192
|
-
| `
|
|
193
|
-
| `
|
|
194
|
-
| `
|
|
195
|
-
| `
|
|
196
|
-
| `
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
200
|
-
| `
|
|
191
|
+
| Variable | Description | Required | Default |
|
|
192
|
+
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | :------: | ------------------------ |
|
|
193
|
+
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
|
|
194
|
+
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
|
|
195
|
+
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
|
|
196
|
+
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
|
|
197
|
+
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
|
|
198
|
+
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
|
|
199
|
+
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
|
|
200
|
+
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
|
|
201
|
+
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
|
|
202
|
+
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
|
|
203
|
+
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
|
|
204
|
+
| `OPEN_BROWSER_ROOTS` | Comma-separated paths `/open` is allowed to browse (supports `~`) | No | `~` (home directory) |
|
|
205
|
+
| `COMMANDS_LIST_LIMIT` | Items per page in `/commands` and `/skills` | No | `10` |
|
|
206
|
+
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
|
|
207
|
+
| `SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES` | Maximum time the bot waits for one scheduled task run before marking it failed | No | `120` |
|
|
208
|
+
| `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` |
|
|
209
|
+
| `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
|
|
210
|
+
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
211
|
+
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
212
|
+
| `HIDE_TOOL_FILE_MESSAGES` | Hide file edit documents sent as `.txt` attachments (`edit_*.txt`, `write_*.txt`) | No | `false` |
|
|
213
|
+
| `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
|
|
214
|
+
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
|
|
215
|
+
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
|
|
216
|
+
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
|
|
217
|
+
| `STT_API_KEY` | API key for your STT provider | No | — |
|
|
218
|
+
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
|
|
219
|
+
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
|
|
220
|
+
| `STT_NOTE_PROMPT` | Optional note prepended to the LLM prompt as `[Note: ...]` for voice transcriptions; empty / `false` / `0` disable it | No | — |
|
|
221
|
+
| `TTS_API_URL` | TTS API base URL | No | — |
|
|
222
|
+
| `TTS_API_KEY` | TTS API key | No | — |
|
|
223
|
+
| `TTS_MODEL` | TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
|
|
224
|
+
| `TTS_VOICE` | OpenAI-compatible TTS voice name | No | `alloy` |
|
|
225
|
+
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
226
|
+
| `LOG_RETENTION` | Number of log files to keep: launch files in `sources`, daily files in `installed` | No | `10` |
|
|
201
227
|
|
|
202
228
|
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
|
|
203
229
|
|
|
@@ -212,6 +238,8 @@ If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
|
|
|
212
238
|
3. Show recognized text in chat
|
|
213
239
|
4. Send the recognized text to OpenCode as a normal prompt
|
|
214
240
|
|
|
241
|
+
If `STT_NOTE_PROMPT` is set to a non-empty value other than `false` or `0`, the bot prepends `[Note: ...]` to the transcription before sending it to the LLM. The recognized text shown in Telegram stays unchanged.
|
|
242
|
+
|
|
215
243
|
If TTS credentials are configured, you can toggle spoken replies globally with `/tts`. The preference is stored in `settings.json` and persists across restarts.
|
|
216
244
|
|
|
217
245
|
TTS configuration example:
|
|
@@ -296,8 +324,9 @@ npm run dev
|
|
|
296
324
|
|
|
297
325
|
**"OpenCode server is not available"**
|
|
298
326
|
|
|
299
|
-
- Ensure
|
|
300
|
-
-
|
|
327
|
+
- Ensure an OpenCode server is running at the configured `OPENCODE_API_URL` (default: `http://localhost:4096`)
|
|
328
|
+
- For a local setup, you can start it with `opencode serve` or use `/opencode_start` in Telegram
|
|
329
|
+
- 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
|
|
301
330
|
|
|
302
331
|
**No models in model picker**
|
|
303
332
|
|
|
@@ -315,7 +344,7 @@ Please follow commit and release note conventions in [CONTRIBUTING.md](CONTRIBUT
|
|
|
315
344
|
|
|
316
345
|
## Community
|
|
317
346
|
|
|
318
|
-
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).
|
|
319
348
|
|
|
320
349
|
## License
|
|
321
350
|
|
|
@@ -3,7 +3,6 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { cleanupBotRuntime, createBot } from "../bot/index.js";
|
|
4
4
|
import { config } from "../config.js";
|
|
5
5
|
import { loadSettings } from "../settings/manager.js";
|
|
6
|
-
import { processManager } from "../process/manager.js";
|
|
7
6
|
import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
|
|
8
7
|
import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
|
|
9
8
|
import { reconcileStoredModelSelection } from "../model/manager.js";
|
|
@@ -39,7 +38,6 @@ export async function startBotApp() {
|
|
|
39
38
|
logger.info(`Allowed User ID: ${config.telegram.allowedUserId}`);
|
|
40
39
|
logger.debug(`[Runtime] Application start mode: ${mode}`);
|
|
41
40
|
await loadSettings();
|
|
42
|
-
await processManager.initialize();
|
|
43
41
|
await reconcileStoredModelSelection();
|
|
44
42
|
await warmupSessionDirectoryCache();
|
|
45
43
|
const bot = createBot();
|
|
@@ -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,
|
|
@@ -10,10 +10,12 @@ const COMMAND_DEFINITIONS = [
|
|
|
10
10
|
{ command: "sessions", descriptionKey: "cmd.description.sessions" },
|
|
11
11
|
{ command: "tts", descriptionKey: "cmd.description.tts" },
|
|
12
12
|
{ command: "projects", descriptionKey: "cmd.description.projects" },
|
|
13
|
+
{ command: "worktree", descriptionKey: "cmd.description.worktree" },
|
|
13
14
|
{ command: "task", descriptionKey: "cmd.description.task" },
|
|
14
15
|
{ command: "tasklist", descriptionKey: "cmd.description.tasklist" },
|
|
15
16
|
{ command: "rename", descriptionKey: "cmd.description.rename" },
|
|
16
17
|
{ command: "commands", descriptionKey: "cmd.description.commands" },
|
|
18
|
+
{ command: "skills", descriptionKey: "cmd.description.skills" },
|
|
17
19
|
{ command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
|
|
18
20
|
{ command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
|
|
19
21
|
{ command: "open", descriptionKey: "cmd.description.open" },
|