Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Open tasks for upcoming iterations:
- [ ] `/messages` command: browse session messages with fork/revert actions
- [ ] `/skills` command: browse skills and choose one for usage
- [ ] `/mcps` command: browse available MCP servers
- [ ] Dynamic subagent activity display during task execution
- [x] Dynamic subagent activity display during task execution
- [ ] Git tree support
- [ ] Docker runtime support and deployment guide
- [ ] OpenCode server monitoring with automatic restart on stop/crash
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
- **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time
- **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
- **Agent modes** — switch between Plan and Build modes on the fly
- **Subagent activity** — watch live subagent progress in chat, including the current task, agent, model, and active tool step
- **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
- **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
Expand Down
84 changes: 68 additions & 16 deletions src/bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
formatToolInfo,
getAssistantParseMode,
} from "../summary/formatter.js";
import { renderSubagentCards } from "../summary/subagent-formatter.js";
import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
import { getCurrentSession } from "../session/manager.js";
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
Expand Down Expand Up @@ -89,6 +90,7 @@ const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
const RESPONSE_STREAM_THROTTLE_MS = 200;
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
const SESSION_RETRY_PREFIX = "🔁";
const SUBAGENT_STREAM_PREFIX = "🧩";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TEMP_DIR = path.join(__dirname, "..", ".tmp");
Expand Down Expand Up @@ -401,7 +403,7 @@ async function ensureEventSubscription(directory: string): Promise<void> {
const chatId = chatIdInstance;

try {
const streamedViaMessages = await finalizeAssistantResponse({
await finalizeAssistantResponse({
responseStreaming: config.bot.responseStreaming,
sessionId,
messageId,
Expand All @@ -425,16 +427,16 @@ async function ensureEventSubscription(directory: string): Promise<void> {
format,
});
},
deleteMessages: async (messageIds) => {
for (const msgId of messageIds) {
try {
await botApi.deleteMessage(chatId, msgId);
} catch (err) {
logger.warn(`[Bot] Failed to delete streamed message ${msgId}:`, err);
}
}
},
});

if (streamedViaMessages) {
logger.debug(
`[Bot] Final assistant message already streamed (session=${sessionId}, message=${messageId})`,
);
foregroundSessionState.markIdle(sessionId);
await scheduledTaskRuntime.flushDeferredDeliveries();
return;
}
} catch (err) {
logger.error("Failed to send message to Telegram:", err);
// Stop processing events after critical error to prevent infinite loop
Expand All @@ -461,7 +463,11 @@ async function ensureEventSubscription(directory: string): Promise<void> {
toolInfo.hasFileAttachment &&
(toolInfo.tool === "write" || toolInfo.tool === "edit" || toolInfo.tool === "apply_patch");

if (config.bot.hideToolCallMessages || shouldIncludeToolInfoInFileCaption) {
if (
config.bot.hideToolCallMessages ||
shouldIncludeToolInfoInFileCaption ||
toolInfo.tool === "task"
) {
return;
}

Expand All @@ -475,6 +481,32 @@ async function ensureEventSubscription(directory: string): Promise<void> {
}
});

summaryAggregator.setOnSubagent(async (sessionId, subagents) => {
if (!botInstance || !chatIdInstance) {
return;
}

if (config.bot.hideToolCallMessages) {
return;
}

const currentSession = getCurrentSession();
if (!currentSession || currentSession.id !== sessionId) {
return;
}

try {
const renderedCards = await renderSubagentCards(subagents);
if (!renderedCards) {
return;
}

toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, renderedCards);
} catch (err) {
logger.error("Failed to render subagent activity for Telegram:", err);
}
});

summaryAggregator.setOnToolFile(async (fileInfo) => {
if (!botInstance || !chatIdInstance) {
logger.error("Bot or chat ID not available for sending file");
Expand Down Expand Up @@ -582,25 +614,45 @@ async function ensureEventSubscription(directory: string): Promise<void> {
responseStreaming: config.bot.responseStreaming,
hideThinkingMessages: config.bot.hideThinkingMessages,
});

// Refresh pinned message so it shows the latest in-memory context
// (accumulated from silent token updates). 1 API call per thinking event.
if (pinnedMessageManager.isInitialized()) {
await pinnedMessageManager.refresh();
}
});

summaryAggregator.setOnTokens(async (tokens) => {
summaryAggregator.setOnTokens(async (tokens, isCompleted) => {
if (!pinnedMessageManager.isInitialized()) {
return;
}

try {
logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}`);
logger.debug(
`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}, completed=${isCompleted}`,
);

// Update keyboardManager SYNCHRONOUSLY before any await
// This ensures keyboard has correct context when onComplete sends the reply
const contextSize = tokens.input + tokens.cacheRead;
const contextLimit = pinnedMessageManager.getContextLimit();

// Skip non-completed messages with zero context: a new assistant message
// starts with tokens={input:0, ...} which would overwrite valid context
// from the previous step. Only accept zeros from completed messages.
if (!isCompleted && contextSize === 0) {
logger.debug("[Bot] Skipping zero-token intermediate update");
return;
}

// Update both keyboard and pinned state in memory (keeps them in sync)
if (contextLimit > 0) {
keyboardManager.updateContext(contextSize, contextLimit);
}
pinnedMessageManager.updateTokensSilent(tokens);

await pinnedMessageManager.onMessageComplete(tokens);
// Full pinned message update (API call) only on completed messages
if (isCompleted) {
await pinnedMessageManager.onMessageComplete(tokens);
}
} catch (err) {
logger.error("[Bot] Error updating pinned message with tokens:", err);
}
Expand Down
18 changes: 13 additions & 5 deletions src/bot/streaming/response-streamer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export interface StreamingMessagePayload {
editOptions?: TelegramEditMessageOptions;
}

export interface StreamCompleteResult {
streamed: boolean;
telegramMessageIds: number[];
}

interface ResponseStreamerCompleteOptions {
flushFinal?: boolean;
}
Expand Down Expand Up @@ -142,10 +147,12 @@ export class ResponseStreamer {
messageId: string,
payload?: StreamingMessagePayload,
options?: ResponseStreamerCompleteOptions,
): Promise<boolean> {
): Promise<StreamCompleteResult> {
const notStreamed: StreamCompleteResult = { streamed: false, telegramMessageIds: [] };

const state = this.states.get(buildStateKey(sessionId, messageId));
if (!state) {
return false;
return notStreamed;
}

if (payload) {
Expand All @@ -163,13 +170,13 @@ export class ResponseStreamer {
await this.cleanupBrokenStream(state, "complete_broken_stream");
this.cancelState(state);
this.states.delete(state.key);
return false;
return notStreamed;
}

if (state.telegramMessageIds.length === 0) {
this.cancelState(state);
this.states.delete(state.key);
return false;
return notStreamed;
}

let synced = true;
Expand All @@ -180,9 +187,10 @@ export class ResponseStreamer {
}
}

const messageIds = [...state.telegramMessageIds];
this.cancelState(state);
this.states.delete(state.key);
return synced;
return { streamed: synced, telegramMessageIds: messageIds };
}

clearMessage(sessionId: string, messageId: string, reason: string): void {
Expand Down
21 changes: 17 additions & 4 deletions src/bot/utils/finalize-assistant-response.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { StreamingMessagePayload, ResponseStreamer } from "../streaming/response-streamer.js";
import type { TelegramTextFormat } from "./telegram-text.js";
import { logger } from "../../utils/logger.js";

interface FinalizeAssistantResponseOptions {
responseStreaming: boolean;
Expand All @@ -17,6 +18,7 @@ interface FinalizeAssistantResponseOptions {
options: { reply_markup: unknown } | undefined,
format: TelegramTextFormat,
) => Promise<void>;
deleteMessages: (messageIds: number[]) => Promise<void>;
}

export async function finalizeAssistantResponse({
Expand All @@ -31,8 +33,9 @@ export async function finalizeAssistantResponse({
resolveFormat,
getReplyKeyboard,
sendText,
deleteMessages,
}: FinalizeAssistantResponseOptions): Promise<boolean> {
let streamedViaMessages = false;
let streamedMessageIds: number[] = [];

if (responseStreaming) {
const preparedStreamPayload = prepareStreamingPayload(messageText);
Expand All @@ -41,17 +44,27 @@ export async function finalizeAssistantResponse({
preparedStreamPayload.editOptions = undefined;
}

streamedViaMessages = await responseStreamer.complete(
const result = await responseStreamer.complete(
sessionId,
messageId,
preparedStreamPayload ?? undefined,
);

if (result.streamed) {
streamedMessageIds = result.telegramMessageIds;
}
}

await flushPendingServiceMessages();

if (streamedViaMessages) {
return true;
// When the response was streamed, delete the streamed messages and re-send
// via the non-streamed path so the reply keyboard carries the latest context.
if (streamedMessageIds.length > 0) {
try {
await deleteMessages(streamedMessageIds);
} catch (err) {
logger.warn("[FinalizeResponse] Failed to delete streamed messages, sending with keyboard anyway:", err);
}
}

const parts = formatSummary(messageText);
Expand Down
12 changes: 12 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,18 @@ export const de: I18nDictionary = {
"pinned.line.model": "Modell: {model}",
"pinned.line.context": "Kontext: {used} / {limit} ({percent}%)",
"pinned.line.cost": "Kosten: {cost} ausgegeben",
"subagent.header": "Subagent {agent}: {description}",
"subagent.line.status": "Status: {status}",
"subagent.line.task": "Aufgabe: {task}",
"subagent.line.agent": "Agent: {agent}",
"subagent.working": "Arbeitet...",
"subagent.working_with_details": "Arbeitet: {details}",
"subagent.completed": "Abgeschlossen",
"subagent.failed": "Aufgabe fehlgeschlagen",
"subagent.status.pending": "ausstehend",
"subagent.status.running": "laeuft",
"subagent.status.completed": "abgeschlossen",
"subagent.status.error": "Fehler",
"pinned.files.title": "Dateien ({count}):",
"pinned.files.item": " {path}{diff}",
"pinned.files.more": " ... und {count} mehr",
Expand Down
12 changes: 12 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,18 @@ export const en = {
"pinned.line.model": "Model: {model}",
"pinned.line.context": "Context: {used} / {limit} ({percent}%)",
"pinned.line.cost": "Cost: {cost} spent",
"subagent.header": "Subagent {agent}: {description}",
"subagent.line.status": "Status: {status}",
"subagent.line.task": "Task: {task}",
"subagent.line.agent": "Agent: {agent}",
"subagent.working": "Working...",
"subagent.working_with_details": "Working: {details}",
"subagent.completed": "Completed",
"subagent.failed": "Task failed",
"subagent.status.pending": "pending",
"subagent.status.running": "running",
"subagent.status.completed": "completed",
"subagent.status.error": "error",
"pinned.files.title": "Files ({count}):",
"pinned.files.item": " {path}{diff}",
"pinned.files.more": " ... and {count} more",
Expand Down
12 changes: 12 additions & 0 deletions src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,18 @@ export const es: I18nDictionary = {
"pinned.line.model": "Modelo: {model}",
"pinned.line.context": "Contexto: {used} / {limit} ({percent}%)",
"pinned.line.cost": "Costo: {cost} gastado",
"subagent.header": "Subagente {agent}: {description}",
"subagent.line.status": "Estado: {status}",
"subagent.line.task": "Tarea: {task}",
"subagent.line.agent": "Agente: {agent}",
"subagent.working": "Trabajando...",
"subagent.working_with_details": "Trabajando: {details}",
"subagent.completed": "Completada",
"subagent.failed": "Error de tarea",
"subagent.status.pending": "pendiente",
"subagent.status.running": "en ejecucion",
"subagent.status.completed": "completado",
"subagent.status.error": "error",
"pinned.files.title": "Archivos ({count}):",
"pinned.files.item": " {path}{diff}",
"pinned.files.more": " ... y {count} más",
Expand Down
12 changes: 12 additions & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,18 @@ export const fr: I18nDictionary = {
"pinned.line.model": "Modèle : {model}",
"pinned.line.context": "Contexte : {used} / {limit} ({percent}%)",
"pinned.line.cost": "Coût : {cost} dépensé",
"subagent.header": "Sous-agent {agent} : {description}",
"subagent.line.status": "Statut : {status}",
"subagent.line.task": "Tache : {task}",
"subagent.line.agent": "Agent : {agent}",
"subagent.working": "En cours...",
"subagent.working_with_details": "En cours : {details}",
"subagent.completed": "Terminee",
"subagent.failed": "Echec de la tache",
"subagent.status.pending": "en attente",
"subagent.status.running": "en cours",
"subagent.status.completed": "termine",
"subagent.status.error": "erreur",
"pinned.files.title": "Fichiers ({count}) :",
"pinned.files.item": " {path}{diff}",
"pinned.files.more": " ... et encore {count}",
Expand Down
12 changes: 12 additions & 0 deletions src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,18 @@ export const ru: I18nDictionary = {
"pinned.line.model": "Модель: {model}",
"pinned.line.context": "Контекст: {used} / {limit} ({percent}%)",
"pinned.line.cost": "Стоимость: {cost} потрачено",
"subagent.header": "Сабагент {agent}: {description}",
"subagent.line.status": "Статус: {status}",
"subagent.line.task": "Задача: {task}",
"subagent.line.agent": "Агент: {agent}",
"subagent.working": "В работе...",
"subagent.working_with_details": "В работе: {details}",
"subagent.completed": "Завершена",
"subagent.failed": "Ошибка задачи",
"subagent.status.pending": "ожидание",
"subagent.status.running": "в работе",
"subagent.status.completed": "завершен",
"subagent.status.error": "ошибка",
"pinned.files.title": "Файлы ({count}):",
"pinned.files.item": " {path}{diff}",
"pinned.files.more": " ... и еще {count}",
Expand Down
12 changes: 12 additions & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,18 @@ export const zh: I18nDictionary = {
"pinned.line.model": "模型: {model}",
"pinned.line.context": "上下文: {used} / {limit} ({percent}%)",
"pinned.line.cost": "费用: {cost}",
"subagent.header": "子代理 {agent}: {description}",
"subagent.line.status": "状态: {status}",
"subagent.line.task": "任务: {task}",
"subagent.line.agent": "代理: {agent}",
"subagent.working": "执行中...",
"subagent.working_with_details": "执行中: {details}",
"subagent.completed": "已完成",
"subagent.failed": "任务失败",
"subagent.status.pending": "等待中",
"subagent.status.running": "运行中",
"subagent.status.completed": "已完成",
"subagent.status.error": "错误",
"pinned.files.title": "文件({count}):",
"pinned.files.item": " {path}{diff}",
"pinned.files.more": " ... 还有 {count} 个",
Expand Down
Loading
Loading