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

## Unreleased

## [0.11.1] - 2026-06-06

### Fixed

- Fixed long OpenCode prompt runs by using async prompt admission plus completion events instead of holding the prompt HTTP request open until transport timeout. (#53)

## [0.11.0] - 2026-06-06

### Added
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 `opencode.promptTimeoutMs`, defaulting to 30 minutes, as the SDK request timeout. 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 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.

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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ 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 an OpenCode prompt request before the SDK times out. 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 `1800000` milliseconds, or 30 minutes, so slower provider runs and complex subagent workflows have time to finish.

`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 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.0",
"version": "0.11.1",
"license": "MIT",
"repository": {
"type": "git",
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.0")
program.name("opencode-remote").description("OpenCode messaging gateway").version("0.11.1")

program
.command("setup")
Expand Down
169 changes: 161 additions & 8 deletions src/core/opencode/client.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto"
import { createOpencodeClient } from "@opencode-ai/sdk"

export class GatewayOpenCodeError extends Error {
Expand All @@ -21,9 +22,6 @@ export function createOpenCodeClient({
baseUrl: apiUrl,
responseStyle: "data",
throwOnError: true,
...(Number.isInteger(promptTimeoutMs) && promptTimeoutMs > 0
? { timeout: promptTimeoutMs }
: {}),
})

return {
Expand All @@ -44,12 +42,25 @@ export function createOpenCodeClient({
},

async sendPrompt(sessionId, prompt, options = {}) {
const progressStream = await startPromptEventStream(client, sessionId, options)
const promptBody = toPromptBody(prompt)
const asyncPrompt = createAsyncPromptCompletion(sessionId, promptTimeoutMs)
const progressStream = await startPromptEventStream(client, sessionId, {
...options,
onEvent: asyncPrompt.handleEvent,
})
try {
if (typeof client.session?.promptAsync === "function" && progressStream.active) {
await client.session.promptAsync({
path: { id: sessionId },
body: { ...promptBody, messageID: asyncPrompt.messageId },
})
return await asyncPrompt.wait()
}

const response = toData(
await client.session.prompt({
path: { id: sessionId },
body: toPromptBody(prompt),
body: promptBody,
}),
)
return extractText(response)
Expand Down Expand Up @@ -145,8 +156,12 @@ export function createOpenCodeClient({
}

async function startPromptEventStream(client, sessionId, options = {}) {
const { onProgress, onSystemEvent, includeChildSessionEvents = false } = options
if (typeof onProgress !== "function" && typeof onSystemEvent !== "function") {
const { onProgress, onSystemEvent, onEvent, includeChildSessionEvents = false } = options
if (
typeof onProgress !== "function" &&
typeof onSystemEvent !== "function" &&
typeof onEvent !== "function"
) {
return noopProgressStream()
}
const eventOptions = { includeChildSessionEvents }
Expand All @@ -169,6 +184,11 @@ async function startPromptEventStream(client, sessionId, options = {}) {
if (stopped) {
break
}
if (typeof onEvent === "function") {
activeCallback = runEventCallback(onEvent, event)
await activeCallback
}

const progress = normalizeOpenCodeProgressEvent(event, sessionId, eventOptions)
if (progress && typeof onProgress === "function") {
activeCallback = runEventCallback(onProgress, progress)
Expand All @@ -187,6 +207,7 @@ async function startPromptEventStream(client, sessionId, options = {}) {
})()

return {
active: true,
async stop() {
stopped = true
eventStream.abort()
Expand Down Expand Up @@ -233,7 +254,139 @@ async function runEventCallback(callback, event) {
}

function noopProgressStream() {
return { stop: async () => undefined }
return { active: false, stop: async () => undefined }
}

function createAsyncPromptCompletion(sessionId, timeoutMs) {
const messageId = randomUUID()
const textPartsByMessage = new Map()
let assistantMessageId = null
let settled = false
let timeoutId = null
let timeoutStarted = false
let resolveCompletion
let rejectCompletion
const completion = new Promise((resolve, reject) => {
resolveCompletion = resolve
rejectCompletion = reject
})

return {
messageId,
handleEvent(event) {
if (settled) {
return
}
recordAssistantTextPart(event)
completeMatchingAssistantMessage(event)
},
wait() {
startTimeout()
return completion
},
}

function startTimeout() {
if (timeoutStarted || !Number.isInteger(timeoutMs) || timeoutMs <= 0) {
return
}
timeoutStarted = true
timeoutId = setTimeout(() => {
settleReject(new Error("OpenCode prompt did not complete before timeout"))
}, timeoutMs)
timeoutId.unref?.()
}

function recordAssistantTextPart(event) {
if (event?.type !== "message.part.updated") {
return
}
const properties = eventProperties(event)
const part = properties.part
if (part?.type !== "text" || part.ignored === true) {
return
}
const partSessionId = firstString(part.sessionID, part.sessionId, properties.sessionID)
const partMessageId = firstString(part.messageID, part.messageId, properties.messageID)
if (!partSessionId || partSessionId !== sessionId || !partMessageId) {
return
}
if (assistantMessageId && partMessageId !== assistantMessageId) {
return
}
const partId = firstString(part.id, part.partID, part.partId) ?? partMessageId
const text = typeof part.text === "string" ? part.text : properties.delta
if (typeof text !== "string") {
return
}

let textParts = textPartsByMessage.get(partMessageId)
if (!textParts) {
textParts = new Map()
textPartsByMessage.set(partMessageId, textParts)
}
textParts.set(partId, text)
}

function completeMatchingAssistantMessage(event) {
if (event?.type !== "message.updated") {
return
}
const info = eventProperties(event).info
if (!isMatchingAssistantMessage(info)) {
return
}
assistantMessageId = firstString(info.id, info.messageID, info.messageId)
if (info.error) {
settleReject(new Error(`OpenCode assistant message failed: ${safeErrorName(info.error)}`))
return
}
if (info.time?.completed === undefined && !firstString(info.finish)) {
return
}
settleResolve(extractAsyncPromptText())
}

function isMatchingAssistantMessage(info) {
return (
info?.role === "assistant" &&
firstString(info.sessionID, info.sessionId) === sessionId &&
firstString(info.parentID, info.parentId) === messageId
)
}

function extractAsyncPromptText() {
const textParts = textPartsByMessage.get(assistantMessageId)
if (!textParts) {
return "OpenCode returned no text response."
}
return [...textParts.values()].join("\n") || "OpenCode returned no text response."
}

function settleResolve(text) {
if (settled) {
return
}
settled = true
clearCompletionTimeout()
resolveCompletion(text)
}

function settleReject(error) {
if (settled) {
return
}
settled = true
clearCompletionTimeout()
rejectCompletion(error)
}

function clearCompletionTimeout() {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
}
}

function toPromptBody(prompt) {
Expand Down
Loading