Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

106 changes: 106 additions & 0 deletions docs/superpowers/specs/2026-04-23-opencode-provider-design.md
Original file line number Diff line number Diff line change
@@ -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 | `<pi-provider>/<model-id>` | `<provider-id>/<model-id>` |
| 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 |
Comment on lines +24 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does opencode server spawned via opencode serveenforce the OPENCODE_SERVER_PASSWORD env variable on HTTP requests, and how is it passed by@opencode-ai/sdk createOpencodeClient?

💡 Result:

Yes, the opencode server spawned via opencode serve enforces the OPENCODE_SERVER_PASSWORD environment variable on HTTP requests using HTTP Basic Authentication. When set, the server requires clients to provide the correct username (default "opencode", override with OPENCODE_SERVER_USERNAME) and password via the Authorization header. Without valid credentials, requests return 401 Unauthorized. The @opencode-ai/sdk's createOpencodeClient does not automatically read or pass the OPENCODE_SERVER_PASSWORD. It supports custom fetch implementations and headers (via options like fetch or headers in wrappers), allowing manual addition of Basic Auth. Internal OpenCode clients (TUI, plugins, run mode) add the header when the env var is set, but plain SDK clients require explicit configuration, such as: const credentials = btoa(opencode:${process.env.OPENCODE_SERVER_PASSWORD}); const client = createOpencodeClient({ baseUrl: "http://localhost:4096", headers: { Authorization: Basic ${credentials} } }); SDK docs list options like baseUrl, fetch, etc., but not auth directly; Basic Auth is handled via headers or custom fetch interceptors. Provider auth (e.g., client.auth.set for Anthropic API keys) is separate from server auth.

Citations:


🏁 Script executed:

fd -t f "provider.ts" | head -20

Repository: coleam00/Archon

Length of output: 243


🏁 Script executed:

fd -t f "server-manager.ts" | head -20

Repository: coleam00/Archon

Length of output: 118


🏁 Script executed:

cat -n "packages/providers/src/community/opencode/provider.ts"

Repository: coleam00/Archon

Length of output: 6643


🏁 Script executed:

cat -n "packages/providers/src/community/opencode/server-manager.ts"

Repository: coleam00/Archon

Length of output: 4874


Wire HTTP Basic Auth into the OpenCode SDK client.

The password is correctly spawned into OPENCODE_SERVER_PASSWORD in server-manager.ts (line 70), and the web search confirms the server enforces HTTP Basic Auth. However, provider.ts (lines 58–60) creates the SDK client with only baseUrl, omitting any Authorization header. The password retrieved on lines 50–51 is never passed to createOpencodeClient(), so every request will fail with 401 Unauthorized.

Update the client creation to include the auth header:

Example fix
const credentials = btoa(`opencode:${password}`);
const client = createOpencodeClient({
  baseUrl: `http://${serverInfo.hostname}:${serverInfo.port}`,
  headers: { Authorization: `Basic ${credentials}` },
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/specs/2026-04-23-opencode-provider-design.md` around lines
24 - 30, provider.ts is creating the OpenCode SDK client with only baseUrl and
never passing the retrieved password (the variable from lines where password is
read), causing 401s; fix createOpencodeClient usage by constructing HTTP Basic
credentials from the static user "opencode" and the retrieved password (base64
encode "opencode:password") and pass it as an Authorization header in the client
options (i.e. add headers: { Authorization: `Basic ${credentials}` }) when
calling createOpencodeClient so all SDK requests include the required auth.


## 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 <port> --hostname <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)
4 changes: 3 additions & 1 deletion packages/providers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
27 changes: 27 additions & 0 deletions packages/providers/src/community/opencode/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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,
};
64 changes: 64 additions & 0 deletions packages/providers/src/community/opencode/config.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>);
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,
});
});
});
44 changes: 44 additions & 0 deletions packages/providers/src/community/opencode/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { ProviderDefaults } from '../../types';

export interface OpencodeProviderDefaults extends ProviderDefaults {
/** Default model ref in '<provider-id>/<model-id>' 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<string, unknown>): 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;
}
Loading