@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.
Files changed (46) hide show
  1. package/.env.example +5 -0
  2. package/README.md +72 -43
  3. package/dist/app/start-bot-app.js +0 -2
  4. package/dist/attach/manager.js +66 -0
  5. package/dist/attach/service.js +166 -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 +2 -0
  9. package/dist/bot/commands/new.js +10 -18
  10. package/dist/bot/commands/opencode-start.js +27 -24
  11. package/dist/bot/commands/opencode-stop.js +51 -23
  12. package/dist/bot/commands/projects.js +30 -6
  13. package/dist/bot/commands/sessions.js +19 -20
  14. package/dist/bot/commands/skills.js +376 -0
  15. package/dist/bot/commands/start.js +2 -0
  16. package/dist/bot/commands/status.js +21 -14
  17. package/dist/bot/commands/worktree.js +196 -0
  18. package/dist/bot/handlers/inline-menu.js +1 -0
  19. package/dist/bot/handlers/prompt.js +26 -31
  20. package/dist/bot/handlers/voice.js +8 -1
  21. package/dist/bot/index.js +90 -10
  22. package/dist/bot/middleware/interaction-guard.js +1 -1
  23. package/dist/bot/utils/busy-guard.js +3 -2
  24. package/dist/bot/utils/external-user-input.js +46 -0
  25. package/dist/bot/utils/switch-project.js +2 -0
  26. package/dist/config.js +2 -0
  27. package/dist/external-input/suppression.js +54 -0
  28. package/dist/git/worktree.js +158 -0
  29. package/dist/i18n/de.js +54 -1
  30. package/dist/i18n/en.js +54 -1
  31. package/dist/i18n/es.js +54 -1
  32. package/dist/i18n/fr.js +54 -1
  33. package/dist/i18n/ru.js +54 -1
  34. package/dist/i18n/zh.js +54 -1
  35. package/dist/interaction/guard.js +2 -1
  36. package/dist/opencode/events.js +13 -1
  37. package/dist/opencode/process.js +182 -0
  38. package/dist/pinned/manager.js +46 -61
  39. package/dist/project/manager.js +47 -32
  40. package/dist/runtime/bootstrap.js +119 -7
  41. package/dist/scheduled-task/executor.js +137 -7
  42. package/dist/settings/manager.js +9 -12
  43. package/dist/summary/aggregator.js +54 -9
  44. package/package.json +1 -1
  45. package/dist/process/manager.js +0 -273
  46. 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: 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,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
- - **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time
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
- Start the OpenCode server:
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 remotely |
134
- | `/opencode_stop` | Stop the OpenCode server remotely |
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
- > `/opencode_start` and `/opencode_stop` are intended as emergency commands for example, if you need to restart a stuck server while away from your computer. Under normal usage, start `opencode serve` yourself before launching the bot.
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 | Description | Required | Default |
168
- | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ------------------------ |
169
- | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
170
- | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
171
- | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
172
- | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
173
- | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
174
- | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
175
- | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
176
- | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
177
- | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
178
- | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
179
- | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
180
- | `OPEN_BROWSER_ROOTS` | Comma-separated paths `/open` is allowed to browse (supports `~`) | No | `~` (home directory) |
181
- | `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
182
- | `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
183
- | `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` |
184
- | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
185
- | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
186
- | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
187
- | `HIDE_TOOL_FILE_MESSAGES` | Hide file edit documents sent as `.txt` attachments (`edit_*.txt`, `write_*.txt`) | No | `false` |
188
- | `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
189
- | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
190
- | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
191
- | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | |
192
- | `STT_API_KEY` | API key for your STT provider | No | — |
193
- | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
194
- | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | |
195
- | `TTS_API_URL` | TTS API base URL | No | — |
196
- | `TTS_API_KEY` | TTS API key | No | — |
197
- | `TTS_MODEL` | TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
198
- | `TTS_VOICE` | OpenAI-compatible TTS voice name | No | `alloy` |
199
- | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
200
- | `LOG_RETENTION` | Number of log files to keep: launch files in `sources`, daily files in `installed` | No | `10` |
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 `opencode serve` is running in your project directory
300
- - Check that `OPENCODE_API_URL` points to the correct address (default: `http://localhost:4096`)
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 conversation in [GitHub Discussions](https://github.com/grinev/opencode-telegram-bot/discussions).
347
+ Have questions, want to share your experience using the bot, or have an idea for a feature? Join the [Telegram group](https://t.me/+Fj_IyKRi6-41MGUy) for announcements and discussions, or start a thread in [GitHub Discussions](https://github.com/grinev/opencode-telegram-bot/discussions).
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 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,
@@ -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" },