@grinev/opencode-telegram-bot 0.16.0 → 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
@@ -69,6 +73,9 @@ OPENCODE_MODEL_ID=big-pickle
69
73
  # Hide tool call service messages (default: false)
70
74
  # HIDE_TOOL_CALL_MESSAGES=false
71
75
 
76
+ # Hide tool file edit documents sent as .txt attachments (default: false)
77
+ # HIDE_TOOL_FILE_MESSAGES=false
78
+
72
79
  # Assistant message formatting mode (default: markdown)
73
80
  # markdown = convert assistant replies to Telegram MarkdownV2
74
81
  # raw = show assistant replies as plain text
@@ -94,6 +101,7 @@ OPENCODE_MODEL_ID=big-pickle
94
101
  # STT_API_KEY=
95
102
  # STT_MODEL=
96
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."
97
105
 
98
106
  # Text-to-Speech credentials (optional)
99
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,22 +62,26 @@ 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
 
73
77
  The fastest way — run directly with `npx`:
74
78
 
75
79
  ```bash
76
- npx @grinev/opencode-telegram-bot
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.
@@ -87,6 +93,20 @@ npm install -g @grinev/opencode-telegram-bot
87
93
  opencode-telegram start
88
94
  ```
89
95
 
96
+ `start` runs in the foreground by default. This is the recommended mode for `systemd`, Docker, local debugging, and other external process managers.
97
+
98
+ To run the bot in the built-in background mode instead:
99
+
100
+ ```bash
101
+ opencode-telegram start --daemon
102
+ opencode-telegram status
103
+ opencode-telegram stop
104
+ ```
105
+
106
+ > Built-in daemon mode is intended for standalone npm installs without an external supervisor. For `systemd`, `pm2`, or Docker, keep using `opencode-telegram start` without `--daemon`.
107
+
108
+ For Linux `systemd` setup, see [`docs/LINUX_SYSTEMD_SETUP.md`](./docs/LINUX_SYSTEMD_SETUP.md).
109
+
90
110
  To reconfigure at any time:
91
111
 
92
112
  ```bash
@@ -110,19 +130,21 @@ opencode-telegram config
110
130
  | `/abort` | Abort the current task |
111
131
  | `/sessions` | Browse and switch between recent sessions |
112
132
  | `/projects` | Switch between OpenCode projects |
133
+ | `/worktree` | Switch between existing git worktrees |
113
134
  | `/open` | Add a project by browsing directories |
114
135
  | `/tts` | Toggle audio replies |
115
136
  | `/rename` | Rename the current session |
116
137
  | `/commands` | Browse and run custom commands |
138
+ | `/skills` | Browse and run OpenCode skills |
117
139
  | `/task` | Create a scheduled task |
118
140
  | `/tasklist` | Browse and delete scheduled tasks |
119
- | `/opencode_start` | Start the OpenCode server remotely |
120
- | `/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 |
121
143
  | `/help` | Show available commands |
122
144
 
123
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.
124
146
 
125
- > `/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.
126
148
 
127
149
  ## Scheduled Tasks
128
150
 
@@ -132,6 +154,8 @@ Scheduled tasks let you prepare prompts in advance and run them automatically la
132
154
  - Scheduled executions currently always run with the `build` agent
133
155
  - Tasks run outside your active chat session, so they do not interrupt or affect the current session flow
134
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
135
159
  - Up to 10 scheduled tasks can exist at once by default; change this with `TASK_LIMIT` in your `.env`
136
160
 
137
161
  ## Configuration
@@ -150,39 +174,42 @@ When installed via npm, the configuration wizard handles the initial setup. The
150
174
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
151
175
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
152
176
 
153
- | Variable | Description | Required | Default |
154
- | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ------------------------ |
155
- | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
156
- | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
157
- | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
158
- | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
159
- | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
160
- | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
161
- | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
162
- | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
163
- | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
164
- | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
165
- | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
166
- | `OPEN_BROWSER_ROOTS` | Comma-separated paths `/open` is allowed to browse (supports `~`) | No | `~` (home directory) |
167
- | `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
168
- | `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
169
- | `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` |
170
- | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
171
- | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
172
- | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
173
- | `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
174
- | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
175
- | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
176
- | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | |
177
- | `STT_API_KEY` | API key for your STT provider | No | |
178
- | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
179
- | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
180
- | `TTS_API_URL` | TTS API base URL | No | |
181
- | `TTS_API_KEY` | TTS API key | No | — |
182
- | `TTS_MODEL` | TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
183
- | `TTS_VOICE` | OpenAI-compatible TTS voice name | No | `alloy` |
184
- | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
185
- | `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` |
186
213
 
187
214
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
188
215
 
@@ -197,6 +224,8 @@ If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
197
224
  3. Show recognized text in chat
198
225
  4. Send the recognized text to OpenCode as a normal prompt
199
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
+
200
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.
201
230
 
202
231
  TTS configuration example:
@@ -281,8 +310,9 @@ npm run dev
281
310
 
282
311
  **"OpenCode server is not available"**
283
312
 
284
- - Ensure `opencode serve` is running in your project directory
285
- - 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
286
316
 
287
317
  **No models in model picker**
288
318
 
@@ -1,14 +1,17 @@
1
+ import fs from "node:fs/promises";
1
2
  import { readFile } from "node:fs/promises";
2
- import { createBot } from "../bot/index.js";
3
+ import { cleanupBotRuntime, createBot } from "../bot/index.js";
3
4
  import { config } from "../config.js";
4
5
  import { loadSettings } from "../settings/manager.js";
5
- import { processManager } from "../process/manager.js";
6
6
  import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
7
7
  import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
8
8
  import { reconcileStoredModelSelection } from "../model/manager.js";
9
9
  import { getRuntimeMode } from "../runtime/mode.js";
10
10
  import { getRuntimePaths } from "../runtime/paths.js";
11
+ import { clearServiceStateFile } from "../service/manager.js";
12
+ import { getServiceStateFilePathFromEnv, isServiceChildProcess } from "../service/runtime.js";
11
13
  import { getLogFilePath, initializeLogger, logger } from "../utils/logger.js";
14
+ const SHUTDOWN_TIMEOUT_MS = 5000;
12
15
  async function getBotVersion() {
13
16
  try {
14
17
  const packageJsonPath = new URL("../../package.json", import.meta.url);
@@ -35,20 +38,85 @@ export async function startBotApp() {
35
38
  logger.info(`Allowed User ID: ${config.telegram.allowedUserId}`);
36
39
  logger.debug(`[Runtime] Application start mode: ${mode}`);
37
40
  await loadSettings();
38
- await processManager.initialize();
39
41
  await reconcileStoredModelSelection();
40
42
  await warmupSessionDirectoryCache();
41
43
  const bot = createBot();
42
44
  await scheduledTaskRuntime.initialize(bot);
45
+ let shutdownStarted = false;
46
+ let serviceStateCleared = false;
47
+ let shutdownTimeout = null;
48
+ const clearManagedServiceState = async () => {
49
+ if (!isServiceChildProcess() || serviceStateCleared) {
50
+ return;
51
+ }
52
+ const stateFilePath = getServiceStateFilePathFromEnv();
53
+ if (!stateFilePath) {
54
+ return;
55
+ }
56
+ try {
57
+ await fs.access(stateFilePath);
58
+ }
59
+ catch (error) {
60
+ if (error.code === "ENOENT") {
61
+ serviceStateCleared = true;
62
+ return;
63
+ }
64
+ throw error;
65
+ }
66
+ await clearServiceStateFile(stateFilePath);
67
+ serviceStateCleared = true;
68
+ };
69
+ const shutdown = (signal) => {
70
+ if (shutdownStarted) {
71
+ return;
72
+ }
73
+ shutdownStarted = true;
74
+ logger.info(`[App] Received ${signal}, shutting down...`);
75
+ cleanupBotRuntime(`app_shutdown_${signal.toLowerCase()}`);
76
+ scheduledTaskRuntime.shutdown();
77
+ shutdownTimeout = setTimeout(() => {
78
+ logger.warn(`[App] Shutdown did not finish in ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit.`);
79
+ process.exit(0);
80
+ }, SHUTDOWN_TIMEOUT_MS);
81
+ shutdownTimeout.unref?.();
82
+ try {
83
+ bot.stop();
84
+ }
85
+ catch (error) {
86
+ logger.warn("[App] Failed to stop Telegram bot cleanly", error);
87
+ }
88
+ void clearManagedServiceState().catch((error) => {
89
+ logger.warn("[App] Failed to clear managed service state", error);
90
+ });
91
+ };
92
+ const handleSigint = () => shutdown("SIGINT");
93
+ const handleSigterm = () => shutdown("SIGTERM");
94
+ process.on("SIGINT", handleSigint);
95
+ process.on("SIGTERM", handleSigterm);
43
96
  const webhookInfo = await bot.api.getWebhookInfo();
44
97
  if (webhookInfo.url) {
45
98
  logger.info(`[Bot] Webhook detected: ${webhookInfo.url}, removing...`);
46
99
  await bot.api.deleteWebhook();
47
100
  logger.info("[Bot] Webhook removed, switching to long polling");
48
101
  }
49
- await bot.start({
50
- onStart: (botInfo) => {
51
- logger.info(`Bot @${botInfo.username} started!`);
52
- },
53
- });
102
+ try {
103
+ await bot.start({
104
+ onStart: (botInfo) => {
105
+ logger.info(`Bot @${botInfo.username} started!`);
106
+ },
107
+ });
108
+ }
109
+ finally {
110
+ process.off("SIGINT", handleSigint);
111
+ process.off("SIGTERM", handleSigterm);
112
+ if (shutdownTimeout) {
113
+ clearTimeout(shutdownTimeout);
114
+ shutdownTimeout = null;
115
+ }
116
+ cleanupBotRuntime("app_shutdown_complete");
117
+ scheduledTaskRuntime.shutdown();
118
+ await clearManagedServiceState().catch((error) => {
119
+ logger.warn("[App] Failed to clear managed service state", error);
120
+ });
121
+ }
54
122
  }
@@ -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);