diff --git a/bun.lock b/bun.lock index 1c6cf3891f..0858bdef32 100644 --- a/bun.lock +++ b/bun.lock @@ -133,6 +133,7 @@ "@mariozechner/pi-ai": "^0.67.5", "@mariozechner/pi-coding-agent": "^0.67.5", "@openai/codex-sdk": "^0.116.0", + "@opencode-ai/sdk": "^1.14.21", "@sinclair/typebox": "^0.34.41", }, "devDependencies": { @@ -710,6 +711,8 @@ "@openai/codex-win32-x64": ["@openai/codex@0.116.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-6sBIMOoA9FNuxQvCCnK0P548Wqrlk3I9SMdtOCUg2zYzYU7jOF2mWS1VpRQ6R+Jvo2x50dxeJZ+W37dBmXfprw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.21", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-WA9a1hTn2Nzjs6rI2fiMlPrBghmsFi771ABRxKlGZkTfT0PpKemJr6LP4vu+PilINFseLze2ZKmylRr84xFHuA=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ=="], diff --git a/docs/superpowers/specs/2026-04-23-opencode-provider-design.md b/docs/superpowers/specs/2026-04-23-opencode-provider-design.md new file mode 100644 index 0000000000..613521d453 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-opencode-provider-design.md @@ -0,0 +1,106 @@ +# OpenCode Community Provider Design + +> Date: 2026-04-23 +> Author: choufeng +> Reference: Issue #1151 + +## Goal + +Add OpenCode as a community provider to Archon, enabling users to use OpenCode's AI coding agent as a backend for Archon workflows. + +## Architecture + +OpenCode is a client/server AI coding agent (unlike Claude Code/Codex which are monolithic CLIs). The provider will: + +1. **Lazy-start** an OpenCode Server (`opencode serve`) on first use +2. **Connect** via `@opencode-ai/sdk` using `createOpencodeClient` +3. **Bridge** SSE events to Archon's `MessageChunk` async generator contract +4. **Manage** session lifecycle (create/resume/abort) + +## Key Differences from Pi Provider + +| Aspect | Pi | OpenCode | +|--------|-----|----------| +| SDK loading | Dynamic import with `PI_PACKAGE_DIR` shim | Clean dynamic import | +| Auth | OAuth + API key file (`~/.pi/agent/auth.json`) | HTTP Basic Auth (`OPENCODE_SERVER_PASSWORD`) | +| Model refs | `/` | `/` | +| Session storage | `~/.pi/agent/sessions/` (filesystem) | OpenCode Server internal | +| Structured output | Prompt engineering (best-effort) | SDK native support | +| MCP | Not supported | Native support | +| Server management | None (library call) | Must manage `opencode serve` lifecycle | + +## File Structure + +``` +packages/providers/src/community/opencode/ +├── provider.ts # OpenCodeProvider class +├── capabilities.ts # OPENCODE_CAPABILITIES +├── config.ts # parseOpencodeConfig +├── server-manager.ts # OpenCode Server lifecycle +├── event-bridge.ts # SSE Event → MessageChunk +├── registration.ts # registerOpencodeProvider() +├── index.ts # Public exports +├── provider.test.ts # Tests +└── config.test.ts # Config tests +``` + +## Capability Declaration (Honest) + +```typescript +export const OPENCODE_CAPABILITIES: ProviderCapabilities = { + sessionResume: true, // ✅ OpenCode sessions have IDs + mcp: true, // ✅ Native MCP support + hooks: false, // ❌ Archon hooks ≠ OpenCode plugins + skills: true, // ✅ Via systemPrompt injection + agents: false, // ❌ No inline sub-agent definitions + toolRestrictions: true, // ✅ Via tools whitelist/blacklist + structuredOutput: true, // ✅ SDK native JSON Schema support + envInjection: true, // ✅ Via request options + costControl: false, // ❌ No cost limit API + effortControl: true, // ✅ Via reasoning effort + thinkingControl: true, // ✅ Via reasoning toggle + fallbackModel: false, // ❌ No automatic fallback + sandbox: false, // ❌ No sandbox support +}; +``` + +## Event Bridge Mapping + +| OpenCode Event | Archon MessageChunk | +|----------------|---------------------| +| `message.part.updated` (text delta) | `assistant` | +| `message.part.updated` (reasoning) | `thinking` | +| `message.part.updated` (tool call) | `tool` | +| `message.updated` (assistant complete) | `result` (with tokens) | +| `session.error` | `result` (isError: true) | +| `message.part.updated` (step-finish) | `result` (with cost/tokens) | + +## Server Lifecycle + +1. On first `sendQuery()`, check if OpenCode Server is running (health check) +2. If not running, spawn `opencode serve --port --hostname ` +3. Wait for health check to pass (timeout: 30s) +4. Create SDK client connected to the server +5. Server process follows Archon process lifecycle (not detached) + +## Configuration + +```yaml +# .archon/config.yaml +assistants: + opencode: + model: anthropic/claude-sonnet-4 + hostname: 127.0.0.1 + port: 4096 + autoStartServer: true +``` + +## Cross-Cutting Changes + +1. `packages/providers/package.json` - Add `@opencode-ai/sdk` dependency +2. `packages/providers/src/registry.ts` - Add `registerOpencodeProvider()` call +3. `packages/providers/package.json` scripts - Add test command + +No changes to: +- `AssistantDefaultsConfig` or `AssistantDefaults` (community provider defaults live behind `[string]` index) +- CLI or server entrypoints (use aggregator pattern) diff --git a/packages/providers/package.json b/packages/providers/package.json index e443cea181..e8d6a963f3 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -14,11 +14,12 @@ "./codex/config": "./src/codex/config.ts", "./codex/binary-resolver": "./src/codex/binary-resolver.ts", "./community/pi": "./src/community/pi/index.ts", + "./community/opencode": "./src/community/opencode/index.ts", "./errors": "./src/errors.ts", "./registry": "./src/registry.ts" }, "scripts": { - "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts", + "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts && bun test src/community/opencode/config.test.ts && bun test src/community/opencode/provider.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { @@ -27,6 +28,7 @@ "@mariozechner/pi-ai": "^0.67.5", "@mariozechner/pi-coding-agent": "^0.67.5", "@openai/codex-sdk": "^0.116.0", + "@opencode-ai/sdk": "^1.14.21", "@sinclair/typebox": "^0.34.41" }, "devDependencies": { diff --git a/packages/providers/src/community/opencode/capabilities.ts b/packages/providers/src/community/opencode/capabilities.ts new file mode 100644 index 0000000000..68957d22fd --- /dev/null +++ b/packages/providers/src/community/opencode/capabilities.ts @@ -0,0 +1,27 @@ +import type { ProviderCapabilities } from '../../types'; + +/** + * OpenCode capabilities — intentionally conservative. Declared flags must + * reflect wired-up behavior, not potential support. The dag-executor uses + * these to warn users when a workflow node specifies a feature the provider + * ignores. + * + * OpenCode is a client/server AI coding agent with native MCP support, + * structured output, and session management. Unlike Pi, it does not require + * package.json shims or filesystem session stores. + */ +export const OPENCODE_CAPABILITIES: ProviderCapabilities = { + sessionResume: true, + mcp: true, + hooks: false, + skills: true, + agents: false, + toolRestrictions: true, + structuredOutput: true, + envInjection: true, + costControl: false, + effortControl: true, + thinkingControl: true, + fallbackModel: false, + sandbox: false, +}; diff --git a/packages/providers/src/community/opencode/config.test.ts b/packages/providers/src/community/opencode/config.test.ts new file mode 100644 index 0000000000..1a4012edbf --- /dev/null +++ b/packages/providers/src/community/opencode/config.test.ts @@ -0,0 +1,64 @@ +import { describe, test, expect } from 'bun:test'; +import { parseOpencodeConfig } from './config'; + +describe('parseOpencodeConfig', () => { + test('returns empty object for empty input', () => { + const result = parseOpencodeConfig({}); + expect(result).toEqual({}); + }); + + test('parses model string', () => { + const result = parseOpencodeConfig({ model: 'anthropic/claude-sonnet-4' }); + expect(result.model).toBe('anthropic/claude-sonnet-4'); + }); + + test('parses hostname', () => { + const result = parseOpencodeConfig({ hostname: '0.0.0.0' }); + expect(result.hostname).toBe('0.0.0.0'); + }); + + test('parses port', () => { + const result = parseOpencodeConfig({ port: 8080 }); + expect(result.port).toBe(8080); + }); + + test('parses serverPassword', () => { + const result = parseOpencodeConfig({ serverPassword: 'secret123' }); + expect(result.serverPassword).toBe('secret123'); + }); + + test('parses autoStartServer', () => { + const result = parseOpencodeConfig({ autoStartServer: false }); + expect(result.autoStartServer).toBe(false); + }); + + test('ignores invalid fields', () => { + const result = parseOpencodeConfig({ + model: 'anthropic/claude-sonnet-4', + port: 'not-a-number', + autoStartServer: 'yes', + unknownField: 'ignored', + } as Record); + expect(result.model).toBe('anthropic/claude-sonnet-4'); + expect(result.port).toBeUndefined(); + expect(result.autoStartServer).toBeUndefined(); + expect(result.unknownField).toBeUndefined(); + }); + + test('parses full config', () => { + const result = parseOpencodeConfig({ + model: 'openai/gpt-5', + hostname: '127.0.0.1', + port: 4096, + serverPassword: 'my-password', + autoStartServer: true, + }); + expect(result).toEqual({ + model: 'openai/gpt-5', + hostname: '127.0.0.1', + port: 4096, + serverPassword: 'my-password', + autoStartServer: true, + }); + }); +}); diff --git a/packages/providers/src/community/opencode/config.ts b/packages/providers/src/community/opencode/config.ts new file mode 100644 index 0000000000..bd25ede20b --- /dev/null +++ b/packages/providers/src/community/opencode/config.ts @@ -0,0 +1,44 @@ +import type { ProviderDefaults } from '../../types'; + +export interface OpencodeProviderDefaults extends ProviderDefaults { + /** Default model ref in '/' format, e.g. 'anthropic/claude-sonnet-4' */ + model?: string; + /** OpenCode Server hostname. @default '127.0.0.1' */ + hostname?: string; + /** OpenCode Server port. @default 4096 */ + port?: number; + /** OpenCode Server password for HTTP Basic Auth. If unset, auto-generated. */ + serverPassword?: string; + /** Auto-start OpenCode Server on first use. @default true */ + autoStartServer?: boolean; +} + +/** + * Parse raw YAML-derived config into typed OpenCode defaults. + * Defensive: invalid fields are dropped silently. + */ +export function parseOpencodeConfig(raw: Record): OpencodeProviderDefaults { + const result: OpencodeProviderDefaults = {}; + + if (typeof raw.model === 'string') { + result.model = raw.model; + } + + if (typeof raw.hostname === 'string') { + result.hostname = raw.hostname; + } + + if (typeof raw.port === 'number') { + result.port = raw.port; + } + + if (typeof raw.serverPassword === 'string') { + result.serverPassword = raw.serverPassword; + } + + if (typeof raw.autoStartServer === 'boolean') { + result.autoStartServer = raw.autoStartServer; + } + + return result; +} diff --git a/packages/providers/src/community/opencode/event-bridge.ts b/packages/providers/src/community/opencode/event-bridge.ts new file mode 100644 index 0000000000..5015805c87 --- /dev/null +++ b/packages/providers/src/community/opencode/event-bridge.ts @@ -0,0 +1,205 @@ +import type { MessageChunk } from '../../types'; + +// Type-only imports from OpenCode SDK to avoid runtime deps at module load. +// The actual SDK is dynamically imported inside sendQuery(). +type OpencodeClient = import('@opencode-ai/sdk').OpencodeClient; +type Event = import('@opencode-ai/sdk').Event; + +/** + * Bridge OpenCode SSE events to Archon MessageChunk async generator. + * + * OpenCode event flow for a single prompt: + * 1. message.part.updated (text delta) → assistant chunks + * 2. message.part.updated (reasoning) → thinking chunks + * 3. message.part.updated (tool call) → tool chunks + * 4. message.part.updated (tool result) → tool_result chunks + * 5. message.updated (assistant complete) → result chunk with tokens + * 6. session.error → result chunk with isError + */ +export async function* bridgeEvents( + client: OpencodeClient, + sessionId: string, + abortSignal?: AbortSignal +): AsyncGenerator { + const events = await client.event.subscribe(); + + const accumulatedTokens = { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }; + let totalCost = 0; + + try { + for await (const event of events.stream) { + if (abortSignal?.aborted) { + try { + await client.session.abort({ path: { id: sessionId } }); + } catch { + // Ignore abort errors + } + throw new Error('Aborted'); + } + + const chunk = mapEventToChunk(event); + if (!chunk) continue; + + // Accumulate token usage from result chunks for final tally + if (chunk.type === 'result' && chunk.tokens) { + accumulatedTokens.input += chunk.tokens.input ?? 0; + accumulatedTokens.output += chunk.tokens.output ?? 0; + } + if (chunk.type === 'result' && typeof chunk.cost === 'number') { + totalCost += chunk.cost; + } + + yield chunk; + + // Stop consuming on final result or error + if (chunk.type === 'result') { + return; + } + } + } finally { + // Ensure the SSE stream is cancelled + try { + await events.stream.return?.(undefined); + } catch { + // Ignore cleanup errors + } + } + + // If the stream ends without a result chunk, yield one with accumulated stats + yield { + type: 'result', + sessionId, + tokens: { + input: accumulatedTokens.input, + output: accumulatedTokens.output, + }, + cost: totalCost > 0 ? totalCost : undefined, + }; +} + +function mapEventToChunk(event: Event): MessageChunk | undefined { + switch (event.type) { + case 'message.part.updated': { + const part = event.properties.part; + const delta = event.properties.delta; + + if (part.type === 'text') { + return { + type: 'assistant', + content: delta ?? part.text ?? '', + }; + } + + if (part.type === 'reasoning') { + return { + type: 'thinking', + content: delta ?? part.text ?? '', + }; + } + + if (part.type === 'tool') { + const state = part.state; + if (state.status === 'pending' || state.status === 'running') { + return { + type: 'tool', + toolName: part.tool, + toolInput: state.input, + toolCallId: part.callID, + }; + } + // Completed tool calls are reported via tool_result + if (state.status === 'completed') { + return { + type: 'tool_result', + toolName: part.tool, + toolOutput: JSON.stringify(state.output ?? state.input ?? {}), + toolCallId: part.callID, + }; + } + return undefined; + } + + // Other part types (file, agent, subtask, etc.) are ignored for now + return undefined; + } + + case 'message.updated': { + const info = event.properties.info; + if (info.role === 'assistant') { + const tokens = info.tokens; + return { + type: 'result', + sessionId: info.sessionID, + tokens: { + input: tokens?.input ?? 0, + output: tokens?.output ?? 0, + total: tokens ? tokens.input + tokens.output + tokens.reasoning : undefined, + }, + cost: info.cost > 0 ? info.cost : undefined, + }; + } + return undefined; + } + + case 'session.error': { + const error = event.properties.error; + let errorMessage = 'Unknown OpenCode error'; + if (error) { + if ('data' in error && error.data && typeof error.data === 'object') { + errorMessage = + (error.data as { message?: string }).message ?? error.name ?? 'Unknown error'; + } else { + errorMessage = error.name ?? 'Unknown error'; + } + } + return { + type: 'result', + isError: true, + errors: [errorMessage], + sessionId: event.properties.sessionID, + }; + } + + case 'session.status': + case 'session.idle': + case 'session.compacted': + case 'file.edited': + case 'todo.updated': + case 'command.executed': + case 'message.removed': + case 'message.part.removed': + case 'permission.updated': + case 'permission.replied': + case 'lsp.updated': + case 'lsp.client.diagnostics': + case 'file.watcher.updated': + case 'vcs.branch.updated': + case 'tui.prompt.append': + case 'tui.command.execute': + case 'tui.toast.show': + case 'pty.created': + case 'pty.updated': + case 'pty.exited': + case 'pty.deleted': + case 'server.connected': + case 'server.instance.disposed': + case 'installation.updated': + case 'installation.update-available': + case 'session.created': + case 'session.updated': + case 'session.deleted': + case 'session.diff': + // Intentionally ignored — not relevant to Archon's MessageChunk contract + return undefined; + + default: + // Exhaustiveness fallback + return undefined; + } +} diff --git a/packages/providers/src/community/opencode/index.ts b/packages/providers/src/community/opencode/index.ts new file mode 100644 index 0000000000..0a20b4f841 --- /dev/null +++ b/packages/providers/src/community/opencode/index.ts @@ -0,0 +1,4 @@ +export { OPENCODE_CAPABILITIES } from './capabilities'; +export { parseOpencodeConfig, type OpencodeProviderDefaults } from './config'; +export { OpenCodeProvider } from './provider'; +export { registerOpencodeProvider } from './registration'; diff --git a/packages/providers/src/community/opencode/provider.test.ts b/packages/providers/src/community/opencode/provider.test.ts new file mode 100644 index 0000000000..f81f1f0e55 --- /dev/null +++ b/packages/providers/src/community/opencode/provider.test.ts @@ -0,0 +1,465 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +import { createMockLogger } from '../../test/mocks/logger'; + +// ─── Mock @archon/paths logger ─────────────────────────────────────────── + +const mockLogger = createMockLogger(); +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), +})); + +// ─── Mock @opencode-ai/sdk ─────────────────────────────────────────────── + +// Shared mutable state for test control +let mockSessionId = 'mock-session-id'; +let mockEventSequence: Array = []; +let mockSessionList: Array<{ id: string }> = []; + +const mockCreateSession = mock(async () => ({ + data: { id: mockSessionId }, +})); + +const mockSessionStatus = mock(async () => ({ data: { id: mockSessionId } })); + +const mockPromptAsync = mock(async () => ({ data: { id: 'msg-123' } })); + +const mockSessionAbort = mock(async () => ({})); + +const mockEventSubscribe = mock(async () => ({ + stream: (async function* () { + for (const ev of mockEventSequence) { + yield ev; + } + })(), +})); + +const mockClient = { + session: { + create: mockCreateSession, + status: mockSessionStatus, + promptAsync: mockPromptAsync, + abort: mockSessionAbort, + list: mock(async () => ({ data: mockSessionList })), + }, + event: { + subscribe: mockEventSubscribe, + }, +}; + +mock.module('@opencode-ai/sdk', () => ({ + createOpencodeClient: mock(() => mockClient), +})); + +// ─── Mock server-manager ───────────────────────────────────────────────── +// Skip actual server lifecycle in tests + +mock.module('./server-manager', () => ({ + ensureServer: mock(async () => ({ + hostname: '127.0.0.1', + port: 4096, + password: 'test-password', + })), + generatePassword: mock(() => 'test-password'), +})); + +// ─── Import provider AFTER mocks are set up ────────────────────────────── + +import { OpenCodeProvider } from './provider'; +import { OPENCODE_CAPABILITIES } from './capabilities'; +import { registerOpencodeProvider } from './registration'; +import { + getRegistration, + getProviderCapabilities, + getProviderInfoList, + getRegisteredProviders, + isRegisteredProvider, + clearRegistry, + registerBuiltinProviders, +} from '../../registry'; + +describe('OpenCodeProvider', () => { + beforeEach(() => { + mockSessionId = 'mock-session-id'; + mockEventSequence = []; + mockSessionList = []; + mockCreateSession.mockClear(); + mockSessionStatus.mockClear(); + mockPromptAsync.mockClear(); + mockEventSubscribe.mockClear(); + }); + + test('getType returns opencode', () => { + const provider = new OpenCodeProvider(); + expect(provider.getType()).toBe('opencode'); + }); + + test('getCapabilities returns OPENCODE_CAPABILITIES', () => { + const provider = new OpenCodeProvider(); + expect(provider.getCapabilities()).toEqual(OPENCODE_CAPABILITIES); + }); + + test('sendQuery creates a new session and sends prompt', async () => { + mockEventSequence = [ + { + type: 'message.part.updated', + properties: { + part: { id: 'p1', sessionID: 's1', messageID: 'm1', type: 'text', text: 'Hello' }, + delta: 'Hello', + }, + } as import('@opencode-ai/sdk').Event, + { + type: 'message.updated', + properties: { + info: { + id: 'm1', + sessionID: 's1', + role: 'assistant', + time: { created: 1 }, + parentID: 'p0', + modelID: 'claude-sonnet-4', + providerID: 'anthropic', + mode: 'chat', + path: { cwd: '/tmp', root: '/tmp' }, + cost: 0.001, + tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }, + } as import('@opencode-ai/sdk').Event, + ]; + + const provider = new OpenCodeProvider(); + const chunks: Array = []; + + for await (const chunk of provider.sendQuery('Test prompt', '/tmp/project')) { + chunks.push(chunk); + } + + expect(mockCreateSession).toHaveBeenCalled(); + expect(mockPromptAsync).toHaveBeenCalled(); + + // Should yield assistant text chunk + expect(chunks.some(c => c.type === 'assistant' && c.content === 'Hello')).toBe(true); + // Should yield result chunk + expect(chunks.some(c => c.type === 'result')).toBe(true); + }); + + test('sendQuery resumes existing session when resumeSessionId is provided', async () => { + mockSessionList = [{ id: 'existing-session-id' }]; + mockEventSequence = [ + { + type: 'message.updated', + properties: { + info: { + id: 'm1', + sessionID: 'existing-session-id', + role: 'assistant', + time: { created: 1 }, + parentID: 'p0', + modelID: 'claude-sonnet-4', + providerID: 'anthropic', + mode: 'chat', + path: { cwd: '/tmp', root: '/tmp' }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }, + } as import('@opencode-ai/sdk').Event, + ]; + + const provider = new OpenCodeProvider(); + const chunks: Array = []; + + for await (const chunk of provider.sendQuery('Test', '/tmp', 'existing-session-id')) { + chunks.push(chunk); + } + + expect(mockSessionStatus).toHaveBeenCalledWith({ path: { id: 'existing-session-id' } }); + expect(mockCreateSession).not.toHaveBeenCalled(); + }); + + test('sendQuery falls back to new session when resumeSessionId is invalid', async () => { + mockSessionStatus.mockImplementationOnce(async () => { + throw new Error('Session not found'); + }); + + mockEventSequence = [ + { + type: 'message.updated', + properties: { + info: { + id: 'm1', + sessionID: 'new-session-id', + role: 'assistant', + time: { created: 1 }, + parentID: 'p0', + modelID: 'claude-sonnet-4', + providerID: 'anthropic', + mode: 'chat', + path: { cwd: '/tmp', root: '/tmp' }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }, + } as import('@opencode-ai/sdk').Event, + ]; + + const provider = new OpenCodeProvider(); + const chunks: Array = []; + + for await (const chunk of provider.sendQuery('Test', '/tmp', 'invalid-id')) { + chunks.push(chunk); + } + + expect(mockSessionStatus).toHaveBeenCalled(); + expect(mockCreateSession).toHaveBeenCalled(); + // Should yield a system warning about resume failure + expect(chunks.some(c => c.type === 'system')).toBe(true); + }); + + test('sendQuery yields thinking chunks for reasoning parts', async () => { + mockEventSequence = [ + { + type: 'message.part.updated', + properties: { + part: { + id: 'p1', + sessionID: 's1', + messageID: 'm1', + type: 'reasoning', + text: 'Let me think...', + }, + delta: 'Let me think...', + }, + } as import('@opencode-ai/sdk').Event, + { + type: 'message.updated', + properties: { + info: { + id: 'm1', + sessionID: 's1', + role: 'assistant', + time: { created: 1 }, + parentID: 'p0', + modelID: 'claude-sonnet-4', + providerID: 'anthropic', + mode: 'chat', + path: { cwd: '/tmp', root: '/tmp' }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }, + } as import('@opencode-ai/sdk').Event, + ]; + + const provider = new OpenCodeProvider(); + const chunks: Array = []; + + for await (const chunk of provider.sendQuery('Test', '/tmp')) { + chunks.push(chunk); + } + + expect(chunks.some(c => c.type === 'thinking' && c.content === 'Let me think...')).toBe(true); + }); + + test('sendQuery handles tool calls', async () => { + mockEventSequence = [ + { + type: 'message.part.updated', + properties: { + part: { + id: 'p1', + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: 'call-1', + tool: 'read', + state: { status: 'pending', input: { path: '/tmp/file.txt' }, raw: '' }, + }, + delta: undefined, + }, + } as import('@opencode-ai/sdk').Event, + { + type: 'message.part.updated', + properties: { + part: { + id: 'p2', + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: 'call-1', + tool: 'read', + state: { + status: 'completed', + input: { path: '/tmp/file.txt' }, + output: { content: 'hello' }, + raw: '', + }, + }, + delta: undefined, + }, + } as import('@opencode-ai/sdk').Event, + { + type: 'message.updated', + properties: { + info: { + id: 'm1', + sessionID: 's1', + role: 'assistant', + time: { created: 1 }, + parentID: 'p0', + modelID: 'claude-sonnet-4', + providerID: 'anthropic', + mode: 'chat', + path: { cwd: '/tmp', root: '/tmp' }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }, + } as import('@opencode-ai/sdk').Event, + ]; + + const provider = new OpenCodeProvider(); + const chunks: Array = []; + + for await (const chunk of provider.sendQuery('Read file', '/tmp')) { + chunks.push(chunk); + } + + const toolChunk = chunks.find(c => c.type === 'tool'); + expect(toolChunk).toBeDefined(); + expect(toolChunk?.type === 'tool' && toolChunk.toolName).toBe('read'); + + const toolResultChunk = chunks.find(c => c.type === 'tool_result'); + expect(toolResultChunk).toBeDefined(); + }); + + test('sendQuery handles session errors', async () => { + mockEventSequence = [ + { + type: 'session.error', + properties: { + sessionID: 's1', + error: { + name: 'ApiError', + data: { message: 'Rate limit exceeded', statusCode: 429, isRetryable: true }, + }, + }, + } as import('@opencode-ai/sdk').Event, + ]; + + const provider = new OpenCodeProvider(); + const chunks: Array = []; + + for await (const chunk of provider.sendQuery('Test', '/tmp')) { + chunks.push(chunk); + } + + const resultChunk = chunks.find(c => c.type === 'result'); + expect(resultChunk).toBeDefined(); + expect(resultChunk?.type === 'result' && resultChunk.isError).toBe(true); + }); + + test('sendQuery passes model config to prompt', async () => { + mockEventSequence = [ + { + type: 'message.updated', + properties: { + info: { + id: 'm1', + sessionID: 's1', + role: 'assistant', + time: { created: 1 }, + parentID: 'p0', + modelID: 'gpt-5', + providerID: 'openai', + mode: 'chat', + path: { cwd: '/tmp', root: '/tmp' }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }, + } as import('@opencode-ai/sdk').Event, + ]; + + const provider = new OpenCodeProvider(); + const gen = provider.sendQuery('Test', '/tmp', undefined, { + model: 'openai/gpt-5', + systemPrompt: 'You are a helpful assistant', + nodeConfig: { allowed_tools: ['read', 'bash'] }, + }); + + // Consume all chunks + for await (const _ of gen) { + // no-op + } + + const promptCall = mockPromptAsync.mock.calls[0]; + expect(promptCall).toBeDefined(); + const body = promptCall[0].body; + expect(body.model).toEqual({ providerID: 'openai', modelID: 'gpt-5' }); + expect(body.system).toBe('You are a helpful assistant'); + expect(body.tools).toEqual({ read: true, bash: true }); + }); +}); + +describe('registerOpencodeProvider', () => { + beforeEach(() => { + clearRegistry(); + registerBuiltinProviders(); + }); + + test('registers opencode with builtIn: false', () => { + registerOpencodeProvider(); + const reg = getRegistration('opencode'); + expect(reg.id).toBe('opencode'); + expect(reg.displayName).toBe('OpenCode (community)'); + expect(reg.builtIn).toBe(false); + }); + + test('is idempotent', () => { + registerOpencodeProvider(); + expect(() => registerOpencodeProvider()).not.toThrow(); + const entries = getRegistration('opencode'); + expect(entries).toBeDefined(); + }); + + test('declares expected capabilities', () => { + registerOpencodeProvider(); + const caps = getProviderCapabilities('opencode'); + expect(caps.sessionResume).toBe(true); + expect(caps.mcp).toBe(true); + expect(caps.structuredOutput).toBe(true); + expect(caps.toolRestrictions).toBe(true); + expect(caps.skills).toBe(true); + expect(caps.hooks).toBe(false); + expect(caps.agents).toBe(false); + expect(caps.costControl).toBe(false); + expect(caps.sandbox).toBe(false); + }); + + test('isModelCompatible accepts provider/model refs', () => { + registerOpencodeProvider(); + const reg = getRegistration('opencode'); + expect(reg.isModelCompatible('anthropic/claude-sonnet-4')).toBe(true); + expect(reg.isModelCompatible('openai/gpt-5')).toBe(true); + expect(reg.isModelCompatible('google/gemini-2.5-pro')).toBe(true); + expect(reg.isModelCompatible('claude-3.5-sonnet')).toBe(true); + expect(reg.isModelCompatible('')).toBe(false); + }); + + test('appears in getProviderInfoList with builtIn: false', () => { + registerOpencodeProvider(); + const info = getProviderInfoList().find(p => p.id === 'opencode'); + expect(info).toBeDefined(); + expect(info?.builtIn).toBe(false); + }); + + test('does not collide with built-ins', () => { + registerOpencodeProvider(); + const ids = getRegisteredProviders() + .map(p => p.id) + .sort(); + expect(ids).toEqual(['claude', 'codex', 'opencode']); + }); +}); diff --git a/packages/providers/src/community/opencode/provider.ts b/packages/providers/src/community/opencode/provider.ts new file mode 100644 index 0000000000..57e2d27b30 --- /dev/null +++ b/packages/providers/src/community/opencode/provider.ts @@ -0,0 +1,171 @@ +import { createLogger } from '@archon/paths'; + +import type { + IAgentProvider, + MessageChunk, + ProviderCapabilities, + SendQueryOptions, +} from '../../types'; + +import { OPENCODE_CAPABILITIES } from './capabilities'; +import { parseOpencodeConfig } from './config'; +import { ensureServer, generatePassword } from './server-manager'; +import { bridgeEvents } from './event-bridge'; + +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.opencode'); + return cachedLog; +} + +/** + * OpenCode community provider — wraps `@opencode-ai/sdk` to connect to an + * OpenCode Server (auto-started on first use or connected to an existing one). + * + * OpenCode is a client/server AI coding agent. Each `sendQuery()` call: + * 1. Ensures the OpenCode Server is running + * 2. Creates an SDK client + * 3. Creates or resumes a session + * 4. Sends the prompt via `session.prompt()` + * 5. Bridges SSE events to Archon MessageChunks + * + * Capabilities: sessionResume, mcp, skills, toolRestrictions, structuredOutput, + * envInjection, effortControl, thinkingControl. + */ +export class OpenCodeProvider implements IAgentProvider { + async *sendQuery( + prompt: string, + cwd: string, + resumeSessionId?: string, + requestOptions?: SendQueryOptions + ): AsyncGenerator { + // Lazy-load SDK to avoid runtime deps at module load. + const { createOpencodeClient } = await import('@opencode-ai/sdk'); + + const assistantConfig = requestOptions?.assistantConfig ?? {}; + const config = parseOpencodeConfig(assistantConfig); + + const hostname = config.hostname ?? '127.0.0.1'; + const port = config.port ?? 4096; + const password = + config.serverPassword ?? process.env.OPENCODE_SERVER_PASSWORD ?? generatePassword(); + const autoStart = config.autoStartServer !== false; + + // 1. Ensure server is running + const serverInfo = await ensureServer({ hostname, port, cwd, password }, autoStart); + + // 2. Create SDK client + const client = createOpencodeClient({ + baseUrl: `http://${serverInfo.hostname}:${serverInfo.port}`, + }); + + // 3. Resolve model + const modelRef = requestOptions?.model ?? config.model; + let modelProvider: string | undefined; + let modelId: string | undefined; + if (modelRef) { + const parts = modelRef.split('/'); + if (parts.length >= 2) { + modelProvider = parts[0]; + modelId = parts.slice(1).join('/'); + } + } + + // 4. Session management + let sessionId: string; + let resumeFailed = false; + + if (resumeSessionId) { + try { + // Verify the session exists + await client.session.get({ path: { id: resumeSessionId } }); + sessionId = resumeSessionId; + getLog().debug({ sessionId }, 'opencode.session.resumed'); + } catch { + resumeFailed = true; + getLog().warn({ sessionId: resumeSessionId }, 'opencode.session.resume_failed'); + const session = await client.session.create({ + body: { title: 'Archon Workflow' }, + query: { directory: cwd }, + }); + sessionId = session.data?.id ?? ''; + } + } else { + const session = await client.session.create({ + body: { title: 'Archon Workflow' }, + query: { directory: cwd }, + }); + sessionId = session.data?.id ?? ''; + } + + if (!sessionId) { + throw new Error('OpenCode: failed to create session'); + } + + if (resumeFailed) { + yield { + type: 'system', + content: '⚠️ Could not resume OpenCode session. Starting fresh conversation.', + }; + } + + // 5. Translate nodeConfig to SDK options + const nodeConfig = requestOptions?.nodeConfig; + + // Tool restrictions + const tools = nodeConfig?.allowed_tools + ? Object.fromEntries(nodeConfig.allowed_tools.map(t => [t, true])) + : undefined; + + // System prompt + const systemPrompt = requestOptions?.systemPrompt ?? nodeConfig?.systemPrompt; + + // Structured output + const outputFormat = requestOptions?.outputFormat; + + getLog().info( + { + model: modelRef, + cwd, + hasSystemPrompt: systemPrompt !== undefined, + hasTools: tools !== undefined, + hasOutputFormat: outputFormat !== undefined, + resumed: resumeSessionId !== undefined && !resumeFailed, + }, + 'opencode.session_started' + ); + + // 6. Send prompt + try { + // Use promptAsync to start the message, then consume events + await client.session.promptAsync({ + path: { id: sessionId }, + body: { + parts: [{ type: 'text', text: prompt }], + ...(modelProvider && modelId + ? { model: { providerID: modelProvider, modelID: modelId } } + : {}), + ...(systemPrompt ? { system: systemPrompt } : {}), + ...(tools ? { tools } : {}), + }, + query: { directory: cwd }, + }); + + // 7. Bridge SSE events to MessageChunks + yield* bridgeEvents(client, sessionId, requestOptions?.abortSignal); + + getLog().info({ sessionId }, 'opencode.prompt_completed'); + } catch (err) { + getLog().error({ err, sessionId }, 'opencode.prompt_failed'); + throw err; + } + } + + getType(): string { + return 'opencode'; + } + + getCapabilities(): ProviderCapabilities { + return OPENCODE_CAPABILITIES; + } +} diff --git a/packages/providers/src/community/opencode/registration.ts b/packages/providers/src/community/opencode/registration.ts new file mode 100644 index 0000000000..da54e8b500 --- /dev/null +++ b/packages/providers/src/community/opencode/registration.ts @@ -0,0 +1,28 @@ +import { isRegisteredProvider, registerProvider } from '../../registry'; + +import { OPENCODE_CAPABILITIES } from './capabilities'; +import { OpenCodeProvider } from './provider'; + +/** + * Register the OpenCode community provider. + * + * Idempotent — safe to call multiple times from process entrypoints. + */ +export function registerOpencodeProvider(): void { + if (isRegisteredProvider('opencode')) return; + + registerProvider({ + id: 'opencode', + displayName: 'OpenCode (community)', + factory: () => new OpenCodeProvider(), + capabilities: OPENCODE_CAPABILITIES, + isModelCompatible: (model: string): boolean => { + // OpenCode supports provider/model refs (e.g. 'anthropic/claude-sonnet-4') + // and common model name prefixes. + if (model.includes('/')) return true; + const prefixes = ['gpt', 'claude', 'gemini', 'llama', 'deepseek', 'qwen']; + return prefixes.some(p => model.toLowerCase().startsWith(p)); + }, + builtIn: false, + }); +} diff --git a/packages/providers/src/community/opencode/server-manager.ts b/packages/providers/src/community/opencode/server-manager.ts new file mode 100644 index 0000000000..390f991ab6 --- /dev/null +++ b/packages/providers/src/community/opencode/server-manager.ts @@ -0,0 +1,127 @@ +import { spawn } from 'node:child_process'; +import { createLogger } from '@archon/paths'; + +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.opencode.server'); + return cachedLog; +} + +export interface ServerConfig { + port: number; + hostname: string; + cwd: string; + password: string; +} + +export interface ServerInfo { + hostname: string; + port: number; + password: string; + pid?: number; +} + +/** Shared server process reference — one server per Archon process. */ +let managedServer: { proc: ReturnType; info: ServerInfo } | undefined; + +/** + * Ensure OpenCode Server is running. If `autoStartServer` is true and no + * server is listening, spawn `opencode serve` and wait for readiness. + * + * Idempotent per Archon process: once a server is started, subsequent calls + * return the same info without spawning a new process. + */ +export async function ensureServer(config: ServerConfig, autoStart = true): Promise { + // 1. Check if a previously managed server is still healthy + if (managedServer) { + const isRunning = await checkHealth(managedServer.info.hostname, managedServer.info.port); + if (isRunning) { + getLog().debug({ port: managedServer.info.port }, 'opencode.server.already_running'); + return managedServer.info; + } + // Server died — clear the reference so we can respawn + managedServer = undefined; + } + + // 2. Check if an external server is already listening + const isRunning = await checkHealth(config.hostname, config.port); + if (isRunning) { + getLog().debug({ port: config.port }, 'opencode.server.external_detected'); + return { hostname: config.hostname, port: config.port, password: config.password }; + } + + if (!autoStart) { + throw new Error( + `OpenCode Server is not running at ${config.hostname}:${config.port} and autoStartServer is disabled. ` + + `Start it manually with: opencode serve --port ${config.port} --hostname ${config.hostname}` + ); + } + + // 3. Start the server + getLog().info({ port: config.port, cwd: config.cwd }, 'opencode.server.starting'); + + const proc = spawn( + 'opencode', + ['serve', '--port', String(config.port), '--hostname', config.hostname], + { + cwd: config.cwd, + env: { + ...process.env, + OPENCODE_SERVER_PASSWORD: config.password, + }, + detached: false, + stdio: 'pipe', + } + ); + + proc.on('error', err => { + getLog().error({ err }, 'opencode.server.process_error'); + }); + + proc.stderr?.on('data', (data: Buffer) => { + getLog().debug({ msg: data.toString().trim() }, 'opencode.server.stderr'); + }); + + // 4. Wait for readiness + await waitForReady(config.hostname, config.port, 30000); + + const info: ServerInfo = { + hostname: config.hostname, + port: config.port, + password: config.password, + pid: proc.pid ?? undefined, + }; + + managedServer = { proc, info }; + + getLog().info({ pid: proc.pid, port: config.port }, 'opencode.server.ready'); + + return info; +} + +async function checkHealth(hostname: string, port: number): Promise { + try { + const res = await fetch(`http://${hostname}:${port}/global/health`, { + signal: AbortSignal.timeout(1000), + }); + return res.ok; + } catch { + return false; + } +} + +async function waitForReady(hostname: string, port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await checkHealth(hostname, port)) return; + await new Promise(r => setTimeout(r, 500)); + } + throw new Error(`OpenCode Server failed to start on ${hostname}:${port} within ${timeoutMs}ms`); +} + +/** + * Generate a random password for the OpenCode Server. + */ +export function generatePassword(): string { + return `archon-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; +} diff --git a/packages/providers/src/registry.test.ts b/packages/providers/src/registry.test.ts index 64b879a91c..d1bc3265f9 100644 --- a/packages/providers/src/registry.test.ts +++ b/packages/providers/src/registry.test.ts @@ -12,6 +12,7 @@ import { clearRegistry, } from './registry'; import { registerPiProvider } from './community/pi/registration'; +import { registerOpencodeProvider } from './community/opencode/registration'; import { UnknownProviderError } from './errors'; import type { ProviderRegistration, IAgentProvider, ProviderCapabilities } from './types'; @@ -275,9 +276,8 @@ describe('registry', () => { describe('registerCommunityProviders (aggregator)', () => { test('registers all bundled community providers', () => { registerCommunityProviders(); - // Pi is currently the only community provider bundled. When more are - // added, they should appear here automatically. expect(isRegisteredProvider('pi')).toBe(true); + expect(isRegisteredProvider('opencode')).toBe(true); }); test('is idempotent', () => { @@ -285,6 +285,8 @@ describe('registry', () => { expect(() => registerCommunityProviders()).not.toThrow(); const piCount = getRegisteredProviders().filter(p => p.id === 'pi').length; expect(piCount).toBe(1); + const opencodeCount = getRegisteredProviders().filter(p => p.id === 'opencode').length; + expect(opencodeCount).toBe(1); }); }); @@ -352,4 +354,67 @@ describe('registry', () => { expect(ids).toEqual(['claude', 'codex', 'pi']); }); }); + + describe('registerOpencodeProvider (community provider)', () => { + test('registers opencode with builtIn: false', () => { + registerOpencodeProvider(); + const reg = getRegistration('opencode'); + expect(reg.id).toBe('opencode'); + expect(reg.displayName).toBe('OpenCode (community)'); + expect(reg.builtIn).toBe(false); + }); + + test('is idempotent', () => { + registerOpencodeProvider(); + expect(() => registerOpencodeProvider()).not.toThrow(); + const entries = getRegisteredProviders().filter(p => p.id === 'opencode'); + expect(entries).toHaveLength(1); + }); + + test('declares expected capabilities', () => { + registerOpencodeProvider(); + const caps = getProviderCapabilities('opencode'); + expect(caps.sessionResume).toBe(true); + expect(caps.mcp).toBe(true); + expect(caps.structuredOutput).toBe(true); + expect(caps.toolRestrictions).toBe(true); + expect(caps.skills).toBe(true); + expect(caps.effortControl).toBe(true); + expect(caps.thinkingControl).toBe(true); + expect(caps.envInjection).toBe(true); + // Still false + expect(caps.hooks).toBe(false); + expect(caps.agents).toBe(false); + expect(caps.costControl).toBe(false); + expect(caps.fallbackModel).toBe(false); + expect(caps.sandbox).toBe(false); + }); + + test('isModelCompatible accepts provider/model refs and common prefixes', () => { + registerOpencodeProvider(); + const reg = getRegistration('opencode'); + expect(reg.isModelCompatible('anthropic/claude-sonnet-4')).toBe(true); + expect(reg.isModelCompatible('openai/gpt-5')).toBe(true); + expect(reg.isModelCompatible('google/gemini-2.5-pro')).toBe(true); + expect(reg.isModelCompatible('claude-3.5-sonnet')).toBe(true); + expect(reg.isModelCompatible('gpt-4')).toBe(true); + expect(reg.isModelCompatible('sonnet')).toBe(false); + expect(reg.isModelCompatible('')).toBe(false); + }); + + test('appears in getProviderInfoList with builtIn: false', () => { + registerOpencodeProvider(); + const info = getProviderInfoList().find(p => p.id === 'opencode'); + expect(info).toBeDefined(); + expect(info?.builtIn).toBe(false); + }); + + test('does not collide with built-ins', () => { + registerOpencodeProvider(); + const ids = getRegisteredProviders() + .map(p => p.id) + .sort(); + expect(ids).toEqual(['claude', 'codex', 'opencode']); + }); + }); }); diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index 1ae16759dc..fc402f8604 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -18,6 +18,7 @@ import { CodexProvider } from './codex/provider'; import { CLAUDE_CAPABILITIES } from './claude/capabilities'; import { CODEX_CAPABILITIES } from './codex/capabilities'; import { registerPiProvider } from './community/pi/registration'; +import { registerOpencodeProvider } from './community/opencode/registration'; import { UnknownProviderError } from './errors'; import { createLogger } from '@archon/paths'; @@ -163,6 +164,7 @@ export function registerBuiltinProviders(): void { */ export function registerCommunityProviders(): void { registerPiProvider(); + registerOpencodeProvider(); } /** @internal Test-only — clears the registry. Not for production use. */