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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ This project follows Semantic Versioning.

## Unreleased

## [0.11.3] - 2026-06-06

### Fixed

- Added timeout-specific Telegram failure replies and safe timeout classifications for nested OpenCode prompt errors. (#57)
- Increased the default gateway prompt completion timeout to 35 minutes and configured the project OpenCode provider timeout to 30 minutes for long research prompts. (#57)

## [0.11.2] - 2026-06-06

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Login startup is user-level and project-folder scoped. `opencode-remote startup

On startup, the gateway checks `opencode.apiUrl`. If it is reachable, the gateway uses that server. If it is not reachable and `opencode.autoStart=true`, the gateway starts `opencode.command serve` and waits for it to become reachable before starting Telegram polling. For local `localhost` and `127.0.0.1` API URLs with a port, auto-start passes that port as `--port` so newer OpenCode CLI versions do not bind a random port. The gateway exits with an error if OpenCode is still unreachable after about 60 seconds. Before polling starts, the gateway refreshes Telegram's slash-command menu for default and private chats.

OpenCode prompt requests use async prompt admission when available, then wait for the matching assistant completion event. `opencode.promptTimeoutMs`, defaulting to 30 minutes, is the completion deadline. The gateway controller serializes prompt sends through the selected active session, and the OpenCode client accepts child subagent session tool, permission, and safe session-error events while that active prompt is running.
OpenCode prompt requests use async prompt admission when available, then wait for the matching assistant completion event. `opencode.promptTimeoutMs`, defaulting to 35 minutes, is the gateway completion deadline. OpenCode provider/model request timeouts are configured separately in OpenCode and may fail long runs earlier; for long research prompts, use a 30-minute provider timeout and keep the gateway deadline above it. The gateway controller serializes prompt sends through the selected active session, and the OpenCode client accepts child subagent session tool, permission, and safe session-error events while that active prompt is running.

If the gateway started the OpenCode child process, it stops that child during shutdown. It does not stop an OpenCode server that was already running.

Expand Down
2 changes: 1 addition & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s
- Optional local OpenCode startup with `opencode.autoStart=true`.
- OpenCode session creation, listing, switching, prompt sending, and stop requests.
- OpenCode permission requests surfaced in Telegram with inline approve/deny buttons.
- Long-running OpenCode prompts use a configurable `opencode.promptTimeoutMs` timeout, defaulting to 30 minutes.
- Long-running OpenCode prompts use a configurable `opencode.promptTimeoutMs` timeout, defaulting to 35 minutes.
- Editable Telegram activity messages showing OpenCode tool and skill usage during prompts.
- Telegram-safe response chunking for long assistant replies.
- Published npm CLI package with the `opencode-remote` bin built to `dist/` with `tsdown`.
Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ The config file is JSON:
"opencode": {
"apiUrl": "http://localhost:4096",
"autoStart": true,
"promptTimeoutMs": 1800000
"promptTimeoutMs": 2100000
},
"voice": {
"enabled": false,
Expand All @@ -145,7 +145,20 @@ The config file is JSON:

`opencode.apiUrl` controls the OpenCode server URL. It defaults to `http://localhost:4096`. When `opencode.autoStart=true` and this URL points to `localhost` or `127.0.0.1` with a port, the gateway starts `opencode serve --port <port>` so it waits on the same URL it configured.

`opencode.promptTimeoutMs` controls how long the gateway waits for OpenCode to complete a prompt. It defaults to `1800000` milliseconds, or 30 minutes, so slower provider runs and complex subagent workflows have time to finish.
`opencode.promptTimeoutMs` controls how long the gateway waits for OpenCode to complete a prompt. It defaults to `2100000` milliseconds, or 35 minutes, so slower provider runs and complex subagent workflows have time to finish. This is separate from OpenCode's provider timeout. If deep research or slow model calls fail after about 5 minutes, increase the provider timeout in your OpenCode config to 30 minutes and keep the gateway wait above it, for example:

```json
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"anthropic": {
"options": {
"timeout": 1800000
}
}
}
}
```

`progressVerbosity` controls the startup default for the prompt activity message in private chats. Supported values are `off`, `new`, `all`, and `verbose`. The default is `verbose`. The Telegram `/progress` command can change this at runtime in private chats. Group chats always suppress the `Activity` message.

Expand All @@ -162,7 +175,7 @@ opencode-remote config set voice.enabled true
opencode-remote config set voice.groqApiKey gsk_...
opencode-remote config set voice.mode all -g
opencode-remote config set voice.captions true
opencode-remote config set opencode.promptTimeoutMs 1800000
opencode-remote config set opencode.promptTimeoutMs 2100000
```

Clear generated voice files from the app-data cache:
Expand Down
7 changes: 7 additions & 0 deletions opencode.jsonc
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"anthropic": {
"options": {
"timeout": 1800000
}
}
},
"skills": {
"paths": ["./skills/development"]
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@crankshift/opencode-remote",
"description": "A messenger-based chat interface for OpenCode, starting with Telegram.",
"version": "0.11.2",
"version": "0.11.3",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
79 changes: 75 additions & 4 deletions src/adapters/telegram/bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
} from "./voice.js"

const SAFE_ERROR_REPLY = "OpenCode Remote failed while handling that request."
const TIMEOUT_ERROR_REPLY =
"OpenCode timed out while handling that request. For long research prompts, check the OpenCode provider timeout and opencode.promptTimeoutMs, then try again."

export async function registerTelegramBotCommands(bot, logger) {
for (const { commands, scope } of [
Expand Down Expand Up @@ -156,10 +158,14 @@ export function createTelegramBot({
if (typeof bot.catch === "function") {
bot.catch(async (botError) => {
const logError = logger.error ?? logger.warn
logError.call(logger, { error: botError.error }, "Telegram update handling failed")
logError.call(
logger,
safeTelegramErrorLogContext(botError.error),
"Telegram update handling failed",
)
try {
if (botError.ctx?.reply) {
await replyAndRemember(botError.ctx, SAFE_ERROR_REPLY, botMessageMemory)
await replyAndRemember(botError.ctx, telegramErrorReply(botError.error), botMessageMemory)
}
} catch (replyError) {
logger.warn({ error: replyError }, "Could not send Telegram error reply")
Expand Down Expand Up @@ -843,9 +849,13 @@ export function createTelegramBot({
await handlePhotoMessages(ctx, messages)
} catch (error) {
const logError = logger.error ?? logger.warn
logError.call(logger, { error }, "Telegram media group handling failed")
logError.call(
logger,
safeTelegramErrorLogContext(error),
"Telegram media group handling failed",
)
try {
await replyAndRemember(ctx, SAFE_ERROR_REPLY, botMessageMemory)
await replyAndRemember(ctx, telegramErrorReply(error), botMessageMemory)
} catch (replyError) {
logger.warn({ error: replyError }, "Could not send Telegram error reply")
}
Expand Down Expand Up @@ -1763,6 +1773,67 @@ function voiceCaptionForText(text, voiceService) {
return text.length <= TELEGRAM_VOICE_CAPTION_LIMIT ? text : null
}

function telegramErrorReply(error) {
return hasTimeoutError(error) ? TIMEOUT_ERROR_REPLY : SAFE_ERROR_REPLY
}

function safeTelegramErrorLogContext(error) {
const context = {
errorName: safeErrorName(error),
errorKind: classifyTelegramError(error),
}
const cause = error?.cause
if (cause) {
context.causeName = safeErrorName(cause)
context.causeKind = classifyTelegramError(cause)
}
return context
}

function hasTimeoutError(error) {
const seen = new Set()
let current = error
while (current && typeof current === "object" && !seen.has(current)) {
seen.add(current)
if (classifyTelegramError(current) === "timeout") {
return true
}
current = current.cause
}
return false
}

function classifyTelegramError(error) {
if (safeErrorName(error) === "GatewayOpenCodeError") {
return "opencode"
}
const text = [error?.name, error?.code, error?.message]
.filter((value) => typeof value === "string")
.join(" ")
.toLocaleLowerCase("en-US")

if (text.includes("timeout") || text.includes("timed out")) {
return "timeout"
}
if (text.includes("opencode")) {
return "opencode"
}
return "unknown"
}

function safeErrorName(error) {
return firstNonEmptyString(error?.name, error?.type, error?.code) ?? "UnknownError"
}

function firstNonEmptyString(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim()
}
}
return undefined
}

function createTelegramProgressRenderer({ ctx, logger, verbosity, editThrottleMs }) {
const state = createProgressTextState({ verbosity })
const enabled = state.verbosity !== "off"
Expand Down
2 changes: 1 addition & 1 deletion src/bin/program.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function createGatewayProgram({
const program = new Command()
const afterCreate = createStartupAfterConfigHook({ enableGatewayStartup, output })

program.name("opencode-remote").description("OpenCode messaging gateway").version("0.11.2")
program.name("opencode-remote").description("OpenCode messaging gateway").version("0.11.3")

program
.command("setup")
Expand Down
2 changes: 1 addition & 1 deletion src/config/loadConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const SETTINGS_FILE_NAME = "settings.json"
const progressVerbositySchema = z.enum(["off", "new", "all", "verbose"])
const voiceModeSchema = z.enum(["off", "on", "all"])
const logLevelSchema = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
const DEFAULT_OPENCODE_PROMPT_TIMEOUT_MS = 1_800_000
const DEFAULT_OPENCODE_PROMPT_TIMEOUT_MS = 2_100_000
const positiveTelegramIdSchema = z.coerce
.number()
.int()
Expand Down
32 changes: 32 additions & 0 deletions tests/adapters/telegramBot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,38 @@ describe("createTelegramBot", () => {
expect(reply).toHaveBeenCalledWith("OpenCode Remote failed while handling that request.")
})

test("error handler classifies timeout failures without raw provider details", async () => {
const logger = { warn: vi.fn(), error: vi.fn() }
const bot = createTelegramBot({
token: "token",
telegram: testTelegram(),
controller: {},
logger,
botFactory: FakeBot,
})
const timeoutCause = new Error("OpenCode assistant message failed: TimeoutError")
const error = new Error("Could not send prompt to OpenCode")
error.name = "GatewayOpenCodeError"
error.cause = timeoutCause
const reply = vi.fn(async () => undefined)

await bot.errorHandler({ ctx: { reply }, error })

expect(logger.error).toHaveBeenCalledWith(
{
errorName: "GatewayOpenCodeError",
errorKind: "opencode",
causeName: "Error",
causeKind: "timeout",
},
"Telegram update handling failed",
)
expect(JSON.stringify(logger.error.mock.calls)).not.toContain("TimeoutError")
expect(reply).toHaveBeenCalledWith(
"OpenCode timed out while handling that request. For long research prompts, check the OpenCode provider timeout and opencode.promptTimeoutMs, then try again.",
)
})

test("sessions command truncates labels and uses bounded callback data", async () => {
const longTitle = "a".repeat(120)
const longId = "ses_".padEnd(120, "x")
Expand Down
2 changes: 1 addition & 1 deletion tests/config/loadConfig.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe("loadConfig", () => {
apiUrl: "http://localhost:4096",
command: "opencode",
autoStart: true,
promptTimeoutMs: 1_800_000,
promptTimeoutMs: 2_100_000,
workdir: cwd,
},
progressVerbosity: "verbose",
Expand Down