diff --git a/.claude/skills/add-opencode/SKILL.md b/.claude/skills/add-opencode/SKILL.md new file mode 100644 index 0000000..89ea3c0 --- /dev/null +++ b/.claude/skills/add-opencode/SKILL.md @@ -0,0 +1,114 @@ +--- +name: add-opencode +description: Use OpenCode as an agent provider on NanoClaw v2 (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers. +--- + +# OpenCode agent provider (v2) + +NanoClaw **v2** runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`), not the v1 `AGENT_RUNNER` env var. + +## What it does (upstream v2) + +- **`container/agent-runner/src/providers/opencode.ts`** — `OpenCodeProvider` implementing `AgentProvider` (SSE via OpenCode server, session resume, MCP from merged `ProviderOptions.mcpServers` only — no `settings.json` MCP bridge). +- **`container/agent-runner/src/providers/mcp-to-opencode.ts`** — maps v2 `McpServerConfig` to OpenCode `mcp` entries. +- **`container/agent-runner/src/providers/factory.ts`** — registers `opencode`. +- **`container/agent-runner/package.json`** — dependency `@opencode-ai/sdk`. +- **`container/Dockerfile`** — global **`opencode-ai`** CLI for `opencode serve`. +- **`src/container-runner.ts`** — when effective provider is `opencode`: `XDG_DATA_HOME=/opencode-xdg`, session-scoped host mount, `NO_PROXY`/`no_proxy` merge for `127.0.0.1,localhost`, passes through `OPENCODE_PROVIDER`, `OPENCODE_MODEL`, `OPENCODE_SMALL_MODEL` from the host environment. + +## Configuration + +### Host `.env` (typical) + +Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs. + +These variables are read **on the host** and passed into the container only when the effective provider is `opencode` (see `src/container-runner.ts`). They do not switch the provider by themselves; the DB still needs `agent_provider` set (below). + +- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic` (if unset, the runner defaults to `anthropic`). +- `OPENCODE_MODEL` — full model id, e.g. `openrouter/anthropic/claude-sonnet-4`. +- `OPENCODE_SMALL_MODEL` — optional second model for “small” tasks. + +Credentials: OneCLI / credential proxy patterns are unchanged. For non-`anthropic` OpenCode providers, the runner registers a placeholder API key and **`ANTHROPIC_BASE_URL`** (the credential proxy) as `baseURL` so the real key never lives in the container. + +#### Example: OpenRouter + +Use ids that match OpenCode’s registry / your custom registrations. Adjust names to what you actually run. + +```env +# OpenCode — host passes these into the container when agent_provider is opencode +OPENCODE_PROVIDER=openrouter +OPENCODE_MODEL=openrouter/anthropic/claude-sonnet-4 +OPENCODE_SMALL_MODEL=openrouter/anthropic/claude-haiku-4.5 +``` + +#### Example: Anthropic via existing proxy env + +When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container (proxy + placeholder key pattern unchanged). + +```env +OPENCODE_PROVIDER=anthropic +OPENCODE_MODEL=anthropic/claude-sonnet-4-20250514 +``` + +#### Example: only a main model + +```env +OPENCODE_PROVIDER=openrouter +OPENCODE_MODEL=openrouter/google/gemini-2.5-pro-preview +``` + +#### OpenCode Zen (`x-api-key`, not Bearer) + +Zen’s HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / “Missing API key”** even though the gateway is working. + +**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (v2 DB `agent_provider`) means “run the **OpenCode agent provider**.” Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode’s **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)). + +**Host `.env` (typical Zen shape):** + +```env +# NanoClaw still resolves AGENT_PROVIDER from agent_groups / sessions; set agent_provider to opencode there. +# OpenCode SDK: Zen as the upstream provider + models under opencode/… +OPENCODE_PROVIDER=opencode +OPENCODE_MODEL=opencode/big-pickle +OPENCODE_SMALL_MODEL=opencode/big-pickle + +# Point the credential proxy at Zen’s Anthropic-compatible base URL (host + OneCLI must forward this host). +ANTHROPIC_BASE_URL=https://opencode.ai/zen/v1 +``` + +Use a real Zen model id from the docs; `big-pickle` is one example. + +**OneCLI:** register the Zen key with **`x-api-key`**, not Bearer: + +```bash +onecli secrets create --name "OpenCode Zen" --type generic \ + --value YOUR_ZEN_KEY --host-pattern opencode.ai \ + --header-name "x-api-key" --value-format "{value}" +``` + +For comparison, OpenRouter uses `Authorization` + `Bearer {value}`. Zen is different by design. + +### Per group / per session + +v2 schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). + +Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers. + +## Operational notes + +- OpenCode keeps a local **`opencode serve`** process and SSE subscription; the provider tears down with **`stream.return`** and **SIGKILL** on the server process on **`abort()`** / shared runtime reset to avoid MCP/zombie hangs. +- Session continuation is opaque (`ses_*` ids); stale sessions are cleared using **`isSessionInvalid`** on OpenCode-specific errors (timeouts, connection resets, not-found patterns) in addition to the poll-loop’s existing recovery. +- **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI). + +## Verify + +```bash +grep -q opencode container/agent-runner/src/providers/factory.ts && echo "OpenCode registered" || echo "Missing" +npm run build --prefix container/agent-runner +``` + +Rebuild the agent image after Dockerfile changes: `./container/build.sh` (or your usual image build). + +## Migrate from v1 wording + +If documentation or habits still say **`AGENT_RUNNER=opencode`**, update to **`AGENT_PROVIDER=opencode`** and store **`agent_provider`** in v2 tables, not v1 runner columns. diff --git a/container/Dockerfile b/container/Dockerfile index 12d2bf6..ac5fd4b 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -18,6 +18,7 @@ ARG INSTALL_CJK_FONTS=false ARG CLAUDE_CODE_VERSION=2.1.112 ARG AGENT_BROWSER_VERSION=latest ARG VERCEL_VERSION=latest +ARG OPENCODE_VERSION=latest ARG BUN_VERSION=1.3.12 # ---- System dependencies ----------------------------------------------------- @@ -79,7 +80,8 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g \ "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ "agent-browser@${AGENT_BROWSER_VERSION}" \ - "vercel@${VERCEL_VERSION}" + "vercel@${VERCEL_VERSION}" \ + "opencode-ai@${OPENCODE_VERSION}" # ---- agent-runner ------------------------------------------------------------ WORKDIR /app diff --git a/container/agent-runner/bun.lock b/container/agent-runner/bun.lock index 99fe840..461d56c 100644 --- a/container/agent-runner/bun.lock +++ b/container/agent-runner/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", "@modelcontextprotocol/sdk": "^1.12.1", + "@opencode-ai/sdk": "^1.4.3", "cron-parser": "^5.0.0", "zod": "^4.0.0", }, @@ -60,6 +61,8 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.7", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-onEtaooQyoDP5gTShQeQSf0Sd8V7949G9pPNyIyRXnVtFqyDIhUDLGtL/a/+EIW9x5s+Y6lDy/3oVoGMvQ0rQQ=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index 06eb394..042f30b 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -11,6 +11,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", "@modelcontextprotocol/sdk": "^1.12.1", + "@opencode-ai/sdk": "^1.4.3", "cron-parser": "^5.0.0", "zod": "^4.0.0" }, diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 5c73dfb..1adb76d 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -41,7 +41,7 @@ function log(msg: string): void { const CWD = '/workspace/agent'; async function main(): Promise { - const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName; + const providerName = (process.env.AGENT_PROVIDER || 'claude').toLowerCase() as ProviderName; const assistantName = process.env.NANOCLAW_ASSISTANT_NAME; const adminUserIds = new Set( (process.env.NANOCLAW_ADMIN_USER_IDS || '') diff --git a/container/agent-runner/src/providers/factory.test.ts b/container/agent-runner/src/providers/factory.test.ts new file mode 100644 index 0000000..15c7e63 --- /dev/null +++ b/container/agent-runner/src/providers/factory.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'bun:test'; + +import { createProvider, type ProviderName } from './factory.js'; +import { ClaudeProvider } from './claude.js'; +import { MockProvider } from './mock.js'; +import { OpenCodeProvider } from './opencode.js'; + +describe('createProvider', () => { + it('returns ClaudeProvider for claude', () => { + expect(createProvider('claude')).toBeInstanceOf(ClaudeProvider); + }); + + it('returns OpenCodeProvider for opencode', () => { + expect(createProvider('opencode')).toBeInstanceOf(OpenCodeProvider); + }); + + it('returns MockProvider for mock', () => { + expect(createProvider('mock')).toBeInstanceOf(MockProvider); + }); + + it('throws for unknown name', () => { + expect(() => createProvider('bogus' as ProviderName)).toThrow(/Unknown provider/); + }); +}); diff --git a/container/agent-runner/src/providers/index.ts b/container/agent-runner/src/providers/index.ts index 70497cf..9bdf7f2 100644 --- a/container/agent-runner/src/providers/index.ts +++ b/container/agent-runner/src/providers/index.ts @@ -4,3 +4,4 @@ import './claude.js'; import './mock.js'; +import './opencode.js'; diff --git a/container/agent-runner/src/providers/mcp-to-opencode.test.ts b/container/agent-runner/src/providers/mcp-to-opencode.test.ts new file mode 100644 index 0000000..f41101a --- /dev/null +++ b/container/agent-runner/src/providers/mcp-to-opencode.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'bun:test'; + +import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js'; + +describe('mcpServersToOpenCodeConfig', () => { + it('maps nanoclaw + extra server like v2 index.ts merge', () => { + const servers = { + nanoclaw: { + command: 'node', + args: ['/app/src/mcp-tools/index.js'], + env: { + SESSION_INBOUND_DB_PATH: '/workspace/inbound.db', + SESSION_OUTBOUND_DB_PATH: '/workspace/outbound.db', + SESSION_HEARTBEAT_PATH: '/workspace/.heartbeat', + }, + }, + extra: { + command: 'npx', + args: ['-y', 'some-mcp'], + env: { FOO: 'bar' }, + }, + }; + + const mcp = mcpServersToOpenCodeConfig(servers); + + expect(mcp.nanoclaw).toEqual({ + type: 'local', + command: ['node', '/app/src/mcp-tools/index.js'], + environment: { + SESSION_INBOUND_DB_PATH: '/workspace/inbound.db', + SESSION_OUTBOUND_DB_PATH: '/workspace/outbound.db', + SESSION_HEARTBEAT_PATH: '/workspace/.heartbeat', + }, + enabled: true, + }); + + expect(mcp.extra).toEqual({ + type: 'local', + command: ['npx', '-y', 'some-mcp'], + environment: { FOO: 'bar' }, + enabled: true, + }); + }); + + it('omits environment when env is empty', () => { + const mcp = mcpServersToOpenCodeConfig({ + x: { command: 'true', args: [], env: {} }, + }); + expect(mcp.x).toEqual({ + type: 'local', + command: ['true'], + enabled: true, + }); + }); + + it('returns empty record for undefined', () => { + expect(mcpServersToOpenCodeConfig(undefined)).toEqual({}); + }); +}); diff --git a/container/agent-runner/src/providers/mcp-to-opencode.ts b/container/agent-runner/src/providers/mcp-to-opencode.ts new file mode 100644 index 0000000..7e90e0e --- /dev/null +++ b/container/agent-runner/src/providers/mcp-to-opencode.ts @@ -0,0 +1,39 @@ +import type { McpServerConfig } from './types.js'; + +/** OpenCode `mcp` entry shape (local stdio server). */ +export type OpenCodeMcpLocal = { + type: 'local'; + command: string[]; + environment?: Record; + enabled: true; +}; + +/** OpenCode `mcp` entry shape (remote HTTP server). */ +export type OpenCodeMcpRemote = { + type: 'remote'; + url: string; + headers?: Record; + enabled: true; +}; + +export type OpenCodeMcpEntry = OpenCodeMcpLocal | OpenCodeMcpRemote; + +/** + * Map NanoClaw v2 MCP definitions (same shape as Claude Agent SDK) into + * OpenCode config `mcp` field. Stdio-only until `McpServerConfig` gains remote. + */ +export function mcpServersToOpenCodeConfig( + servers: Record | undefined, +): Record { + const out: Record = {}; + if (!servers) return out; + for (const [name, cfg] of Object.entries(servers)) { + out[name] = { + type: 'local', + command: [cfg.command, ...cfg.args], + ...(Object.keys(cfg.env).length > 0 ? { environment: cfg.env } : {}), + enabled: true, + }; + } + return out; +} diff --git a/container/agent-runner/src/providers/opencode.ts b/container/agent-runner/src/providers/opencode.ts new file mode 100644 index 0000000..657c209 --- /dev/null +++ b/container/agent-runner/src/providers/opencode.ts @@ -0,0 +1,422 @@ +import * as fs from 'fs'; +import { spawn, type ChildProcess } from 'child_process'; + +import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk'; + +import { registerProvider } from './provider-registry.js'; +import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; +import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js'; + +function log(msg: string): void { + console.error(`[opencode-provider] ${msg}`); +} + +const SESSION_STATUS_RETRY_ERROR_AFTER = 3; + +/** Stale / dead OpenCode session heuristics (complement Claude-centric host patterns). */ +const STALE_SESSION_RE = + /no conversation found|ENOENT.*\.jsonl|session.*not found|NotFoundError|connection reset|ECONNRESET|404|event timeout/i; + +function spawnOpencodeServer(config: Record, timeoutMs = 10_000): Promise<{ url: string; proc: ChildProcess }> { + return new Promise((resolve, reject) => { + const hostname = '127.0.0.1'; + const port = 4096; + const proc = spawn('opencode', ['serve', `--hostname=${hostname}`, `--port=${port}`], { + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(config), + }, + }); + + const id = setTimeout(() => { + proc.kill('SIGKILL'); + reject(new Error(`Timeout waiting for OpenCode server to start after ${timeoutMs}ms`)); + }, timeoutMs); + + let output = ''; + proc.stdout?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + for (const line of output.split('\n')) { + if (line.startsWith('opencode server listening')) { + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + if (match) { + clearTimeout(id); + resolve({ url: match[1], proc }); + } + } + } + }); + proc.stderr?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + proc.on('exit', (code) => { + clearTimeout(id); + let msg = `OpenCode server exited with code ${code}`; + if (output.trim()) msg += `\nServer output: ${output}`; + reject(new Error(msg)); + }); + proc.on('error', (err) => { + clearTimeout(id); + reject(err); + }); + }); +} + +function readClaudeMdForPrompt(): string | undefined { + const groupPath = '/workspace/agent/CLAUDE.md'; + const globalPath = '/workspace/global/CLAUDE.md'; + let content = ''; + if (fs.existsSync(groupPath)) { + content += fs.readFileSync(groupPath, 'utf-8'); + } + const isMain = process.env.NANOCLAW_IS_MAIN === '1'; + if (!isMain && fs.existsSync(globalPath)) { + if (content) content += '\n\n---\n\n'; + content += fs.readFileSync(globalPath, 'utf-8'); + } + return content || undefined; +} + +function wrapPromptWithContext(text: string, systemInstructions?: string): string { + let out = text; + if (systemInstructions) { + out = `\n${systemInstructions}\n\n\n${out}`; + } + const claudeMd = readClaudeMdForPrompt(); + if (claudeMd) { + out = `\n${claudeMd}\n\n\n${out}`; + } + return out; +} + +function buildOpenCodeConfig(options: ProviderOptions): Record { + const provider = process.env.OPENCODE_PROVIDER || 'anthropic'; + const model = process.env.OPENCODE_MODEL; + const smallModel = process.env.OPENCODE_SMALL_MODEL; + const proxyUrl = process.env.ANTHROPIC_BASE_URL; + + const providerModelId = model ? model.replace(new RegExp(`^${provider}/`), '') : undefined; + const providerSmallModelId = smallModel ? smallModel.replace(new RegExp(`^${provider}/`), '') : undefined; + const modelsToRegister = [providerModelId, providerSmallModelId] + .filter(Boolean) + .filter((mid, i, a) => a.indexOf(mid as string) === i); + + const providerOptions: Record = + provider === 'anthropic' + ? {} + : { + [provider]: { + options: { apiKey: 'placeholder', baseURL: proxyUrl }, + ...(modelsToRegister.length > 0 + ? { + models: Object.fromEntries( + modelsToRegister.map((mid) => [mid, { id: mid, name: mid, tool_call: true }]), + ), + } + : {}), + }, + }; + + const mcp = mcpServersToOpenCodeConfig(options.mcpServers); + + return { + ...(model ? { model } : {}), + ...(smallModel ? { small_model: smallModel } : {}), + enabled_providers: [provider], + permission: 'allow', + autoupdate: false, + snapshot: false, + provider: providerOptions, + mcp, + }; +} + +type SharedRuntime = { + proc: ChildProcess; + client: OpencodeClient; + stream: AsyncGenerator<{ type: string; properties: Record }, void, void>; + streamRelease: () => void; +}; + +let sharedRuntime: SharedRuntime | null = null; +let sharedConfigKey: string | null = null; +let sharedInit: Promise | null = null; + +function runtimeConfigKey(options: ProviderOptions): string { + return JSON.stringify({ + mcp: mcpServersToOpenCodeConfig(options.mcpServers), + model: process.env.OPENCODE_MODEL, + small: process.env.OPENCODE_SMALL_MODEL, + op: process.env.OPENCODE_PROVIDER, + }); +} + +async function ensureSharedRuntime(options: ProviderOptions): Promise { + const key = runtimeConfigKey(options); + if (sharedRuntime && sharedConfigKey === key) return sharedRuntime; + + if (sharedInit) return sharedInit; + + sharedInit = (async () => { + if (sharedRuntime) { + destroySharedRuntime(); + } + const config = buildOpenCodeConfig(options); + const { url, proc } = await spawnOpencodeServer(config); + const client = createOpencodeClient({ baseUrl: url }); + const sub = await client.event.subscribe(); + const stream = sub.stream as AsyncGenerator<{ type: string; properties: Record }, void, void>; + sharedRuntime = { + proc, + client, + stream, + streamRelease: () => { + void stream.return?.(undefined); + }, + }; + sharedConfigKey = key; + sharedInit = null; + return sharedRuntime; + })(); + + return sharedInit; +} + +export function destroySharedRuntime(): void { + if (sharedRuntime) { + try { + sharedRuntime.streamRelease(); + } catch { + /* ignore */ + } + try { + sharedRuntime.proc.kill('SIGKILL'); + } catch { + /* ignore */ + } + sharedRuntime = null; + sharedConfigKey = null; + } + sharedInit = null; +} + +function sessionErrorMessage(props: { error?: unknown }): string { + const err = props.error as { data?: { message?: string } } | undefined; + if (err && typeof err === 'object' && err.data && typeof err.data.message === 'string') { + return err.data.message; + } + return JSON.stringify(props.error) || 'OpenCode session error'; +} + +export class OpenCodeProvider implements AgentProvider { + readonly supportsNativeSlashCommands = false; + + private readonly options: ProviderOptions; + private activeSessionId: string | undefined; + + constructor(options: ProviderOptions = {}) { + this.options = options; + } + + isSessionInvalid(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return STALE_SESSION_RE.test(msg); + } + + query(input: QueryInput): AgentQuery { + if (input.continuation) { + this.activeSessionId = input.continuation; + } else { + this.activeSessionId = undefined; + } + + const pending: string[] = []; + let waiting: (() => void) | null = null; + let ended = false; + let aborted = false; + + const systemInstructions = input.systemContext?.instructions; + pending.push(wrapPromptWithContext(input.prompt, systemInstructions)); + + const kick = (): void => { + waiting?.(); + }; + + const self = this; + const IDLE_TIMEOUT_MS = 90_000; + + async function* gen(): AsyncGenerator { + let initYielded = false; + const rt = await ensureSharedRuntime(self.options); + const { client, stream } = rt; + + while (!aborted) { + while (pending.length === 0 && !ended && !aborted) { + await new Promise((resolve) => { + waiting = resolve; + }); + waiting = null; + } + + if (aborted) return; + if (pending.length === 0 && ended) return; + + const text = pending.shift()!; + let sessionId = self.activeSessionId; + + if (!sessionId) { + const created = await client.session.create(); + if (created.error) { + throw new Error(`OpenCode: failed to create session: ${JSON.stringify(created.error)}`); + } + sessionId = created.data?.id; + if (!sessionId) throw new Error('OpenCode: failed to create session (no id)'); + self.activeSessionId = sessionId; + } + + if (!initYielded) { + yield { type: 'init', continuation: sessionId }; + initYielded = true; + } + + const promptRes = await client.session.promptAsync({ + path: { id: sessionId }, + body: { parts: [{ type: 'text', text }] }, + }); + if (promptRes.error) { + self.activeSessionId = undefined; + throw new Error(`OpenCode promptAsync: ${JSON.stringify(promptRes.error)}`); + } + + const partTextByMessageId = new Map(); + const roleByMessageId = new Map(); + let lastEventAt = Date.now(); + let eventTimedOut = false; + const timeoutCheck = setInterval(() => { + if (Date.now() - lastEventAt > IDLE_TIMEOUT_MS) { + log(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms) — clearing session ${sessionId}`); + eventTimedOut = true; + self.activeSessionId = undefined; + destroySharedRuntime(); + kick(); + } + }, 5000); + + try { + turn: while (true) { + if (aborted) return; + if (eventTimedOut) { + throw new Error(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms)`); + } + + const { value: ev, done } = await stream.next(); + if (done) { + throw new Error('OpenCode SSE stream ended unexpectedly'); + } + + if (!ev?.type || ev.type === 'server.connected' || ev.type === 'server.heartbeat') continue; + + lastEventAt = Date.now(); + yield { type: 'activity' }; + + switch (ev.type) { + case 'message.updated': { + const info = ev.properties.info as { id?: string; role?: string } | undefined; + if (info?.id && info?.role) { + roleByMessageId.set(info.id, info.role); + } + break; + } + case 'message.part.updated': { + const part = ev.properties.part as { type?: string; messageID?: string; text?: string } | undefined; + if (part?.type === 'text' && part.messageID && part.text) { + partTextByMessageId.set(part.messageID, part.text); + } + break; + } + case 'permission.updated': { + const perm = ev.properties as { id?: string; sessionID?: string }; + if (perm.sessionID === sessionId && perm.id) { + try { + await client.postSessionIdPermissionsPermissionId({ + path: { id: sessionId, permissionID: perm.id }, + body: { response: 'always' }, + }); + } catch (err) { + log(`Failed to auto-reply permission: ${err instanceof Error ? err.message : String(err)}`); + } + } + break; + } + case 'session.status': { + const props = ev.properties as { + sessionID?: string; + status?: { type?: string; attempt?: number; message?: string }; + }; + if (props.sessionID !== sessionId) break; + const st = props.status; + if ( + st?.type === 'retry' && + typeof st.attempt === 'number' && + st.attempt >= SESSION_STATUS_RETRY_ERROR_AFTER && + st.message + ) { + self.activeSessionId = undefined; + throw new Error(`OpenCode retry limit (${st.attempt}): ${st.message}`); + } + break; + } + case 'session.error': { + const props = ev.properties as { sessionID?: string; error?: unknown }; + if (props.sessionID === sessionId || props.sessionID === undefined) { + self.activeSessionId = undefined; + throw new Error(sessionErrorMessage(props)); + } + break; + } + case 'session.idle': { + const sid = (ev.properties as { sessionID?: string }).sessionID; + if (sid === sessionId) { + break turn; + } + break; + } + default: + break; + } + } + } finally { + clearInterval(timeoutCheck); + } + + let resultText = ''; + for (const [msgId, role] of roleByMessageId) { + if (role === 'assistant') { + resultText = partTextByMessageId.get(msgId) ?? resultText; + } + } + yield { type: 'result', text: resultText || null }; + } + } + + return { + push: (message: string) => { + pending.push(wrapPromptWithContext(message, systemInstructions)); + kick(); + }, + end: () => { + ended = true; + kick(); + }, + events: gen(), + abort: () => { + aborted = true; + this.activeSessionId = undefined; + kick(); + destroySharedRuntime(); + }, + }; + } +} + +registerProvider('opencode', (opts) => new OpenCodeProvider(opts)); diff --git a/docs/v2-architecture-diagram.md b/docs/v2-architecture-diagram.md index bc35098..7f8134b 100644 --- a/docs/v2-architecture-diagram.md +++ b/docs/v2-architecture-diagram.md @@ -31,7 +31,7 @@ flowchart TB subgraph Session["Per-Session Container (Docker / Apple Container)"] direction TB PollLoop["Poll Loop
(container/agent-runner)"] - Provider["Claude Agent SDK
(providers: claude, mock, todo: codex/opencode)"] + Provider["Agent providers
(claude, opencode, mock; todo: codex)"] MCP["MCP Tools
send_message, send_file, edit_message,
add_reaction, send_card, ask_user_question,
schedule_task, create_agent,
install_packages, add_mcp_server, request_rebuild"] Skills["Container Skills
(container/skills/)"] InDB[("inbound.db
host writes
even seq
messages_in
destinations
processing_ack")] @@ -88,7 +88,7 @@ sequenceDiagram R->>IDB: INSERT messages_in (even seq) R->>C: wake container (docker run / already running) C->>IDB: poll messages_in - C->>C: format xml, stream to Claude SDK + C->>C: format xml, stream to selected provider C->>ODB: INSERT messages_out (odd seq)
parse blocks D->>ODB: 1s poll (active) / 60s (sweep) D->>D: hasDestination() re-validate diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index f07475e..9d6f76b 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -34,7 +34,7 @@ Status: [x] done, [~] partial, [ ] not started - [x] Mock provider (testing) - [x] Provider factory - [ ] Codex provider -- [~] OpenCode provider +- [x] OpenCode provider ## Channel Adapters diff --git a/src/providers/index.ts b/src/providers/index.ts index 3ec9512..f5c22c4 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -4,3 +4,5 @@ // needs (claude, mock) don't appear here. // // Skills add a new provider by appending one import line below. + +import './opencode.js'; diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts new file mode 100644 index 0000000..3e283e6 --- /dev/null +++ b/src/providers/opencode.ts @@ -0,0 +1,49 @@ +/** + * Host-side container config for the `opencode` provider. + * + * OpenCode's `opencode serve` process stores state under XDG_DATA_HOME, which + * we pin to a per-session host directory mounted at /opencode-xdg. The + * OPENCODE_* env vars tell the CLI which provider/model to use at runtime + * (read on the host, injected into the container). NO_PROXY / no_proxy are + * merged with host values so the in-container OpenCode client can talk to + * 127.0.0.1 even when HTTPS_PROXY is set by OneCLI. + */ +import fs from 'fs'; +import path from 'path'; + +import { registerProviderContainerConfig } from './provider-container-registry.js'; + +function mergeNoProxy(current: string | undefined, additions: string): string { + if (!current?.trim()) return additions; + const parts = new Set( + current + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter(Boolean), + ); + for (const addition of additions.split(',')) { + const trimmed = addition.trim(); + if (trimmed) parts.add(trimmed); + } + return [...parts].join(','); +} + +registerProviderContainerConfig('opencode', (ctx) => { + const opencodeDir = path.join(ctx.sessionDir, 'opencode-xdg'); + fs.mkdirSync(opencodeDir, { recursive: true }); + + const env: Record = { + XDG_DATA_HOME: '/opencode-xdg', + NO_PROXY: mergeNoProxy(ctx.hostEnv.NO_PROXY, '127.0.0.1,localhost'), + no_proxy: mergeNoProxy(ctx.hostEnv.no_proxy, '127.0.0.1,localhost'), + }; + for (const key of ['OPENCODE_PROVIDER', 'OPENCODE_MODEL', 'OPENCODE_SMALL_MODEL'] as const) { + const value = ctx.hostEnv[key]; + if (value) env[key] = value; + } + + return { + mounts: [{ hostPath: opencodeDir, containerPath: '/opencode-xdg', readonly: false }], + env, + }; +});