@grinev/opencode-telegram-bot 0.21.2 → 0.22.1

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 (37) hide show
  1. package/.env.example +0 -15
  2. package/README.md +15 -8
  3. package/dist/app/formatters/summary-formatter.js +16 -0
  4. package/dist/app/managers/summary-aggregation-manager.js +174 -10
  5. package/dist/app/services/file-download-service.js +51 -2
  6. package/dist/app/services/scheduled-task-executor-service.js +22 -7
  7. package/dist/app/stores/settings-store.js +46 -4
  8. package/dist/bot/callbacks/callback-router.js +4 -1
  9. package/dist/bot/callbacks/scheduled-task-callback-handler.js +20 -1
  10. package/dist/bot/callbacks/settings-callback-handler.js +105 -0
  11. package/dist/bot/commands/definitions.js +1 -1
  12. package/dist/bot/commands/settings-command.js +10 -0
  13. package/dist/bot/commands/status-command.js +7 -2
  14. package/dist/bot/handlers/document-handler.js +1 -1
  15. package/dist/bot/handlers/media-group-handler.js +1 -1
  16. package/dist/bot/handlers/prompt.js +3 -3
  17. package/dist/bot/handlers/voice-handler.js +5 -2
  18. package/dist/bot/menus/inline-menu.js +3 -1
  19. package/dist/bot/menus/settings-menu.js +52 -0
  20. package/dist/bot/messages/thinking-message.js +1 -4
  21. package/dist/bot/messages/thinking-rendering.js +66 -0
  22. package/dist/bot/routers/command-router.js +2 -2
  23. package/dist/bot/services/event-subscription-service.js +304 -30
  24. package/dist/bot/streaming/compact-progress-streamer.js +168 -0
  25. package/dist/bot/streaming/finalize-assistant-response.js +9 -3
  26. package/dist/bot/streaming/response-streamer.js +16 -1
  27. package/dist/config.js +0 -15
  28. package/dist/i18n/ar.js +31 -6
  29. package/dist/i18n/de.js +32 -7
  30. package/dist/i18n/en.js +32 -7
  31. package/dist/i18n/es.js +32 -7
  32. package/dist/i18n/fr.js +32 -7
  33. package/dist/i18n/ru.js +32 -7
  34. package/dist/i18n/zh.js +32 -7
  35. package/dist/opencode/process.js +40 -3
  36. package/package.json +1 -1
  37. package/dist/bot/commands/tts-command.js +0 -13
package/.env.example CHANGED
@@ -83,12 +83,6 @@ OPENCODE_MODEL_ID=big-pickle
83
83
  # Send scheduled task result/error messages without Telegram push notifications (default: false)
84
84
  # SCHEDULED_TASK_DISABLE_NOTIFICATION=false
85
85
 
86
- # Response streaming mode: "edit" (default) or "draft" (default: edit)
87
- # edit = uses editMessageText to update messages incrementally (may flicker)
88
- # draft = uses sendMessageDraft (Bot API 9.5+) for smooth animated draft previews,
89
- # then finalizes with sendMessage. Only works in private chats.
90
- # RESPONSE_STREAMING_MODE=edit
91
-
92
86
  # Stream update throttle in milliseconds for assistant/tool message edits (default: 1000)
93
87
  # Higher value = fewer Telegram edit requests, lower value = more real-time updates
94
88
  # RESPONSE_STREAM_THROTTLE_MS=1000
@@ -101,15 +95,6 @@ OPENCODE_MODEL_ID=big-pickle
101
95
  # Supported locales: en, ar, de, es, fr, ru, zh
102
96
  # BOT_LOCALE=en
103
97
 
104
- # Hide thinking indicator messages (default: false)
105
- # HIDE_THINKING_MESSAGES=false
106
-
107
- # Hide tool call service messages (default: false)
108
- # HIDE_TOOL_CALL_MESSAGES=false
109
-
110
- # Hide tool file edit documents sent as .txt attachments (default: false)
111
- # HIDE_TOOL_FILE_MESSAGES=false
112
-
113
98
  # Track background sessions in the currently selected project/worktree (default: true)
114
99
  # Sends short notifications when detached or non-current sessions in the current directory reply,
115
100
  # ask questions, or request permissions. Other projects/worktrees are not tracked.
package/README.md CHANGED
@@ -38,7 +38,8 @@ Languages: English (`en`), العربية (`ar`), Deutsch (`de`), Español (`es`
38
38
  - **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
39
39
  - **Skills Catalog** — browse OpenCode skills from an inline menu and run them immediately or with arguments in the next message
40
40
  - **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
41
- - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, and optionally enable spoken replies with `/tts`
41
+ - **Runtime settings** — use `/settings` to change runtime preferences; see [Runtime Settings](#runtime-settings)
42
+ - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, and optionally enable spoken replies in `/settings`
42
43
  - **File attachments** — send images, PDF documents, and text-based files to OpenCode, including multiple files in one Telegram album
43
44
  - **Scheduled tasks** — schedule prompts to run later or on a recurring interval; see [Scheduled Tasks](#scheduled-tasks)
44
45
  - **Context control** — compact context when it gets too large, right from the chat
@@ -141,7 +142,7 @@ opencode-telegram config
141
142
  | `/worktree` | Switch between existing git worktrees |
142
143
  | `/open` | Add a project by browsing directories |
143
144
  | `/ls` | List directory contents, then tap to open or download |
144
- | `/tts` | Toggle audio replies |
145
+ | `/settings` | Change bot settings |
145
146
  | `/rename` | Rename the current session |
146
147
  | `/commands` | Browse and run custom commands |
147
148
  | `/skills` | Browse and run OpenCode skills |
@@ -229,12 +230,8 @@ When installed via npm, the configuration wizard handles the initial setup. The
229
230
  | `SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES` | Maximum time the bot waits for one scheduled task run before marking it failed | No | `120` |
230
231
  | `SCHEDULED_TASK_DISABLE_NOTIFICATION` | Send scheduled task result/error messages without Telegram push notifications | No | `false` |
231
232
  | `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` |
232
- | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
233
- | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
234
- | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
235
- | `HIDE_TOOL_FILE_MESSAGES` | Hide file edit documents sent as `.txt` attachments (`edit_*.txt`, `write_*.txt`) | No | `false` |
236
233
  | `TRACK_BACKGROUND_SESSIONS` | Track detached/non-current sessions in the current selected project/worktree and send short notifications | No | `true` |
237
- | `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
234
+ | `RESPONSE_STREAM_THROTTLE_MS` | Stream update throttle in milliseconds for assistant, thinking, and tool message edits | No | `1000` |
238
235
  | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
239
236
  | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
240
237
  | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
@@ -255,6 +252,16 @@ When installed via npm, the configuration wizard handles the initial setup. The
255
252
 
256
253
  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`.
257
254
 
255
+ ### Runtime Settings
256
+
257
+ Runtime preferences are changed from `/settings` and stored in `settings.json`:
258
+
259
+ - Compact output mode
260
+ - Thinking content display
261
+ - Diff file attachments
262
+ - Response streaming mode: `edit` or `draft (experimental)`; applies only to final assistant replies, not thinking messages
263
+ - Audio replies: `off`, `all`, or `auto` when TTS is configured
264
+
258
265
  ### Reverse Proxy (Optional)
259
266
 
260
267
  For environments that block `api.telegram.org` but allow your own HTTPS endpoint (corporate networks, restricted regions), you can route Bot API traffic through a reverse proxy you control. This is an alternative to the SOCKS/HTTP forward proxy configured with `TELEGRAM_PROXY_URL`.
@@ -314,7 +321,7 @@ If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
314
321
 
315
322
  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.
316
323
 
317
- If TTS credentials are configured, you can toggle spoken replies globally with `/tts`. The preference is stored in `settings.json` and persists across restarts.
324
+ If TTS credentials are configured, you can choose spoken reply behavior in `/settings`: `off` disables audio replies, `all` sends audio for every assistant reply, and `auto` sends audio only after voice/audio prompts. The preference is stored in `settings.json` and persists across restarts.
318
325
 
319
326
  OpenAI-compatible TTS configuration example:
320
327
 
@@ -227,6 +227,22 @@ export function formatCompactToolInfo(toolInfo, maxLength = 64, fallback = "-")
227
227
  }
228
228
  return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
229
229
  }
230
+ export function formatCompactToolActivity(toolInfo, maxLength = 64) {
231
+ if (!hasCompactToolDetails(toolInfo)) {
232
+ return null;
233
+ }
234
+ const formatted = formatCompactToolInfo(toolInfo, maxLength, "").trim();
235
+ return formatted || null;
236
+ }
237
+ function hasCompactToolDetails(toolInfo) {
238
+ if (typeof toolInfo.title === "string" && toolInfo.title.trim().length > 0) {
239
+ return true;
240
+ }
241
+ if (toolInfo.tool === "todowrite" && Array.isArray(toolInfo.metadata?.todos)) {
242
+ return true;
243
+ }
244
+ return getToolDetails(toolInfo.tool, toolInfo.input).trim().length > 0;
245
+ }
230
246
  function countLines(text) {
231
247
  return text.split("\n").length;
232
248
  }
@@ -38,6 +38,7 @@ function normalizeSnapshotValue(value) {
38
38
  class SummaryAggregator {
39
39
  currentSessionId = null;
40
40
  textMessageStates = new Map();
41
+ thinkingMessageStates = new Map();
41
42
  messages = new Map();
42
43
  messageCount = 0;
43
44
  lastUpdated = 0;
@@ -45,10 +46,12 @@ class SummaryAggregator {
45
46
  onPartialCallback = null;
46
47
  onExternalUserInputCallback = null;
47
48
  onToolCallback = null;
49
+ onRootToolUpdateCallback = null;
48
50
  onToolFileCallback = null;
49
51
  onQuestionCallback = null;
50
52
  onQuestionErrorCallback = null;
51
53
  onThinkingCallback = null;
54
+ onThinkingFinishedCallback = null;
52
55
  onTokensCallback = null;
53
56
  onCostCallback = null;
54
57
  onSubagentCallback = null;
@@ -62,6 +65,7 @@ class SummaryAggregator {
62
65
  onClearedCallback = null;
63
66
  processedToolStates = new Set();
64
67
  thinkingFiredForMessages = new Set();
68
+ thinkingFinishedForMessages = new Set();
65
69
  deliveredExternalUserMessageIds = new Set();
66
70
  knownTextPartIds = new Map();
67
71
  bot = null;
@@ -93,6 +97,9 @@ class SummaryAggregator {
93
97
  setOnTool(callback) {
94
98
  this.onToolCallback = callback;
95
99
  }
100
+ setOnRootToolUpdate(callback) {
101
+ this.onRootToolUpdateCallback = callback;
102
+ }
96
103
  setOnToolFile(callback) {
97
104
  this.onToolFileCallback = callback;
98
105
  }
@@ -105,6 +112,9 @@ class SummaryAggregator {
105
112
  setOnThinking(callback) {
106
113
  this.onThinkingCallback = callback;
107
114
  }
115
+ setOnThinkingFinished(callback) {
116
+ this.onThinkingFinishedCallback = callback;
117
+ }
108
118
  setOnTokens(callback) {
109
119
  this.onTokensCallback = callback;
110
120
  }
@@ -242,11 +252,13 @@ class SummaryAggregator {
242
252
  this.stopTypingIndicator();
243
253
  this.currentSessionId = null;
244
254
  this.textMessageStates.clear();
255
+ this.thinkingMessageStates.clear();
245
256
  this.messages.clear();
246
257
  this.partHashes.clear();
247
258
  this.knownTextPartIds.clear();
248
259
  this.processedToolStates.clear();
249
260
  this.thinkingFiredForMessages.clear();
261
+ this.thinkingFinishedForMessages.clear();
250
262
  this.deliveredExternalUserMessageIds.clear();
251
263
  this.trackedSessionParents.clear();
252
264
  this.subagentStates.clear();
@@ -755,26 +767,38 @@ class SummaryAggregator {
755
767
  this.registerKnownTextPart(messageID, part.id);
756
768
  this.registerTextPart(messageID, part.id);
757
769
  }
770
+ if (part.type === "reasoning") {
771
+ this.registerThinkingPart(messageID, part.id, this.extractReasoningTitle(part));
772
+ }
758
773
  const deltaFromUpdated = event.properties.delta;
759
774
  if (part.type === "text" &&
760
775
  typeof deltaFromUpdated === "string" &&
761
776
  deltaFromUpdated.length > 0) {
777
+ this.emitThinkingFinishedOnce(part.sessionID, messageID);
762
778
  this.applyTextDelta(part.sessionID, messageID, part.id, deltaFromUpdated, part.text);
763
779
  this.lastUpdated = Date.now();
764
780
  return;
765
781
  }
782
+ if (part.type === "reasoning" &&
783
+ typeof deltaFromUpdated === "string" &&
784
+ deltaFromUpdated.length > 0) {
785
+ const partText = "text" in part && typeof part.text === "string" ? part.text : undefined;
786
+ this.applyThinkingDelta(part.sessionID, messageID, part.id, deltaFromUpdated, partText, this.extractReasoningTitle(part));
787
+ this.lastUpdated = Date.now();
788
+ return;
789
+ }
766
790
  if (part.type === "reasoning") {
767
- // Fire the thinking callback once per message on the first reasoning part.
768
- // This is the signal that the model is actually doing extended thinking.
769
- if (!this.thinkingFiredForMessages.has(messageID) && this.onThinkingCallback) {
791
+ // Fire the thinking callback on every reasoning update. The first update
792
+ // preserves the old lightweight indicator behavior for callers that do
793
+ // not display full reasoning content.
794
+ const isFirstUpdate = !this.thinkingFiredForMessages.has(messageID);
795
+ if (isFirstUpdate) {
770
796
  this.thinkingFiredForMessages.add(messageID);
771
- const callback = this.onThinkingCallback;
772
- const sessionID = part.sessionID;
773
- setImmediate(() => {
774
- if (typeof callback === "function") {
775
- callback(sessionID);
776
- }
777
- });
797
+ }
798
+ const partText = "text" in part && typeof part.text === "string" ? part.text : "";
799
+ const wasUpdated = this.setThinkingPartSnapshot(messageID, part.id, partText, this.extractReasoningTitle(part));
800
+ if (isFirstUpdate || wasUpdated) {
801
+ this.emitThinkingUpdate(part.sessionID, messageID, isFirstUpdate);
778
802
  }
779
803
  }
780
804
  else if (part.type === "text" && "text" in part && part.text) {
@@ -784,6 +808,7 @@ class SummaryAggregator {
784
808
  if (!wasUpdated) {
785
809
  return;
786
810
  }
811
+ this.emitThinkingFinishedOnce(part.sessionID, messageID);
787
812
  const fullText = this.getCombinedMessageText(messageID);
788
813
  if (messageInfo && messageInfo.role === "assistant") {
789
814
  this.startTypingIndicator();
@@ -808,6 +833,19 @@ class SummaryAggregator {
808
833
  this.updateSubagentFromTaskTool(part.sessionID, input);
809
834
  }
810
835
  logger.debug(`[Aggregator] Tool event: callID=${part.callID}, tool=${part.tool}, status=${"status" in state ? state.status : "unknown"}`);
836
+ if (this.onRootToolUpdateCallback) {
837
+ this.onRootToolUpdateCallback({
838
+ sessionId: part.sessionID,
839
+ messageId: messageID,
840
+ callId: part.callID,
841
+ tool: part.tool,
842
+ state: part.state,
843
+ input,
844
+ title,
845
+ metadata: "metadata" in state ? state.metadata : undefined,
846
+ hasFileAttachment: false,
847
+ });
848
+ }
811
849
  if (part.tool === "question") {
812
850
  logger.debug(`[Aggregator] Question tool part update:`, JSON.stringify(part, null, 2));
813
851
  // If the question tool fails, clear the active poll
@@ -871,6 +909,13 @@ class SummaryAggregator {
871
909
  if (!sessionID || !messageID || typeof delta !== "string" || delta.length === 0) {
872
910
  return;
873
911
  }
912
+ if (partType === "reasoning" || (!partType && this.isKnownThinkingPart(messageID, partID))) {
913
+ const title = part
914
+ ? this.extractReasoningTitle(part)
915
+ : undefined;
916
+ this.applyThinkingDelta(sessionID, messageID, partID, delta, part?.text, title);
917
+ return;
918
+ }
874
919
  if (partType && partType !== "text") {
875
920
  return;
876
921
  }
@@ -890,6 +935,7 @@ class SummaryAggregator {
890
935
  this.registerTextPart(messageID, partID);
891
936
  }
892
937
  }
938
+ this.emitThinkingFinishedOnce(sessionID, messageID);
893
939
  this.applyTextDelta(sessionID, messageID, partID, delta, part?.text);
894
940
  }
895
941
  applyTextDelta(sessionID, messageID, partID, delta, fullTextHint) {
@@ -916,6 +962,121 @@ class SummaryAggregator {
916
962
  this.startTypingIndicator();
917
963
  this.emitPartialText(sessionID, messageID, combined);
918
964
  }
965
+ extractReasoningTitle(part) {
966
+ for (const key of ["title", "heading", "summary", "name"]) {
967
+ const value = part[key];
968
+ if (typeof value === "string" && value.trim()) {
969
+ return value;
970
+ }
971
+ }
972
+ const metadata = part.metadata;
973
+ if (metadata && typeof metadata === "object") {
974
+ for (const key of ["title", "heading", "summary", "name"]) {
975
+ const value = metadata[key];
976
+ if (typeof value === "string" && value.trim()) {
977
+ return value;
978
+ }
979
+ }
980
+ }
981
+ return undefined;
982
+ }
983
+ getOrCreateThinkingMessageState(messageID) {
984
+ let state = this.thinkingMessageStates.get(messageID);
985
+ if (!state) {
986
+ state = {
987
+ orderedPartIds: [],
988
+ sections: new Map(),
989
+ };
990
+ this.thinkingMessageStates.set(messageID, state);
991
+ }
992
+ return state;
993
+ }
994
+ isKnownThinkingPart(messageID, partID) {
995
+ return this.thinkingMessageStates.get(messageID)?.sections.has(partID) ?? false;
996
+ }
997
+ registerThinkingPart(messageID, partID, title) {
998
+ const state = this.getOrCreateThinkingMessageState(messageID);
999
+ if (!state.orderedPartIds.includes(partID)) {
1000
+ state.orderedPartIds.push(partID);
1001
+ }
1002
+ const existing = state.sections.get(partID);
1003
+ if (!existing) {
1004
+ const section = { id: partID, text: "" };
1005
+ if (title) {
1006
+ section.title = title;
1007
+ }
1008
+ state.sections.set(partID, section);
1009
+ return;
1010
+ }
1011
+ if (title && existing.title !== title) {
1012
+ existing.title = title;
1013
+ }
1014
+ }
1015
+ setThinkingPartSnapshot(messageID, partID, text, title) {
1016
+ this.registerThinkingPart(messageID, partID, title);
1017
+ const state = this.getOrCreateThinkingMessageState(messageID);
1018
+ const existing = state.sections.get(partID);
1019
+ const nextTitle = title ?? existing?.title;
1020
+ if (existing && existing.text === text && existing.title === nextTitle) {
1021
+ return false;
1022
+ }
1023
+ const next = { id: partID, text };
1024
+ if (nextTitle) {
1025
+ next.title = nextTitle;
1026
+ }
1027
+ state.sections.set(partID, next);
1028
+ return true;
1029
+ }
1030
+ applyThinkingDelta(sessionID, messageID, partID, delta, fullTextHint, title) {
1031
+ if (sessionID !== this.currentSessionId) {
1032
+ return;
1033
+ }
1034
+ this.registerThinkingPart(messageID, partID, title);
1035
+ const state = this.getOrCreateThinkingMessageState(messageID);
1036
+ const existing = state.sections.get(partID);
1037
+ const previous = existing?.text ?? "";
1038
+ let accumulated = `${previous}${delta}`;
1039
+ if (typeof fullTextHint === "string" && fullTextHint.length > accumulated.length) {
1040
+ accumulated = fullTextHint;
1041
+ }
1042
+ this.setThinkingPartSnapshot(messageID, partID, accumulated, title ?? existing?.title);
1043
+ this.emitThinkingUpdate(sessionID, messageID, false);
1044
+ }
1045
+ getThinkingSections(messageID) {
1046
+ const state = this.thinkingMessageStates.get(messageID);
1047
+ if (!state) {
1048
+ return [];
1049
+ }
1050
+ return state.orderedPartIds
1051
+ .map((partID) => state.sections.get(partID))
1052
+ .filter((section) => Boolean(section))
1053
+ .map((section) => ({ ...section }));
1054
+ }
1055
+ emitThinkingUpdate(sessionId, messageId, isFirstUpdate) {
1056
+ if (!this.onThinkingCallback) {
1057
+ return;
1058
+ }
1059
+ const sections = this.getThinkingSections(messageId);
1060
+ if (sections.length === 0) {
1061
+ return;
1062
+ }
1063
+ const callback = this.onThinkingCallback;
1064
+ setImmediate(() => {
1065
+ callback({ sessionId, messageId, sections, isFirstUpdate });
1066
+ });
1067
+ }
1068
+ emitThinkingFinishedOnce(sessionId, messageId) {
1069
+ if (!this.onThinkingFinishedCallback ||
1070
+ !this.thinkingFiredForMessages.has(messageId) ||
1071
+ this.thinkingFinishedForMessages.has(messageId)) {
1072
+ return;
1073
+ }
1074
+ this.thinkingFinishedForMessages.add(messageId);
1075
+ const callback = this.onThinkingFinishedCallback;
1076
+ setImmediate(() => {
1077
+ callback(sessionId, messageId);
1078
+ });
1079
+ }
919
1080
  emitExternalUserInputIfReady(sessionId, messageId) {
920
1081
  if (sessionId !== this.currentSessionId || this.deliveredExternalUserMessageIds.has(messageId)) {
921
1082
  return;
@@ -942,9 +1103,12 @@ class SummaryAggregator {
942
1103
  }
943
1104
  cleanupCompletedMessage(messageId) {
944
1105
  this.textMessageStates.delete(messageId);
1106
+ this.thinkingMessageStates.delete(messageId);
945
1107
  this.messages.delete(messageId);
946
1108
  this.partHashes.delete(messageId);
947
1109
  this.knownTextPartIds.delete(messageId);
1110
+ this.thinkingFiredForMessages.delete(messageId);
1111
+ this.thinkingFinishedForMessages.delete(messageId);
948
1112
  if (this.textMessageStates.size === 0) {
949
1113
  logger.debug("[Aggregator] No more active messages, stopping typing indicator");
950
1114
  this.stopTypingIndicator();
@@ -79,12 +79,61 @@ const APPLICATION_TEXT_MIME_TYPES = new Set([
79
79
  "application/x-yaml",
80
80
  "application/sql",
81
81
  ]);
82
- export function isTextMimeType(mimeType) {
82
+ const TEXT_FILE_EXTENSIONS = new Set([
83
+ "svelte",
84
+ "vue",
85
+ "ts",
86
+ "tsx",
87
+ "jsx",
88
+ "mjs",
89
+ "cjs",
90
+ "go",
91
+ "rs",
92
+ "rb",
93
+ "py",
94
+ "java",
95
+ "c",
96
+ "cpp",
97
+ "h",
98
+ "hpp",
99
+ "cs",
100
+ "swift",
101
+ "kt",
102
+ "kts",
103
+ "sh",
104
+ "bash",
105
+ "yaml",
106
+ "yml",
107
+ "toml",
108
+ "ini",
109
+ "cfg",
110
+ "md",
111
+ "mdx",
112
+ "css",
113
+ "scss",
114
+ "less",
115
+ "html",
116
+ "htm",
117
+ "graphql",
118
+ "gql",
119
+ "proto",
120
+ "gradle",
121
+ ]);
122
+ export function isTextMimeType(mimeType, filename) {
83
123
  if (!mimeType) {
84
124
  return false;
85
125
  }
86
126
  if (mimeType.startsWith("text/")) {
87
127
  return true;
88
128
  }
89
- return APPLICATION_TEXT_MIME_TYPES.has(mimeType);
129
+ if (APPLICATION_TEXT_MIME_TYPES.has(mimeType)) {
130
+ return true;
131
+ }
132
+ if (filename) {
133
+ const ext = filename.split(".").pop()?.toLowerCase();
134
+ if (ext && TEXT_FILE_EXTENSIONS.has(ext)) {
135
+ return true;
136
+ }
137
+ }
138
+ return false;
90
139
  }
@@ -7,6 +7,8 @@ export const SCHEDULED_TASK_AGENT = "build";
7
7
  const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
8
8
  const EXECUTION_POLL_INTERVAL_MS = 2000;
9
9
  const MAX_IDLE_POLLS_WITHOUT_RESULT = 3;
10
+ // Grace period for the server to start the session before any activity is seen.
11
+ const MAX_STARTUP_POLLS_WITHOUT_ACTIVITY = 45;
10
12
  const COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS = 500;
11
13
  const MAX_COMPLETED_EMPTY_RESULT_RECHECKS = 3;
12
14
  const MODELS_DOCS_URL = "https://opencode.ai/docs/config/#models";
@@ -258,6 +260,8 @@ async function waitForScheduledTaskResult(taskId, sessionId, directory) {
258
260
  const startedAtMs = Date.now();
259
261
  const executionTimeoutMs = getExecutionTimeoutMs();
260
262
  let idlePollsWithoutResult = 0;
263
+ let startupPollsWithoutActivity = 0;
264
+ let hasObservedActivity = false;
261
265
  let completedEmptyResultReadCount = 0;
262
266
  while (true) {
263
267
  if (Date.now() - startedAtMs >= executionTimeoutMs) {
@@ -288,7 +292,13 @@ async function waitForScheduledTaskResult(taskId, sessionId, directory) {
288
292
  throw statusError || new Error("Failed to load scheduled task status");
289
293
  }
290
294
  const sessionStatus = statuses[sessionId];
291
- if (!sessionStatus || sessionStatus.type === "idle") {
295
+ const sessionIsActive = sessionStatus !== undefined && sessionStatus.type !== "idle";
296
+ if (sessionIsActive) {
297
+ hasObservedActivity = true;
298
+ idlePollsWithoutResult = 0;
299
+ startupPollsWithoutActivity = 0;
300
+ }
301
+ else {
292
302
  const confirmedAssistantResult = await loadAssistantResult(sessionId, directory);
293
303
  if (confirmedAssistantResult.errorMessage) {
294
304
  throw new Error(confirmedAssistantResult.errorMessage);
@@ -305,13 +315,18 @@ async function waitForScheduledTaskResult(taskId, sessionId, directory) {
305
315
  await sleep(COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS);
306
316
  continue;
307
317
  }
308
- idlePollsWithoutResult += 1;
309
- if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
310
- throw new Error("Scheduled task finished without a completed assistant response");
318
+ if (hasObservedActivity) {
319
+ idlePollsWithoutResult += 1;
320
+ if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
321
+ throw new Error("Scheduled task finished without a completed assistant response");
322
+ }
323
+ }
324
+ else {
325
+ startupPollsWithoutActivity += 1;
326
+ if (startupPollsWithoutActivity >= MAX_STARTUP_POLLS_WITHOUT_ACTIVITY) {
327
+ throw new Error("Scheduled task did not start producing a response in time");
328
+ }
311
329
  }
312
- }
313
- else {
314
- idlePollsWithoutResult = 0;
315
330
  }
316
331
  await sleep(EXECUTION_POLL_INTERVAL_MS);
317
332
  }
@@ -66,11 +66,46 @@ export function clearSession() {
66
66
  currentSettings.currentSession = undefined;
67
67
  void writeSettingsFile(currentSettings);
68
68
  }
69
- export function isTtsEnabled() {
70
- return currentSettings.ttsEnabled === true;
69
+ export function getTtsMode() {
70
+ return currentSettings.ttsMode ?? "off";
71
71
  }
72
- export function setTtsEnabled(enabled) {
73
- currentSettings.ttsEnabled = enabled;
72
+ export function setTtsMode(mode) {
73
+ currentSettings.ttsMode = mode;
74
+ void writeSettingsFile(currentSettings);
75
+ }
76
+ export function getCompactOutputMode() {
77
+ return currentSettings.compactOutputMode ?? false;
78
+ }
79
+ export function setCompactOutputMode(enabled) {
80
+ currentSettings.compactOutputMode = enabled;
81
+ void writeSettingsFile(currentSettings);
82
+ }
83
+ export function getShowThinkingContent() {
84
+ return currentSettings.showThinkingContent ?? true;
85
+ }
86
+ export function setShowThinkingContent(enabled) {
87
+ currentSettings.showThinkingContent = enabled;
88
+ void writeSettingsFile(currentSettings);
89
+ }
90
+ export function getShowAssistantRunFooter() {
91
+ return currentSettings.showAssistantRunFooter ?? true;
92
+ }
93
+ export function setShowAssistantRunFooter(enabled) {
94
+ currentSettings.showAssistantRunFooter = enabled;
95
+ void writeSettingsFile(currentSettings);
96
+ }
97
+ export function getResponseStreamingMode() {
98
+ return currentSettings.responseStreamingMode === "draft" ? "draft" : "edit";
99
+ }
100
+ export function setResponseStreamingMode(mode) {
101
+ currentSettings.responseStreamingMode = mode;
102
+ void writeSettingsFile(currentSettings);
103
+ }
104
+ export function getSendDiffFileAttachments() {
105
+ return currentSettings.sendDiffFileAttachments ?? true;
106
+ }
107
+ export function setSendDiffFileAttachments(enabled) {
108
+ currentSettings.sendDiffFileAttachments = enabled;
74
109
  void writeSettingsFile(currentSettings);
75
110
  }
76
111
  export function getCurrentAgent() {
@@ -146,6 +181,13 @@ export async function loadSettings() {
146
181
  delete loadedSettings.serverProcess;
147
182
  requiresRewrite = true;
148
183
  }
184
+ // Migrate old ttsEnabled boolean to new ttsMode
185
+ if ("ttsEnabled" in loadedSettings) {
186
+ const oldEnabled = loadedSettings.ttsEnabled;
187
+ loadedSettings.ttsMode = oldEnabled === true ? "all" : "off";
188
+ delete loadedSettings.ttsEnabled;
189
+ requiresRewrite = true;
190
+ }
149
191
  currentSettings = loadedSettings;
150
192
  currentSettings.scheduledTasks = cloneScheduledTasks(loadedSettings.scheduledTasks) ?? [];
151
193
  currentSettings.scheduledTaskSessionIgnores =
@@ -13,6 +13,7 @@ import { handlePermissionCallback } from "./permission-callback-handler.js";
13
13
  import { handleProjectSelect } from "./project-callback-handler.js";
14
14
  import { handleQuestionCallback } from "./question-callback-handler.js";
15
15
  import { handleRenameCancel } from "./rename-callback-handler.js";
16
+ import { handleSettingsCallback } from "./settings-callback-handler.js";
16
17
  import { handleBackgroundSessionOpen, handleSessionSelect, } from "./session-callback-handler.js";
17
18
  import { handleSkillsCallback } from "./skills-catalog-callback-handler.js";
18
19
  import { handleTaskCallback, handleTaskListCallback, } from "./scheduled-task-callback-handler.js";
@@ -57,6 +58,7 @@ export function registerCallbackRouter(bot, deps) {
57
58
  const handledModelSearchResults = await handleModelSearchResults(ctx);
58
59
  const handledModel = await handleModelSelect(ctx);
59
60
  const handledVariant = await handleVariantSelect(ctx);
61
+ const handledSettings = await handleSettingsCallback(ctx);
60
62
  const handledCompactConfirm = await handleCompactConfirm(ctx);
61
63
  const handledTask = await handleTaskCallback(ctx);
62
64
  const handledTaskList = await handleTaskListCallback(ctx);
@@ -74,7 +76,7 @@ export function registerCallbackRouter(bot, deps) {
74
76
  ensureEventSubscription: deps.ensureEventSubscription,
75
77
  });
76
78
  const handledMcps = await handleMcpsCallback(ctx);
77
- logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, modelSearch=${handledModelSearch}, modelSearchResults=${handledModelSearchResults}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, messages=${handledMessages}, skills=${handledSkills}, mcps=${handledMcps}`);
79
+ logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, modelSearch=${handledModelSearch}, modelSearchResults=${handledModelSearchResults}, model=${handledModel}, variant=${handledVariant}, settings=${handledSettings}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, messages=${handledMessages}, skills=${handledSkills}, mcps=${handledMcps}`);
78
80
  if (!handledBackgroundSession &&
79
81
  !handledInlineCancel &&
80
82
  !handledSession &&
@@ -89,6 +91,7 @@ export function registerCallbackRouter(bot, deps) {
89
91
  !handledModelSearchResults &&
90
92
  !handledModel &&
91
93
  !handledVariant &&
94
+ !handledSettings &&
92
95
  !handledCompactConfirm &&
93
96
  !handledTask &&
94
97
  !handledTaskList &&
@@ -100,12 +100,31 @@ function formatDateTime(dateIso, timezone) {
100
100
  return dateIso;
101
101
  }
102
102
  }
103
+ const TASK_DETAIL_PROMPT_BYTE_BUDGET = 3400;
104
+ function truncatePromptForDetails(prompt) {
105
+ if (Buffer.byteLength(prompt, "utf-8") <= TASK_DETAIL_PROMPT_BYTE_BUDGET) {
106
+ return prompt;
107
+ }
108
+ const budget = TASK_DETAIL_PROMPT_BYTE_BUDGET - 3;
109
+ let lo = 0;
110
+ let hi = prompt.length;
111
+ while (lo < hi) {
112
+ const mid = (lo + hi + 1) >>> 1;
113
+ if (Buffer.byteLength(prompt.slice(0, mid), "utf-8") <= budget) {
114
+ lo = mid;
115
+ }
116
+ else {
117
+ hi = mid - 1;
118
+ }
119
+ }
120
+ return `${prompt.slice(0, lo)}...`;
121
+ }
103
122
  function formatTaskDetails(task) {
104
123
  const variant = task.model.variant ? ` (${task.model.variant})` : "";
105
124
  const model = `${task.model.providerID}/${task.model.modelID}${variant}`;
106
125
  const cronLine = task.kind === "cron" ? `${t("tasklist.details.cron", { cron: task.cron })}\n` : "";
107
126
  return t("tasklist.details", {
108
- prompt: task.prompt,
127
+ prompt: truncatePromptForDetails(task.prompt),
109
128
  project: `${task.projectWorktree}\n${t("status.line.model", { model })}`,
110
129
  schedule: task.scheduleSummary,
111
130
  cronLine,