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

## Unreleased

## [0.11.0] - 2026-06-06

### Added

- Added configurable `opencode.promptTimeoutMs` for long-running OpenCode prompt requests, defaulting to 30 minutes. (#51)
- Added active-prompt handling for child subagent session tool, permission, and safe session-error events. (#51)

### Changed

- Changed gateway prompt sends to serialize through the selected active session so complex OpenCode runs do not overlap accidentally. (#51)
- Removed generated-media guidance that blocked delegated generation work. (#51)

## [0.10.5] - 2026-05-31

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ 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.

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.

## Build
Expand Down
5 changes: 4 additions & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +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.
- 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 Expand Up @@ -43,7 +44,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s
- Telegram text, photo, album, voice, and sticker prompts include safe author context, including forwarded original authors and messages sent by anonymous admins or on behalf of chats/channels when Telegram provides usable names.
- The bot shows Telegram typing activity while a prompt is running.
- In private chats, the bot can show an editable `Activity` message with OpenCode tools and skills used during a prompt. Group chats always suppress this activity message.
- OpenCode permission requests are sent as text with `Allow once`, `Always allow`, and `Deny` buttons, even when voice replies are enabled.
- OpenCode permission requests, including child subagent session permission requests, are sent as text with `Allow once`, `Always allow`, and `Deny` buttons, even when voice replies are enabled.
- Incoming text prompts get a temporary eye reaction while processing.
- OpenCode can request one Telegram emoji reaction by returning a hidden `[telegram_reaction: ...]` marker, which is removed before the user sees the reply.
- When saved sticker packs are available, eligible hidden reaction markers may be answered with a saved sticker reply instead of an emoji reaction.
Expand All @@ -57,6 +58,8 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s

- If no active session is selected, the gateway creates one before sending a prompt.
- New sessions receive hidden gateway context with no assistant reply so OpenCode understands messenger, voice, activity, and permission behavior without showing a setup response to the user.
- Prompt sends are serialized through the active session so complex OpenCode runs are not overlapped accidentally.
- Child subagent session tool and permission events are associated with the active gateway prompt while it is running.
- Selected session state is stored in `opencode-remote.db` under the platform app-data directory and scoped by project identity.
- Stopping a task uses OpenCode's session abort API for the active session.
- Session state is messenger-neutral in the gateway core, so future adapters can reuse it.
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ The config file is JSON:
"allowedUserIds": [123456789],
"allowedChatIds": [-1001234567890]
},
"opencode": {
"apiUrl": "http://localhost:4096",
"autoStart": true,
"promptTimeoutMs": 1800000
},
"voice": {
"enabled": false,
"mode": "on",
Expand All @@ -140,6 +145,8 @@ 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.

`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.

Group behavior is managed from a private DM with the bot using `/group`. The DM menu lists known allowed groups, including groups from `telegram.allowedChatIds` and groups the bot has seen. Only configured `allowedUserIds` can use this menu. Running `/group` inside a group replies with a short notice to configure the bot in DM instead. Custom trigger phrases are configured per group from this DM menu; they are plain text, case-insensitive, and match as bounded words or phrases anywhere in text, captions, and voice transcripts.
Expand All @@ -155,6 +162,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
```

Clear generated voice files from the app-data cache:
Expand Down Expand Up @@ -192,7 +200,7 @@ Telegram text, photo, album, voice, and sticker prompts include safe author cont

When a new OpenCode session starts, OpenCode Remote sends hidden gateway context with no assistant reply. This helps the agent understand that voice input may arrive as transcripts and that final text can be delivered as voice notes when voice mode is enabled.

When OpenCode requests permission during a prompt, the bot sends a text message with `Allow once`, `Always allow`, and `Deny` buttons. Permission prompts are always text, including when `/voice on` or `/voice all` would make normal assistant replies voice-only.
When OpenCode requests permission during a prompt, including permission requests from child subagent sessions, the bot sends a text message with `Allow once`, `Always allow`, and `Deny` buttons. Permission prompts are always text, including when `/voice on` or `/voice all` would make normal assistant replies voice-only.

Telegram photo albums are handled as one OpenCode prompt when Telegram provides a shared `media_group_id`. The album caption becomes the prompt text. Separate text messages sent after an album are treated as separate prompts.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
- [x] Install bundled skills under a project-local configured `skills.paths` directory when available, otherwise under `.opencode/skills/opencode-remote-bundled/<skill-name>/SKILL.md`.
- [x] Remove legacy `.opencode/agent/opencode-remote-meme.md` during install if present.
- [x] Keep bundled runtime assets project-local and avoid global OpenCode config writes.
- [x] Make the bundled meme skill direct and bounded, with no task/subagent delegation.
- [x] Make the bundled meme skill bounded by the generated-media contract.
- [x] Add local meme renderer and hidden `opencode-remote meme render --spec` CLI.
- [x] Restrict renderer remote templates to HTTPS Imgflip template URLs and forbid public meme creation endpoints.
- [x] Add safe generated-media marker parsing and Telegram delivery.
Expand Down
188 changes: 188 additions & 0 deletions docs/superpowers/plans/2026-06-06-opencode-subagent-orchestration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# OpenCode Subagent Orchestration Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make OpenCode Remote handle long-running, tool-heavy, subagent-driven prompts without dropping child-session permissions or failing at the default SDK request timeout.

**Architecture:** Keep Telegram rendering in the adapter and OpenCode SDK/event shapes in `src/core/opencode/client.js`. Serialize prompt sends through the gateway controller so one active OpenCode run owns the event stream at a time, then let that run accept child-session tool and permission events safely. Add a bounded prompt timeout config so slow providers like Ollama Cloud can finish instead of failing around five minutes.

**Tech Stack:** Node.js ESM, `@opencode-ai/sdk`, grammY, Zod config validation, Vitest.

---

### Task 1: Configurable OpenCode Prompt Timeout

**Files:**
- Modify: `src/config/loadConfig.js`
- Modify: `src/runtime/bootstrap.js`
- Modify: `src/core/opencode/client.js`
- Test: `tests/config/loadConfig.test.js`
- Test: `tests/runtime/bootstrap.test.js`
- Test: `tests/core/opencodeClient.test.js`

- [ ] **Step 1: Write failing config tests**

Add tests showing `opencode.promptTimeoutMs` defaults to `1800000` and accepts a custom positive integer.

- [ ] **Step 2: Run config tests and verify they fail**

Run: `pnpm exec vitest run tests/config/loadConfig.test.js`

Expected: FAIL because `promptTimeoutMs` is missing from normalized config.

- [ ] **Step 3: Implement config parsing**

Add `promptTimeoutMs: z.number().int().positive().default(1_800_000)` under `opencode`, and include it in the normalized return object.

- [ ] **Step 4: Write failing SDK/bootstrap tests**

Add a client factory test proving `createOpenCodeClient({ promptTimeoutMs })` passes `timeout` to `createOpencodeClient`, and a bootstrap test proving runtime passes `resolvedConfig.opencode.promptTimeoutMs` into the OpenCode client factory.

- [ ] **Step 5: Run targeted tests and verify they fail**

Run: `pnpm exec vitest run tests/core/opencodeClient.test.js tests/runtime/bootstrap.test.js`

Expected: FAIL because timeout is not passed through yet.

- [ ] **Step 6: Implement timeout pass-through**

Add `promptTimeoutMs` and injectable `sdkFactory` to `createOpenCodeClient`, pass `timeout: promptTimeoutMs` to `createOpencodeClient`, and pass the config value from `runGateway`.

- [ ] **Step 7: Run targeted tests and verify they pass**

Run: `pnpm exec vitest run tests/config/loadConfig.test.js tests/core/opencodeClient.test.js tests/runtime/bootstrap.test.js`

Expected: PASS.

### Task 2: Serialize Gateway Prompt Sends

**Files:**
- Modify: `src/core/gateway/controller.js`
- Test: `tests/core/controller.test.js`

- [ ] **Step 1: Write failing serialization test**

Add a test that starts two `controller.sendPrompt()` calls on the same active session, blocks the first inside the OpenCode mock, and asserts the second `opencode.sendPrompt` call does not start until the first resolves.

- [ ] **Step 2: Run controller test and verify it fails**

Run: `pnpm exec vitest run tests/core/controller.test.js`

Expected: FAIL because current controller sends both prompts concurrently.

- [ ] **Step 3: Implement prompt queue**

Add a controller-local promise queue wrapping `sendPrompt`. Resolve active session and call OpenCode inside the queued action. Ensure rejected prompt calls do not poison the queue by storing `promptQueue = run.catch(() => undefined)`.

- [ ] **Step 4: Run controller test and verify it passes**

Run: `pnpm exec vitest run tests/core/controller.test.js`

Expected: PASS.

### Task 3: Accept Child Session Tool And Permission Events During Active Runs

**Files:**
- Modify: `src/core/opencode/client.js`
- Modify: `src/core/gateway/controller.js`
- Modify: `src/core/formatting/progressText.js`
- Modify: `src/adapters/telegram/bot.js`
- Test: `tests/core/opencodeClient.test.js`
- Test: `tests/core/progressText.test.js`

- [ ] **Step 1: Write failing child permission test**

Add an OpenCode client test where `sendPrompt("ses_parent", ..., { onSystemEvent, includeChildSessionEvents: true })` receives a `permission.asked` event with `sessionID: "ses_child"` and asserts `onSystemEvent` receives `sessionId: "ses_child"`.

- [ ] **Step 2: Write failing child tool progress test**

Add an OpenCode client test where a `message.part.updated` tool event with `sessionID: "ses_child"` is emitted during a parent prompt and `includeChildSessionEvents: true` forwards it with `parentSessionId: "ses_parent"` and `childSession: true`.

- [ ] **Step 3: Run OpenCode client tests and verify they fail**

Run: `pnpm exec vitest run tests/core/opencodeClient.test.js`

Expected: FAIL because child session IDs are currently filtered out.

- [ ] **Step 4: Implement child-session event acceptance**

Thread an `includeChildSessionEvents` option through `startPromptEventStream`. Update progress and permission normalization to accept non-parent session IDs when that option is true. Preserve the actual child `sessionId`, add `parentSessionId` for child events, and add `childSession: true` without exposing raw session IDs in Telegram logs.

- [ ] **Step 5: Make controller enable child events for gateway prompts**

When controller passes options to `opencode.sendPrompt`, include `includeChildSessionEvents: true`. Preserve existing callbacks and do not put this option into the OpenCode prompt body.

- [ ] **Step 6: Update formatting for task/subagent progress**

Keep existing `task` emoji behavior and ensure child-session progress still renders as a normal activity line without exposing session IDs.

- [ ] **Step 7: Run targeted tests and verify they pass**

Run: `pnpm exec vitest run tests/core/opencodeClient.test.js tests/core/controller.test.js tests/core/progressText.test.js`

Expected: PASS.

### Task 4: Normalize And Log OpenCode Session Errors Safely

**Files:**
- Modify: `src/core/opencode/client.js`
- Modify: `src/adapters/telegram/bot.js`
- Test: `tests/core/opencodeClient.test.js`
- Test: `tests/adapters/telegramBot.test.js`

- [ ] **Step 1: Write failing session error normalization test**

Add a test where the event stream emits `session.error` with a provider-ish error object and asserts `onSystemEvent` receives `{ type: "session.error", sessionId, errorName, errorKind }` without raw payload details.

- [ ] **Step 2: Run OpenCode client test and verify it fails**

Run: `pnpm exec vitest run tests/core/opencodeClient.test.js`

Expected: FAIL because `session.error` is ignored.

- [ ] **Step 3: Implement safe session error normalization**

Extend `normalizeOpenCodeSystemEvent` to return `session.error` events. Keep only safe metadata: type, session presence, child-session flag, error name, and a coarse kind such as `provider_auth`, `aborted`, `timeout`, or `unknown`.

- [ ] **Step 4: Write Telegram logging test**

Add a Telegram bot test that calls a prompt whose `onSystemEvent` receives a `session.error`, and assert the logger warning uses safe metadata only.

- [ ] **Step 5: Implement Telegram safe log handling**

Update `handleSystemEvent` to log `OpenCode session error reported` with safe fields and no raw session ID, provider body, prompt text, or stack trace.

- [ ] **Step 6: Run targeted tests and verify they pass**

Run: `pnpm exec vitest run tests/core/opencodeClient.test.js tests/adapters/telegramBot.test.js`

Expected: PASS.

### Task 5: Docs And Verification

**Files:**
- Modify: `README.md`
- Modify: `FEATURES.md`
- Modify: `DEVELOPMENT.md`

- [ ] **Step 1: Document prompt timeout config and subagent handling**

Update public docs to mention `opencode.promptTimeoutMs` and that the gateway handles tool/skill/subagent activity and permission events during long prompts.

- [ ] **Step 2: Run lint**

Run: `pnpm run lint`

Expected: PASS.

- [ ] **Step 3: Run tests**

Run: `pnpm test`

Expected: PASS.

- [ ] **Step 4: Run package check if time permits**

Run: `pnpm run check`

Expected: PASS.
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.10.5",
"version": "0.11.0",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
15 changes: 14 additions & 1 deletion src/adapters/telegram/bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,20 @@ export function createTelegramBot({
async function handleSystemEvent(ctx, event) {
if (event?.type === "permission.requested") {
await sendPermissionRequest(ctx, event)
return
}
if (event?.type === "session.error") {
logger.warn?.(safeSessionErrorLogContext(event), "OpenCode session error reported")
}
}

function safeSessionErrorLogContext(event) {
return {
childSession: event.childSession === true,
errorKind: event.errorKind ?? "unknown",
errorName: event.errorName ?? "UnknownError",
hasParentSessionId: Boolean(event.parentSessionId),
hasSessionId: Boolean(event.sessionId),
}
}

Expand Down Expand Up @@ -2044,7 +2058,6 @@ function createGeneratedMediaInstruction(directory, { memeRenderCommand } = {})
return [
"Generated media delivery capability:",
`If you create a local image to send back, write it under this exact directory: ${directory}`,
"Do the image work directly in this OpenCode session. Do not call the task tool, delegate to subagents, or load brainstorming/planning skills for generated media.",
"For meme requests, use the meme-generation skill and Imgflip template discovery as the primary path. Do not hand-write custom poster art or raw image scripts instead of using a meme template.",
"For meme files, call opencode-remote meme render --spec with an Imgflip template.url or allowed local template.imagePath. Use fallback design or image-generation skills only after template discovery fails.",
...(renderCommand
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.10.5")
program.name("opencode-remote").description("OpenCode messaging gateway").version("0.11.0")

program
.command("setup")
Expand Down
Loading