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
13 changes: 10 additions & 3 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ No public inbound ports are required for normal usage.

- Send each completed assistant response after completion signal from SSE
- Show elapsed time for tool calls running longer than 20 seconds, updated on a timer so it keeps counting while a tool blocks without producing output; covers subagent cards and compact mode, and the total duration stays on the finished tool line. A finished subagent card keeps the time its whole run took. Durations use the same `· 🕒 1h 2m 3s` format as the assistant run footer
- A subagent card shows Task, Agent, and Model; when OpenCode sends a variant, the Model line is `provider/id (variant)`
- Render assistant replies with native Telegram formatting: real tables with the column alignment declared in markdown, bullet lists with their nesting, block quotes that keep their nested content, headings, and syntax-highlighted code. Numbered lists and checklists keep literal markers (`1.`, ✅/🔲), because Telegram clients number a native ordered list from zero and do not draw the native checkbox at all
- Deliver reasoning as a collapsed quote that expands on tap
- Hide full model reasoning by default; optionally stream it in the thinking message when explicitly enabled
Expand All @@ -72,7 +73,7 @@ No public inbound ports are required for normal usage.
### Session status in chat

- Keep a pinned status message in the chat
- Show session title, project, model, context usage, and changed files
- Show session title, project, model, context usage, and changed files; when a variant is set, the model line is `provider/id (variant)`
- Auto-update status from SSE and tool events
- Preserve pinned message ID across bot restarts

Expand Down Expand Up @@ -109,7 +110,7 @@ Current command set:
- `/status` - bot version, server, project, and session status
- `/new` - create a new session
- `/abort` - stop the current task
- `/detach` - detach the bot from the current session without stopping it
- `/detach` - detach the bot from the current session without stopping it; a later command or prompt HTTP failure for that session is not posted to chat unless the bot has re-attached to it
- `/sessions` - show and switch recent sessions
- `/messages` - browse user messages in the current session
- `/projects` - show and switch projects
Expand Down Expand Up @@ -148,6 +149,12 @@ Model picker behavior:
- Picking a model opens the variant picker right after the confirmation when the model offers
more than one selectable variant; a model with only `Default` ends at the confirmation

Agent picker behavior:

- Picking an agent applies that agent's configured model and/or variant when the agent names
them; a field the agent does not name is left as it is. This is not a model pick and does
not open the variant menu

### Main features already implemented

- [x] Single-user access control by allowed Telegram user ID
Expand All @@ -158,7 +165,7 @@ Model picker behavior:
- [x] Background notifications for detached/non-current sessions in the currently selected project/worktree
- [x] Telegram-friendly result delivery, including sending generated code/files when needed
- [x] Interactive question and permission handling directly in chat (buttons + custom answers)
- [x] Live pinned session status in chat (project, model, context usage, changed files)
- [x] Live pinned session status in chat (project, model with variant in parentheses when set, context usage, changed files)
- [x] In-chat controls for model, agent, variant, and context
- [x] Built-in and custom command catalog access (`/commands`)
- [x] Trusted local JSON commands from the persistent application home, executed without OpenCode or model tokens
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Languages: English (`en`), العربية (`ar`), Deutsch (`de`), Español (`es`
- **Live status** — pinned message with current project/worktree, 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), or browse all models by provider
- **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
- **Subagent activity** — watch live subagent progress in chat, including the current task, agent, model (with variant when set), and active tool step
- **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
- **Skills Catalog** — browse OpenCode skills from an inline menu and run them immediately or with arguments in the next message
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
Expand Down
19 changes: 14 additions & 5 deletions src/app/formatters/subagent-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ import { formatCompactToolInfo } from "./summary-formatter.js";
import type { SubagentInfo } from "../managers/summary-aggregation-manager.js";
import type { ToolInfo } from "../managers/summary-aggregation-manager.js";

function formatModelDisplayName(providerID?: string | null, modelID?: string | null): string {
if (providerID && modelID) {
return `${providerID}/${modelID}`;
function formatModelDisplayName(
providerID?: string | null,
modelID?: string | null,
variant?: string | null,
): string {
const name = providerID && modelID ? `${providerID}/${modelID}` : t("pinned.unknown");
if (variant) {
return `${name} (${variant})`;
}

return t("pinned.unknown");
return name;
}

function shouldPreferInputDetails(tool: string, input?: { [key: string]: unknown }): boolean {
Expand Down Expand Up @@ -121,7 +126,11 @@ export async function renderSubagentCard(
subagent: SubagentInfo,
now: number = Date.now(),
): Promise<string> {
const modelName = formatModelDisplayName(subagent.providerID, subagent.modelID);
const modelName = formatModelDisplayName(
subagent.providerID,
subagent.modelID,
subagent.variant,
);
const lines = [
`🧩 ${t("subagent.line.task", { task: subagent.description })}`,
t("subagent.line.agent", { agent: subagent.agent }),
Expand Down
7 changes: 7 additions & 0 deletions src/app/managers/summary-aggregation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export interface SubagentInfo {
status: SubagentStatus;
providerID?: string | undefined;
modelID?: string | undefined;
variant?: string | undefined;
tokens: TokensInfo;
cost: number;
currentTool?: string | undefined;
Expand Down Expand Up @@ -693,6 +694,7 @@ class SummaryAggregator {
status: state.status,
providerID: state.providerID,
modelID: state.modelID,
variant: state.variant,
tokens: { ...state.tokens },
cost: state.cost,
currentTool: state.currentTool,
Expand All @@ -718,6 +720,7 @@ class SummaryAggregator {
status: subagent.status,
providerID: subagent.providerID,
modelID: subagent.modelID,
variant: subagent.variant,
tokens: subagent.tokens,
cost: subagent.cost,
currentTool: subagent.currentTool,
Expand Down Expand Up @@ -1005,6 +1008,7 @@ class SummaryAggregator {
sessionID: string;
providerID?: string;
modelID?: string;
variant?: string;
agent?: string;
tokens?: {
input: number;
Expand All @@ -1028,6 +1032,9 @@ class SummaryAggregator {
if (info.modelID) {
subagent.modelID = info.modelID;
}
if (info.variant) {
subagent.variant = info.variant;
}
if (info.tokens) {
subagent.tokens = {
input: info.tokens.input,
Expand Down
58 changes: 58 additions & 0 deletions src/app/services/agent-selection-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { opencodeClient } from "../../opencode/client.js";
import { getCurrentAgent, getCurrentProject, setCurrentAgent } from "../stores/settings-store.js";
import { getCurrentSession } from "./session-service.js";
import { getStoredModel, selectModel } from "./model-selection-service.js";
import { setCurrentVariant } from "./variant-selection-service.js";
import { logger } from "../../utils/logger.js";
import type { AgentInfo } from "../types/agent.js";

Expand Down Expand Up @@ -142,6 +144,62 @@ export function selectAgent(agentName: string): void {
setCurrentAgent(agentName);
}

function configuredField(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

/**
* Apply the listed agent's configured model and/or variant to current settings.
* Independent fields: a missing or empty one is left as it is. Used only by the
* agent picker — selectAgent itself stays a name write.
* @returns true when a model or variant was written (so the pinned dashboard can follow)
*/
export async function applyAgentConfiguredSettings(agentName: string): Promise<boolean> {
try {
const agents = await getAvailableAgents();
const agent = agents.find((entry) => entry.name === agentName);
if (!agent) {
logger.warn(
`[AgentManager] Could not read configured model/variant for agent "${agentName}"; leaving them unchanged`,
);
return false;
}

const providerID = configuredField(agent.model?.providerID);
const modelID = configuredField(agent.model?.modelID);
const variant = configuredField(agent.variant);
const hasModel = Boolean(providerID && modelID);

if (hasModel && providerID && modelID) {
const storedVariant = variant ?? getStoredModel().variant ?? "default";
selectModel({
providerID,
modelID,
variant: storedVariant,
});
logger.info(
`[AgentManager] Applied agent "${agentName}" model ${providerID}/${modelID} (${storedVariant})`,
);
return true;
}

if (variant) {
setCurrentVariant(variant);
logger.info(`[AgentManager] Applied agent "${agentName}" variant ${variant}`);
return true;
}

return false;
} catch (err) {
logger.warn(
`[AgentManager] Failed to apply configured model/variant for agent "${agentName}"; leaving them unchanged`,
err,
);
return false;
}
}

/**
* Get stored agent from settings (synchronous)
* @returns Current agent name or default "build"
Expand Down
11 changes: 7 additions & 4 deletions src/app/services/variant-selection-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { opencodeClient } from "../../opencode/client.js";
import { getCurrentModel, setCurrentModel } from "../stores/settings-store.js";
import { getStoredModel } from "./model-selection-service.js";
import { logger } from "../../utils/logger.js";
import type { VariantInfo } from "../types/variant.js";

Expand Down Expand Up @@ -79,15 +80,17 @@ export function getCurrentVariant(): string {
* @param variantId Variant ID to set
*/
export function setCurrentVariant(variantId: string): void {
const currentModel = getCurrentModel();
const currentModel = getStoredModel();

if (!currentModel) {
if (!currentModel.providerID || !currentModel.modelID) {
logger.warn("[VariantManager] Cannot set variant: no current model");
return;
}

currentModel.variant = variantId;
setCurrentModel(currentModel);
setCurrentModel({
...currentModel,
variant: variantId,
});
logger.info(`[VariantManager] Variant set to: ${variantId}`);
}

Expand Down
5 changes: 5 additions & 0 deletions src/app/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export interface AgentInfo {
mode: "subagent" | "primary" | "all";
hidden?: boolean;
steps?: number;
model?: {
modelID: string;
providerID: string;
};
variant?: string;
}

/**
Expand Down
14 changes: 9 additions & 5 deletions src/bot/callbacks/agent-selection-callback-handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Context } from "grammy";
import { selectAgent } from "../../app/services/agent-selection-service.js";
import {
applyAgentConfiguredSettings,
selectAgent,
} from "../../app/services/agent-selection-service.js";
import { getStoredModel } from "../../app/services/model-selection-service.js";
import { formatVariantForButton } from "../../app/services/variant-selection-service.js";
import { getAgentDisplayName } from "../../app/types/agent.js";
Expand Down Expand Up @@ -41,13 +44,11 @@ export async function handleAgentSelect(ctx: Context): Promise<boolean> {

const agentName = callbackQuery.data.replace("agent:", "");

// Select agent and persist
selectAgent(agentName);
const settingsApplied = await applyAgentConfiguredSettings(agentName);

// Update keyboard manager state
keyboardManager.updateAgent(agentName);

// Update Reply Keyboard with new agent, current model, and context
const currentModel = getStoredModel();
const contextInfo =
pinnedMessageManager.getContextInfo() ??
Expand All @@ -73,9 +74,12 @@ export async function handleAgentSelect(ctx: Context): Promise<boolean> {

clearActiveInlineMenu("agent_selected");

// Send confirmation message with updated keyboard, then drop the inline menu
await switched(ctx, t("agent.changed_message", { name: displayName }), keyboard);

if (settingsApplied) {
await pinnedMessageManager.refresh();
}

return true;
} catch (err) {
clearActiveInlineMenu("agent_select_error");
Expand Down
9 changes: 7 additions & 2 deletions src/bot/callbacks/command-catalog-callback-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { t } from "../../i18n/index.js";
import { cancelMenu } from "./feedback.js";
import { foregroundSessionState } from "../../app/managers/foreground-session-state-manager.js";
import { assistantRunState } from "../../app/managers/assistant-run-state-manager.js";
import { attachManager } from "../../app/managers/attach-manager.js";
import {
attachToSession,
detachAttachedSession,
Expand Down Expand Up @@ -312,7 +313,9 @@ export async function executeCommand(
args,
});
logger.error("[Commands] session.command error details:", error);
void ctx.api.sendMessage(ctx.chat!.id, t("commands.execute_error")).catch(() => {});
if (attachManager.isAttachedSession(session.id)) {
void ctx.api.sendMessage(ctx.chat!.id, t("commands.execute_error")).catch(() => {});
}
return;
}

Expand All @@ -330,7 +333,9 @@ export async function executeCommand(
args,
});
logger.error("[Commands] session.command background failure details:", error);
void ctx.api.sendMessage(ctx.chat!.id, t("commands.execute_error")).catch(() => {});
if (attachManager.isAttachedSession(session.id)) {
void ctx.api.sendMessage(ctx.chat!.id, t("commands.execute_error")).catch(() => {});
}
},
});
}
Expand Down
1 change: 1 addition & 0 deletions src/bot/callbacks/variant-selection-callback-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export async function handleVariantSelect(ctx: Context): Promise<boolean> {

// Send confirmation message with updated keyboard, then drop the inline menu
await switched(ctx, t("variant.changed_message", { name: displayName }), keyboard);
await pinnedMessageManager.refresh();

return true;
} catch (err) {
Expand Down
5 changes: 4 additions & 1 deletion src/bot/commands/status-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ export async function statusCommand(ctx: CommandContext<Context>) {

// Add model information
const currentModel = fetchCurrentModel();
const modelDisplay = `🧠 ${currentModel.providerID}/${currentModel.modelID}`;
const modelName = `${currentModel.providerID}/${currentModel.modelID}`;
const modelDisplay = currentModel.variant
? `🧠 ${modelName} (${currentModel.variant})`
: `🧠 ${modelName}`;
message += `${t("status.line.model", { model: modelDisplay })}\n`;

const currentProject = getCurrentProject();
Expand Down
9 changes: 7 additions & 2 deletions src/bot/handlers/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { logger } from "../../utils/logger.js";
import { t } from "../../i18n/index.js";
import { foregroundSessionState } from "../../app/managers/foreground-session-state-manager.js";
import { assistantRunState } from "../../app/managers/assistant-run-state-manager.js";
import { attachManager } from "../../app/managers/attach-manager.js";
import {
attachToSession,
detachAttachedSession,
Expand Down Expand Up @@ -397,7 +398,9 @@ export async function processUserPrompt(
logger.error("[Bot] session.promptAsync raw API error object:", error);

// Send user-friendly error via API directly because ctx is no longer available
void bot.api.sendMessage(ctx.chat!.id, t("bot.prompt_send_error")).catch(() => {});
if (attachManager.isAttachedSession(currentSession.id)) {
void bot.api.sendMessage(ctx.chat!.id, t("bot.prompt_send_error")).catch(() => {});
}
return;
}

Expand All @@ -412,7 +415,9 @@ export async function processUserPrompt(
logger.error("[Bot] session.promptAsync background task failed", promptErrorLogContext);
logger.error("[Bot] session.promptAsync background failure details:", details);
logger.error("[Bot] session.promptAsync raw background error object:", error);
void bot.api.sendMessage(ctx.chat!.id, t("bot.prompt_send_error")).catch(() => {});
if (attachManager.isAttachedSession(currentSession.id)) {
void bot.api.sendMessage(ctx.chat!.id, t("bot.prompt_send_error")).catch(() => {});
}
},
});

Expand Down
8 changes: 7 additions & 1 deletion src/bot/pinned/pinned-message-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@ export function formatTokenCount(count: number): string {
export function formatModelDisplayName(
providerID?: string | null,
modelID?: string | null,
variant?: string | null,
): string {
if (providerID && modelID) {
return `${providerID}/${modelID}`;
const name = `${providerID}/${modelID}`;
if (variant) {
return `${name} (${variant})`;
}

return name;
}

return t("pinned.unknown");
Expand Down
6 changes: 5 additions & 1 deletion src/bot/pinned/pinned-message-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,11 @@ class PinnedMessageManager {
*/
private formatMessage(): string {
const currentModel = getStoredModel();
const modelName = formatModelDisplayName(currentModel.providerID, currentModel.modelID);
const modelName = formatModelDisplayName(
currentModel.providerID,
currentModel.modelID,
currentModel.variant,
);
const projectDisplayName = this.state.projectBranch
? `${this.state.projectPath}: ${this.state.projectBranch}`
: this.state.projectPath;
Expand Down
Loading
Loading