feat(v2): OpenCode agent provider
- Add OpenCodeProvider (SSE, session resume, MCP via mcp-to-opencode) - Register opencode in factory; AGENT_PROVIDER passthrough from DB - Host: XDG mount, NO_PROXY merge, OPENCODE_* env for opencode sessions - Dockerfile: opencode-ai CLI; docs checklist + architecture diagram - Skill add-opencode for v2; AgentProviderName in src/types.ts Made-with: Cursor
This commit is contained in:
83
.claude/skills/add-opencode/SKILL.md
Normal file
83
.claude/skills/add-opencode/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -18,6 +18,7 @@ ARG INSTALL_CJK_FONTS=false
|
|||||||
ARG CLAUDE_CODE_VERSION=2.1.112
|
ARG CLAUDE_CODE_VERSION=2.1.112
|
||||||
ARG AGENT_BROWSER_VERSION=latest
|
ARG AGENT_BROWSER_VERSION=latest
|
||||||
ARG VERCEL_VERSION=latest
|
ARG VERCEL_VERSION=latest
|
||||||
|
ARG OPENCODE_VERSION=latest
|
||||||
ARG BUN_VERSION=1.3.12
|
ARG BUN_VERSION=1.3.12
|
||||||
|
|
||||||
# ---- System dependencies -----------------------------------------------------
|
# ---- System dependencies -----------------------------------------------------
|
||||||
@@ -79,7 +80,8 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
|
|||||||
pnpm install -g \
|
pnpm install -g \
|
||||||
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
||||||
"agent-browser@${AGENT_BROWSER_VERSION}" \
|
"agent-browser@${AGENT_BROWSER_VERSION}" \
|
||||||
"vercel@${VERCEL_VERSION}"
|
"vercel@${VERCEL_VERSION}" \
|
||||||
|
"opencode-ai@${OPENCODE_VERSION}"
|
||||||
|
|
||||||
# ---- agent-runner ------------------------------------------------------------
|
# ---- agent-runner ------------------------------------------------------------
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
|
"@opencode-ai/sdk": "^1.4.3",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.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=="],
|
"@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/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=="],
|
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
|
"@opencode-ai/sdk": "^1.4.3",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function log(msg: string): void {
|
|||||||
const CWD = '/workspace/agent';
|
const CWD = '/workspace/agent';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
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 assistantName = process.env.NANOCLAW_ASSISTANT_NAME;
|
||||||
const adminUserIds = new Set(
|
const adminUserIds = new Set(
|
||||||
(process.env.NANOCLAW_ADMIN_USER_IDS || '')
|
(process.env.NANOCLAW_ADMIN_USER_IDS || '')
|
||||||
|
|||||||
24
container/agent-runner/src/providers/factory.test.ts
Normal file
24
container/agent-runner/src/providers/factory.test.ts
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,3 +4,4 @@
|
|||||||
|
|
||||||
import './claude.js';
|
import './claude.js';
|
||||||
import './mock.js';
|
import './mock.js';
|
||||||
|
import './opencode.js';
|
||||||
|
|||||||
59
container/agent-runner/src/providers/mcp-to-opencode.test.ts
Normal file
59
container/agent-runner/src/providers/mcp-to-opencode.test.ts
Normal file
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
39
container/agent-runner/src/providers/mcp-to-opencode.ts
Normal file
39
container/agent-runner/src/providers/mcp-to-opencode.ts
Normal file
@@ -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<string, string>;
|
||||||
|
enabled: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** OpenCode `mcp` entry shape (remote HTTP server). */
|
||||||
|
export type OpenCodeMcpRemote = {
|
||||||
|
type: 'remote';
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
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<string, McpServerConfig> | undefined,
|
||||||
|
): Record<string, OpenCodeMcpEntry> {
|
||||||
|
const out: Record<string, OpenCodeMcpEntry> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
422
container/agent-runner/src/providers/opencode.ts
Normal file
422
container/agent-runner/src/providers/opencode.ts
Normal file
@@ -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<string, unknown>, 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 = `<system>\n${systemInstructions}\n</system>\n\n${out}`;
|
||||||
|
}
|
||||||
|
const claudeMd = readClaudeMdForPrompt();
|
||||||
|
if (claudeMd) {
|
||||||
|
out = `<system>\n${claudeMd}\n</system>\n\n${out}`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenCodeConfig(options: ProviderOptions): Record<string, unknown> {
|
||||||
|
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<string, unknown> =
|
||||||
|
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<string, unknown> }, void, void>;
|
||||||
|
streamRelease: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let sharedRuntime: SharedRuntime | null = null;
|
||||||
|
let sharedConfigKey: string | null = null;
|
||||||
|
let sharedInit: Promise<SharedRuntime> | 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<SharedRuntime> {
|
||||||
|
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<string, unknown> }, 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<ProviderEvent> {
|
||||||
|
let initYielded = false;
|
||||||
|
const rt = await ensureSharedRuntime(self.options);
|
||||||
|
const { client, stream } = rt;
|
||||||
|
|
||||||
|
while (!aborted) {
|
||||||
|
while (pending.length === 0 && !ended && !aborted) {
|
||||||
|
await new Promise<void>((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<string, string>();
|
||||||
|
const roleByMessageId = new Map<string, string>();
|
||||||
|
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));
|
||||||
@@ -31,7 +31,7 @@ flowchart TB
|
|||||||
subgraph Session["Per-Session Container (Docker / Apple Container)"]
|
subgraph Session["Per-Session Container (Docker / Apple Container)"]
|
||||||
direction TB
|
direction TB
|
||||||
PollLoop["Poll Loop<br/>(container/agent-runner)"]
|
PollLoop["Poll Loop<br/>(container/agent-runner)"]
|
||||||
Provider["Claude Agent SDK<br/>(providers: claude, mock, todo: codex/opencode)"]
|
Provider["Agent providers<br/>(claude, opencode, mock; todo: codex)"]
|
||||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server, request_rebuild"]
|
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server, request_rebuild"]
|
||||||
Skills["Container Skills<br/>(container/skills/)"]
|
Skills["Container Skills<br/>(container/skills/)"]
|
||||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
|
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
|
||||||
@@ -88,7 +88,7 @@ sequenceDiagram
|
|||||||
R->>IDB: INSERT messages_in (even seq)
|
R->>IDB: INSERT messages_in (even seq)
|
||||||
R->>C: wake container (docker run / already running)
|
R->>C: wake container (docker run / already running)
|
||||||
C->>IDB: poll messages_in
|
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)<br/>parse <message to="name"> blocks
|
C->>ODB: INSERT messages_out (odd seq)<br/>parse <message to="name"> blocks
|
||||||
D->>ODB: 1s poll (active) / 60s (sweep)
|
D->>ODB: 1s poll (active) / 60s (sweep)
|
||||||
D->>D: hasDestination() re-validate
|
D->>D: hasDestination() re-validate
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Status: [x] done, [~] partial, [ ] not started
|
|||||||
- [x] Mock provider (testing)
|
- [x] Mock provider (testing)
|
||||||
- [x] Provider factory
|
- [x] Provider factory
|
||||||
- [ ] Codex provider
|
- [ ] Codex provider
|
||||||
- [~] OpenCode provider
|
- [x] OpenCode provider
|
||||||
|
|
||||||
## Channel Adapters
|
## Channel Adapters
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,5 @@
|
|||||||
// needs (claude, mock) don't appear here.
|
// needs (claude, mock) don't appear here.
|
||||||
//
|
//
|
||||||
// Skills add a new provider by appending one import line below.
|
// Skills add a new provider by appending one import line below.
|
||||||
|
|
||||||
|
import './opencode.js';
|
||||||
|
|||||||
49
src/providers/opencode.ts
Normal file
49
src/providers/opencode.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user