@grinev/opencode-telegram-bot 0.15.0 → 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 (45) hide show
  1. package/.env.example +13 -0
  2. package/README.md +6 -0
  3. package/dist/app/start-bot-app.js +6 -1
  4. package/dist/bot/assistant-run-state.js +60 -0
  5. package/dist/bot/commands/abort.js +2 -0
  6. package/dist/bot/commands/commands.js +10 -0
  7. package/dist/bot/commands/definitions.js +1 -0
  8. package/dist/bot/commands/open.js +314 -0
  9. package/dist/bot/commands/projects.js +5 -38
  10. package/dist/bot/commands/start.js +2 -0
  11. package/dist/bot/handlers/inline-menu.js +9 -1
  12. package/dist/bot/handlers/prompt.js +11 -0
  13. package/dist/bot/index.js +162 -98
  14. package/dist/bot/streaming/response-streamer.js +26 -17
  15. package/dist/bot/utils/assistant-rendering.js +117 -0
  16. package/dist/bot/utils/assistant-run-footer.js +9 -0
  17. package/dist/bot/utils/browser-roots.js +140 -0
  18. package/dist/bot/utils/file-tree.js +92 -0
  19. package/dist/bot/utils/finalize-assistant-response.js +18 -24
  20. package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
  21. package/dist/bot/utils/switch-project.js +48 -0
  22. package/dist/bot/utils/telegram-text.js +95 -1
  23. package/dist/cli.js +4 -0
  24. package/dist/config.js +3 -0
  25. package/dist/i18n/de.js +15 -0
  26. package/dist/i18n/en.js +15 -0
  27. package/dist/i18n/es.js +15 -0
  28. package/dist/i18n/fr.js +15 -0
  29. package/dist/i18n/ru.js +15 -0
  30. package/dist/i18n/zh.js +15 -0
  31. package/dist/index.js +2 -0
  32. package/dist/project/manager.js +2 -1
  33. package/dist/summary/aggregator.js +17 -1
  34. package/dist/summary/formatter.js +2 -88
  35. package/dist/telegram/render/block-fallback.js +28 -0
  36. package/dist/telegram/render/block-parser.js +295 -0
  37. package/dist/telegram/render/block-renderer.js +457 -0
  38. package/dist/telegram/render/chunker.js +281 -0
  39. package/dist/telegram/render/inline-renderer.js +128 -0
  40. package/dist/telegram/render/markdown-normalizer.js +94 -0
  41. package/dist/telegram/render/pipeline.js +9 -0
  42. package/dist/telegram/render/types.js +1 -0
  43. package/dist/telegram/render/validator.js +160 -0
  44. package/dist/utils/logger.js +200 -73
  45. 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
@@ -69,6 +74,14 @@ OPENCODE_MODEL_ID=big-pickle
69
74
  # raw = show assistant replies as plain text
70
75
  # MESSAGE_FORMAT_MODE=markdown
71
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
+
72
85
  # Code File Settings (optional)
73
86
  # Maximum file size in KB to send as document (default: 100)
74
87
  # CODE_FILE_MAX_SIZE_KB=100
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
 
@@ -109,6 +110,7 @@ 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 |
112
114
  | `/tts` | Toggle audio replies |
113
115
  | `/rename` | Rename the current session |
114
116
  | `/commands` | Browse and run custom commands |
@@ -161,6 +163,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
161
163
  | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
162
164
  | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
163
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) |
164
167
  | `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
165
168
  | `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
166
169
  | `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` |
@@ -179,9 +182,12 @@ When installed via npm, the configuration wizard handles the initial setup. The
179
182
  | `TTS_MODEL` | TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
180
183
  | `TTS_VOICE` | OpenAI-compatible TTS voice name | No | `alloy` |
181
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` |
182
186
 
183
187
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
184
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
+
185
191
  ### Voice and Audio Transcription (Optional)
186
192
 
187
193
  If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
@@ -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
  }
@@ -12,6 +12,7 @@ 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
  }
@@ -285,6 +287,12 @@ async function executeCommand(ctx, deps, params) {
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,
@@ -16,6 +16,7 @@ const COMMAND_DEFINITIONS = [
16
16
  { command: "commands", descriptionKey: "cmd.description.commands" },
17
17
  { command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
18
18
  { command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
19
+ { command: "open", descriptionKey: "cmd.description.open" },
19
20
  { command: "help", descriptionKey: "cmd.description.help" },
20
21
  ];
21
22
  export function getLocalizedBotCommands() {
@@ -0,0 +1,314 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import path from "node:path";
3
+ import { appendInlineMenuCancelButton, ensureActiveInlineMenu } from "../handlers/inline-menu.js";
4
+ import { interactionManager } from "../../interaction/manager.js";
5
+ import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
6
+ import { pathToDisplayPath, scanDirectory, buildEntryLabel, buildTreeHeader, isScanError, MAX_ENTRIES_PER_PAGE, } from "../utils/file-tree.js";
7
+ import { getBrowserRoots, isWithinAllowedRoot, isAllowedRoot } from "../utils/browser-roots.js";
8
+ import { upsertSessionDirectory } from "../../session/cache-manager.js";
9
+ import { getProjectByWorktree } from "../../project/manager.js";
10
+ import { switchToProject } from "../utils/switch-project.js";
11
+ import { logger } from "../../utils/logger.js";
12
+ import { t } from "../../i18n/index.js";
13
+ const CALLBACK_PREFIX = "open:";
14
+ const CALLBACK_NAV_PREFIX = "open:nav:";
15
+ const CALLBACK_SELECT_PREFIX = "open:sel:";
16
+ const CALLBACK_PAGE_PREFIX = "open:pg:";
17
+ const CALLBACK_ROOTS = "open:roots";
18
+ const MAX_BUTTON_LABEL_LENGTH = 64;
19
+ /**
20
+ * Separator used inside pagination callback data between the encoded path
21
+ * reference and the page number. We avoid `:` because it appears in Windows
22
+ * drive letters (e.g. `C:\`) and is already used as a prefix delimiter.
23
+ */
24
+ const PAGE_SEPARATOR = "|";
25
+ function truncateLabel(label, maxLen = MAX_BUTTON_LABEL_LENGTH) {
26
+ if (label.length <= maxLen) {
27
+ return label;
28
+ }
29
+ return label.slice(0, Math.max(0, maxLen - 3)) + "...";
30
+ }
31
+ /**
32
+ * Encode a path into callback data. Telegram limits callback_data to 64 bytes.
33
+ * Long absolute paths can exceed this, so we encode them as a compact index
34
+ * when necessary. The index is stored in a module-level map that lives for the
35
+ * duration of the current inline menu interaction.
36
+ */
37
+ const pathIndex = new Map();
38
+ let pathCounter = 0;
39
+ /** Clear the path index. Exported so it can be called on menu cancel/cleanup. */
40
+ export function clearOpenPathIndex() {
41
+ pathIndex.clear();
42
+ pathCounter = 0;
43
+ }
44
+ /**
45
+ * @param prefix Callback-data prefix that precedes the path.
46
+ * @param fullPath Absolute path to encode.
47
+ * @param reserveBytes Extra bytes to reserve for a suffix that will be
48
+ * appended *after* the returned value (e.g. the page separator + digits in
49
+ * pagination callbacks). The total callback_data must stay ≤ 64 bytes.
50
+ */
51
+ function encodePathForCallback(prefix, fullPath, reserveBytes = 0) {
52
+ const naive = `${prefix}${fullPath}`;
53
+ if (Buffer.byteLength(naive, "utf-8") + reserveBytes <= 64) {
54
+ return naive;
55
+ }
56
+ // Use a short numeric key instead
57
+ const key = `#${pathCounter++}`;
58
+ pathIndex.set(key, fullPath);
59
+ return `${prefix}${key}`;
60
+ }
61
+ function decodePathFromCallback(prefix, data) {
62
+ if (!data.startsWith(prefix)) {
63
+ return null;
64
+ }
65
+ const raw = data.slice(prefix.length);
66
+ if (raw.startsWith("#")) {
67
+ return pathIndex.get(raw) ?? null;
68
+ }
69
+ return raw;
70
+ }
71
+ /**
72
+ * Encode a pagination callback. The path part goes through the same 64-byte
73
+ * safe encoding used by nav/select callbacks, followed by a separator and
74
+ * the page number.
75
+ *
76
+ * We reserve bytes for the page suffix so the 64-byte check inside
77
+ * `encodePathForCallback` accounts for the complete final callback length.
78
+ */
79
+ function encodePaginationCallback(currentPath, page) {
80
+ const pageSuffix = `${PAGE_SEPARATOR}${page}`;
81
+ const reserveBytes = Buffer.byteLength(pageSuffix, "utf-8");
82
+ const pathRef = encodePathForCallback(CALLBACK_PAGE_PREFIX, currentPath, reserveBytes);
83
+ return `${pathRef}${pageSuffix}`;
84
+ }
85
+ /**
86
+ * Decode a pagination callback into { path, page } or null on failure.
87
+ */
88
+ function decodePaginationCallback(data) {
89
+ if (!data.startsWith(CALLBACK_PAGE_PREFIX)) {
90
+ return null;
91
+ }
92
+ const payload = data.slice(CALLBACK_PAGE_PREFIX.length);
93
+ const sepIndex = payload.lastIndexOf(PAGE_SEPARATOR);
94
+ if (sepIndex < 0) {
95
+ return null;
96
+ }
97
+ const pathRef = payload.slice(0, sepIndex);
98
+ const pageNum = Number.parseInt(payload.slice(sepIndex + 1), 10);
99
+ if (Number.isNaN(pageNum)) {
100
+ return null;
101
+ }
102
+ // Resolve indexed path references
103
+ const resolvedPath = pathRef.startsWith("#") ? (pathIndex.get(pathRef) ?? null) : pathRef;
104
+ if (resolvedPath === null) {
105
+ return null;
106
+ }
107
+ return { path: resolvedPath, page: pageNum };
108
+ }
109
+ function buildBrowseKeyboard(entries, currentPath, hasParent, page, totalCount) {
110
+ const keyboard = new InlineKeyboard();
111
+ const totalPages = Math.max(1, Math.ceil(totalCount / MAX_ENTRIES_PER_PAGE));
112
+ // Directory entries
113
+ for (const entry of entries) {
114
+ const label = truncateLabel(buildEntryLabel(entry));
115
+ keyboard.text(label, encodePathForCallback(CALLBACK_NAV_PREFIX, entry.fullPath)).row();
116
+ }
117
+ // Navigation: Up + Back to roots
118
+ // Suppress "Up" when at an allowed root (don't let user navigate above it)
119
+ const atRoot = isAllowedRoot(currentPath);
120
+ const showUp = hasParent && !atRoot;
121
+ const roots = getBrowserRoots();
122
+ const showRoots = roots.length > 1;
123
+ if (showUp || showRoots) {
124
+ if (showUp) {
125
+ const parentPath = path.dirname(currentPath);
126
+ keyboard.text(t("open.back"), encodePathForCallback(CALLBACK_NAV_PREFIX, parentPath));
127
+ }
128
+ if (showRoots) {
129
+ keyboard.text(t("open.roots"), CALLBACK_ROOTS);
130
+ }
131
+ keyboard.row();
132
+ }
133
+ // Pagination
134
+ if (totalPages > 1) {
135
+ if (page > 0) {
136
+ keyboard.text(t("open.prev_page"), encodePaginationCallback(currentPath, page - 1));
137
+ }
138
+ if (page < totalPages - 1) {
139
+ keyboard.text(t("open.next_page"), encodePaginationCallback(currentPath, page + 1));
140
+ }
141
+ keyboard.row();
142
+ }
143
+ // Select current folder
144
+ keyboard
145
+ .text(t("open.select_current"), encodePathForCallback(CALLBACK_SELECT_PREFIX, currentPath))
146
+ .row();
147
+ // Cancel
148
+ appendInlineMenuCancelButton(keyboard, "open");
149
+ return keyboard;
150
+ }
151
+ async function renderBrowseView(dirPath, page = 0) {
152
+ const result = await scanDirectory(dirPath, page);
153
+ if (isScanError(result)) {
154
+ return { error: result.error };
155
+ }
156
+ const { entries, totalCount, page: clampedPage, currentPath, displayPath, hasParent } = result;
157
+ const totalPages = Math.max(1, Math.ceil(totalCount / MAX_ENTRIES_PER_PAGE));
158
+ const header = buildTreeHeader(displayPath, totalCount, clampedPage, totalPages);
159
+ const keyboard = buildBrowseKeyboard(entries, currentPath, hasParent, clampedPage, totalCount);
160
+ return { text: header, keyboard };
161
+ }
162
+ function buildRootsKeyboard() {
163
+ const keyboard = new InlineKeyboard();
164
+ const roots = getBrowserRoots();
165
+ for (const root of roots) {
166
+ const label = truncateLabel(`📂 ${pathToDisplayPath(root)}`);
167
+ keyboard.text(label, encodePathForCallback(CALLBACK_NAV_PREFIX, root)).row();
168
+ }
169
+ appendInlineMenuCancelButton(keyboard, "open");
170
+ return keyboard;
171
+ }
172
+ export async function openCommand(ctx) {
173
+ try {
174
+ if (isForegroundBusy()) {
175
+ await replyBusyBlocked(ctx);
176
+ return;
177
+ }
178
+ // Reset path index for new interaction
179
+ clearOpenPathIndex();
180
+ const roots = getBrowserRoots();
181
+ let text;
182
+ let keyboard;
183
+ if (roots.length === 1) {
184
+ // Single root — navigate directly into it
185
+ const view = await renderBrowseView(roots[0]);
186
+ if ("error" in view) {
187
+ await ctx.reply(t("open.scan_error", { error: view.error }));
188
+ return;
189
+ }
190
+ text = view.text;
191
+ keyboard = view.keyboard;
192
+ }
193
+ else {
194
+ // Multiple roots — show root selection
195
+ text = t("open.select_root");
196
+ keyboard = buildRootsKeyboard();
197
+ }
198
+ const message = await ctx.reply(text, { reply_markup: keyboard });
199
+ interactionManager.start({
200
+ kind: "inline",
201
+ expectedInput: "callback",
202
+ metadata: {
203
+ menuKind: "open",
204
+ messageId: message.message_id,
205
+ },
206
+ });
207
+ }
208
+ catch (error) {
209
+ logger.error("[Bot] Error opening directory browser:", error);
210
+ await ctx.reply(t("open.open_error"));
211
+ }
212
+ }
213
+ export async function handleOpenCallback(ctx) {
214
+ const data = ctx.callbackQuery?.data;
215
+ if (!data || !data.startsWith(CALLBACK_PREFIX)) {
216
+ return false;
217
+ }
218
+ if (isForegroundBusy()) {
219
+ await replyBusyBlocked(ctx);
220
+ return true;
221
+ }
222
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "open");
223
+ if (!isActiveMenu) {
224
+ return true;
225
+ }
226
+ try {
227
+ // Back to root selection (multi-root mode)
228
+ if (data === CALLBACK_ROOTS) {
229
+ await showRoots(ctx);
230
+ return true;
231
+ }
232
+ // Navigate into a directory (including "up")
233
+ const navPath = decodePathFromCallback(CALLBACK_NAV_PREFIX, data);
234
+ if (navPath !== null) {
235
+ if (!isWithinAllowedRoot(navPath)) {
236
+ await ctx.answerCallbackQuery({ text: t("open.access_denied") });
237
+ return true;
238
+ }
239
+ await navigateTo(ctx, navPath);
240
+ return true;
241
+ }
242
+ // Pagination
243
+ const pageInfo = decodePaginationCallback(data);
244
+ if (pageInfo !== null) {
245
+ if (!isWithinAllowedRoot(pageInfo.path)) {
246
+ await ctx.answerCallbackQuery({ text: t("open.access_denied") });
247
+ return true;
248
+ }
249
+ await navigateTo(ctx, pageInfo.path, pageInfo.page);
250
+ return true;
251
+ }
252
+ // Select directory as project
253
+ const selectPath = decodePathFromCallback(CALLBACK_SELECT_PREFIX, data);
254
+ if (selectPath !== null) {
255
+ if (!isWithinAllowedRoot(selectPath)) {
256
+ await ctx.answerCallbackQuery({ text: t("open.access_denied") });
257
+ return true;
258
+ }
259
+ await selectDirectory(ctx, selectPath);
260
+ return true;
261
+ }
262
+ return false;
263
+ }
264
+ catch (error) {
265
+ logger.error("[Bot] Error handling open callback:", error);
266
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
267
+ return true;
268
+ }
269
+ }
270
+ async function showRoots(ctx) {
271
+ const text = t("open.select_root");
272
+ const keyboard = buildRootsKeyboard();
273
+ await ctx.answerCallbackQuery();
274
+ await ctx.editMessageText(text, { reply_markup: keyboard });
275
+ }
276
+ async function navigateTo(ctx, dirPath, page = 0) {
277
+ const view = await renderBrowseView(dirPath, page);
278
+ if ("error" in view) {
279
+ await ctx.answerCallbackQuery({ text: view.error });
280
+ return;
281
+ }
282
+ await ctx.answerCallbackQuery();
283
+ await ctx.editMessageText(view.text, {
284
+ reply_markup: view.keyboard,
285
+ });
286
+ }
287
+ async function selectDirectory(ctx, directory) {
288
+ const displayPath = pathToDisplayPath(directory);
289
+ try {
290
+ logger.info(`[Bot] Adding project directory: ${directory}`);
291
+ // Register directory in the session cache first — getProjectByWorktree
292
+ // needs the entry to exist so it can resolve the project. If the
293
+ // subsequent switch fails, the entry stays in the cache, which is
294
+ // acceptable: it's a real directory the user explicitly selected and
295
+ // will simply appear in /projects for retry.
296
+ await upsertSessionDirectory(directory, Date.now());
297
+ const projectInfo = await getProjectByWorktree(directory);
298
+ const replyKeyboard = await switchToProject(ctx, { ...projectInfo, name: displayPath }, "open_project_selected");
299
+ await ctx.answerCallbackQuery();
300
+ await ctx.reply(t("open.selected", { project: displayPath }), {
301
+ reply_markup: replyKeyboard,
302
+ });
303
+ // Clean up the inline menu message
304
+ await ctx.deleteMessage();
305
+ // Clear path index after selection
306
+ clearOpenPathIndex();
307
+ logger.info(`[Bot] Project added and selected: ${displayPath}`);
308
+ }
309
+ catch (error) {
310
+ logger.error("[Bot] Error selecting directory:", error);
311
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
312
+ await ctx.reply(t("open.select_error"));
313
+ }
314
+ }
@@ -1,17 +1,10 @@
1
1
  import { InlineKeyboard } from "grammy";
2
- import { setCurrentProject, getCurrentProject } from "../../settings/manager.js";
2
+ import { getCurrentProject } from "../../settings/manager.js";
3
3
  import { getProjects } from "../../project/manager.js";
4
4
  import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
5
- import { clearSession } from "../../session/manager.js";
6
- import { summaryAggregator } from "../../summary/aggregator.js";
7
- import { pinnedMessageManager } from "../../pinned/manager.js";
8
- import { keyboardManager } from "../../keyboard/manager.js";
9
- import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
10
- import { getStoredModel } from "../../model/manager.js";
11
- import { formatVariantForButton } from "../../variant/manager.js";
12
- import { clearAllInteractionState } from "../../interaction/cleanup.js";
13
- import { createMainKeyboard } from "../utils/keyboard.js";
14
5
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
6
+ import { switchToProject } from "../utils/switch-project.js";
7
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
15
8
  import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
16
9
  import { logger } from "../../utils/logger.js";
17
10
  import { t } from "../../i18n/index.js";
@@ -179,35 +172,9 @@ export async function handleProjectSelect(ctx) {
179
172
  if (!selectedProject) {
180
173
  throw new Error(`Project with id ${projectId} not found`);
181
174
  }
182
- logger.info(`[Bot] Project selected: ${selectedProject.name || selectedProject.worktree} (id: ${projectId})`);
183
- setCurrentProject(selectedProject);
184
- clearSession();
185
- summaryAggregator.clear();
186
- clearAllInteractionState("project_switched");
187
- // Clear pinned message when switching projects
188
- try {
189
- await pinnedMessageManager.clear();
190
- }
191
- catch (err) {
192
- logger.error("[Bot] Error clearing pinned message:", err);
193
- }
194
- // Initialize keyboard manager if not already
195
- if (ctx.chat) {
196
- keyboardManager.initialize(ctx.api, ctx.chat.id);
197
- }
198
- // Refresh context limit for current model
199
- await pinnedMessageManager.refreshContextLimit();
200
- const contextLimit = pinnedMessageManager.getContextLimit();
201
- // Reset context to 0 (no session selected) with current model's limit
202
- keyboardManager.updateContext(0, contextLimit);
203
- // Get current state for keyboard (with context = 0)
204
- const currentAgent = await resolveProjectAgent(getStoredAgent());
205
- const currentModel = getStoredModel();
206
- const contextInfo = { tokensUsed: 0, tokensLimit: contextLimit };
207
- const variantName = formatVariantForButton(currentModel.variant || "default");
208
- keyboardManager.updateAgent(currentAgent);
209
- const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo, variantName);
210
175
  const projectName = selectedProject.name || selectedProject.worktree;
176
+ logger.info(`[Bot] Project selected: ${projectName} (id: ${projectId})`);
177
+ const keyboard = await switchToProject(ctx, selectedProject, "project_switched");
211
178
  await ctx.answerCallbackQuery();
212
179
  await ctx.reply(t("projects.selected", { project: projectName }), {
213
180
  reply_markup: keyboard,
@@ -9,6 +9,7 @@ import { clearProject } from "../../settings/manager.js";
9
9
  import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
10
10
  import { abortCurrentOperation } from "./abort.js";
11
11
  import { t } from "../../i18n/index.js";
12
+ import { assistantRunState } from "../assistant-run-state.js";
12
13
  export async function startCommand(ctx) {
13
14
  if (ctx.chat) {
14
15
  if (!pinnedMessageManager.isInitialized()) {
@@ -18,6 +19,7 @@ export async function startCommand(ctx) {
18
19
  }
19
20
  await abortCurrentOperation(ctx, { notifyUser: false });
20
21
  foregroundSessionState.clearAll("start_command_reset");
22
+ assistantRunState.clearAll("start_command_reset");
21
23
  clearSession();
22
24
  clearProject();
23
25
  keyboardManager.clearContext();
@@ -3,7 +3,15 @@ import { logger } from "../../utils/logger.js";
3
3
  import { t } from "../../i18n/index.js";
4
4
  const INLINE_MENU_CANCEL_PREFIX = "inline:cancel:";
5
5
  const LEGACY_CONTEXT_CANCEL_CALLBACK = "compact:cancel";
6
- const INLINE_MENU_KINDS = ["project", "session", "model", "agent", "variant", "context"];
6
+ const INLINE_MENU_KINDS = [
7
+ "project",
8
+ "session",
9
+ "model",
10
+ "agent",
11
+ "variant",
12
+ "context",
13
+ "open",
14
+ ];
7
15
  function isInlineMenuKind(value) {
8
16
  return INLINE_MENU_KINDS.includes(value);
9
17
  }