@grinev/opencode-telegram-bot 0.14.1 → 0.16.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 (61) hide show
  1. package/.env.example +25 -0
  2. package/README.md +50 -26
  3. package/dist/agent/manager.js +34 -6
  4. package/dist/agent/types.js +7 -1
  5. package/dist/app/start-bot-app.js +6 -1
  6. package/dist/bot/assistant-run-state.js +60 -0
  7. package/dist/bot/commands/abort.js +2 -0
  8. package/dist/bot/commands/commands.js +12 -2
  9. package/dist/bot/commands/definitions.js +2 -0
  10. package/dist/bot/commands/new.js +3 -2
  11. package/dist/bot/commands/open.js +314 -0
  12. package/dist/bot/commands/projects.js +5 -37
  13. package/dist/bot/commands/sessions.js +3 -0
  14. package/dist/bot/commands/start.js +2 -0
  15. package/dist/bot/commands/status.js +5 -2
  16. package/dist/bot/commands/tts.js +13 -0
  17. package/dist/bot/handlers/agent.js +3 -4
  18. package/dist/bot/handlers/inline-menu.js +9 -1
  19. package/dist/bot/handlers/model.js +3 -2
  20. package/dist/bot/handlers/prompt.js +33 -5
  21. package/dist/bot/handlers/variant.js +3 -2
  22. package/dist/bot/index.js +179 -94
  23. package/dist/bot/message-patterns.js +2 -2
  24. package/dist/bot/streaming/response-streamer.js +26 -17
  25. package/dist/bot/streaming/tool-call-streamer.js +31 -24
  26. package/dist/bot/utils/assistant-rendering.js +117 -0
  27. package/dist/bot/utils/assistant-run-footer.js +9 -0
  28. package/dist/bot/utils/browser-roots.js +140 -0
  29. package/dist/bot/utils/file-tree.js +92 -0
  30. package/dist/bot/utils/finalize-assistant-response.js +18 -24
  31. package/dist/bot/utils/keyboard.js +6 -6
  32. package/dist/bot/utils/send-tts-response.js +37 -0
  33. package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
  34. package/dist/bot/utils/switch-project.js +48 -0
  35. package/dist/bot/utils/telegram-text.js +95 -1
  36. package/dist/cli.js +4 -0
  37. package/dist/config.js +10 -0
  38. package/dist/i18n/de.js +32 -9
  39. package/dist/i18n/en.js +32 -9
  40. package/dist/i18n/es.js +32 -9
  41. package/dist/i18n/fr.js +32 -9
  42. package/dist/i18n/ru.js +32 -9
  43. package/dist/i18n/zh.js +32 -9
  44. package/dist/index.js +2 -0
  45. package/dist/pinned/manager.js +66 -4
  46. package/dist/project/manager.js +2 -1
  47. package/dist/settings/manager.js +7 -0
  48. package/dist/summary/aggregator.js +17 -1
  49. package/dist/summary/formatter.js +12 -89
  50. package/dist/telegram/render/block-fallback.js +28 -0
  51. package/dist/telegram/render/block-parser.js +295 -0
  52. package/dist/telegram/render/block-renderer.js +457 -0
  53. package/dist/telegram/render/chunker.js +281 -0
  54. package/dist/telegram/render/inline-renderer.js +128 -0
  55. package/dist/telegram/render/markdown-normalizer.js +94 -0
  56. package/dist/telegram/render/pipeline.js +9 -0
  57. package/dist/telegram/render/types.js +1 -0
  58. package/dist/telegram/render/validator.js +160 -0
  59. package/dist/tts/client.js +59 -0
  60. package/dist/utils/logger.js +200 -73
  61. package/package.json +6 -2
package/.env.example CHANGED
@@ -33,6 +33,11 @@ OPENCODE_MODEL_ID=big-pickle
33
33
  # Use "debug" to see detailed diagnostic logs including all bot events
34
34
  # LOG_LEVEL=info
35
35
 
36
+ # Number of log files to keep (default: 10)
37
+ # sources mode: keeps the latest N launch logs
38
+ # installed mode: keeps the latest N daily logs
39
+ # LOG_RETENTION=10
40
+
36
41
  # Bot Configuration (optional)
37
42
  # Maximum number of sessions shown in /sessions (default: 10)
38
43
  # SESSIONS_LIST_LIMIT=10
@@ -50,6 +55,10 @@ OPENCODE_MODEL_ID=big-pickle
50
55
  # Higher value = fewer Telegram edit requests, lower value = more real-time updates
51
56
  # RESPONSE_STREAM_THROTTLE_MS=500
52
57
 
58
+ # Maximum displayed length for bash tool commands in Telegram summaries (default: 128)
59
+ # Longer commands are truncated with "..."
60
+ # BASH_TOOL_DISPLAY_MAX_LENGTH=128
61
+
53
62
  # Bot locale: supported locale code (default: en)
54
63
  # Supported locales: en, de, es, fr, ru, zh
55
64
  # BOT_LOCALE=en
@@ -65,6 +74,14 @@ OPENCODE_MODEL_ID=big-pickle
65
74
  # raw = show assistant replies as plain text
66
75
  # MESSAGE_FORMAT_MODE=markdown
67
76
 
77
+ # Directory Browser Roots (optional)
78
+ # Comma-separated list of paths that /open is allowed to browse.
79
+ # Supports ~ for home directory. Defaults to ~ when not set.
80
+ # Examples:
81
+ # OPEN_BROWSER_ROOTS=~/projects
82
+ # OPEN_BROWSER_ROOTS=~/projects,~/work,/opt/repos
83
+ # OPEN_BROWSER_ROOTS=
84
+
68
85
  # Code File Settings (optional)
69
86
  # Maximum file size in KB to send as document (default: 100)
70
87
  # CODE_FILE_MAX_SIZE_KB=100
@@ -77,3 +94,11 @@ OPENCODE_MODEL_ID=big-pickle
77
94
  # STT_API_KEY=
78
95
  # STT_MODEL=
79
96
  # STT_LANGUAGE=
97
+
98
+ # Text-to-Speech credentials (optional)
99
+ # TTS reply behavior is controlled globally with /tts and persisted in settings.json.
100
+ # Set both TTS_API_URL and TTS_API_KEY to enable audio replies.
101
+ # TTS_API_URL=
102
+ # TTS_API_KEY=
103
+ # TTS_MODEL=gpt-4o-mini-tts
104
+ # TTS_VOICE=alloy
package/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
  [![CI](https://github.com/grinev/opencode-telegram-bot/actions/workflows/ci.yml/badge.svg)](https://github.com/grinev/opencode-telegram-bot/actions/workflows/ci.yml)
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
+ [![Follow updates](https://img.shields.io/badge/-Follow%20updates-333333?logo=x)](https://x.com/grin_rus)
7
8
 
8
9
  OpenCode Telegram Bot is a secure Telegram client for [OpenCode](https://opencode.ai) CLI that runs on your local machine.
9
10
 
@@ -31,7 +32,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
31
32
  - **Subagent activity** — watch live subagent progress in chat, including the current task, agent, model, and active tool step
32
33
  - **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
33
34
  - **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
34
- - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
35
+ - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, and optionally enable spoken replies with `/tts`
35
36
  - **File attachments** — send images, PDF documents, and any text-based files to OpenCode (code, logs, configs etc.)
36
37
  - **Scheduled tasks** — schedule prompts to run later or on a recurring interval; see [Scheduled Tasks](#scheduled-tasks)
37
38
  - **Context control** — compact context when it gets too large, right from the chat
@@ -109,6 +110,8 @@ opencode-telegram config
109
110
  | `/abort` | Abort the current task |
110
111
  | `/sessions` | Browse and switch between recent sessions |
111
112
  | `/projects` | Switch between OpenCode projects |
113
+ | `/open` | Add a project by browsing directories |
114
+ | `/tts` | Toggle audio replies |
112
115
  | `/rename` | Rename the current session |
113
116
  | `/commands` | Browse and run custom commands |
114
117
  | `/task` | Create a scheduled task |
@@ -147,34 +150,44 @@ When installed via npm, the configuration wizard handles the initial setup. The
147
150
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
148
151
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
149
152
 
150
- | Variable | Description | Required | Default |
151
- | ----------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
152
- | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
153
- | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
154
- | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
155
- | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
156
- | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
157
- | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
158
- | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
159
- | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
160
- | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
161
- | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
162
- | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
163
- | `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
164
- | `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
165
- | `RESPONSE_STREAM_THROTTLE_MS` | Stream edit throttle (ms) for assistant and tool updates | No | `500` |
166
- | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
167
- | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
168
- | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
169
- | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
170
- | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | |
171
- | `STT_API_KEY` | API key for your STT provider | No | |
172
- | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
173
- | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
174
- | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
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` |
175
186
 
176
187
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
177
188
 
189
+ Logs are written to `./logs` when running from sources and to the runtime config directory `logs/` folder in `installed` mode. Log rotation depends on runtime mode: `sources` creates one file per bot launch, while `installed` appends to one file per day. Old log files are removed according to `LOG_RETENTION`.
190
+
178
191
  ### Voice and Audio Transcription (Optional)
179
192
 
180
193
  If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
@@ -184,6 +197,17 @@ If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
184
197
  3. Show recognized text in chat
185
198
  4. Send the recognized text to OpenCode as a normal prompt
186
199
 
200
+ 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
+
202
+ TTS configuration example:
203
+
204
+ ```env
205
+ TTS_API_URL=https://api.openai.com/v1
206
+ TTS_API_KEY=your-tts-api-key
207
+ TTS_MODEL=gpt-4o-mini-tts
208
+ TTS_VOICE=alloy
209
+ ```
210
+
187
211
  Supported provider examples (Whisper-compatible):
188
212
 
189
213
  - **OpenAI**
@@ -29,6 +29,31 @@ export async function getAvailableAgents() {
29
29
  }
30
30
  }
31
31
  const DEFAULT_AGENT = "build";
32
+ function pickFallbackAgent(agents) {
33
+ const defaultAgent = agents.find((agent) => agent.name === DEFAULT_AGENT);
34
+ if (defaultAgent) {
35
+ return defaultAgent.name;
36
+ }
37
+ return agents[0]?.name ?? DEFAULT_AGENT;
38
+ }
39
+ export async function resolveProjectAgent(preferredAgent) {
40
+ const requestedAgent = preferredAgent ?? getCurrentAgent() ?? DEFAULT_AGENT;
41
+ const project = getCurrentProject();
42
+ if (!project) {
43
+ return requestedAgent;
44
+ }
45
+ const agents = await getAvailableAgents();
46
+ if (agents.length === 0) {
47
+ return requestedAgent;
48
+ }
49
+ if (agents.some((agent) => agent.name === requestedAgent)) {
50
+ return requestedAgent;
51
+ }
52
+ const fallbackAgent = pickFallbackAgent(agents);
53
+ logger.warn(`[AgentManager] Agent "${requestedAgent}" is not available for project ${project.worktree}. Falling back to "${fallbackAgent}".`);
54
+ setCurrentAgent(fallbackAgent);
55
+ return fallbackAgent;
56
+ }
32
57
  /**
33
58
  * Get current agent from last session message or settings.
34
59
  * Falls back to "build" if nothing is stored.
@@ -38,10 +63,13 @@ export async function fetchCurrentAgent() {
38
63
  const storedAgent = getCurrentAgent();
39
64
  const session = getCurrentSession();
40
65
  const project = getCurrentProject();
41
- if (!session || !project) {
42
- // No active session, return stored agent from settings
66
+ if (!project) {
67
+ // No active project, return stored agent from settings
43
68
  return storedAgent ?? DEFAULT_AGENT;
44
69
  }
70
+ if (!session) {
71
+ return resolveProjectAgent(storedAgent ?? DEFAULT_AGENT);
72
+ }
45
73
  try {
46
74
  const { data: messages, error } = await opencodeClient.session.messages({
47
75
  sessionID: session.id,
@@ -50,7 +78,7 @@ export async function fetchCurrentAgent() {
50
78
  });
51
79
  if (error || !messages || messages.length === 0) {
52
80
  logger.debug("[AgentManager] No messages found, using stored agent");
53
- return storedAgent ?? DEFAULT_AGENT;
81
+ return resolveProjectAgent(storedAgent ?? DEFAULT_AGENT);
54
82
  }
55
83
  const lastAgent = messages[0].info.agent;
56
84
  logger.debug(`[AgentManager] Current agent from session: ${lastAgent}`);
@@ -58,17 +86,17 @@ export async function fetchCurrentAgent() {
58
86
  // Session messages may contain stale agent until next prompt is sent.
59
87
  if (storedAgent && lastAgent !== storedAgent) {
60
88
  logger.debug(`[AgentManager] Using stored agent "${storedAgent}" instead of session agent "${lastAgent}"`);
61
- return storedAgent;
89
+ return resolveProjectAgent(storedAgent);
62
90
  }
63
91
  // No stored agent yet: sync from session history
64
92
  if (lastAgent && lastAgent !== storedAgent) {
65
93
  setCurrentAgent(lastAgent);
66
94
  }
67
- return lastAgent || storedAgent || DEFAULT_AGENT;
95
+ return resolveProjectAgent(lastAgent || storedAgent || DEFAULT_AGENT);
68
96
  }
69
97
  catch (err) {
70
98
  logger.error("[AgentManager] Error fetching current agent:", err);
71
- return storedAgent ?? DEFAULT_AGENT;
99
+ return resolveProjectAgent(storedAgent ?? DEFAULT_AGENT);
72
100
  }
73
101
  }
74
102
  /**
@@ -22,5 +22,11 @@ export function getAgentEmoji(agentName) {
22
22
  export function getAgentDisplayName(agentName) {
23
23
  const emoji = getAgentEmoji(agentName);
24
24
  const capitalizedName = agentName.charAt(0).toUpperCase() + agentName.slice(1);
25
- return `${emoji} ${capitalizedName} Mode`;
25
+ return `${emoji} ${capitalizedName}`;
26
+ }
27
+ /**
28
+ * Get reply keyboard label for agent button.
29
+ */
30
+ export function getAgentButtonLabel(agentName) {
31
+ return `${getAgentDisplayName(agentName)} Agent`;
26
32
  }
@@ -8,7 +8,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 { logger } from "../utils/logger.js";
11
+ import { getLogFilePath, initializeLogger, logger } from "../utils/logger.js";
12
12
  async function getBotVersion() {
13
13
  try {
14
14
  const packageJsonPath = new URL("../../package.json", import.meta.url);
@@ -22,11 +22,16 @@ async function getBotVersion() {
22
22
  }
23
23
  }
24
24
  export async function startBotApp() {
25
+ await initializeLogger();
25
26
  const mode = getRuntimeMode();
26
27
  const runtimePaths = getRuntimePaths();
27
28
  const version = await getBotVersion();
29
+ const logFilePath = getLogFilePath();
28
30
  logger.info(`Starting OpenCode Telegram Bot v${version}...`);
29
31
  logger.info(`Config loaded from ${runtimePaths.envFilePath}`);
32
+ if (logFilePath) {
33
+ logger.info(`Logs are written to ${logFilePath}`);
34
+ }
30
35
  logger.info(`Allowed User ID: ${config.telegram.allowedUserId}`);
31
36
  logger.debug(`[Runtime] Application start mode: ${mode}`);
32
37
  await loadSettings();
@@ -0,0 +1,60 @@
1
+ import { logger } from "../utils/logger.js";
2
+ class AssistantRunState {
3
+ runs = new Map();
4
+ startRun(sessionId, info) {
5
+ if (!sessionId) {
6
+ return;
7
+ }
8
+ this.runs.set(sessionId, {
9
+ sessionId,
10
+ startedAt: info.startedAt,
11
+ configuredAgent: info.configuredAgent,
12
+ configuredProviderID: info.configuredProviderID,
13
+ configuredModelID: info.configuredModelID,
14
+ hasCompletedResponse: false,
15
+ });
16
+ logger.debug(`[AssistantRunState] Started run: session=${sessionId}, agent=${info.configuredAgent || "unknown"}, model=${info.configuredProviderID || "unknown"}/${info.configuredModelID || "unknown"}`);
17
+ }
18
+ markResponseCompleted(sessionId, info) {
19
+ const run = this.runs.get(sessionId);
20
+ if (!run) {
21
+ return;
22
+ }
23
+ run.hasCompletedResponse = true;
24
+ if (info?.agent) {
25
+ run.actualAgent = info.agent;
26
+ }
27
+ if (info?.providerID) {
28
+ run.actualProviderID = info.providerID;
29
+ }
30
+ if (info?.modelID) {
31
+ run.actualModelID = info.modelID;
32
+ }
33
+ }
34
+ finishRun(sessionId, reason) {
35
+ const run = this.runs.get(sessionId) ?? null;
36
+ if (!run) {
37
+ return null;
38
+ }
39
+ this.runs.delete(sessionId);
40
+ logger.debug(`[AssistantRunState] Finished run: session=${sessionId}, reason=${reason}`);
41
+ return { ...run };
42
+ }
43
+ clearRun(sessionId, reason) {
44
+ if (!this.runs.delete(sessionId)) {
45
+ return;
46
+ }
47
+ logger.debug(`[AssistantRunState] Cleared run: session=${sessionId}, reason=${reason}`);
48
+ }
49
+ clearAll(reason) {
50
+ if (this.runs.size === 0) {
51
+ return;
52
+ }
53
+ logger.debug(`[AssistantRunState] Cleared all runs: count=${this.runs.size}, reason=${reason}`);
54
+ this.runs.clear();
55
+ }
56
+ __resetForTests() {
57
+ this.runs.clear();
58
+ }
59
+ }
60
+ export const assistantRunState = new AssistantRunState();
@@ -6,6 +6,7 @@ import { summaryAggregator } from "../../summary/aggregator.js";
6
6
  import { logger } from "../../utils/logger.js";
7
7
  import { t } from "../../i18n/index.js";
8
8
  import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
9
+ import { assistantRunState } from "../assistant-run-state.js";
9
10
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
11
  function abortLocalStreaming() {
11
12
  stopEventListening();
@@ -86,6 +87,7 @@ export async function abortCurrentOperation(ctx, options = {}) {
86
87
  const finalStatus = await pollSessionStatus(currentSession.id, currentSession.directory, 5000);
87
88
  if (finalStatus === "idle" || finalStatus === "not-found") {
88
89
  foregroundSessionState.markIdle(currentSession.id);
90
+ assistantRunState.clearRun(currentSession.id, "abort_confirmed");
89
91
  if (notifyUser && chatId !== null && waitingMessageId !== null) {
90
92
  await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.success"));
91
93
  }
@@ -5,13 +5,14 @@ import { clearSession, getCurrentSession, setCurrentSession, } from "../../sessi
5
5
  import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
6
6
  import { interactionManager } from "../../interaction/manager.js";
7
7
  import { summaryAggregator } from "../../summary/aggregator.js";
8
- import { getStoredAgent } from "../../agent/manager.js";
8
+ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
9
9
  import { getStoredModel } from "../../model/manager.js";
10
10
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
11
11
  import { logger } from "../../utils/logger.js";
12
12
  import { t } from "../../i18n/index.js";
13
13
  import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
14
14
  import { config } from "../../config.js";
15
+ import { assistantRunState } from "../assistant-run-state.js";
15
16
  const COMMANDS_CALLBACK_PREFIX = "commands:";
16
17
  const COMMANDS_CALLBACK_SELECT_PREFIX = `${COMMANDS_CALLBACK_PREFIX}select:`;
17
18
  const COMMANDS_CALLBACK_PAGE_PREFIX = `${COMMANDS_CALLBACK_PREFIX}page:`;
@@ -236,6 +237,7 @@ async function ensureSessionForProject(ctx, projectDirectory) {
236
237
  clearSession();
237
238
  summaryAggregator.clear();
238
239
  foregroundSessionState.clearAll("session_mismatch_reset");
240
+ assistantRunState.clearAll("session_mismatch_reset");
239
241
  await ctx.reply(t("bot.session_reset_project_mismatch"));
240
242
  currentSession = null;
241
243
  }
@@ -279,12 +281,18 @@ async function executeCommand(ctx, deps, params) {
279
281
  await ctx.reply(t("bot.session_busy"));
280
282
  return;
281
283
  }
282
- const currentAgent = getStoredAgent();
284
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
283
285
  const storedModel = getStoredModel();
284
286
  const model = storedModel.providerID && storedModel.modelID
285
287
  ? `${storedModel.providerID}/${storedModel.modelID}`
286
288
  : undefined;
287
289
  foregroundSessionState.markBusy(session.id);
290
+ assistantRunState.startRun(session.id, {
291
+ startedAt: Date.now(),
292
+ configuredAgent: currentAgent,
293
+ configuredProviderID: storedModel.providerID,
294
+ configuredModelID: storedModel.modelID,
295
+ });
288
296
  safeBackgroundTask({
289
297
  taskName: "session.command",
290
298
  task: () => opencodeClient.session.command({
@@ -299,6 +307,7 @@ async function executeCommand(ctx, deps, params) {
299
307
  onSuccess: ({ error }) => {
300
308
  if (error) {
301
309
  foregroundSessionState.markIdle(session.id);
310
+ assistantRunState.clearRun(session.id, "session_command_api_error");
302
311
  logger.error("[Commands] OpenCode API returned an error for session.command", {
303
312
  sessionId: session.id,
304
313
  command: params.commandName,
@@ -312,6 +321,7 @@ async function executeCommand(ctx, deps, params) {
312
321
  },
313
322
  onError: (error) => {
314
323
  foregroundSessionState.markIdle(session.id);
324
+ assistantRunState.clearRun(session.id, "session_command_background_error");
315
325
  logger.error("[Commands] session.command background task failed", {
316
326
  sessionId: session.id,
317
327
  command: params.commandName,
@@ -8,6 +8,7 @@ const COMMAND_DEFINITIONS = [
8
8
  { command: "new", descriptionKey: "cmd.description.new" },
9
9
  { command: "abort", descriptionKey: "cmd.description.stop" },
10
10
  { command: "sessions", descriptionKey: "cmd.description.sessions" },
11
+ { command: "tts", descriptionKey: "cmd.description.tts" },
11
12
  { command: "projects", descriptionKey: "cmd.description.projects" },
12
13
  { command: "task", descriptionKey: "cmd.description.task" },
13
14
  { command: "tasklist", descriptionKey: "cmd.description.tasklist" },
@@ -15,6 +16,7 @@ const COMMAND_DEFINITIONS = [
15
16
  { command: "commands", descriptionKey: "cmd.description.commands" },
16
17
  { command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
17
18
  { command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
19
+ { command: "open", descriptionKey: "cmd.description.open" },
18
20
  { command: "help", descriptionKey: "cmd.description.help" },
19
21
  ];
20
22
  export function getLocalizedBotCommands() {
@@ -6,7 +6,7 @@ import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
6
  import { summaryAggregator } from "../../summary/aggregator.js";
7
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
8
8
  import { keyboardManager } from "../../keyboard/manager.js";
9
- import { getStoredAgent } from "../../agent/manager.js";
9
+ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
10
10
  import { getStoredModel } from "../../model/manager.js";
11
11
  import { formatVariantForButton } from "../../variant/manager.js";
12
12
  import { createMainKeyboard } from "../utils/keyboard.js";
@@ -54,10 +54,11 @@ export async function newCommand(ctx) {
54
54
  logger.error("[Bot] Error creating pinned message:", err);
55
55
  }
56
56
  // Get current state for keyboard
57
- const currentAgent = getStoredAgent();
57
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
58
58
  const currentModel = getStoredModel();
59
59
  const contextInfo = pinnedMessageManager.getContextInfo();
60
60
  const variantName = formatVariantForButton(currentModel.variant || "default");
61
+ keyboardManager.updateAgent(currentAgent);
61
62
  const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
62
63
  await ctx.reply(t("new.created", { title: session.title }), {
63
64
  reply_markup: keyboard,