@grinev/opencode-telegram-bot 0.16.1 → 0.17.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 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
@@ -26,17 +26,19 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
26
26
 
27
27
  - **Remote coding** — send prompts to OpenCode from anywhere, receive complete results with code sent as files
28
28
  - **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
29
+ - **Live status** — pinned message with current project/worktree, model, context usage, and changed files list, updated in real time
30
30
  - **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
31
31
  - **Agent modes** — switch between Plan and Build modes on the fly
32
32
  - **Subagent activity** — watch live subagent progress in chat, including the current task, agent, model, and active tool step
33
33
  - **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
34
+ - **Skills Catalog** — browse OpenCode skills from an inline menu and run them immediately or with arguments in the next message
34
35
  - **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
35
36
  - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, and optionally enable spoken replies with `/tts`
36
37
  - **File attachments** — send images, PDF documents, and any text-based files to OpenCode (code, logs, configs etc.)
37
38
  - **Scheduled tasks** — schedule prompts to run later or on a recurring interval; see [Scheduled Tasks](#scheduled-tasks)
38
39
  - **Context control** — compact context when it gets too large, right from the chat
39
40
  - **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions
41
+ - **Git worktree switching** — browse and switch between existing git worktrees for the current repository with `/worktree`
40
42
  - **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
41
43
  - **Localization** — UI localization is supported for multiple languages (`BOT_LOCALE`)
42
44
 
@@ -60,13 +62,15 @@ You'll also need your **Telegram User ID** — send any message to [@userinfobot
60
62
 
61
63
  ### 2. Start OpenCode Server
62
64
 
63
- Start the OpenCode server:
65
+ Run the OpenCode server on the same machine where the bot runs:
64
66
 
65
67
  ```bash
66
68
  opencode serve
67
69
  ```
68
70
 
69
- > The bot connects to the OpenCode API at `http://localhost:4096` by default.
71
+ > The bot connects to the local OpenCode API at `http://localhost:4096` by default.
72
+
73
+ > After the bot is configured, you can also start and stop the local OpenCode server from Telegram with `/opencode_start` and `/opencode_stop`.
70
74
 
71
75
  ### 3. Install & Run
72
76
 
@@ -76,6 +80,8 @@ The fastest way — run directly with `npx`:
76
80
  npx @grinev/opencode-telegram-bot@latest
77
81
  ```
78
82
 
83
+ > **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).
84
+
79
85
  > 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
86
 
81
87
  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 +130,21 @@ opencode-telegram config
124
130
  | `/abort` | Abort the current task |
125
131
  | `/sessions` | Browse and switch between recent sessions |
126
132
  | `/projects` | Switch between OpenCode projects |
133
+ | `/worktree` | Switch between existing git worktrees |
127
134
  | `/open` | Add a project by browsing directories |
128
135
  | `/tts` | Toggle audio replies |
129
136
  | `/rename` | Rename the current session |
130
137
  | `/commands` | Browse and run custom commands |
138
+ | `/skills` | Browse and run OpenCode skills |
131
139
  | `/task` | Create a scheduled task |
132
140
  | `/tasklist` | Browse and delete scheduled tasks |
133
- | `/opencode_start` | Start the OpenCode server remotely |
134
- | `/opencode_stop` | Stop the OpenCode server remotely |
141
+ | `/opencode_start` | Start the local OpenCode server on the bot machine |
142
+ | `/opencode_stop` | Stop the local OpenCode server on the bot machine |
135
143
  | `/help` | Show available commands |
136
144
 
137
145
  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
146
 
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.
147
+ 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
148
 
141
149
  ## Scheduled Tasks
142
150
 
@@ -146,6 +154,8 @@ Scheduled tasks let you prepare prompts in advance and run them automatically la
146
154
  - Scheduled executions currently always run with the `build` agent
147
155
  - Tasks run outside your active chat session, so they do not interrupt or affect the current session flow
148
156
  - The minimum recurring interval is 5 minutes
157
+ - 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
158
+ - 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
159
  - Up to 10 scheduled tasks can exist at once by default; change this with `TASK_LIMIT` in your `.env`
150
160
 
151
161
  ## Configuration
@@ -164,40 +174,42 @@ When installed via npm, the configuration wizard handles the initial setup. The
164
174
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
165
175
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
166
176
 
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` |
177
+ | Variable | Description | Required | Default |
178
+ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | :------: | ------------------------ |
179
+ | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
180
+ | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
181
+ | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
182
+ | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
183
+ | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
184
+ | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
185
+ | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
186
+ | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
187
+ | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
188
+ | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
189
+ | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
190
+ | `OPEN_BROWSER_ROOTS` | Comma-separated paths `/open` is allowed to browse (supports `~`) | No | `~` (home directory) |
191
+ | `COMMANDS_LIST_LIMIT` | Items per page in `/commands` and `/skills` | No | `10` |
192
+ | `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
193
+ | `SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES` | Maximum time the bot waits for one scheduled task run before marking it failed | No | `120` |
194
+ | `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` |
195
+ | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
196
+ | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
197
+ | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
198
+ | `HIDE_TOOL_FILE_MESSAGES` | Hide file edit documents sent as `.txt` attachments (`edit_*.txt`, `write_*.txt`) | No | `false` |
199
+ | `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
200
+ | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
201
+ | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
202
+ | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
203
+ | `STT_API_KEY` | API key for your STT provider | No | |
204
+ | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
205
+ | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
206
+ | `STT_NOTE_PROMPT` | Optional note prepended to the LLM prompt as `[Note: ...]` for voice transcriptions; empty / `false` / `0` disable it | No | — |
207
+ | `TTS_API_URL` | TTS API base URL | No | |
208
+ | `TTS_API_KEY` | TTS API key | No | |
209
+ | `TTS_MODEL` | TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
210
+ | `TTS_VOICE` | OpenAI-compatible TTS voice name | No | `alloy` |
211
+ | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
212
+ | `LOG_RETENTION` | Number of log files to keep: launch files in `sources`, daily files in `installed` | No | `10` |
201
213
 
202
214
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
203
215
 
@@ -212,6 +224,8 @@ If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
212
224
  3. Show recognized text in chat
213
225
  4. Send the recognized text to OpenCode as a normal prompt
214
226
 
227
+ 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.
228
+
215
229
  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
230
 
217
231
  TTS configuration example:
@@ -296,8 +310,9 @@ npm run dev
296
310
 
297
311
  **"OpenCode server is not available"**
298
312
 
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`)
313
+ - Ensure an OpenCode server is running at the configured `OPENCODE_API_URL` (default: `http://localhost:4096`)
314
+ - For a local setup, you can start it with `opencode serve` or use `/opencode_start` in Telegram
315
+ - 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
316
 
302
317
  **No models in model picker**
303
318
 
@@ -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();
@@ -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" },
@@ -1,5 +1,7 @@
1
+ import { spawn } from "node:child_process";
2
+ import { config } from "../../config.js";
1
3
  import { opencodeClient } from "../../opencode/client.js";
2
- import { processManager } from "../../process/manager.js";
4
+ import { resolveLocalOpencodeTarget } from "../../opencode/process.js";
3
5
  import { logger } from "../../utils/logger.js";
4
6
  import { t } from "../../i18n/index.js";
5
7
  import { editBotText } from "../utils/telegram-text.js";
@@ -31,43 +33,45 @@ async function waitForServerReady(maxWaitMs = 10000) {
31
33
  */
32
34
  export async function opencodeStartCommand(ctx) {
33
35
  try {
34
- // 1. Check if process is already running under our management
35
- if (processManager.isRunning()) {
36
- const uptime = processManager.getUptime();
37
- const uptimeStr = uptime ? Math.floor(uptime / 1000) : 0;
38
- await ctx.reply(t("opencode_start.already_running_managed", {
39
- pid: processManager.getPID() ?? "-",
40
- seconds: uptimeStr,
41
- }));
36
+ const localTarget = resolveLocalOpencodeTarget(config.opencode.apiUrl);
37
+ if (!localTarget) {
38
+ await ctx.reply(t("opencode_start.remote_configured"));
42
39
  return;
43
40
  }
44
- // 2. Check if server is accessible (external process)
41
+ // Check if server is already accessible.
45
42
  try {
46
43
  const { data, error } = await opencodeClient.global.health();
47
44
  if (!error && data?.healthy) {
48
- await ctx.reply(t("opencode_start.already_running_external", {
49
- version: data.version || t("common.unknown"),
50
- }));
45
+ await ctx.reply(t("opencode_start.already_running", { version: data.version || t("common.unknown") }));
51
46
  return;
52
47
  }
53
48
  }
54
49
  catch {
55
- // Server not accessible, continue with start
50
+ // Server not accessible, continue with start.
56
51
  }
57
- // 3. Notify user that we're starting the server
58
52
  const statusMessage = await ctx.reply(t("opencode_start.starting"));
59
- // 4. Start the process
60
- const { success, error } = await processManager.start();
61
- if (!success) {
53
+ const isWindows = process.platform === "win32";
54
+ const command = isWindows ? "cmd.exe" : "opencode";
55
+ const args = isWindows ? ["/c", "opencode", "serve"] : ["serve"];
56
+ const childProcess = spawn(command, args, {
57
+ detached: true,
58
+ stdio: "ignore",
59
+ windowsHide: isWindows,
60
+ });
61
+ childProcess.once("error", (error) => {
62
+ logger.error("[Bot] OpenCode server process failed to start", error);
63
+ });
64
+ const pid = childProcess.pid;
65
+ if (!pid) {
62
66
  await editBotText({
63
67
  api: ctx.api,
64
68
  chatId: ctx.chat.id,
65
69
  messageId: statusMessage.message_id,
66
- text: t("opencode_start.start_error", { error: error || t("common.unknown_error") }),
70
+ text: t("opencode_start.start_error", { error: t("common.unknown_error") }),
67
71
  });
68
72
  return;
69
73
  }
70
- // 5. Wait for server to become ready
74
+ childProcess.unref();
71
75
  logger.info("[Bot] Waiting for OpenCode server to become ready...");
72
76
  const ready = await waitForServerReady(10000);
73
77
  if (!ready) {
@@ -76,23 +80,22 @@ export async function opencodeStartCommand(ctx) {
76
80
  chatId: ctx.chat.id,
77
81
  messageId: statusMessage.message_id,
78
82
  text: t("opencode_start.started_not_ready", {
79
- pid: processManager.getPID() ?? "-",
83
+ pid,
80
84
  }),
81
85
  });
82
86
  return;
83
87
  }
84
- // 6. Get server version and send success message
85
88
  const { data: health } = await opencodeClient.global.health();
86
89
  await editBotText({
87
90
  api: ctx.api,
88
91
  chatId: ctx.chat.id,
89
92
  messageId: statusMessage.message_id,
90
93
  text: t("opencode_start.success", {
91
- pid: processManager.getPID() ?? "-",
94
+ pid,
92
95
  version: health?.version || t("common.unknown"),
93
96
  }),
94
97
  });
95
- logger.info(`[Bot] OpenCode server started successfully, PID=${processManager.getPID()}`);
98
+ logger.info(`[Bot] OpenCode server started successfully, PID=${pid}, port=${localTarget.port}`);
96
99
  }
97
100
  catch (err) {
98
101
  logger.error("[Bot] Error in /opencode-start command:", err);
@@ -1,41 +1,69 @@
1
+ import { config } from "../../config.js";
1
2
  import { opencodeClient } from "../../opencode/client.js";
2
- import { processManager } from "../../process/manager.js";
3
+ import { findServerPid, killServerProcess, resolveLocalOpencodeTarget, } from "../../opencode/process.js";
3
4
  import { logger } from "../../utils/logger.js";
4
5
  import { t } from "../../i18n/index.js";
6
+ import { editBotText } from "../utils/telegram-text.js";
5
7
  /**
6
8
  * Command handler for /opencode-stop
7
9
  * Stops the OpenCode server process
8
10
  */
9
11
  export async function opencodeStopCommand(ctx) {
10
12
  try {
11
- // 1. Check if process is running under our management
12
- if (!processManager.isRunning()) {
13
- // Check if there's an external server running
14
- try {
15
- const { data, error } = await opencodeClient.global.health();
16
- if (!error && data?.healthy) {
17
- await ctx.reply(t("opencode_stop.external_running"));
18
- return;
19
- }
20
- }
21
- catch {
22
- // Server not accessible
13
+ const localTarget = resolveLocalOpencodeTarget(config.opencode.apiUrl);
14
+ if (!localTarget) {
15
+ await ctx.reply(t("opencode_stop.remote_configured"));
16
+ return;
17
+ }
18
+ try {
19
+ const { data, error } = await opencodeClient.global.health();
20
+ if (error || !data?.healthy) {
21
+ await ctx.reply(t("opencode_stop.not_running"));
22
+ return;
23
23
  }
24
+ }
25
+ catch {
24
26
  await ctx.reply(t("opencode_stop.not_running"));
25
27
  return;
26
28
  }
27
- // 2. Notify user that we're stopping the server
28
- const pid = processManager.getPID();
29
- const statusMessage = await ctx.reply(t("opencode_stop.stopping", { pid: pid ?? "-" }));
30
- // 3. Stop the process
31
- const { success, error } = await processManager.stop(5000);
32
- if (!success) {
33
- await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("opencode_stop.stop_error", { error: error || t("common.unknown_error") }));
29
+ const pid = await findServerPid(localTarget.port);
30
+ if (!pid) {
31
+ await ctx.reply(t("opencode_stop.pid_not_found", { port: localTarget.port }));
34
32
  return;
35
33
  }
36
- // 4. Success - process has been stopped
37
- await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("opencode_stop.success"));
38
- logger.info("[Bot] OpenCode server stopped successfully");
34
+ const statusMessage = await ctx.reply(t("opencode_stop.stopping", { pid }));
35
+ const stopped = await killServerProcess(pid, 5000);
36
+ if (!stopped) {
37
+ await editBotText({
38
+ api: ctx.api,
39
+ chatId: ctx.chat.id,
40
+ messageId: statusMessage.message_id,
41
+ text: t("opencode_stop.stop_error", { error: t("common.unknown_error") }),
42
+ });
43
+ return;
44
+ }
45
+ try {
46
+ const { data, error } = await opencodeClient.global.health();
47
+ if (!error && data?.healthy) {
48
+ await editBotText({
49
+ api: ctx.api,
50
+ chatId: ctx.chat.id,
51
+ messageId: statusMessage.message_id,
52
+ text: t("opencode_stop.stop_error", { error: t("opencode_stop.still_running") }),
53
+ });
54
+ return;
55
+ }
56
+ }
57
+ catch {
58
+ // Health check failure after stop is expected.
59
+ }
60
+ await editBotText({
61
+ api: ctx.api,
62
+ chatId: ctx.chat.id,
63
+ messageId: statusMessage.message_id,
64
+ text: t("opencode_stop.success"),
65
+ });
66
+ logger.info(`[Bot] OpenCode server stopped successfully, PID=${pid}, port=${localTarget.port}`);
39
67
  }
40
68
  catch (err) {
41
69
  logger.error("[Bot] Error in /opencode-stop command:", err);
@@ -1,5 +1,6 @@
1
1
  import { InlineKeyboard } from "grammy";
2
2
  import { getCurrentProject } from "../../settings/manager.js";
3
+ import { getGitWorktreeContext } from "../../git/worktree.js";
3
4
  import { getProjects } from "../../project/manager.js";
4
5
  import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
5
6
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
@@ -68,14 +69,37 @@ function buildProjectsMenuText(currentProjectName, page, totalPages) {
68
69
  total: String(totalPages),
69
70
  })}`;
70
71
  }
71
- function buildProjectsKeyboard(projects, page) {
72
+ function worktreeKey(worktree) {
73
+ return process.platform === "win32" ? worktree.toLowerCase() : worktree;
74
+ }
75
+ async function getActiveProjectWorktree() {
76
+ const currentProject = getCurrentProject();
77
+ if (!currentProject) {
78
+ return null;
79
+ }
80
+ try {
81
+ const worktreeContext = await getGitWorktreeContext(currentProject.worktree);
82
+ if (worktreeContext) {
83
+ return worktreeContext.mainProjectPath;
84
+ }
85
+ }
86
+ catch (error) {
87
+ logger.debug("[Projects] Could not resolve active git worktree metadata:", error);
88
+ }
89
+ return currentProject.worktree;
90
+ }
91
+ async function buildProjectsKeyboard(projects, page) {
72
92
  const keyboard = new InlineKeyboard();
73
93
  const currentProject = getCurrentProject();
94
+ const activeProjectWorktree = await getActiveProjectWorktree();
74
95
  const pageSize = config.bot.projectsListLimit;
75
96
  const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateProjectsPaginationRange(projects.length, page, pageSize);
76
97
  projects.slice(startIndex, endIndex).forEach((project, index) => {
77
98
  const isActive = currentProject &&
78
- (project.id === currentProject.id || project.worktree === currentProject.worktree);
99
+ (project.id === currentProject.id ||
100
+ project.worktree === currentProject.worktree ||
101
+ (activeProjectWorktree !== null &&
102
+ worktreeKey(project.worktree) === worktreeKey(activeProjectWorktree)));
79
103
  const label = buildProjectButtonLabel(startIndex + index, project.worktree);
80
104
  const labelWithCheck = formatProjectButtonLabel(label, Boolean(isActive));
81
105
  keyboard.text(labelWithCheck, `project:${project.id}`).row();
@@ -90,14 +114,14 @@ function buildProjectsKeyboard(projects, page) {
90
114
  }
91
115
  return keyboard;
92
116
  }
93
- function buildProjectsMenuView(projects, page) {
117
+ async function buildProjectsMenuView(projects, page) {
94
118
  const currentProject = getCurrentProject();
95
119
  const pageSize = config.bot.projectsListLimit;
96
120
  const { page: normalizedPage, totalPages } = calculateProjectsPaginationRange(projects.length, page, pageSize);
97
121
  const currentProjectName = currentProject?.name || currentProject?.worktree || null;
98
122
  return {
99
123
  text: buildProjectsMenuText(currentProjectName, normalizedPage, totalPages),
100
- keyboard: buildProjectsKeyboard(projects, normalizedPage),
124
+ keyboard: await buildProjectsKeyboard(projects, normalizedPage),
101
125
  };
102
126
  }
103
127
  export async function projectsCommand(ctx) {
@@ -112,7 +136,7 @@ export async function projectsCommand(ctx) {
112
136
  await ctx.reply(t("projects.empty"));
113
137
  return;
114
138
  }
115
- const { text, keyboard } = buildProjectsMenuView(projects, 0);
139
+ const { text, keyboard } = await buildProjectsMenuView(projects, 0);
116
140
  await replyWithInlineMenu(ctx, {
117
141
  menuKind: "project",
118
142
  text,
@@ -146,7 +170,7 @@ export async function handleProjectSelect(ctx) {
146
170
  await ctx.reply(t("projects.empty"));
147
171
  return true;
148
172
  }
149
- const { text, keyboard } = buildProjectsMenuView(projects, page);
173
+ const { text, keyboard } = await buildProjectsMenuView(projects, page);
150
174
  await ctx.answerCallbackQuery();
151
175
  await ctx.editMessageText(text, {
152
176
  reply_markup: appendInlineMenuCancelButton(keyboard, "project"),