diff --git a/bun.lock b/bun.lock index 204f0df73a5..84ca98c3c91 100644 --- a/bun.lock +++ b/bun.lock @@ -206,6 +206,22 @@ "typescript": "catalog:", }, }, + "packages/memsh-cli": { + "name": "@opencode-ai/memsh-cli", + "version": "0.1.0", + "bin": { + "memsh-cli": "./bin/memsh-cli", + }, + "dependencies": { + "ws": "^8.18.0", + "zod": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "packages/opencode": { "name": "opencode", "version": "1.0.133", @@ -269,6 +285,7 @@ "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "xdg-basedir": "5.1.0", + "yaml": "2.8.0", "yargs": "18.0.0", "zod": "catalog:", "zod-to-json-schema": "3.24.5", @@ -1066,6 +1083,8 @@ "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], + "@opencode-ai/memsh-cli": ["@opencode-ai/memsh-cli@workspace:packages/memsh-cli"], + "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], @@ -3694,7 +3713,7 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], + "yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="], "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], @@ -4112,6 +4131,8 @@ "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + "postcss-load-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], + "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], diff --git a/docs/NEW_CLIENT_FEASIBILITY.md b/docs/NEW_CLIENT_FEASIBILITY.md new file mode 100644 index 00000000000..e23e73a022e --- /dev/null +++ b/docs/NEW_CLIENT_FEASIBILITY.md @@ -0,0 +1,650 @@ +# New Client Feasibility Analysis + +This document analyzes the feasibility of building a new client for OpenCode with full subagent and task management support. + +## Executive Summary + +**Verdict: Highly Feasible** + +OpenCode's architecture is well-suited for alternative client implementations. The HTTP API is comprehensive, events are streamed via SSE, and all schemas are well-defined with Zod. + +**Important:** OpenCode already has: +- A **generated TypeScript SDK** (`@opencode-ai/sdk`) with all API methods +- A **SolidJS web client** (`packages/desktop`) with full subagent support +- A **TUI client** in the core package + +New clients can either use the existing SDK (for TypeScript/JavaScript) or implement their own HTTP client based on the OpenAPI spec. + +--- + +## Current Architecture + +### Communication Patterns + +``` +┌─────────────┐ HTTP/SSE ┌──────────────┐ +│ Client │ ◄─────────────────────► │ Server │ +│ (TUI/Web) │ │ (Hono) │ +└─────────────┘ └──────────────┘ + │ + ┌───────┴───────┐ + │ │ + ┌─────▼─────┐ ┌─────▼─────┐ + │ Session │ │ Agent │ + │ Manager │ │ Executor │ + └───────────┘ └───────────┘ +``` + +### Key Components + +| Component | Role | Client Access | +|-----------|------|---------------| +| Server (Hono) | HTTP API gateway | Direct HTTP | +| Bus | Event pub/sub | SSE streaming | +| Storage | Persistence | Via API only | +| Session Manager | Session CRUD | HTTP endpoints | +| Prompt Executor | LLM execution | POST /message | +| Agent Registry | Agent config | GET /agent | + +--- + +## API Completeness Analysis + +### Session Management: Complete + +| Operation | Endpoint | Status | +|-----------|----------|--------| +| Create | POST /session | Available | +| Read | GET /session/:id | Available | +| List | GET /session | Available | +| Update | PATCH /session/:id | Available | +| Delete | DELETE /session/:id | Available | +| Fork | POST /session/:id/fork | Available | +| Children | GET /session/:id/children | Available | +| Share | POST /session/:id/share | Available | + +### Message Execution: Complete + +| Operation | Endpoint | Status | +|-----------|----------|--------| +| Create & Execute | POST /session/:id/message | Available (streams) | +| List Messages | GET /session/:id/message | Available | +| Get Message | GET /session/:id/message/:msgID | Available | +| Execute Command | POST /session/:id/command | Available | +| Execute Shell | POST /session/:id/shell | Available | +| Abort | POST /session/:id/abort | Available | +| Revert | POST /session/:id/revert | Available | + +### Event Streaming: Complete + +| Operation | Endpoint | Status | +|-----------|----------|--------| +| Session Events | GET /event | SSE stream | +| Global Events | GET /global/event | SSE stream | +| Status Polling | GET /session/status | Available | + +### Agent Configuration: Complete + +| Operation | Endpoint | Status | +|-----------|----------|--------| +| List Agents | GET /agent | Available | +| Permissions | POST /session/:id/permissions/:id | Available | + +--- + +## New Client Capabilities + +### Tier 1: Basic Client (1-2 weeks) + +**Features:** +- Session CRUD +- Message sending/receiving +- Basic streaming output +- Agent selection + +**APIs Required:** +- POST/GET/DELETE /session +- POST /session/:id/message +- GET /session/:id/message + +**Complexity:** Low + +--- + +### Tier 2: Full-Featured Client (3-4 weeks) + +**Additional Features:** +- Real-time event streaming +- Subagent monitoring +- File diff visualization +- Permission handling +- Session forking + +**APIs Required:** +- All Tier 1 APIs +- GET /event (SSE) +- GET /session/:id/children +- POST /session/:id/fork +- POST /session/:id/permissions/:id + +**Complexity:** Medium + +--- + +### Tier 3: Advanced Client (5-8 weeks) + +**Additional Features:** +- Custom agent creation +- Model management +- Cost analytics +- Session sharing +- Compaction handling + +**APIs Required:** +- All Tier 2 APIs +- Full event handling +- Share endpoints +- Usage aggregation logic + +**Complexity:** High + +--- + +## Implementation Approaches + +### Approach 1: Use Existing SDK (TypeScript/JavaScript) + +For TypeScript/JavaScript projects, use the existing generated SDK: + +```typescript +import { createOpencodeClient } from "@opencode-ai/sdk/client" + +const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + directory: "/path/to/project", +}) + +// Full type safety and all methods available +const session = await client.session.create() +const response = await client.session.prompt({ + path: { id: session.id }, + body: { parts: [{ type: "text", text: "Hello" }] } +}) +``` + +**Pros:** +- Pre-built, tested, and maintained +- Full TypeScript types +- Generated from OpenAPI spec +- Handles authentication and headers + +**Cons:** +- TypeScript/JavaScript only + +**Recommended for:** Web apps, Electron apps, Node.js tools, VS Code extensions + +--- + +### Approach 2: HTTP Client Only (Other Languages) + +**Pros:** +- Simplest implementation +- Works in any language +- No special dependencies + +**Cons:** +- Must poll for some operations +- No direct storage access + +**Recommended for:** Python, Go, Rust clients, mobile apps, integrations + +--- + +### Approach 3: WebSocket Enhancement + +Currently OpenCode uses SSE for events. A WebSocket client could be built: + +**Implementation:** +```typescript +// Wrap SSE in WebSocket adapter +class WebSocketAdapter { + private sse: EventSource + private ws: WebSocket + + connect() { + this.sse = new EventSource("/event") + this.sse.onmessage = (e) => { + this.ws.send(e.data) + } + } +} +``` + +**Pros:** +- Bidirectional communication +- Better mobile support + +**Cons:** +- Additional server changes needed + +--- + +### Approach 4: Direct Integration + +Import OpenCode modules directly: + +```typescript +import { Session, SessionPrompt, Bus } from "@opencode/core" + +// Direct access to all internals +const session = await Session.create({ title: "My Session" }) +Bus.subscribe(Session.Event.Created, handleCreated) +``` + +**Pros:** +- Full access to internals +- Best performance +- No network overhead + +**Cons:** +- Node.js/Bun only +- Tight coupling to internals + +**Recommended for:** CLI tools, IDE plugins + +--- + +## Language-Specific Implementations + +### TypeScript/JavaScript + +```typescript +import { OpencodeClient } from "@opencode/sdk" + +const client = new OpencodeClient("http://localhost:3000") +const session = await client.session.create() +const message = await client.message.prompt({ + sessionID: session.id, + parts: [{ type: "text", text: "Hello" }], +}) +``` + +**Advantages:** Native types, existing SDK patterns + +--- + +### Python + +```python +import opencode + +client = opencode.Client("http://localhost:3000") +session = client.sessions.create() +message = client.messages.prompt( + session_id=session.id, + parts=[{"type": "text", "text": "Hello"}] +) + +# Event streaming +for event in client.events.stream(): + if event.type == "message.part.updated": + print(event.properties.part.text) +``` + +**Advantages:** Large AI/ML ecosystem + +--- + +### Go + +```go +client := opencode.NewClient("http://localhost:3000") +session, _ := client.Sessions.Create(nil) +message, _ := client.Messages.Prompt(opencode.PromptInput{ + SessionID: session.ID, + Parts: []opencode.Part{ + {Type: "text", Text: "Hello"}, + }, +}) + +// Event streaming +events := client.Events.Subscribe() +for event := range events { + switch e := event.(type) { + case *opencode.MessagePartUpdated: + fmt.Println(e.Part.Text) + } +} +``` + +**Advantages:** Performance, concurrency + +--- + +### Rust + +```rust +let client = OpenCodeClient::new("http://localhost:3000"); +let session = client.sessions().create(None).await?; +let message = client.messages().prompt(PromptInput { + session_id: session.id, + parts: vec![Part::Text { text: "Hello".into() }], +}).await?; + +// Event streaming +let mut events = client.events().subscribe().await?; +while let Some(event) = events.next().await { + match event { + Event::MessagePartUpdated { part, .. } => { + println!("{}", part.text); + } + _ => {} + } +} +``` + +**Advantages:** Performance, safety, WASM support + +--- + +## Client Architecture Patterns + +### Pattern 1: Thin Client + +``` +┌─────────────────┐ +│ Thin Client │ +│ (just HTTP) │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ OpenCode API │ +└─────────────────┘ +``` + +All logic in server. Client only renders. + +**Use case:** Web dashboards, monitoring tools + +--- + +### Pattern 2: Smart Client + +``` +┌─────────────────┐ +│ Smart Client │ +│ ┌─────────────┐ │ +│ │ Local State │ │ +│ │ Cache │ │ +│ └─────────────┘ │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ OpenCode API │ +└─────────────────┘ +``` + +Local state, caching, optimistic updates. + +**Use case:** TUI, IDE plugins + +--- + +### Pattern 3: Offline-First Client + +``` +┌─────────────────┐ +│ Offline Client │ +│ ┌─────────────┐ │ +│ │ Local Store │ │ +│ │ (SQLite) │ │ +│ └─────────────┘ │ +└────────┬────────┘ + │ Sync + ▼ +┌─────────────────┐ +│ OpenCode API │ +└─────────────────┘ +``` + +Full offline support with sync. + +**Use case:** Mobile apps, distributed teams + +--- + +## Feature Parity Matrix + +| Feature | Current TUI | New Client Possible | +|---------|-------------|---------------------| +| Session management | Yes | Yes | +| Real-time streaming | Yes | Yes | +| Subagent monitoring | Yes | Yes | +| File diff view | Yes | Yes | +| Cost tracking | Yes | Yes | +| Permission dialogs | Yes | Yes | +| Vim keybindings | Yes | Implementation choice | +| Markdown rendering | Yes | Implementation choice | +| Syntax highlighting | Yes | Implementation choice | +| Theme customization | Yes | Implementation choice | +| Session navigation | Yes | Yes | + +--- + +## Challenges and Solutions + +### Challenge 1: Streaming Response Parsing + +**Problem:** POST /message returns streaming JSON chunks. + +**Solution:** +```typescript +async function* streamPrompt(input: PromptInput) { + const response = await fetch(url, { + method: "POST", + body: JSON.stringify(input), + }) + + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let buffer = "" + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop()! + + for (const line of lines) { + if (line.trim()) { + yield JSON.parse(line) + } + } + } +} +``` + +--- + +### Challenge 2: Event Reconnection + +**Problem:** SSE connections can drop. + +**Solution:** +```typescript +class ResilientEventSource { + private url: string + private eventSource?: EventSource + private retryDelay = 1000 + + connect() { + this.eventSource = new EventSource(this.url) + + this.eventSource.onerror = () => { + this.eventSource?.close() + setTimeout(() => this.connect(), this.retryDelay) + this.retryDelay = Math.min(this.retryDelay * 2, 30000) + } + + this.eventSource.onopen = () => { + this.retryDelay = 1000 + } + } +} +``` + +--- + +### Challenge 3: Parent-Child Session Tracking + +**Problem:** Need to track relationships for subagent monitoring. + +**Solution:** +```typescript +class SessionTree { + private sessions: Map = new Map() + private children: Map> = new Map() + + add(session: Session.Info) { + this.sessions.set(session.id, session) + if (session.parentID) { + if (!this.children.has(session.parentID)) { + this.children.set(session.parentID, new Set()) + } + this.children.get(session.parentID)!.add(session.id) + } + } + + getChildren(id: string): Session.Info[] { + const childIds = this.children.get(id) || new Set() + return [...childIds].map(id => this.sessions.get(id)!) + } + + getAncestors(id: string): Session.Info[] { + const result: Session.Info[] = [] + let current = this.sessions.get(id) + while (current?.parentID) { + current = this.sessions.get(current.parentID) + if (current) result.push(current) + } + return result + } +} +``` + +--- + +### Challenge 4: Permission Handling + +**Problem:** Server may pause execution for permission requests. + +**Solution:** +```typescript +class PermissionHandler { + private pending: Map void + permission: Permission + }> = new Map() + + async handle(event: PermissionEvent) { + const permission = event.properties.permission + + // Show UI dialog + const approved = await this.showDialog(permission) + + // Send response + await fetch(`/session/${permission.sessionID}/permissions/${permission.id}`, { + method: "POST", + body: JSON.stringify({ approved }), + }) + } + + private async showDialog(permission: Permission): Promise { + // Implementation depends on UI framework + } +} +``` + +--- + +## Estimated Development Effort + +### TypeScript Web Client + +| Component | Effort | Priority | +|-----------|--------|----------| +| HTTP client wrapper | 2-3 days | P0 | +| SSE event handling | 1-2 days | P0 | +| Session state management | 2-3 days | P0 | +| Message rendering | 3-5 days | P0 | +| Subagent monitoring | 2-3 days | P1 | +| Permission dialogs | 1-2 days | P1 | +| File diff viewer | 3-5 days | P1 | +| Cost dashboard | 1-2 days | P2 | +| Session sharing | 1 day | P2 | + +**Total: 2-4 weeks** for full-featured client + +--- + +### Python SDK + +| Component | Effort | Priority | +|-----------|--------|----------| +| HTTP client | 2-3 days | P0 | +| Async streaming | 2-3 days | P0 | +| Type definitions | 1-2 days | P0 | +| Event handling | 1-2 days | P0 | +| Documentation | 2-3 days | P1 | + +**Total: 1-2 weeks** for SDK + +--- + +## Recommendations + +### For Web Client + +1. Use React/Vue/Svelte with reactive state +2. Implement SSE event batching for performance +3. Use virtual scrolling for message lists +4. Consider Monaco editor for code blocks + +### For CLI Client + +1. Use Ink (React for CLI) or Bubble Tea (Go) +2. Implement local caching +3. Support pipe/redirect for automation +4. Consider TUI framework like Ratatui (Rust) + +### For IDE Plugin + +1. Use direct module import for performance +2. Integrate with IDE's existing event loop +3. Leverage IDE's UI components +4. Support multiple concurrent sessions + +--- + +## Conclusion + +Building a new OpenCode client is highly feasible due to: + +1. **Complete HTTP API** - All operations exposed via REST +2. **Real-time Events** - SSE provides live updates +3. **Well-Defined Schemas** - Zod schemas can generate types +4. **Clear Architecture** - Parent-child session model is straightforward +5. **Flexible Permission System** - Async permission handling + +**Recommended starting point:** +1. Implement basic session/message CRUD +2. Add SSE event streaming +3. Build subagent monitoring +4. Add permission handling +5. Enhance with file diffs, costs, sharing + +The modular API design ensures any client can achieve feature parity with the existing TUI while potentially adding new capabilities like web UIs, mobile apps, or IDE integrations. diff --git a/docs/SUBAGENT_API_REFERENCE.md b/docs/SUBAGENT_API_REFERENCE.md new file mode 100644 index 00000000000..1ac698f32f8 --- /dev/null +++ b/docs/SUBAGENT_API_REFERENCE.md @@ -0,0 +1,1226 @@ +# OpenCode Subagent & Task Management API Reference + +This document provides a comprehensive reference for all client and server APIs related to subagents and task management in OpenCode. + +## Table of Contents + +1. [Existing Clients & SDK](#existing-clients--sdk) +2. [Architecture Overview](#architecture-overview) +3. [Server-Side APIs](#server-side-apis) +4. [Client-Side APIs](#client-side-apis) +5. [Event System](#event-system) +6. [New Client Implementation Guide](#new-client-implementation-guide) + +--- + +## Existing Clients & SDK + +OpenCode already provides multiple client implementations and a generated SDK: + +### Packages Overview + +| Package | Type | Description | +|---------|------|-------------| +| `packages/opencode` | Core | Main OpenCode server and TUI client | +| `packages/desktop` | Web Client | SolidJS web client for browser/Electron | +| `packages/sdk/js` | SDK | Generated TypeScript SDK from OpenAPI | +| `packages/ui` | Components | Shared UI component library | +| `packages/console` | Console | Management console web app | +| `packages/tauri` | Desktop | Tauri-based desktop application | +| `packages/enterprise` | Enterprise | Enterprise features | +| `sdks/vscode` | IDE | VS Code extension | + +### Generated SDK (`@opencode-ai/sdk`) + +The SDK is auto-generated from OpenAPI specs using `@hey-api/openapi-ts`: + +```typescript +import { createOpencodeClient } from "@opencode-ai/sdk/client" + +const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + directory: "/path/to/project", +}) + +// All methods are typed and available +const sessions = await client.session.list() +const session = await client.session.create() +const messages = await client.session.messages({ path: { id: session.id } }) +``` + +**SDK Classes:** +- `Global` - Global events +- `Project` - Project management +- `Config` - Configuration +- `Tool` - Tool management +- `Instance` - Instance control +- `Path` - Path utilities +- `Session` - Session CRUD and messaging +- `Command` - Commands +- `Provider` - Model providers +- `Find` - Search functionality +- `File` - File operations +- `App` - App info and agents +- `Mcp` - MCP server management +- `Lsp` - LSP status +- `Formatter` - Formatter status +- `Tui` - TUI control +- `Auth` - Authentication +- `Event` - Event subscription + +### Desktop Web Client (`packages/desktop`) + +A full SolidJS web application with: + +- **Session management** - Create, list, navigate sessions +- **Message rendering** - Real-time streaming messages with `` +- **File browser** - Open, view, and edit files +- **Diff review** - Side-by-side and unified diff views +- **Drag-and-drop tabs** - Reorderable file tabs +- **Progress tracking** - Context usage and token counts +- **Keyboard shortcuts** - Vim-style navigation + +```typescript +// Desktop client uses the SDK +import { createOpencodeClient } from "@opencode-ai/sdk/client" +import { useSDK, SDKProvider } from "./context/sdk" + +// Context provides SDK to all components +const { client, event } = useSDK() + +// Make API calls +const session = await client.session.create() +await client.session.prompt({ + path: { id: session.id }, + body: { parts: [{ type: "text", text: "Hello" }] } +}) +``` + +--- + +## Architecture Overview + +OpenCode uses a parent-child session architecture for subagent management: + +``` +Parent Session (sessionID: "session_abc") +│ +├─ User Message +├─ Assistant Response +│ └─ Task Tool Invocation +│ ├─ Child Session 1 (parentID: "session_abc") +│ ├─ Child Session 2 (parentID: "session_abc") +│ └─ Child Session 3 (parentID: "session_abc") +│ +└─ Results aggregated back to parent +``` + +### Key Concepts + +- **Session**: Container for a conversation with messages and parts +- **Message**: User or assistant turn in a session +- **Part**: Individual content blocks (text, tool calls, reasoning, etc.) +- **Agent**: Configuration for AI behavior (primary, subagent, or all modes) +- **Task Tool**: Mechanism for spawning child sessions + +--- + +## Server-Side APIs + +### Session Management + +#### Session.create() + +Creates a new session, optionally as a child of another session. + +**Location:** `packages/opencode/src/session/index.ts:122-136` + +```typescript +const create = fn( + z.object({ + parentID: Identifier.schema("session").optional(), + title: z.string().optional(), + }).optional(), + async (input) => Session.Info +) +``` + +**HTTP Endpoint:** `POST /session` + +**Request Body:** +```json +{ + "parentID": "session_abc123", // Optional: parent for child sessions + "title": "My Session" // Optional: custom title +} +``` + +**Response:** `Session.Info` + +--- + +#### Session.get() + +Retrieves a session by ID. + +**Location:** `packages/opencode/src/session/index.ts:210-213` + +```typescript +const get = fn(Identifier.schema("session"), async (id) => Session.Info) +``` + +**HTTP Endpoint:** `GET /session/:id` + +--- + +#### Session.list() + +Lists all sessions in the current project. + +**Location:** `packages/opencode/src/session/index.ts:303-308` + +```typescript +async function* list(): AsyncGenerator +``` + +**HTTP Endpoint:** `GET /session` + +--- + +#### Session.update() + +Updates session properties. + +**Location:** `packages/opencode/src/session/index.ts:270-280` + +```typescript +async function update( + id: string, + editor: (session: Info) => void +): Promise +``` + +**HTTP Endpoint:** `PATCH /session/:id` + +--- + +#### Session.remove() + +Deletes a session and all its children. + +**Location:** `packages/opencode/src/session/index.ts:321-342` + +```typescript +const remove = fn(Identifier.schema("session"), async (sessionID) => void) +``` + +**HTTP Endpoint:** `DELETE /session/:id` + +--- + +#### Session.fork() + +Creates a new session by copying messages up to a point. + +**Location:** `packages/opencode/src/session/index.ts:138-167` + +```typescript +const fork = fn( + z.object({ + sessionID: Identifier.schema("session"), + messageID: Identifier.schema("message").optional(), + }), + async (input) => Session.Info +) +``` + +**HTTP Endpoint:** `POST /session/:id/fork` + +--- + +#### Session.children() + +Gets all child sessions of a parent. + +**HTTP Endpoint:** `GET /session/:id/children` + +--- + +#### Session.messages() + +Retrieves messages for a session. + +**Location:** `packages/opencode/src/session/index.ts:287-301` + +```typescript +const messages = fn( + z.object({ + sessionID: Identifier.schema("session"), + limit: z.number().optional(), + }), + async (input) => MessageV2.WithParts[] +) +``` + +**HTTP Endpoint:** `GET /session/:id/message?limit=` + +--- + +### Session.Info Schema + +```typescript +const Info = z.object({ + id: Identifier.schema("session"), + projectID: z.string(), + directory: z.string(), + parentID: Identifier.schema("session").optional(), + summary: z.object({ + additions: z.number(), + deletions: z.number(), + files: z.number(), + diffs: Snapshot.FileDiff.array().optional(), + }).optional(), + share: z.object({ url: z.string() }).optional(), + title: z.string(), + version: z.string(), + time: z.object({ + created: z.number(), + updated: z.number(), + compacting: z.number().optional(), + }), + revert: z.object({ + messageID: z.string(), + partID: z.string().optional(), + snapshot: z.string().optional(), + diff: z.string().optional(), + }).optional(), +}) +``` + +--- + +### Prompt Execution + +#### SessionPrompt.prompt() + +Creates a user message and starts the execution loop. + +**Location:** `packages/opencode/src/session/prompt.ts:193-205` + +```typescript +const PromptInput = z.object({ + sessionID: Identifier.schema("session"), + messageID: Identifier.schema("message").optional(), + model: z.object({ + providerID: z.string(), + modelID: z.string(), + }).optional(), + agent: z.string().optional(), + noReply: z.boolean().optional(), + system: z.string().optional(), + tools: z.record(z.string(), z.boolean()).optional(), + parts: z.array(TextPart | FilePart | AgentPart | SubtaskPart), +}) + +const prompt = fn(PromptInput, async (input) => MessageV2.WithParts) +``` + +**HTTP Endpoint:** `POST /session/:id/message` (streams JSON) + +--- + +#### SessionPrompt.loop() + +Main execution loop for processing agent responses. + +**Location:** `packages/opencode/src/session/prompt.ts:232-612` + +```typescript +const loop = fn(Identifier.schema("session"), async (sessionID) => MessageV2.WithParts) +``` + +**Execution Flow:** +1. Fetch last user & assistant messages +2. Check for pending subtasks/compaction +3. Resolve system prompts & tools +4. Stream text from LLM +5. Process tool calls +6. Handle errors and retries +7. Continue until completion + +--- + +#### SessionPrompt.command() + +Executes a slash command. + +**Location:** `packages/opencode/src/session/prompt.ts:1292-1396` + +```typescript +const CommandInput = z.object({ + messageID: Identifier.schema("message").optional(), + sessionID: Identifier.schema("session"), + agent: z.string().optional(), + model: z.string().optional(), + arguments: z.string(), + command: z.string(), +}) + +async function command(input: CommandInput): Promise +``` + +**HTTP Endpoint:** `POST /session/:id/command` + +--- + +#### SessionPrompt.shell() + +Executes a shell command and records output. + +**Location:** `packages/opencode/src/session/prompt.ts:1106-1290` + +```typescript +const ShellInput = z.object({ + sessionID: Identifier.schema("session"), + agent: z.string(), + model: z.object({ + providerID: z.string(), + modelID: z.string(), + }).optional(), + command: z.string(), +}) + +async function shell(input: ShellInput): Promise +``` + +**HTTP Endpoint:** `POST /session/:id/shell` + +--- + +### Task Tool API + +The Task tool enables spawning subagent sessions. + +**Location:** `packages/opencode/src/tool/task.ts:13-115` + +#### Parameters + +```typescript +z.object({ + description: z.string(), // Short task description (3-5 words) + prompt: z.string(), // Full task prompt + subagent_type: z.string(), // Agent name (e.g., "general") + session_id: z.string().optional(), // Continue existing session +}) +``` + +#### Return Value + +```typescript +{ + title: string, + metadata: { + summary: ToolPart[], + sessionId: string, + }, + output: string, +} +``` + +#### Execution Flow + +1. Get subagent configuration by type +2. Create child session (or reuse existing) +3. Execute `SessionPrompt.prompt()` in child session +4. Monitor tool execution via Bus subscription +5. Return output with task metadata + +--- + +### Agent APIs + +#### Agent.get() + +**Location:** `packages/opencode/src/agent/agent.ts:182-184` + +```typescript +async function get(agent: string): Promise +``` + +--- + +#### Agent.list() + +**Location:** `packages/opencode/src/agent/agent.ts:186-188` + +```typescript +async function list(): Promise +``` + +**HTTP Endpoint:** `GET /agent` + +--- + +#### Agent.Info Schema + +```typescript +const Info = z.object({ + name: z.string(), + description: z.string().optional(), + mode: z.enum(["subagent", "primary", "all"]), + builtIn: z.boolean(), + topP: z.number().optional(), + temperature: z.number().optional(), + color: z.string().optional(), + permission: z.object({ + edit: Config.Permission, + bash: z.record(z.string(), Config.Permission), + webfetch: Config.Permission.optional(), + doom_loop: Config.Permission.optional(), + external_directory: Config.Permission.optional(), + }), + model: z.object({ + modelID: z.string(), + providerID: z.string(), + }).optional(), + prompt: z.string().optional(), + tools: z.record(z.string(), z.boolean()), + options: z.record(z.string(), z.any()), +}) +``` + +**Agent Modes:** +- `primary` - User-selectable, initiates conversations +- `subagent` - Called by other agents for subtasks +- `all` - Can function as both + +--- + +### Message APIs + +#### MessageV2.Info Schema + +```typescript +// User message +const User = Base.extend({ + role: z.literal("user"), + time: z.object({ created: z.number() }), + agent: z.string(), + model: z.object({ providerID: z.string(), modelID: z.string() }), +}) + +// Assistant message +const Assistant = Base.extend({ + role: z.literal("assistant"), + time: z.object({ created: z.number(), completed: z.number().optional() }), + error: z.discriminatedUnion("name", [...]).optional(), + parentID: z.string(), + modelID: z.string(), + providerID: z.string(), + mode: z.string(), + path: z.object({ cwd: z.string(), root: z.string() }), + cost: z.number(), + tokens: z.object({ + input: z.number(), + output: z.number(), + reasoning: z.number(), + cache: z.object({ read: z.number(), write: z.number() }), + }), +}) +``` + +--- + +#### Message Part Types + +| Type | Description | Key Fields | +|------|-------------|------------| +| `TextPart` | Plain text output | `text`, `synthetic` | +| `ReasoningPart` | Extended thinking | `text`, `time` | +| `FilePart` | File references | `filename`, `mime` | +| `ToolPart` | Tool invocations | `tool`, `state`, `callID` | +| `SnapshotPart` | Filesystem snapshots | `snapshot` | +| `PatchPart` | Diff patches | `hash`, `files` | +| `SubtaskPart` | Subtask references | `prompt`, `agent` | +| `StepStartPart` | Step markers | `snapshot` | +| `StepFinishPart` | Step completion | `cost`, `tokens` | + +--- + +### HTTP Endpoints Summary + +#### Session Endpoints + +| Method | Path | Operation | +|--------|------|-----------| +| POST | `/session` | Create session | +| GET | `/session` | List sessions | +| GET | `/session/:id` | Get session | +| PATCH | `/session/:id` | Update session | +| DELETE | `/session/:id` | Delete session | +| GET | `/session/:id/children` | Get children | +| POST | `/session/:id/fork` | Fork session | +| POST | `/session/:id/share` | Share session | +| POST | `/session/:id/abort` | Abort execution | + +#### Message Endpoints + +| Method | Path | Operation | +|--------|------|-----------| +| GET | `/session/:id/message` | List messages | +| GET | `/session/:id/message/:msgID` | Get message | +| POST | `/session/:id/message` | Create & execute | +| POST | `/session/:id/command` | Execute command | +| POST | `/session/:id/shell` | Execute shell | +| POST | `/session/:id/revert` | Revert message | + +#### Event Endpoints + +| Method | Path | Operation | +|--------|------|-----------| +| GET | `/event` | Subscribe to events (SSE) | +| GET | `/global/event` | Global events (SSE) | +| GET | `/session/status` | Session status | + +--- + +## Client-Side APIs + +### Bus/Event System + +The Bus system provides typed pub/sub messaging. + +**Location:** `packages/opencode/src/bus/index.ts` + +#### Bus.event() + +Define a typed event. + +```typescript +function event( + type: Type, + properties: Properties +): EventDefinition +``` + +**Example:** +```typescript +const Created = Bus.event("session.created", z.object({ info: Session.Info })) +``` + +--- + +#### Bus.publish() + +Broadcast an event to all subscribers. + +```typescript +async function publish( + def: Definition, + properties: z.output +): Promise +``` + +**Example:** +```typescript +await Bus.publish(Session.Event.Created, { info: newSession }) +``` + +--- + +#### Bus.subscribe() + +Listen for specific events. + +```typescript +function subscribe( + def: Definition, + callback: (event: EventPayload) => void +): () => void // Returns unsubscribe function +``` + +**Example:** +```typescript +const unsubscribe = Bus.subscribe(Session.Event.Created, (event) => { + console.log("Session created:", event.properties.info.id) +}) +``` + +--- + +#### Bus.once() + +One-time event listener. + +```typescript +function once( + def: Definition, + callback: (event: EventPayload) => "done" | undefined +): void +``` + +--- + +#### Bus.subscribeAll() + +Listen to all events (wildcard). + +```typescript +function subscribeAll(callback: (event: any) => void): () => void +``` + +--- + +### Defined Events + +#### Session Events + +```typescript +const Event = { + Created: Bus.event("session.created", z.object({ info: Info })), + Updated: Bus.event("session.updated", z.object({ info: Info })), + Deleted: Bus.event("session.deleted", z.object({ info: Info })), + Diff: Bus.event("session.diff", z.object({ + sessionID: z.string(), + diff: Snapshot.FileDiff.array(), + })), + Error: Bus.event("session.error", z.object({ + sessionID: z.string().optional(), + error: MessageV2.Assistant.shape.error, + })), +} +``` + +#### Message Events + +```typescript +const Event = { + Updated: Bus.event("message.updated", z.object({ info: Info })), + Removed: Bus.event("message.removed", z.object({ + sessionID: z.string(), + messageID: z.string(), + })), + PartUpdated: Bus.event("message.part.updated", z.object({ + part: Part, + delta: z.string().optional(), + })), + PartRemoved: Bus.event("message.part.removed", z.object({ + sessionID: z.string(), + messageID: z.string(), + partID: z.string(), + })), +} +``` + +--- + +### Storage API + +File-based JSON storage system. + +**Location:** `packages/opencode/src/storage/storage.ts` + +#### Storage.read() + +```typescript +async function read(key: string[]): Promise +``` + +**Example:** +```typescript +const session = await Storage.read(["session", projectID, sessionID]) +``` + +--- + +#### Storage.write() + +```typescript +async function write(key: string[], content: T): Promise +``` + +--- + +#### Storage.update() + +Atomic read-modify-write. + +```typescript +async function update( + key: string[], + fn: (draft: T) => void +): Promise +``` + +--- + +#### Storage.list() + +List records by prefix. + +```typescript +async function list(prefix: string[]): Promise +``` + +**Example:** +```typescript +const sessions = await Storage.list(["session", projectID]) +// Returns: [["session", "proj_abc", "sess_123"], ...] +``` + +--- + +### Provider API + +Model and provider management. + +**Location:** `packages/opencode/src/provider/provider.ts` + +#### Provider.getModel() + +```typescript +async function getModel( + providerID: string, + modelID: string +): Promise<{ + modelID: string + providerID: string + info: ModelsDev.Model + language: LanguageModel + npm?: string +}> +``` + +--- + +#### Provider.list() + +```typescript +async function list(): Promise<{ + [providerID: string]: { + source: Source + info: ModelsDev.Provider + options: Record + } +}> +``` + +--- + +#### Provider.defaultModel() + +```typescript +async function defaultModel(): Promise<{ + providerID: string + modelID: string +}> +``` + +--- + +### Worker/RPC API + +For multi-process communication. + +**Location:** `packages/opencode/src/util/rpc.ts` + +#### Rpc.listen() + +Server-side RPC handler (in worker). + +```typescript +function listen(rpc: Definition): void +``` + +**Example:** +```typescript +Rpc.listen({ + async server(input: { port: number }) { + return { url: `http://localhost:${input.port}` } + }, +}) +``` + +--- + +#### Rpc.client() + +Client-side RPC caller (main thread). + +```typescript +function client(target: Worker): { + call( + method: Method, + input: Parameters[0] + ): Promise> +} +``` + +**Example:** +```typescript +const client = Rpc.client(worker) +const result = await client.call("server", { port: 3000 }) +``` + +--- + +### State Management Contexts + +TUI state management using Solid.js contexts. + +#### useSDK() + +SDK client and event subscription. + +**Location:** `packages/opencode/src/cli/cmd/tui/context/sdk.tsx` + +```typescript +const { client, event } = useSDK() +// client: OpencodeClient - HTTP client for API calls +// event: EventEmitter - Batched event emissions +``` + +--- + +#### useSync() + +Global state synchronization. + +**Location:** `packages/opencode/src/cli/cmd/tui/context/sync.tsx` + +```typescript +const sync = useSync() + +// Access data +sync.data.session // Session[] +sync.data.message // { [sessionID]: Message[] } +sync.data.part // { [messageID]: Part[] } +sync.data.agent // Agent[] +sync.data.provider // Provider[] +sync.data.permission // { [sessionID]: Permission[] } + +// Session utilities +sync.session.get(id) // Get session by ID +sync.session.status(id) // "idle" | "working" | "compacting" +await sync.session.sync(id) // Fetch messages for session + +// Bootstrap +await sync.bootstrap() // Load initial data +``` + +--- + +#### useLocal() + +Local preferences (model, agent). + +**Location:** `packages/opencode/src/cli/cmd/tui/context/local.tsx` + +```typescript +const local = useLocal() + +// Model management +local.model.current() // Current model +local.model.set(model) // Set model +local.model.cycle(1) // Cycle to next model + +// Agent management +local.agent.current() // Current agent +local.agent.set(name) // Set agent +local.agent.list() // Available agents +``` + +--- + +#### useRoute() + +Navigation state. + +**Location:** `packages/opencode/src/cli/cmd/tui/context/route.tsx` + +```typescript +const route = useRoute() + +route.data // Current route +route.navigate({ type: "session", sessionID: "..." }) +``` + +--- + +## Event System + +### Event Flow + +``` +Tool Execution / State Change + ↓ + Bus.publish() + ↓ + GlobalBus.emit() → Other processes + ↓ + Local subscribers + ↓ + SSE to HTTP clients +``` + +### Subscribing via HTTP (SSE) + +```typescript +const eventSource = new EventSource("/event") +eventSource.onmessage = (e) => { + const event = JSON.parse(e.data) + switch (event.type) { + case "session.created": + handleSessionCreated(event.properties.info) + break + case "message.part.updated": + handlePartUpdated(event.properties.part) + break + } +} +``` + +### Event Types for Subagent Monitoring + +| Event | Description | Payload | +|-------|-------------|---------| +| `session.created` | Child session created | `{ info: Session.Info }` | +| `message.updated` | Message state changed | `{ info: MessageV2.Info }` | +| `message.part.updated` | Part updated (streaming) | `{ part: Part, delta?: string }` | +| `session.diff` | File changes | `{ sessionID, diff: FileDiff[] }` | +| `session.error` | Error occurred | `{ sessionID?, error }` | + +--- + +## New Client Implementation Guide + +### Minimum Required APIs + +To build a new client with full subagent support, implement these core integrations: + +#### 1. Session Management + +```typescript +interface SessionClient { + create(input?: { parentID?: string; title?: string }): Promise + get(id: string): Promise + list(): Promise + children(id: string): Promise + remove(id: string): Promise +} +``` + +#### 2. Message Execution + +```typescript +interface MessageClient { + prompt(input: { + sessionID: string + parts: Part[] + agent?: string + model?: { providerID: string; modelID: string } + }): Promise + + messages(sessionID: string, limit?: number): Promise + abort(sessionID: string): Promise +} +``` + +#### 3. Event Subscription + +```typescript +interface EventClient { + subscribe(callback: (event: BusEvent) => void): () => void + + // Or via SSE + connect(): EventSource +} +``` + +#### 4. Agent Configuration + +```typescript +interface AgentClient { + list(): Promise + get(name: string): Promise +} +``` + +### Implementation Example + +```typescript +class OpencodeClient { + private baseUrl: string + private eventSource?: EventSource + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + // Session APIs + async createSession(parentID?: string): Promise { + const res = await fetch(`${this.baseUrl}/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parentID }), + }) + return res.json() + } + + async getSession(id: string): Promise { + const res = await fetch(`${this.baseUrl}/session/${id}`) + return res.json() + } + + async listSessions(): Promise { + const res = await fetch(`${this.baseUrl}/session`) + return res.json() + } + + async getChildren(sessionID: string): Promise { + const res = await fetch(`${this.baseUrl}/session/${sessionID}/children`) + return res.json() + } + + // Message APIs + async prompt(input: { + sessionID: string + parts: Part[] + agent?: string + }): Promise { + const res = await fetch(`${this.baseUrl}/session/${input.sessionID}/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }) + return res.json() + } + + async getMessages(sessionID: string): Promise { + const res = await fetch(`${this.baseUrl}/session/${sessionID}/message`) + return res.json() + } + + // Event subscription + subscribeToEvents(callback: (event: any) => void): () => void { + this.eventSource = new EventSource(`${this.baseUrl}/event`) + + this.eventSource.onmessage = (e) => { + callback(JSON.parse(e.data)) + } + + return () => { + this.eventSource?.close() + } + } + + // Agent APIs + async listAgents(): Promise { + const res = await fetch(`${this.baseUrl}/agent`) + return res.json() + } +} +``` + +### Subagent Monitoring + +To monitor subagent execution in real-time: + +```typescript +class SubagentMonitor { + private client: OpencodeClient + private parentSessionID: string + + constructor(client: OpencodeClient, parentSessionID: string) { + this.client = client + this.parentSessionID = parentSessionID + } + + async watchSubagents(callback: (event: SubagentEvent) => void): Promise<() => void> { + const children = new Set() + + // Get existing children + const existing = await this.client.getChildren(this.parentSessionID) + existing.forEach(s => children.add(s.id)) + + // Subscribe to events + return this.client.subscribeToEvents((event) => { + switch (event.type) { + case "session.created": + if (event.properties.info.parentID === this.parentSessionID) { + children.add(event.properties.info.id) + callback({ + type: "child_created", + session: event.properties.info, + }) + } + break + + case "message.part.updated": + if (children.has(event.properties.part.sessionID)) { + callback({ + type: "child_progress", + sessionID: event.properties.part.sessionID, + part: event.properties.part, + }) + } + break + + case "session.error": + if (children.has(event.properties.sessionID)) { + callback({ + type: "child_error", + sessionID: event.properties.sessionID, + error: event.properties.error, + }) + } + break + } + }) + } +} +``` + +### Key Considerations for New Clients + +1. **Streaming Support**: Handle streaming responses for real-time output +2. **Event Batching**: Batch rapid events to avoid UI thrashing +3. **Session Tree Navigation**: Support parent-child relationships +4. **Permission Handling**: Respond to permission requests via `/session/:id/permissions/:permissionID` +5. **Error Recovery**: Handle network errors, retries, and reconnection +6. **Cost Tracking**: Aggregate costs across parent and child sessions + +### Feature Matrix + +| Feature | API Required | Complexity | +|---------|-------------|------------| +| Basic sessions | Session CRUD | Low | +| Message execution | POST /message | Medium | +| Real-time updates | SSE /event | Medium | +| Subagent spawning | Task tool | High | +| Permission handling | Permission endpoints | Medium | +| File diffs | Session.Diff events | Medium | +| Cost tracking | Message tokens | Low | +| Session sharing | Share endpoints | Low | + +--- + +## Conclusion + +OpenCode provides a comprehensive API surface for building clients with full subagent support: + +- **15+ HTTP endpoints** for session and message management +- **10+ event types** for real-time monitoring +- **Typed schemas** with Zod validation +- **Parent-child session** architecture +- **Flexible agent configuration** + +A new client can leverage these APIs to implement: +- Multi-session management +- Real-time streaming output +- Subagent progress monitoring +- Cost aggregation +- File change tracking +- Custom UI experiences + +The modular architecture makes it straightforward to implement clients in any language or framework that supports HTTP and Server-Sent Events. diff --git a/docs/design/client-side-tools-testing.md b/docs/design/client-side-tools-testing.md new file mode 100644 index 00000000000..f00e17cb4d3 --- /dev/null +++ b/docs/design/client-side-tools-testing.md @@ -0,0 +1,718 @@ +# Client-Side Tools Testing Strategy + +**Related Document:** [Client-Side Tools Design](./client-side-tools.md) + +--- + +## Executive Summary + +**Yes, the current test infrastructure can be utilized to implement and validate the client tool feature.** This document details how each testing pattern can be applied and identifies any gaps that need to be addressed. + +### Test Coverage Matrix + +| Component | Test Type | Infrastructure Available | Additional Needs | +|-----------|-----------|-------------------------|------------------| +| ClientToolRegistry | Unit | ✅ Bun test framework | None | +| API Routes | Integration | ✅ Python integration tests | None | +| Event Bus Integration | Unit | ✅ Bus subscribe/publish tests | None | +| SSE Streaming | Integration | ✅ Python SSE client tests | None | +| WebSocket Handler | Integration | ⚠️ Partial (needs WebSocket client) | WebSocket test utility | +| SDK ClientToolsManager | Unit | ✅ Mock transport pattern | None | +| End-to-End Flow | Integration | ✅ Python subprocess pattern | None | +| Tool Execution Timeout | Unit | ✅ Retry/timeout test patterns | None | + +--- + +## Applicable Test Infrastructure + +### 1. Bun Test Framework (TypeScript Unit Tests) + +**Location:** `packages/opencode/test/` + +**Applicable For:** +- `ClientToolRegistry` module testing +- Tool registration/unregistration logic +- Event emission verification +- Timeout and error handling + +**Example Pattern (from `session/session.test.ts:11-41`):** + +```typescript +import { describe, expect, test } from "bun:test" +import { ClientToolRegistry } from "../../src/tool/client-registry" +import { Instance } from "../../src/project/instance" +import { Bus } from "../../src/bus" + +describe("ClientToolRegistry", () => { + test("should register tools for a client", async () => { + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + const tools = [ + { id: "test_tool", description: "A test tool", parameters: {} } + ] + + const registered = ClientToolRegistry.register("client-123", tools) + + expect(registered).toEqual(["client_client-123_test_tool"]) + expect(ClientToolRegistry.getTools("client-123")).toHaveLength(1) + }, + }) + }) + + test("should emit ToolRequest event when executing", async () => { + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + let eventReceived = false + const unsub = Bus.subscribe(ClientToolRegistry.Event.ToolRequest, () => { + eventReceived = true + }) + + // Register tool first + ClientToolRegistry.register("client-123", [ + { id: "test", description: "test", parameters: {} } + ]) + + // Start execution (will emit event) + const executePromise = ClientToolRegistry.execute("client-123", { + requestID: "req-1", + sessionID: "sess-1", + messageID: "msg-1", + callID: "call-1", + tool: "client_client-123_test", + input: {}, + }, 100) // Short timeout for test + + await new Promise(resolve => setTimeout(resolve, 50)) + unsub() + + expect(eventReceived).toBe(true) + }, + }) + }) +}) +``` + +### 2. Instance.provide() Pattern + +**Purpose:** Provides isolated project context for each test. + +**Applicable For:** +- Tests that require project/session context +- Event bus isolation between tests +- Tool registry state isolation + +**Usage:** +```typescript +await Instance.provide({ + directory: projectRoot, + fn: async () => { + // Test code runs in isolated instance context + // Bus subscriptions are scoped to this instance + }, +}) +``` + +### 3. Temporary Directory Fixture + +**Location:** `packages/opencode/test/fixture/fixture.ts` + +**Applicable For:** +- Tests that need file system operations +- Integration tests with real server subprocess + +**Usage:** +```typescript +import { tmpdir } from "../fixture/fixture" + +test("client tool with file access", async () => { + await using tmp = await tmpdir({ git: true }) + + // tmp.path is the isolated directory + // Automatically cleaned up after test +}) +``` + +### 4. Fake Server Pattern (LSP Tests) + +**Location:** `packages/opencode/test/fixture/lsp/fake-lsp-server.js` + +**Applicable For:** +- Testing client-server communication protocols +- SSE and WebSocket message exchange +- JSON-RPC style request/response testing + +**New Fake Client Tool Server:** + +```javascript +// packages/opencode/test/fixture/client-tools/fake-client.js +// Simulates an SDK client that handles tool requests + +const EventSource = require("eventsource") + +class FakeToolClient { + constructor(baseUrl, clientId) { + this.baseUrl = baseUrl + this.clientId = clientId + this.handlers = new Map() + } + + registerTool(id, handler) { + this.handlers.set(id, handler) + } + + async register(tools) { + const response = await fetch(`${this.baseUrl}/client-tools/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientID: this.clientId, + tools, + }), + }) + return response.json() + } + + connect() { + this.es = new EventSource( + `${this.baseUrl}/client-tools/pending/${this.clientId}` + ) + + this.es.addEventListener("tool-request", async (event) => { + const request = JSON.parse(event.data) + const handler = this.handlers.get( + request.tool.replace(`client_${this.clientId}_`, "") + ) + + let result + if (handler) { + try { + result = { status: "success", ...await handler(request.input) } + } catch (error) { + result = { status: "error", error: error.message } + } + } else { + result = { status: "error", error: "Unknown tool" } + } + + await fetch(`${this.baseUrl}/client-tools/result`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestID: request.requestID, result }), + }) + }) + } + + disconnect() { + this.es?.close() + } +} + +module.exports = { FakeToolClient } +``` + +### 5. Python Integration Tests (Subprocess Server) + +**Location:** `packages/sdk/python/tests/test_integration.py` + +**Applicable For:** +- Full server startup and API validation +- SSE streaming tests +- End-to-end client tool flow + +**Extended Integration Test:** + +```python +# packages/sdk/python/tests/test_client_tools.py + +import json +import subprocess +import time +import threading +import pytest +from pathlib import Path +from sseclient import SSEClient +import httpx + +@pytest.mark.timeout(60) +def test_client_tool_registration_and_execution(): + """Test full client tool flow: register -> execute -> result""" + + # Start server (reuse pattern from test_integration.py) + repo_root = find_repo_root() + pkg_opencode = repo_root / "packages" / "opencode" + + proc = subprocess.Popen( + ["bun", "run", "./src/index.ts", "serve", "--port", "0"], + cwd=str(pkg_opencode), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + url = wait_for_server_url(proc, timeout=15) + client_id = "test-client-123" + + try: + # 1. Register client tool + register_response = httpx.post( + f"{url}/client-tools/register", + json={ + "clientID": client_id, + "tools": [{ + "id": "echo", + "description": "Echo input back", + "parameters": { + "type": "object", + "properties": { + "message": {"type": "string"} + } + } + }] + } + ) + assert register_response.status_code == 200 + registered = register_response.json()["registered"] + assert len(registered) == 1 + assert "echo" in registered[0] + + # 2. Start SSE listener for tool requests + tool_requests = [] + def listen_for_requests(): + response = httpx.get( + f"{url}/client-tools/pending/{client_id}", + timeout=30.0 + ) + client = SSEClient(response) + for event in client.events(): + if event.event == "tool-request": + tool_requests.append(json.loads(event.data)) + break + + listener_thread = threading.Thread(target=listen_for_requests) + listener_thread.start() + time.sleep(0.5) # Wait for SSE connection + + # 3. Create session and send prompt that would trigger tool + # (This would require actual AI model - skip for unit test) + # Instead, simulate tool request via internal API if available + + # 4. Submit result + if tool_requests: + result_response = httpx.post( + f"{url}/client-tools/result", + json={ + "requestID": tool_requests[0]["requestID"], + "result": { + "status": "success", + "title": "Echo result", + "output": "Hello, World!" + } + } + ) + assert result_response.status_code == 200 + + finally: + terminate_process(proc) + + +@pytest.mark.timeout(30) +def test_client_tool_unregister(): + """Test tool unregistration""" + # Similar setup... + pass + + +@pytest.mark.timeout(30) +def test_client_tool_timeout(): + """Test that tool execution times out if client doesn't respond""" + # Register tool, trigger execution, don't respond, verify timeout + pass +``` + +### 6. Mock HTTP Transport (SDK Tests) + +**Location:** `packages/sdk/python/tests/test_wrapper.py`, `packages/sdk/go/client_test.go` + +**Applicable For:** +- SDK ClientToolsManager unit tests +- Isolated testing without real server + +**Python Example:** +```python +def test_client_tools_manager_register(): + """Test ClientToolsManager registration without server""" + + registered_tools = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/client-tools/register": + body = json.loads(request.content) + registered_tools.extend(body["tools"]) + return httpx.Response(200, json={ + "registered": [f"client_{body['clientID']}_{t['id']}" for t in body["tools"]] + }) + return httpx.Response(404) + + transport = httpx.MockTransport(handler) + client = httpx.Client(base_url="http://test", transport=transport) + + manager = ClientToolsManager("test-client", "http://test") + manager._http_client = client + + result = manager.register_sync([ + {"id": "tool1", "description": "Test", "parameters": {}} + ]) + + assert len(registered_tools) == 1 + assert "tool1" in result[0] +``` + +**Go Example:** +```go +func TestClientToolRegistration(t *testing.T) { + var registeredTools []map[string]interface{} + + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/client-tools/register" { + body, _ := io.ReadAll(req.Body) + var payload map[string]interface{} + json.Unmarshal(body, &payload) + registeredTools = payload["tools"].([]map[string]interface{}) + + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`{"registered":["client_test_tool1"]}`)), + }, nil + } + return &http.Response{StatusCode: 404}, nil + }, + }, + }), + ) + + // Test registration + // ... +} +``` + +### 7. Event Bus Testing Pattern + +**Applicable For:** +- Testing ClientToolRegistry event emission +- Testing event subscription/unsubscription +- Testing event ordering + +**Pattern (from `session/session.test.ts`):** +```typescript +test("tool request event should be emitted", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const events: any[] = [] + + const unsub = Bus.subscribe(ClientToolRegistry.Event.ToolRequest, (event) => { + events.push(event) + }) + + // Trigger tool execution + ClientToolRegistry.register("client-1", [ + { id: "test", description: "test", parameters: {} } + ]) + + const executePromise = ClientToolRegistry.execute("client-1", { + requestID: "req-1", + sessionID: "sess-1", + messageID: "msg-1", + callID: "call-1", + tool: "client_client-1_test", + input: { foo: "bar" }, + }, 1000) + + await new Promise(resolve => setTimeout(resolve, 100)) + unsub() + + expect(events).toHaveLength(1) + expect(events[0].properties.clientID).toBe("client-1") + expect(events[0].properties.request.input).toEqual({ foo: "bar" }) + }, + }) +}) +``` + +### 8. Retry and Timeout Testing Pattern + +**Location:** `packages/opencode/test/session/retry.test.ts` + +**Applicable For:** +- Client tool execution timeout testing +- Retry logic for failed tool executions +- Exponential backoff validation + +**Example:** +```typescript +describe("ClientToolRegistry.execute timeout", () => { + test("should timeout after specified duration", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + ClientToolRegistry.register("client-1", [ + { id: "slow_tool", description: "Slow tool", parameters: {} } + ]) + + const startTime = Date.now() + + await expect( + ClientToolRegistry.execute("client-1", { + requestID: "req-1", + sessionID: "sess-1", + messageID: "msg-1", + callID: "call-1", + tool: "client_client-1_slow_tool", + input: {}, + }, 100) // 100ms timeout + ).rejects.toThrow("timed out") + + const elapsed = Date.now() - startTime + expect(elapsed).toBeGreaterThanOrEqual(100) + expect(elapsed).toBeLessThan(200) + }, + }) + }) +}) +``` + +--- + +## Proposed Test Structure + +``` +packages/opencode/test/ +├── tool/ +│ ├── bash.test.ts # Existing +│ ├── patch.test.ts # Existing +│ ├── client-registry.test.ts # NEW: ClientToolRegistry unit tests +│ └── client-tools-api.test.ts # NEW: API route tests +├── fixture/ +│ ├── fixture.ts # Existing +│ ├── lsp/ +│ │ └── fake-lsp-server.js # Existing +│ └── client-tools/ # NEW +│ └── fake-client.js # Fake SDK client for testing + +packages/sdk/ +├── js/test/ # NEW +│ └── client-tools.test.ts # ClientToolsManager tests +├── python/tests/ +│ ├── test_wrapper.py # Existing +│ ├── test_integration.py # Existing +│ └── test_client_tools.py # NEW: Client tools integration +└── go/ + ├── client_test.go # Existing + └── clienttools_test.go # NEW: Client tools tests +``` + +--- + +## Test Categories + +### Unit Tests (No External Dependencies) + +| Test File | Coverage | +|-----------|----------| +| `client-registry.test.ts` | Registration, unregistration, tool lookup | +| `client-registry.test.ts` | Event emission, pending request management | +| `client-registry.test.ts` | Timeout handling, cleanup | +| `js/client-tools.test.ts` | ClientToolsManager with mock transport | + +### Integration Tests (Server Subprocess) + +| Test File | Coverage | +|-----------|----------| +| `test_client_tools.py` | Full registration/execution flow via SSE | +| `test_client_tools.py` | WebSocket communication (if implemented) | +| `test_client_tools.py` | Multi-client scenarios | + +### End-to-End Tests (Requires Real Model) + +| Test | Coverage | Feasibility | +|------|----------|-------------| +| AI triggers client tool | Complete flow | **Not feasible without real model** | +| Tool result used in response | Complete flow | **Not feasible without real model** | + +--- + +## Implementation Recommendations + +### 1. Start with Unit Tests + +```typescript +// packages/opencode/test/tool/client-registry.test.ts + +describe("ClientToolRegistry", () => { + describe("register", () => { + test("registers tools with prefixed IDs") + test("handles multiple tools") + test("handles duplicate registration") + }) + + describe("unregister", () => { + test("removes specific tools") + test("removes all tools for client") + test("handles non-existent client") + }) + + describe("getTools", () => { + test("returns tools for client") + test("returns empty array for unknown client") + }) + + describe("execute", () => { + test("emits ToolRequest event") + test("times out if no response") + test("resolves on successful result") + test("rejects on error result") + }) + + describe("submitResult", () => { + test("resolves pending request") + test("returns false for unknown request") + test("clears timeout on submission") + }) + + describe("cleanup", () => { + test("cancels pending requests") + test("removes all client tools") + }) +}) +``` + +### 2. Add API Route Tests + +```typescript +// packages/opencode/test/tool/client-tools-api.test.ts + +describe("Client Tools API Routes", () => { + // Use Python integration test pattern: start server subprocess + + test("POST /client-tools/register creates tools") + test("DELETE /client-tools/unregister removes tools") + test("POST /client-tools/result submits execution result") + test("GET /client-tools/pending/:clientID streams requests") +}) +``` + +### 3. Add SDK Tests + +```typescript +// packages/sdk/js/test/client-tools.test.ts + +describe("ClientToolsManager", () => { + test("register sends HTTP request to server") + test("connect establishes SSE connection") + test("handles incoming tool requests") + test("submits tool results") + test("disconnect cleans up connections") +}) +``` + +--- + +## Gaps and Additional Infrastructure Needed + +### 1. WebSocket Test Utility + +The current infrastructure doesn't have WebSocket testing utilities. Options: + +**Option A: Skip WebSocket in initial tests** +- Focus on SSE which is already testable +- WebSocket is optional in the design + +**Option B: Add WebSocket test helper** +```typescript +// packages/opencode/test/fixture/websocket.ts +import WebSocket from "ws" + +export function createTestWebSocket(url: string): Promise<{ + ws: WebSocket + messages: any[] + send: (data: any) => void + waitForMessage: (predicate: (msg: any) => boolean) => Promise + close: () => void +}> { + // Implementation +} +``` + +### 2. SSE Test Utility for TypeScript + +Python has `sseclient-py`, but TypeScript tests may need: + +```typescript +// packages/opencode/test/fixture/sse-client.ts +export async function* sseStream(url: string): AsyncGenerator<{ + event: string + data: string +}> { + const response = await fetch(url) + const reader = response.body!.getReader() + // Parse SSE format +} +``` + +### 3. Test Server Startup Helper + +Consolidate server startup logic: + +```typescript +// packages/opencode/test/fixture/server.ts +export async function startTestServer(): Promise<{ + url: string + close: () => Promise +}> { + // Start server with random port + // Wait for startup + // Return URL and cleanup function +} +``` + +--- + +## CI/CD Considerations + +The existing CI pipeline (`test.yml`) will automatically run new tests: + +```yaml +- run: | + bun turbo typecheck + bun turbo test # This runs all tests including new client-tools tests +``` + +For Python tests: +```yaml +- run: uv run --project packages/sdk/python pytest -q +``` + +**No changes needed to CI configuration.** + +--- + +## Conclusion + +The current test infrastructure **fully supports** implementing and validating the client tools feature: + +| Requirement | Available Infrastructure | Confidence | +|-------------|-------------------------|------------| +| Unit testing ClientToolRegistry | Bun test + Instance.provide | ✅ High | +| Event bus integration testing | Bus.subscribe pattern | ✅ High | +| API route testing | Python subprocess pattern | ✅ High | +| SSE streaming testing | Python sseclient | ✅ High | +| SDK unit testing | Mock HTTP transport | ✅ High | +| WebSocket testing | Needs utility addition | ⚠️ Medium | +| End-to-end with real AI | Not possible without model | ❌ N/A | + +**Recommendation:** Proceed with implementation using the existing patterns. Add WebSocket test utility only if WebSocket support is prioritized over SSE. diff --git a/docs/design/client-side-tools.md b/docs/design/client-side-tools.md new file mode 100644 index 00000000000..c39fe58c7ab --- /dev/null +++ b/docs/design/client-side-tools.md @@ -0,0 +1,1257 @@ +# Client-Side Tools Design Document + +## Overview + +This document describes the design for client-side tools in OpenCode, where clients can register tool definitions with the server, and the server delegates tool execution back to the client. + +### Goals + +1. **Client Tool Registration**: Allow SDK clients to define and register tools with the server +2. **Server Delegation**: Enable the server to delegate tool execution to the originating client +3. **Bidirectional Communication**: Support real-time communication for tool execution requests/responses +4. **Seamless Integration**: Integrate with existing tool infrastructure (permissions, hooks, streaming) +5. **Multi-Client Support**: Handle multiple clients with different tool sets + +### Non-Goals + +- Replacing existing server-side tools +- Cross-client tool sharing (tools are scoped to their registering client) +- Persistent tool registration (tools exist only for session lifetime) + +--- + +## Architecture Overview + +``` +┌─────────────────┐ ┌─────────────────┐ +│ SDK Client │ │ OpenCode │ +│ │ │ Server │ +│ ┌─────────────┐ │ Register Tools │ │ +│ │ Tool Defs │─┼───────────────────►│ ┌─────────────┐ │ +│ └─────────────┘ │ │ │Client Tool │ │ +│ │ │ │Registry │ │ +│ ┌─────────────┐ │ Execute Request │ └─────────────┘ │ +│ │ Tool │◄├────────────────────┤ │ +│ │ Handlers │ │ │ ┌─────────────┐ │ +│ └──────┬──────┘ │ Execute Result │ │Session │ │ +│ │ ├───────────────────►│ │Processor │ │ +│ ▼ │ │ └─────────────┘ │ +│ ┌─────────────┐ │ │ │ +│ │ Local │ │ │ ┌─────────────┐ │ +│ │ Execution │ │ Stream │ │AI Model │ │ +│ └─────────────┘ │◄───────────────────┤ └─────────────┘ │ +└─────────────────┘ └─────────────────┘ +``` + +--- + +## Protocol Design + +### New Message Types + +Add to `/packages/opencode/src/session/message-v2.ts`: + +```typescript +// Client tool definition sent during registration +export type ClientToolDefinition = { + id: string + description: string + parameters: JsonSchema7 // JSON Schema for tool parameters +} + +// Request sent from server to client for tool execution +export type ClientToolExecutionRequest = { + type: "client-tool-request" + requestID: string + sessionID: string + messageID: string + callID: string + tool: string + input: Record +} + +// Response sent from client to server after execution +export type ClientToolExecutionResponse = { + type: "client-tool-response" + requestID: string + result: ClientToolResult | ClientToolError +} + +export type ClientToolResult = { + status: "success" + title: string + output: string + metadata?: Record + attachments?: FilePart[] +} + +export type ClientToolError = { + status: "error" + error: string +} +``` + +### New API Endpoints + +Add to server API (in `/packages/opencode/src/server/`): + +```typescript +// POST /client-tools/register +// Register client tools for a session +interface RegisterClientToolsRequest { + sessionID: string + clientID: string + tools: ClientToolDefinition[] +} + +interface RegisterClientToolsResponse { + registered: string[] // Tool IDs that were registered +} + +// POST /client-tools/result +// Submit tool execution result +interface SubmitToolResultRequest { + requestID: string + result: ClientToolResult | ClientToolError +} + +// GET /client-tools/pending/:clientID (SSE endpoint) +// Stream pending tool execution requests to client +// Returns: Server-Sent Events stream of ClientToolExecutionRequest + +// DELETE /client-tools/unregister +// Unregister client tools +interface UnregisterClientToolsRequest { + sessionID: string + clientID: string + toolIDs?: string[] // If omitted, unregister all +} +``` + +### WebSocket Alternative + +For lower latency, support WebSocket connections: + +```typescript +// WS /client-tools/ws/:clientID +// Bidirectional WebSocket for tool requests/responses + +// Client -> Server messages: +type WSClientMessage = + | { type: "register"; tools: ClientToolDefinition[] } + | { type: "result"; requestID: string; result: ClientToolResult | ClientToolError } + | { type: "unregister"; toolIDs?: string[] } + +// Server -> Client messages: +type WSServerMessage = + | { type: "registered"; toolIDs: string[] } + | { type: "request"; request: ClientToolExecutionRequest } + | { type: "error"; error: string } +``` + +--- + +## Server-Side Implementation + +### 1. Client Tool Registry + +Create `/packages/opencode/src/tool/client-registry.ts`: + +```typescript +import { z } from "zod" +import { Bus } from "../bus" +import { Tool } from "./tool" +import type { ClientToolDefinition, ClientToolExecutionRequest } from "../session/message-v2" + +export namespace ClientToolRegistry { + // Store client tools by clientID -> toolID -> definition + const registry = new Map>() + + // Pending execution requests by requestID + const pendingRequests = new Map void + reject: (error: Error) => void + timeout: Timer + }>() + + // Event emitter for tool execution requests + export const Event = { + ToolRequest: Bus.event( + "client-tool.request", + z.object({ + clientID: z.string(), + request: z.custom(), + }) + ), + } + + /** + * Register tools for a client + */ + export function register( + clientID: string, + tools: ClientToolDefinition[] + ): string[] { + if (!registry.has(clientID)) { + registry.set(clientID, new Map()) + } + + const clientTools = registry.get(clientID)! + const registered: string[] = [] + + for (const tool of tools) { + // Prefix with client ID to avoid collisions + const toolID = `client_${clientID}_${tool.id}` + clientTools.set(toolID, { + ...tool, + id: toolID, + }) + registered.push(toolID) + } + + return registered + } + + /** + * Unregister tools for a client + */ + export function unregister(clientID: string, toolIDs?: string[]): void { + const clientTools = registry.get(clientID) + if (!clientTools) return + + if (toolIDs) { + for (const id of toolIDs) { + clientTools.delete(id) + } + } else { + registry.delete(clientID) + } + } + + /** + * Get all tools for a client + */ + export function getTools(clientID: string): ClientToolDefinition[] { + const clientTools = registry.get(clientID) + if (!clientTools) return [] + return Array.from(clientTools.values()) + } + + /** + * Get all client tools across all clients + */ + export function getAllTools(): Map { + const all = new Map() + for (const [_, clientTools] of registry) { + for (const [toolID, tool] of clientTools) { + all.set(toolID, tool) + } + } + return all + } + + /** + * Find which client owns a tool + */ + export function findClientForTool(toolID: string): string | undefined { + for (const [clientID, clientTools] of registry) { + if (clientTools.has(toolID)) { + return clientID + } + } + return undefined + } + + /** + * Execute a client tool + * Sends request to client and waits for response + */ + export async function execute( + clientID: string, + request: Omit, + timeoutMs: number = 30000 + ): Promise { + const fullRequest: ClientToolExecutionRequest = { + type: "client-tool-request", + ...request, + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingRequests.delete(request.requestID) + reject(new Error(`Client tool execution timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + pendingRequests.set(request.requestID, { + request: fullRequest, + resolve, + reject, + timeout, + }) + + // Emit event for client to receive + Event.ToolRequest.publish({ + clientID, + request: fullRequest, + }) + }) + } + + /** + * Submit result from client + */ + export function submitResult( + requestID: string, + result: ClientToolResult | ClientToolError + ): boolean { + const pending = pendingRequests.get(requestID) + if (!pending) return false + + clearTimeout(pending.timeout) + pendingRequests.delete(requestID) + + if (result.status === "error") { + pending.reject(new Error(result.error)) + } else { + pending.resolve(result) + } + + return true + } + + /** + * Clean up all tools for a client (on disconnect) + */ + export function cleanup(clientID: string): void { + // Cancel all pending requests for this client + for (const [requestID, pending] of pendingRequests) { + if (pending.request.requestID.startsWith(clientID)) { + clearTimeout(pending.timeout) + pending.reject(new Error("Client disconnected")) + pendingRequests.delete(requestID) + } + } + + // Remove all tools + registry.delete(clientID) + } +} +``` + +### 2. Integration with Tool Registry + +Modify `/packages/opencode/src/tool/registry.ts`: + +```typescript +import { ClientToolRegistry } from "./client-registry" + +export namespace ToolRegistry { + // ... existing code ... + + /** + * Get all tools including client tools + */ + export async function tools( + providerID: string, + modelID: string, + clientID?: string + ) { + const serverTools = await all() + const result = await Promise.all( + serverTools.map(async (t) => ({ + id: t.id, + ...(await t.init()), + })), + ) + + // Add client tools if clientID provided + if (clientID) { + const clientTools = ClientToolRegistry.getTools(clientID) + for (const tool of clientTools) { + result.push({ + id: tool.id, + description: tool.description, + parameters: tool.parameters as any, + execute: createClientToolExecutor(clientID, tool.id), + }) + } + } + + return result + } + + /** + * Create executor function for client tool + */ + function createClientToolExecutor(clientID: string, toolID: string) { + return async ( + args: Record, + ctx: Tool.Context + ): Promise => { + const requestID = `${clientID}_${ctx.callID}_${Date.now()}` + + const result = await ClientToolRegistry.execute(clientID, { + requestID, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID!, + tool: toolID, + input: args, + }) + + return { + title: result.title, + metadata: result.metadata ?? {}, + output: result.output, + attachments: result.attachments, + } + } + } +} +``` + +### 3. API Routes + +Create `/packages/opencode/src/server/routes/client-tools.ts`: + +```typescript +import { Hono } from "hono" +import { streamSSE } from "hono/streaming" +import { ClientToolRegistry } from "../../tool/client-registry" +import { Identifier } from "../../util/identifier" + +export const clientToolsRouter = new Hono() + +// Register client tools +clientToolsRouter.post("/register", async (c) => { + const body = await c.req.json() + const { sessionID, clientID, tools } = body + + const registered = ClientToolRegistry.register(clientID, tools) + + return c.json({ registered }) +}) + +// Unregister client tools +clientToolsRouter.delete("/unregister", async (c) => { + const body = await c.req.json() + const { sessionID, clientID, toolIDs } = body + + ClientToolRegistry.unregister(clientID, toolIDs) + + return c.json({ success: true }) +}) + +// Submit tool execution result +clientToolsRouter.post("/result", async (c) => { + const body = await c.req.json() + const { requestID, result } = body + + const success = ClientToolRegistry.submitResult(requestID, result) + + if (!success) { + return c.json({ error: "Unknown request ID" }, 404) + } + + return c.json({ success: true }) +}) + +// SSE endpoint for tool execution requests +clientToolsRouter.get("/pending/:clientID", async (c) => { + const clientID = c.req.param("clientID") + + return streamSSE(c, async (stream) => { + // Subscribe to tool request events + const unsubscribe = ClientToolRegistry.Event.ToolRequest.subscribe( + async (event) => { + if (event.clientID === clientID) { + await stream.writeSSE({ + event: "tool-request", + data: JSON.stringify(event.request), + }) + } + } + ) + + // Keep connection alive + const keepAlive = setInterval(async () => { + await stream.writeSSE({ + event: "ping", + data: "", + }) + }, 30000) + + // Cleanup on disconnect + c.req.raw.signal.addEventListener("abort", () => { + unsubscribe() + clearInterval(keepAlive) + ClientToolRegistry.cleanup(clientID) + }) + + // Block until client disconnects + await new Promise(() => {}) + }) +}) +``` + +### 4. WebSocket Handler + +Create `/packages/opencode/src/server/routes/client-tools-ws.ts`: + +```typescript +import { Hono } from "hono" +import { upgradeWebSocket } from "hono/cloudflare-workers" +import { ClientToolRegistry } from "../../tool/client-registry" + +export const clientToolsWSRouter = new Hono() + +clientToolsWSRouter.get( + "/ws/:clientID", + upgradeWebSocket((c) => { + const clientID = c.req.param("clientID") + let unsubscribe: (() => void) | undefined + + return { + onOpen(event, ws) { + // Subscribe to tool requests for this client + unsubscribe = ClientToolRegistry.Event.ToolRequest.subscribe( + (evt) => { + if (evt.clientID === clientID) { + ws.send(JSON.stringify({ + type: "request", + request: evt.request, + })) + } + } + ) + }, + + onMessage(event, ws) { + try { + const message = JSON.parse(event.data as string) + + switch (message.type) { + case "register": { + const registered = ClientToolRegistry.register( + clientID, + message.tools + ) + ws.send(JSON.stringify({ + type: "registered", + toolIDs: registered, + })) + break + } + + case "result": { + ClientToolRegistry.submitResult( + message.requestID, + message.result + ) + break + } + + case "unregister": { + ClientToolRegistry.unregister(clientID, message.toolIDs) + break + } + } + } catch (error) { + ws.send(JSON.stringify({ + type: "error", + error: String(error), + })) + } + }, + + onClose() { + unsubscribe?.() + ClientToolRegistry.cleanup(clientID) + }, + + onError(event) { + unsubscribe?.() + ClientToolRegistry.cleanup(clientID) + }, + } + }) +) +``` + +--- + +## Client SDK Implementation + +### 1. Types + +Add to `/packages/sdk/js/src/types.ts`: + +```typescript +export interface ClientToolDefinition { + id: string + description: string + parameters: Record // JSON Schema +} + +export interface ClientToolHandler { + (input: Record, context: ClientToolContext): Promise +} + +export interface ClientToolContext { + sessionID: string + messageID: string + callID: string + signal: AbortSignal +} + +export interface ClientToolResult { + title: string + output: string + metadata?: Record +} + +export interface ClientTool { + definition: ClientToolDefinition + handler: ClientToolHandler +} + +export interface ClientToolsConfig { + /** Timeout for tool execution in ms (default: 30000) */ + timeout?: number + /** Use WebSocket instead of SSE (default: false) */ + useWebSocket?: boolean +} +``` + +### 2. Client Tools Manager + +Create `/packages/sdk/js/src/client-tools.ts`: + +```typescript +import type { + ClientTool, + ClientToolDefinition, + ClientToolHandler, + ClientToolResult, + ClientToolsConfig, +} from "./types" + +export class ClientToolsManager { + private clientID: string + private baseUrl: string + private tools = new Map() + private eventSource?: EventSource + private ws?: WebSocket + private config: Required + private abortController = new AbortController() + + constructor( + clientID: string, + baseUrl: string, + config?: ClientToolsConfig + ) { + this.clientID = clientID + this.baseUrl = baseUrl + this.config = { + timeout: config?.timeout ?? 30000, + useWebSocket: config?.useWebSocket ?? false, + } + } + + /** + * Register a tool with the server + */ + async register( + id: string, + definition: Omit, + handler: ClientToolHandler + ): Promise { + const tool: ClientTool = { + definition: { id, ...definition }, + handler, + } + this.tools.set(id, tool) + + // If already connected, register immediately + if (this.eventSource || this.ws) { + await this.syncTools() + } + } + + /** + * Unregister a tool + */ + async unregister(id: string): Promise { + this.tools.delete(id) + + if (this.eventSource || this.ws) { + await fetch(`${this.baseUrl}/client-tools/unregister`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientID: this.clientID, + toolIDs: [id], + }), + }) + } + } + + /** + * Start listening for tool execution requests + */ + async connect(sessionID: string): Promise { + // Register all tools first + await this.syncTools() + + if (this.config.useWebSocket) { + await this.connectWebSocket() + } else { + await this.connectSSE() + } + } + + /** + * Stop listening and cleanup + */ + disconnect(): void { + this.abortController.abort() + this.eventSource?.close() + this.ws?.close() + } + + private async syncTools(): Promise { + const definitions = Array.from(this.tools.values()).map(t => t.definition) + + await fetch(`${this.baseUrl}/client-tools/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientID: this.clientID, + tools: definitions, + }), + }) + } + + private async connectSSE(): Promise { + this.eventSource = new EventSource( + `${this.baseUrl}/client-tools/pending/${this.clientID}` + ) + + this.eventSource.addEventListener("tool-request", async (event) => { + const request = JSON.parse(event.data) + await this.handleToolRequest(request) + }) + + this.eventSource.onerror = (error) => { + console.error("Client tools SSE error:", error) + } + } + + private async connectWebSocket(): Promise { + const wsUrl = this.baseUrl.replace(/^http/, "ws") + this.ws = new WebSocket(`${wsUrl}/client-tools/ws/${this.clientID}`) + + this.ws.onopen = async () => { + // Register tools via WebSocket + const definitions = Array.from(this.tools.values()).map(t => t.definition) + this.ws!.send(JSON.stringify({ + type: "register", + tools: definitions, + })) + } + + this.ws.onmessage = async (event) => { + const message = JSON.parse(event.data) + + if (message.type === "request") { + await this.handleToolRequest(message.request) + } + } + + this.ws.onerror = (error) => { + console.error("Client tools WebSocket error:", error) + } + } + + private async handleToolRequest(request: { + requestID: string + sessionID: string + messageID: string + callID: string + tool: string + input: Record + }): Promise { + // Extract original tool ID (remove client_ prefix) + const prefixedID = request.tool + const originalID = prefixedID.replace(`client_${this.clientID}_`, "") + + const tool = this.tools.get(originalID) + + if (!tool) { + await this.submitResult(request.requestID, { + status: "error", + error: `Unknown tool: ${originalID}`, + }) + return + } + + try { + // Create abort controller for this execution + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, this.config.timeout) + + const result = await tool.handler(request.input, { + sessionID: request.sessionID, + messageID: request.messageID, + callID: request.callID, + signal: controller.signal, + }) + + clearTimeout(timeout) + + await this.submitResult(request.requestID, { + status: "success", + title: result.title, + output: result.output, + metadata: result.metadata, + }) + } catch (error) { + await this.submitResult(request.requestID, { + status: "error", + error: error instanceof Error ? error.message : String(error), + }) + } + } + + private async submitResult( + requestID: string, + result: { status: "success" | "error"; [key: string]: unknown } + ): Promise { + if (this.ws) { + this.ws.send(JSON.stringify({ + type: "result", + requestID, + result, + })) + } else { + await fetch(`${this.baseUrl}/client-tools/result`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestID, result }), + }) + } + } +} +``` + +### 3. Integration with OpencodeClient + +Modify `/packages/sdk/js/src/client.ts`: + +```typescript +import { ClientToolsManager } from "./client-tools" + +export class OpencodeClient { + private _client: Client + private _clientTools?: ClientToolsManager + private _clientID: string + + constructor(config: { client: Client }) { + this._client = config.client + this._clientID = crypto.randomUUID() + } + + /** + * Get client tools manager for registering and handling client-side tools + */ + get clientTools(): ClientToolsManager { + if (!this._clientTools) { + const baseUrl = (this._client as any).baseUrl + this._clientTools = new ClientToolsManager(this._clientID, baseUrl) + } + return this._clientTools + } + + /** + * Start a session with client tools support + */ + async startSession(options?: { + tools?: boolean + }): Promise { + const session = await this.session.create() + + if (options?.tools !== false) { + await this.clientTools.connect(session.id) + } + + return { + session, + prompt: (input: string) => this.session.prompt(session.id, input), + close: () => { + this.clientTools.disconnect() + }, + } + } +} + +interface SessionHandle { + session: Session + prompt: (input: string) => Promise + close: () => void +} +``` + +--- + +## Security Considerations + +### 1. Client Authentication + +```typescript +// Validate client owns the session +export function validateClientSession( + clientID: string, + sessionID: string +): boolean { + const session = Session.get(sessionID) + return session?.clientID === clientID +} + +// Add clientID to session creation +export async function createSession(clientID: string) { + return Session.create({ + clientID, + // ... other fields + }) +} +``` + +### 2. Tool Sandboxing + +- Client tools run in the client's environment (inherently sandboxed from server) +- Server tools continue to run on server +- Clear naming convention distinguishes client vs server tools + +### 3. Input Validation + +```typescript +// Validate tool input against JSON Schema before sending to client +import Ajv from "ajv" + +const ajv = new Ajv() + +export function validateToolInput( + tool: ClientToolDefinition, + input: Record +): boolean { + const validate = ajv.compile(tool.parameters) + return validate(input) +} +``` + +### 4. Timeout and Rate Limiting + +```typescript +// Server-side timeout for client tool execution +const CLIENT_TOOL_TIMEOUT = 30000 // 30 seconds + +// Rate limiting per client +const rateLimiter = new Map() + +export function checkRateLimit(clientID: string): boolean { + const limit = rateLimiter.get(clientID) + const now = Date.now() + + if (!limit || now > limit.reset) { + rateLimiter.set(clientID, { + count: 1, + reset: now + 60000, // 1 minute window + }) + return true + } + + if (limit.count >= 100) { // 100 requests per minute + return false + } + + limit.count++ + return true +} +``` + +### 5. Permission Integration + +```typescript +// Add client tool permission to Agent +export interface AgentPermission { + // ... existing permissions + client_tools: "allow" | "ask" | "deny" +} + +// Check permission before executing client tool +if (agent.permission.client_tools === "deny") { + throw new Error("Client tools are not allowed for this agent") +} + +if (agent.permission.client_tools === "ask") { + await Permission.ask({ + type: "client_tool", + tool: toolID, + sessionID, + messageID, + callID, + }) +} +``` + +--- + +## Error Handling + +### 1. Connection Errors + +```typescript +// Auto-reconnect with exponential backoff +class ClientToolsManager { + private reconnectAttempts = 0 + private maxReconnectAttempts = 5 + + private async reconnect(): Promise { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + throw new Error("Max reconnection attempts reached") + } + + const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000) + await new Promise(resolve => setTimeout(resolve, delay)) + + this.reconnectAttempts++ + await this.connect(this.sessionID) + this.reconnectAttempts = 0 + } +} +``` + +### 2. Tool Execution Errors + +```typescript +// Graceful error handling in tool execution +try { + const result = await tool.handler(input, context) + return { status: "success", ...result } +} catch (error) { + // Log error for debugging + console.error(`Client tool ${toolID} failed:`, error) + + // Return error to server + return { + status: "error", + error: error instanceof Error ? error.message : "Unknown error", + } +} +``` + +### 3. Timeout Handling + +```typescript +// Server-side timeout +const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Client tool timed out after ${timeout}ms`)) + }, timeout) +}) + +const result = await Promise.race([ + ClientToolRegistry.execute(clientID, request), + timeoutPromise, +]) +``` + +--- + +## Usage Examples + +### Basic Client Tool + +```typescript +import { createOpencode } from "@opencode/sdk" + +const { client, server } = await createOpencode() + +// Register a client tool +await client.clientTools.register( + "get_local_time", + { + description: "Get the current local time on the client machine", + parameters: { + type: "object", + properties: { + timezone: { + type: "string", + description: "Timezone (e.g., 'America/New_York')", + }, + }, + }, + }, + async (input, ctx) => { + const tz = input.timezone as string || "UTC" + const time = new Date().toLocaleString("en-US", { timeZone: tz }) + + return { + title: `Local time (${tz})`, + output: time, + } + } +) + +// Start session with client tools +const { session, prompt, close } = await client.startSession() + +// Use the session - model can now call get_local_time +const response = await prompt("What time is it locally?") + +// Cleanup +close() +server.close() +``` + +### File System Access Tool + +```typescript +import { readFile } from "fs/promises" + +await client.clientTools.register( + "read_local_file", + { + description: "Read a file from the client's local filesystem", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Absolute path to the file", + }, + }, + required: ["path"], + }, + }, + async (input) => { + const path = input.path as string + const content = await readFile(path, "utf-8") + + return { + title: `Read ${path}`, + output: content, + } + } +) +``` + +### Database Query Tool + +```typescript +import { createConnection } from "mysql2/promise" + +const connection = await createConnection({ + host: "localhost", + user: "root", + database: "myapp", +}) + +await client.clientTools.register( + "query_database", + { + description: "Execute a read-only SQL query on the local database", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "SQL SELECT query to execute", + }, + }, + required: ["query"], + }, + }, + async (input) => { + const query = input.query as string + + // Security: only allow SELECT queries + if (!query.trim().toLowerCase().startsWith("select")) { + throw new Error("Only SELECT queries are allowed") + } + + const [rows] = await connection.execute(query) + + return { + title: "Query results", + output: JSON.stringify(rows, null, 2), + metadata: { rowCount: (rows as any[]).length }, + } + } +) +``` + +--- + +## Implementation Plan + +### Phase 1: Core Infrastructure +1. Add message types to `message-v2.ts` +2. Create `ClientToolRegistry` module +3. Add API routes for registration and results +4. Integrate with `ToolRegistry` + +### Phase 2: SDK Implementation +1. Create `ClientToolsManager` class +2. Add SSE connection support +3. Integrate with `OpencodeClient` +4. Add TypeScript types + +### Phase 3: WebSocket Support +1. Add WebSocket handler on server +2. Add WebSocket connection option in SDK +3. Implement bidirectional messaging + +### Phase 4: Security & Polish +1. Add client authentication +2. Implement rate limiting +3. Add permission integration +4. Add comprehensive error handling + +### Phase 5: Testing & Documentation +1. Unit tests for registry and manager +2. Integration tests for full flow +3. Update SDK documentation +4. Add usage examples + +--- + +## Appendix: Modified Files Summary + +### New Files +- `/packages/opencode/src/tool/client-registry.ts` +- `/packages/opencode/src/server/routes/client-tools.ts` +- `/packages/opencode/src/server/routes/client-tools-ws.ts` +- `/packages/sdk/js/src/client-tools.ts` +- `/packages/sdk/js/src/types.ts` (new types) + +### Modified Files +- `/packages/opencode/src/session/message-v2.ts` (new message types) +- `/packages/opencode/src/tool/registry.ts` (integrate client tools) +- `/packages/opencode/src/server/index.ts` (add routes) +- `/packages/sdk/js/src/client.ts` (add clientTools property) +- `/packages/sdk/js/src/index.ts` (export new types) + +--- + +## Future Enhancements + +1. **Tool Discovery**: Allow clients to query available server tools +2. **Tool Streaming**: Support streaming output from client tools +3. **Tool Composition**: Allow client tools to call server tools +4. **Persistent Tools**: Option to persist tool registrations across sessions +5. **Tool Marketplace**: Share and discover community tools +6. **Tool Versioning**: Support multiple versions of the same tool diff --git a/docs/design/server-side-deployment/README.md b/docs/design/server-side-deployment/README.md new file mode 100644 index 00000000000..23c09053bc4 --- /dev/null +++ b/docs/design/server-side-deployment/README.md @@ -0,0 +1,95 @@ +# OpenCode Server-Side Web Service Design + +## Overview + +This document describes the architecture for deploying OpenCode as a multi-tenant web service, enabling organizations to provide AI-powered coding assistance to multiple users through a centralized, scalable deployment. + +## Goals + +1. **Multi-tenancy**: Support multiple users and organizations with proper isolation +2. **Scalability**: Handle thousands of concurrent users with horizontal scaling +3. **Security**: Enterprise-grade authentication, authorization, and data protection +4. **Reliability**: High availability with fault tolerance and disaster recovery +5. **Observability**: Comprehensive monitoring, logging, and tracing + +## Current Architecture vs. Target Architecture + +| Aspect | Current (Desktop/CLI) | Target (Web Service) | +|--------|----------------------|---------------------| +| Users | Single user | Multi-tenant | +| Storage | Local filesystem (JSON) | Distributed database | +| Auth | Provider API keys only | User auth + provider delegation | +| Scaling | Single instance | Horizontal scaling | +| State | Per-directory instance | Per-user/workspace scoped | +| Networking | Local only | Internet-facing | + +## Design Documents + +1. **[Architecture](./architecture.md)** - System architecture and component design +2. **[Authentication](./authentication.md)** - User authentication and authorization +3. **[Storage](./storage.md)** - Data persistence and caching strategies (PostgreSQL) +4. **[Storage - MySQL](./storage-mysql.md)** - Alternative MySQL design with BIGINT keys +5. **[Scaling](./scaling.md)** - Horizontal scaling and deployment patterns +6. **[Security](./security.md)** - Security controls and compliance +7. **[API](./api.md)** - API design and versioning + +## High-Level Architecture + +``` + ┌─────────────────┐ + │ CDN/WAF │ + │ (Cloudflare) │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Load Balancer │ + │ (L7/HTTP) │ + └────────┬────────┘ + │ + ┌────────────────────────┼────────────────────────┐ + │ │ │ + ┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐ + │ API Server │ │ API Server │ │ API Server │ + │ (Stateless) │ │ (Stateless) │ │ (Stateless) │ + └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └────────────────────────┼────────────────────────┘ + │ + ┌──────────────┬───────────────┼───────────────┬──────────────┐ + │ │ │ │ │ + ┌────────▼────────┐ ┌───▼───┐ ┌───────▼───────┐ ┌────▼────┐ ┌──────▼──────┐ + │ PostgreSQL │ │ Redis │ │ Object Store │ │ Queue │ │ Metrics │ + │ (Sessions) │ │(Cache)│ │ (S3/R2/GCS) │ │ (NATS) │ │ (Prometheus)│ + └─────────────────┘ └───────┘ └───────────────┘ └─────────┘ └─────────────┘ +``` + +## Key Design Decisions + +### 1. Stateless API Servers +API servers are stateless, enabling horizontal scaling. Session state is stored in Redis, persistent data in PostgreSQL. + +### 2. Workspace-Based Multi-Tenancy +Each user has isolated workspaces. Workspaces contain projects, sessions, and configurations. + +### 3. Federated LLM Provider Access +Users can bring their own API keys or use organization-provided quotas with usage tracking. + +### 4. Event-Driven Architecture +Real-time updates via Server-Sent Events (SSE) with Redis Pub/Sub for cross-instance coordination. + +### 5. Git-First Project Model +Projects are identified by Git repositories. The service can integrate with GitHub/GitLab for workspace provisioning. + +## Deployment Options + +1. **Kubernetes** - Recommended for production (see [scaling.md](./scaling.md)) +2. **Docker Compose** - Development and small deployments +3. **Serverless** - AWS Lambda/Cloudflare Workers for specific endpoints + +## Getting Started + +See the individual design documents for detailed specifications: + +- Start with [Architecture](./architecture.md) for system overview +- Review [Authentication](./authentication.md) for auth implementation +- Check [Security](./security.md) for compliance requirements diff --git a/docs/design/server-side-deployment/api.md b/docs/design/server-side-deployment/api.md new file mode 100644 index 00000000000..752fef825eb --- /dev/null +++ b/docs/design/server-side-deployment/api.md @@ -0,0 +1,743 @@ +# API Design + +## Overview + +This document specifies the API design for the OpenCode server-side deployment, including versioning strategy, authentication, error handling, and endpoint specifications. + +## API Versioning + +### Versioning Strategy + +Use URL path versioning for major versions with header-based minor versioning: + +``` +https://api.opencode.io/v1/sessions + ^^ + Major version + +Accept: application/json; version=1.2 + ^^^ + Minor version +``` + +### Version Lifecycle + +| Status | Description | Support | +|--------|-------------|---------| +| Current | Latest stable version | Full support | +| Deprecated | Previous version | 6 months | +| Sunset | End of life | No support | + +### Deprecation Headers + +```typescript +// Response headers for deprecated endpoints +c.header("Deprecation", "Sun, 01 Jan 2025 00:00:00 GMT") +c.header("Sunset", "Sun, 01 Jul 2025 00:00:00 GMT") +c.header("Link", '; rel="successor-version"') +``` + +## Authentication + +### Request Authentication + +```typescript +// Bearer token authentication +app.use("/api/*", async (c, next) => { + const authHeader = c.req.header("Authorization") + + if (!authHeader?.startsWith("Bearer ")) { + throw new AuthError("Missing authorization header", "MISSING_AUTH") + } + + const token = authHeader.substring(7) + + // Check if API key or JWT + if (token.startsWith("oc_")) { + // API key authentication + const apiKey = await validateApiKey(token) + c.set("auth", { type: "apikey", ...apiKey }) + } else { + // JWT authentication + const jwt = await validateJwt(token) + c.set("auth", { type: "jwt", ...jwt }) + } + + await next() +}) +``` + +### API Key Format + +``` +oc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx +^^ ^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +| | | +| | +-- 24 bytes base64url +| +-- Environment (live/test) ++-- Prefix +``` + +## Request/Response Format + +### Request Headers + +``` +Content-Type: application/json +Authorization: Bearer +Accept: application/json +X-Request-ID: # Optional, for tracing +X-Idempotency-Key: # Optional, for idempotent operations +``` + +### Response Headers + +``` +Content-Type: application/json +X-Request-ID: +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 95 +X-RateLimit-Reset: 1609459200 +``` + +### Pagination + +```typescript +// Cursor-based pagination +interface PaginatedResponse { + data: T[] + pagination: { + cursor?: string + hasMore: boolean + total?: number + } +} + +// Query parameters +interface PaginationParams { + cursor?: string // Opaque cursor + limit?: number // Default: 50, Max: 100 +} + +// Example request +// GET /api/v1/sessions?limit=20&cursor=eyJpZCI6IjEyMyJ9 +``` + +### Filtering & Sorting + +```typescript +// Query parameter format +interface ListParams { + // Filtering + filter?: { + status?: string[] + createdAfter?: string // ISO 8601 + createdBefore?: string + } + // Sorting + sort?: string // Field name + order?: "asc" | "desc" +} + +// Example +// GET /api/v1/sessions?filter[status]=active&sort=createdAt&order=desc +``` + +## Error Handling + +### Error Response Format + +```typescript +interface ErrorResponse { + error: { + code: string // Machine-readable error code + message: string // Human-readable message + details?: unknown // Additional context + requestId: string // For support reference + docs?: string // Link to documentation + } +} +``` + +### Error Codes + +```typescript +// Error code hierarchy +const ErrorCodes = { + // Authentication errors (401) + AUTH_MISSING_TOKEN: "Missing authentication token", + AUTH_INVALID_TOKEN: "Invalid or expired token", + AUTH_INSUFFICIENT_SCOPE: "Token lacks required scope", + + // Authorization errors (403) + FORBIDDEN: "Access denied", + ORG_ACCESS_DENIED: "Not a member of this organization", + RESOURCE_ACCESS_DENIED: "No access to this resource", + + // Validation errors (400) + VALIDATION_ERROR: "Request validation failed", + INVALID_PARAMETER: "Invalid parameter value", + MISSING_PARAMETER: "Required parameter missing", + + // Not found errors (404) + NOT_FOUND: "Resource not found", + SESSION_NOT_FOUND: "Session not found", + PROJECT_NOT_FOUND: "Project not found", + + // Conflict errors (409) + CONFLICT: "Resource conflict", + SESSION_ALREADY_EXISTS: "Session already exists", + CONCURRENT_MODIFICATION: "Resource was modified", + + // Rate limiting (429) + RATE_LIMITED: "Too many requests", + QUOTA_EXCEEDED: "Usage quota exceeded", + + // Server errors (500) + INTERNAL_ERROR: "Internal server error", + SERVICE_UNAVAILABLE: "Service temporarily unavailable", + PROVIDER_ERROR: "LLM provider error", +} +``` + +### HTTP Status Codes + +| Code | Usage | +|------|-------| +| 200 | Success with body | +| 201 | Resource created | +| 204 | Success, no body | +| 400 | Validation error | +| 401 | Authentication required | +| 403 | Authorization denied | +| 404 | Resource not found | +| 409 | Conflict | +| 422 | Unprocessable entity | +| 429 | Rate limited | +| 500 | Server error | +| 503 | Service unavailable | + +## Streaming Responses + +### Server-Sent Events + +```typescript +// SSE endpoint for real-time events +app.get("/api/v1/events", async (c) => { + return streamSSE(c, async (stream) => { + // Connection established + await stream.writeSSE({ + event: "connected", + data: JSON.stringify({ timestamp: Date.now() }), + }) + + // Subscribe to events + const unsub = eventBus.subscribe(c.get("userId"), async (event) => { + await stream.writeSSE({ + event: event.type, + data: JSON.stringify(event.payload), + id: event.id, + }) + }) + + // Heartbeat every 30 seconds + const heartbeat = setInterval(() => { + stream.writeSSE({ event: "ping", data: "" }) + }, 30000) + + // Cleanup on disconnect + stream.onAbort(() => { + clearInterval(heartbeat) + unsub() + }) + }) +}) +``` + +### Streaming Chat Response + +```typescript +// POST /api/v1/sessions/:id/messages +// Returns streaming response +app.post("/api/v1/sessions/:id/messages", async (c) => { + const { id } = c.req.param() + const body = await c.req.json() + + return streamSSE(c, async (stream) => { + const generator = sessionOrchestrator.chat(id, body) + + for await (const event of generator) { + await stream.writeSSE({ + event: event.type, + data: JSON.stringify(event), + }) + } + + // Signal completion + await stream.writeSSE({ + event: "done", + data: JSON.stringify({ messageId: "..." }), + }) + }) +}) +``` + +### Event Types + +```typescript +type StreamEvent = + | { type: "message.start"; messageId: string } + | { type: "text.delta"; content: string } + | { type: "text.done"; content: string } + | { type: "tool.start"; toolId: string; name: string } + | { type: "tool.input"; content: string } + | { type: "tool.output"; content: string } + | { type: "tool.done"; result: unknown } + | { type: "message.done"; usage: Usage } + | { type: "error"; error: Error } +``` + +## API Endpoints + +### Sessions + +```typescript +// List sessions +// GET /api/v1/sessions +interface ListSessionsResponse { + data: Session[] + pagination: Pagination +} + +// Create session +// POST /api/v1/sessions +interface CreateSessionRequest { + workspaceId: string + title?: string + model?: { + providerId: string + modelId: string + } +} + +// Get session +// GET /api/v1/sessions/:id +interface GetSessionResponse { + data: Session +} + +// Update session +// PATCH /api/v1/sessions/:id +interface UpdateSessionRequest { + title?: string +} + +// Delete session +// DELETE /api/v1/sessions/:id + +// Send message (streaming) +// POST /api/v1/sessions/:id/messages +interface SendMessageRequest { + content: string + files?: FileAttachment[] +} + +// List messages +// GET /api/v1/sessions/:id/messages +interface ListMessagesResponse { + data: Message[] + pagination: Pagination +} + +// Abort session +// POST /api/v1/sessions/:id/abort + +// Fork session +// POST /api/v1/sessions/:id/fork +interface ForkSessionRequest { + messageId: string +} + +// Share session +// POST /api/v1/sessions/:id/share +interface ShareSessionResponse { + url: string + expiresAt: string +} +``` + +### Workspaces + +```typescript +// List workspaces +// GET /api/v1/workspaces +interface ListWorkspacesResponse { + data: Workspace[] + pagination: Pagination +} + +// Create workspace +// POST /api/v1/workspaces +interface CreateWorkspaceRequest { + name: string + description?: string + gitConfig?: { + provider: "github" | "gitlab" + repoUrl: string + branch?: string + } +} + +// Get workspace +// GET /api/v1/workspaces/:id + +// Update workspace +// PATCH /api/v1/workspaces/:id + +// Delete workspace +// DELETE /api/v1/workspaces/:id + +// List workspace projects +// GET /api/v1/workspaces/:id/projects +``` + +### Projects + +```typescript +// List projects +// GET /api/v1/projects + +// Create project +// POST /api/v1/projects +interface CreateProjectRequest { + workspaceId: string + name: string + path?: string +} + +// Get project +// GET /api/v1/projects/:id + +// Update project +// PATCH /api/v1/projects/:id + +// Delete project +// DELETE /api/v1/projects/:id +``` + +### Files + +```typescript +// List files in workspace +// GET /api/v1/workspaces/:id/files +interface ListFilesRequest { + path?: string // Directory path + pattern?: string // Glob pattern +} + +// Get file content +// GET /api/v1/workspaces/:id/files/content +interface GetFileContentRequest { + path: string + encoding?: "utf8" | "base64" +} + +// Search in files +// GET /api/v1/workspaces/:id/files/search +interface SearchFilesRequest { + query: string + path?: string + type?: string // File type filter +} + +// Git status +// GET /api/v1/workspaces/:id/git/status +``` + +### Providers + +```typescript +// List available providers +// GET /api/v1/providers +interface ListProvidersResponse { + data: Provider[] +} + +// List models for provider +// GET /api/v1/providers/:id/models +interface ListModelsResponse { + data: Model[] +} + +// Get user's provider config +// GET /api/v1/providers/:id/config + +// Set provider API key (BYOK) +// PUT /api/v1/providers/:id/key +interface SetProviderKeyRequest { + apiKey: string +} + +// Delete provider key +// DELETE /api/v1/providers/:id/key +``` + +### Users & Organizations + +```typescript +// Get current user +// GET /api/v1/users/me +interface GetCurrentUserResponse { + data: User +} + +// Update user preferences +// PATCH /api/v1/users/me +interface UpdateUserRequest { + name?: string + preferences?: UserPreferences +} + +// Get organization +// GET /api/v1/organizations/:id + +// List organization members +// GET /api/v1/organizations/:id/members + +// Invite member +// POST /api/v1/organizations/:id/invitations + +// Remove member +// DELETE /api/v1/organizations/:id/members/:userId +``` + +### API Keys + +```typescript +// List API keys +// GET /api/v1/api-keys +interface ListApiKeysResponse { + data: ApiKey[] // Keys shown with prefix only +} + +// Create API key +// POST /api/v1/api-keys +interface CreateApiKeyRequest { + name: string + scopes: Scope[] + expiresAt?: string +} +interface CreateApiKeyResponse { + key: string // Full key shown once + data: ApiKey +} + +// Delete API key +// DELETE /api/v1/api-keys/:id +``` + +### Usage & Billing + +```typescript +// Get usage summary +// GET /api/v1/usage +interface GetUsageRequest { + period?: "day" | "week" | "month" + startDate?: string + endDate?: string +} +interface GetUsageResponse { + data: { + tokens: { + input: number + output: number + total: number + } + cost: number + byProvider: Record + byModel: Record + } +} + +// Get usage breakdown +// GET /api/v1/usage/breakdown +interface UsageBreakdownResponse { + data: UsageRecord[] + pagination: Pagination +} +``` + +## Webhooks + +### Webhook Configuration + +```typescript +// Register webhook +// POST /api/v1/webhooks +interface CreateWebhookRequest { + url: string + events: WebhookEvent[] + secret?: string +} + +// Webhook events +type WebhookEvent = + | "session.created" + | "session.completed" + | "session.error" + | "message.created" + | "usage.threshold" +``` + +### Webhook Payload + +```typescript +interface WebhookPayload { + id: string + type: WebhookEvent + timestamp: string + data: unknown +} + +// Signature verification +// X-Webhook-Signature: sha256= +function verifyWebhook(payload: string, signature: string, secret: string): boolean { + const expected = crypto + .createHmac("sha256", secret) + .update(payload) + .digest("hex") + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(`sha256=${expected}`) + ) +} +``` + +## Rate Limiting + +### Limits by Plan + +| Plan | Requests/min | Messages/day | Tokens/month | +|------|-------------|--------------|--------------| +| Free | 20 | 100 | 100K | +| Team | 100 | 1,000 | 1M | +| Enterprise | Custom | Custom | Custom | + +### Rate Limit Headers + +``` +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 95 +X-RateLimit-Reset: 1609459200 +Retry-After: 30 +``` + +### Rate Limit Response + +```json +{ + "error": { + "code": "RATE_LIMITED", + "message": "Too many requests", + "details": { + "limit": 100, + "remaining": 0, + "reset": 1609459200, + "retryAfter": 30 + }, + "requestId": "req_xxx" + } +} +``` + +## SDK Examples + +### TypeScript/JavaScript + +```typescript +import { OpenCodeClient } from "@opencode/sdk" + +const client = new OpenCodeClient({ + apiKey: "oc_live_xxx", + baseUrl: "https://api.opencode.io", +}) + +// Create session +const session = await client.sessions.create({ + workspaceId: "ws_xxx", + title: "Debug authentication", +}) + +// Send message and stream response +const stream = client.sessions.chat(session.id, { + content: "Find and fix the authentication bug", +}) + +for await (const event of stream) { + if (event.type === "text.delta") { + process.stdout.write(event.content) + } +} + +// List sessions +const sessions = await client.sessions.list({ + limit: 20, + filter: { status: ["active"] }, +}) +``` + +### Python + +```python +from opencode import OpenCodeClient + +client = OpenCodeClient(api_key="oc_live_xxx") + +# Create session +session = client.sessions.create( + workspace_id="ws_xxx", + title="Debug authentication" +) + +# Send message and stream response +stream = client.sessions.chat( + session.id, + content="Find and fix the authentication bug" +) + +for event in stream: + if event.type == "text.delta": + print(event.content, end="", flush=True) +``` + +### cURL + +```bash +# Create session +curl -X POST https://api.opencode.io/v1/sessions \ + -H "Authorization: Bearer oc_live_xxx" \ + -H "Content-Type: application/json" \ + -d '{"workspaceId": "ws_xxx", "title": "Debug auth"}' + +# Send message (streaming) +curl -X POST https://api.opencode.io/v1/sessions/sess_xxx/messages \ + -H "Authorization: Bearer oc_live_xxx" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{"content": "Find and fix the authentication bug"}' +``` + +## OpenAPI Specification + +The complete OpenAPI 3.1 specification is available at: + +``` +GET /api/v1/openapi.json +GET /api/v1/openapi.yaml +``` + +Interactive documentation (Swagger UI): + +``` +GET /docs +``` diff --git a/docs/design/server-side-deployment/architecture.md b/docs/design/server-side-deployment/architecture.md new file mode 100644 index 00000000000..ef40ab3e6ef --- /dev/null +++ b/docs/design/server-side-deployment/architecture.md @@ -0,0 +1,530 @@ +# System Architecture + +## Component Overview + +### 1. API Gateway Layer + +**Purpose**: Entry point for all client requests, handling routing, rate limiting, and initial authentication. + +```typescript +interface GatewayConfig { + rateLimiting: { + requests: number // per window + window: "second" | "minute" | "hour" + byUser: boolean // per-user limits + byOrg: boolean // per-org limits + } + cors: { + origins: string[] + credentials: boolean + } + tls: { + minVersion: "1.2" | "1.3" + ciphers: string[] + } +} +``` + +**Responsibilities**: +- TLS termination +- Request routing +- Rate limiting (token bucket algorithm) +- Request/response logging +- CORS handling +- Request ID injection + +### 2. API Server (Hono) + +**Purpose**: Core business logic, session management, and LLM orchestration. + +```typescript +// Server initialization with multi-tenant support +export function createServer(config: ServerConfig) { + const app = new Hono() + + // Middleware stack + app.use(requestId()) + app.use(logger()) + app.use(authenticate()) // JWT validation + app.use(tenantContext()) // Inject user/org context + app.use(rateLimitMiddleware()) + + // Routes + app.route("/api/v1/sessions", sessionRoutes) + app.route("/api/v1/projects", projectRoutes) + app.route("/api/v1/workspaces", workspaceRoutes) + app.route("/api/v1/providers", providerRoutes) + + return app +} +``` + +**Key Modifications from Current Architecture**: + +| Current | Server-Side | +|---------|-------------| +| `Instance.provide({ directory })` | `TenantContext.provide({ userId, orgId, workspaceId })` | +| File-based storage | Database + Object storage | +| Single event bus | Redis Pub/Sub | +| Local Git operations | Remote Git service integration | + +### 3. Session Orchestrator + +**Purpose**: Manages AI sessions, tool execution, and streaming responses. + +```typescript +interface SessionOrchestrator { + // Create new session in workspace + create(ctx: TenantContext, input: CreateSessionInput): Promise + + // Send message and stream response + chat(ctx: TenantContext, sessionId: string, message: Message): AsyncGenerator + + // Execute tool with sandboxing + executeTool(ctx: TenantContext, sessionId: string, tool: ToolCall): Promise + + // Abort running session + abort(ctx: TenantContext, sessionId: string): Promise +} +``` + +**Session Lifecycle**: +``` +┌─────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐ +│ Created │ ──▶ │ Active │ ──▶ │ Idle │ ──▶ │ Archived │ +└─────────┘ └──────────┘ └─────────┘ └───────────┘ + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │ Aborted │ │ Expired │ + └──────────┘ └──────────┘ +``` + +### 4. Tool Execution Engine + +**Purpose**: Sandboxed execution of code tools (Bash, file operations, etc.) + +```typescript +interface ToolExecutionConfig { + sandbox: { + type: "docker" | "firecracker" | "gvisor" + image: string + resources: { + cpuLimit: string // "1000m" + memoryLimit: string // "512Mi" + diskLimit: string // "1Gi" + timeout: number // ms + } + network: { + enabled: boolean + egress: string[] // allowed domains + } + } + workspace: { + mount: string // /workspace + readonly: string[] // paths + } +} +``` + +**Execution Flow**: +``` +Tool Request ──▶ Validate ──▶ Acquire Sandbox ──▶ Mount Workspace + │ + ▼ +Tool Response ◀── Cleanup ◀── Capture Output ◀── Execute Command +``` + +### 5. Provider Gateway + +**Purpose**: Manages LLM provider connections with key rotation and failover. + +```typescript +interface ProviderGateway { + // Route request to appropriate provider + route(ctx: TenantContext, request: LLMRequest): Promise + + // Stream response from provider + stream(ctx: TenantContext, request: LLMRequest): AsyncGenerator + + // Get available models for user + models(ctx: TenantContext): Promise +} + +interface ProviderConfig { + anthropic: { + apiKey: string | { vault: string } + baseUrl?: string + rateLimit: RateLimit + } + openai: { + apiKey: string | { vault: string } + organization?: string + rateLimit: RateLimit + } + // ... other providers +} +``` + +**Key Management**: +- Organization-level keys stored in Vault/KMS +- User BYOK (Bring Your Own Key) with encryption at rest +- Automatic key rotation support +- Usage attribution per key + +## Data Models + +### Tenant Hierarchy + +``` +Organization +├── Users (members) +├── Teams +├── API Keys +├── Provider Configs +└── Workspaces + ├── Projects + │ ├── Git Config + │ └── Project Settings + └── Sessions + ├── Messages + │ └── Parts + └── Diffs +``` + +### Core Entities + +```typescript +// Organization - top-level tenant +interface Organization { + id: string + name: string + slug: string + plan: "free" | "team" | "enterprise" + settings: OrgSettings + createdAt: Date + updatedAt: Date +} + +// User within organization +interface User { + id: string + orgId: string + email: string + name: string + role: "owner" | "admin" | "member" + preferences: UserPreferences + createdAt: Date + lastActiveAt: Date +} + +// Workspace - isolated environment +interface Workspace { + id: string + orgId: string + name: string + description?: string + gitConfig?: { + provider: "github" | "gitlab" | "bitbucket" + repoUrl: string + branch: string + credentials: EncryptedCredentials + } + settings: WorkspaceSettings + createdAt: Date + updatedAt: Date +} + +// Project within workspace +interface Project { + id: string + workspaceId: string + name: string + path: string + gitCommit?: string + settings: ProjectSettings + createdAt: Date + updatedAt: Date +} + +// Session (conversation) +interface Session { + id: string + projectId: string + userId: string + title: string + status: SessionStatus + model: { + providerId: string + modelId: string + } + summary?: SessionSummary + createdAt: Date + updatedAt: Date + expiresAt?: Date +} + +// Message within session +interface Message { + id: string + sessionId: string + role: "user" | "assistant" | "system" + content: MessageContent + metadata: MessageMetadata + createdAt: Date +} + +// Message part (text, tool, file, etc.) +interface MessagePart { + id: string + messageId: string + type: PartType + content: PartContent + order: number + createdAt: Date +} +``` + +## Request Flow + +### Chat Request Flow + +``` +1. Client sends POST /api/v1/sessions/:id/messages + │ +2. API Gateway validates JWT, applies rate limit + │ +3. API Server receives request + │ ├── Validate session ownership + │ ├── Load session context from DB + │ └── Check user quota + │ +4. Session Orchestrator processes message + │ ├── Build prompt with history + │ ├── Select provider/model + │ └── Apply system prompts + │ +5. Provider Gateway streams to LLM + │ ├── Apply org/user API key + │ ├── Track token usage + │ └── Handle retries/failover + │ +6. Tool Execution (if needed) + │ ├── Spawn sandboxed container + │ ├── Mount workspace files + │ ├── Execute tool + │ └── Capture output + │ +7. Stream response to client + │ ├── Publish events to Redis + │ ├── Persist to database + │ └── SSE to client + │ +8. Update usage metrics +``` + +### Event Distribution + +```typescript +// Cross-instance event distribution +interface EventDistributor { + // Publish event to all subscribers + publish(channel: string, event: Event): Promise + + // Subscribe to events for user/session + subscribe(channel: string, handler: EventHandler): Unsubscribe +} + +// Redis Pub/Sub channels +const channels = { + session: (sessionId: string) => `session:${sessionId}`, + user: (userId: string) => `user:${userId}`, + workspace: (workspaceId: string) => `workspace:${workspaceId}`, +} +``` + +**SSE Connection Management**: +```typescript +// Server-Sent Events with Redis coordination +app.get("/api/v1/events", async (c) => { + const { userId, sessionId } = c.get("tenant") + + return streamSSE(c, async (stream) => { + // Subscribe to user's events + const unsub = await eventDistributor.subscribe( + channels.user(userId), + async (event) => { + await stream.writeSSE({ data: JSON.stringify(event) }) + } + ) + + // Heartbeat to keep connection alive + const heartbeat = setInterval(() => { + stream.writeSSE({ event: "ping", data: "" }) + }, 30000) + + stream.onAbort(() => { + clearInterval(heartbeat) + unsub() + }) + }) +}) +``` + +## Service Dependencies + +### Required Services + +| Service | Purpose | Recommended | +|---------|---------|-------------| +| PostgreSQL | Primary database | PostgreSQL 15+ | +| Redis | Cache, pub/sub, sessions | Redis 7+ / Valkey | +| Object Storage | File storage, artifacts | S3/R2/GCS | +| Message Queue | Background jobs | NATS / Redis Streams | + +### Optional Services + +| Service | Purpose | Options | +|---------|---------|---------| +| Vault | Secret management | HashiCorp Vault, AWS KMS | +| Git Service | Repo management | GitHub, GitLab, Gitea | +| Metrics | Observability | Prometheus, Datadog | +| Tracing | Distributed tracing | Jaeger, Tempo | + +## Configuration + +### Environment Variables + +```bash +# Server +PORT=3000 +HOST=0.0.0.0 +NODE_ENV=production + +# Database +DATABASE_URL=postgresql://user:pass@host:5432/opencode +DATABASE_POOL_SIZE=20 + +# Redis +REDIS_URL=redis://host:6379 +REDIS_CLUSTER=true + +# Object Storage +STORAGE_PROVIDER=s3 +STORAGE_BUCKET=opencode-files +STORAGE_REGION=us-east-1 +AWS_ACCESS_KEY_ID=xxx +AWS_SECRET_ACCESS_KEY=xxx + +# Auth +JWT_SECRET=xxx +JWT_ISSUER=https://auth.opencode.io +OAUTH_GITHUB_CLIENT_ID=xxx +OAUTH_GITHUB_CLIENT_SECRET=xxx + +# LLM Providers (org defaults) +ANTHROPIC_API_KEY=xxx +OPENAI_API_KEY=xxx + +# Feature Flags +ENABLE_SANDBOXED_EXECUTION=true +ENABLE_GIT_INTEGRATION=true +MAX_CONCURRENT_SESSIONS=10 +``` + +### Runtime Configuration + +```typescript +interface ServerConfig { + server: { + port: number + host: string + trustProxy: boolean + } + database: { + url: string + poolSize: number + ssl: boolean + } + redis: { + url: string + cluster: boolean + } + storage: { + provider: "s3" | "r2" | "gcs" | "local" + bucket: string + region: string + } + auth: { + jwtSecret: string + jwtIssuer: string + sessionTtl: number + } + limits: { + maxSessionsPerUser: number + maxMessagesPerSession: number + maxFileSizeMb: number + requestTimeoutMs: number + } + sandbox: { + enabled: boolean + provider: "docker" | "firecracker" + poolSize: number + } +} +``` + +## Deployment Architecture + +### Kubernetes Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: opencode-api +spec: + replicas: 3 + selector: + matchLabels: + app: opencode-api + template: + metadata: + labels: + app: opencode-api + spec: + containers: + - name: api + image: opencode/api:latest + ports: + - containerPort: 3000 + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "2Gi" + cpu: "2000m" + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: opencode-secrets + key: database-url + livenessProbe: + httpGet: + path: /health + port: 3000 + readinessProbe: + httpGet: + path: /ready + port: 3000 +``` + +### Service Mesh + +For production deployments, consider: +- **Istio/Linkerd** for service mesh +- **mTLS** between services +- **Circuit breakers** for provider calls +- **Retry policies** with exponential backoff diff --git a/docs/design/server-side-deployment/authentication.md b/docs/design/server-side-deployment/authentication.md new file mode 100644 index 00000000000..4cd3e914acd --- /dev/null +++ b/docs/design/server-side-deployment/authentication.md @@ -0,0 +1,695 @@ +# Authentication & Authorization + +## Overview + +The server-side deployment requires a comprehensive auth system supporting multiple authentication methods, organization-based multi-tenancy, and fine-grained access control. + +## Authentication Methods + +### 1. OAuth 2.0 / OIDC + +Primary authentication method for web and desktop clients. + +```typescript +interface OAuthConfig { + providers: { + github: { + clientId: string + clientSecret: string + scopes: ["user:email", "read:org"] + } + google: { + clientId: string + clientSecret: string + scopes: ["email", "profile"] + } + microsoft: { + clientId: string + clientSecret: string + tenant: string + } + // Custom OIDC provider for enterprise + oidc?: { + issuer: string + clientId: string + clientSecret: string + scopes: string[] + } + } +} +``` + +**OAuth Flow**: +``` +1. Client redirects to /auth/login/:provider +2. Server redirects to provider authorization URL +3. User authenticates with provider +4. Provider redirects to /auth/callback/:provider +5. Server exchanges code for tokens +6. Server creates/updates user record +7. Server issues JWT + refresh token +8. Client stores tokens securely +``` + +### 2. API Keys + +For programmatic access (CI/CD, SDK, CLI). + +```typescript +interface ApiKey { + id: string + orgId: string + userId: string + name: string + prefix: string // First 8 chars for identification + hash: string // Argon2 hash of full key + scopes: Scope[] + rateLimit?: RateLimit + expiresAt?: Date + lastUsedAt?: Date + createdAt: Date +} + +// Key format: oc_live_xxxxxxxxxxxxxxxxxxxx +// Prefix identifies key type (live/test) +``` + +**Key Generation**: +```typescript +async function generateApiKey(input: CreateKeyInput): Promise<{ key: string; record: ApiKey }> { + const key = `oc_live_${crypto.randomBytes(24).toString('base64url')}` + const hash = await argon2.hash(key) + + const record: ApiKey = { + id: generateId(), + orgId: input.orgId, + userId: input.userId, + name: input.name, + prefix: key.substring(0, 16), + hash, + scopes: input.scopes, + createdAt: new Date(), + } + + await db.apiKeys.insert(record) + + return { key, record } // Return full key only once +} +``` + +### 3. Personal Access Tokens (PAT) + +User-scoped tokens with limited lifetime. + +```typescript +interface PersonalAccessToken { + id: string + userId: string + name: string + hash: string + scopes: Scope[] + expiresAt: Date + createdAt: Date +} +``` + +## Token Management + +### JWT Structure + +```typescript +interface JWTPayload { + // Standard claims + iss: string // Issuer + sub: string // User ID + aud: string[] // Audience + exp: number // Expiration + iat: number // Issued at + jti: string // Token ID + + // Custom claims + org_id: string // Organization ID + org_role: OrgRole // Role in organization + scopes: string[] // Granted scopes + session_id?: string // For session-specific tokens +} +``` + +### Token Lifecycle + +```typescript +const tokenConfig = { + access: { + ttl: 15 * 60, // 15 minutes + algorithm: "RS256", + }, + refresh: { + ttl: 7 * 24 * 60 * 60, // 7 days + rotation: true, // Single-use refresh tokens + family: true, // Track token families + }, +} +``` + +**Refresh Token Rotation**: +```typescript +async function refreshTokens(refreshToken: string): Promise { + const payload = await verifyRefreshToken(refreshToken) + + // Check if token was already used (replay attack) + const tokenRecord = await db.refreshTokens.findById(payload.jti) + if (tokenRecord.used) { + // Token reuse detected - revoke entire family + await db.refreshTokens.revokeFamily(tokenRecord.familyId) + throw new AuthError("Token reuse detected", "TOKEN_REUSE") + } + + // Mark current token as used + await db.refreshTokens.markUsed(payload.jti) + + // Issue new token pair + return issueTokens(payload.sub, { + familyId: tokenRecord.familyId, + }) +} +``` + +## Authorization Model + +### Role-Based Access Control (RBAC) + +```typescript +type OrgRole = "owner" | "admin" | "member" | "guest" + +interface Permission { + resource: Resource + action: Action +} + +type Resource = + | "organization" + | "workspace" + | "project" + | "session" + | "user" + | "api_key" + | "provider" + | "billing" + +type Action = + | "create" + | "read" + | "update" + | "delete" + | "manage" + | "execute" +``` + +**Role Permissions Matrix**: + +| Permission | Owner | Admin | Member | Guest | +|------------|-------|-------|--------|-------| +| org:manage | yes | no | no | no | +| org:read | yes | yes | yes | yes | +| workspace:create | yes | yes | no | no | +| workspace:delete | yes | yes | no | no | +| project:create | yes | yes | yes | no | +| session:create | yes | yes | yes | yes | +| session:read (own) | yes | yes | yes | yes | +| session:read (all) | yes | yes | no | no | +| api_key:create | yes | yes | yes | no | +| provider:manage | yes | yes | no | no | +| billing:manage | yes | no | no | no | + +### Scope-Based Access (API Keys) + +```typescript +type Scope = + | "sessions:read" + | "sessions:write" + | "projects:read" + | "projects:write" + | "workspaces:read" + | "workspaces:write" + | "files:read" + | "files:write" + | "tools:execute" + | "admin" +``` + +**Scope Validation**: +```typescript +function requireScopes(...required: Scope[]) { + return async (c: Context, next: Next) => { + const granted = c.get("scopes") as Scope[] + + for (const scope of required) { + if (!granted.includes(scope) && !granted.includes("admin")) { + throw new AuthError(`Missing scope: ${scope}`, "INSUFFICIENT_SCOPE") + } + } + + await next() + } +} + +// Usage +app.post("/sessions/:id/messages", + requireScopes("sessions:write"), + sessionController.sendMessage +) +``` + +### Resource-Level Authorization + +```typescript +interface ResourcePolicy { + check(ctx: TenantContext, resource: Resource, action: Action): Promise +} + +class SessionPolicy implements ResourcePolicy { + async check(ctx: TenantContext, session: Session, action: Action): Promise { + // Owners can do anything + if (ctx.orgRole === "owner") return true + + // Check if user owns the session + const isOwner = session.userId === ctx.userId + + switch (action) { + case "read": + // Members can read own sessions, admins can read all + return isOwner || ctx.orgRole === "admin" + + case "update": + case "delete": + // Only owner or admin can modify + return isOwner || ctx.orgRole === "admin" + + case "execute": + // Only owner can execute tools in session + return isOwner + + default: + return false + } + } +} +``` + +## Multi-Tenancy + +### Tenant Context + +```typescript +interface TenantContext { + userId: string + orgId: string + orgRole: OrgRole + workspaceId?: string + sessionId?: string + scopes: Scope[] + metadata: { + ip: string + userAgent: string + requestId: string + } +} + +// Middleware to inject tenant context +async function tenantContext(c: Context, next: Next) { + const jwt = c.get("jwt") as JWTPayload + + const ctx: TenantContext = { + userId: jwt.sub, + orgId: jwt.org_id, + orgRole: jwt.org_role, + scopes: jwt.scopes, + metadata: { + ip: c.req.header("x-forwarded-for") || c.req.ip, + userAgent: c.req.header("user-agent") || "", + requestId: c.get("requestId"), + }, + } + + c.set("tenant", ctx) + await next() +} +``` + +### Organization Isolation + +```typescript +// Database queries automatically scoped to organization +class SessionRepository { + constructor(private ctx: TenantContext) {} + + async findById(id: string): Promise { + return db.sessions.findFirst({ + where: { + id, + project: { + workspace: { + orgId: this.ctx.orgId, // Automatic org scoping + }, + }, + }, + }) + } + + async list(filter: SessionFilter): Promise { + return db.sessions.findMany({ + where: { + ...filter, + project: { + workspace: { + orgId: this.ctx.orgId, + }, + }, + // Non-admins only see own sessions + ...(this.ctx.orgRole !== "admin" && { + userId: this.ctx.userId, + }), + }, + }) + } +} +``` + +## LLM Provider Authentication + +### User BYOK (Bring Your Own Key) + +```typescript +interface UserProviderKey { + id: string + userId: string + providerId: string + encryptedKey: string // AES-256-GCM encrypted + keyId: string // KMS key ID used + createdAt: Date + lastUsedAt?: Date +} + +// Encrypt user's API key before storage +async function storeProviderKey( + userId: string, + providerId: string, + apiKey: string +): Promise { + const { ciphertext, keyId } = await kms.encrypt(apiKey) + + await db.userProviderKeys.upsert({ + where: { userId, providerId }, + create: { + id: generateId(), + userId, + providerId, + encryptedKey: ciphertext, + keyId, + createdAt: new Date(), + }, + update: { + encryptedKey: ciphertext, + keyId, + }, + }) +} +``` + +### Organization Default Keys + +```typescript +interface OrgProviderConfig { + orgId: string + providerId: string + encryptedKey: string + rateLimit?: RateLimit + allowUserOverride: boolean + usageTracking: boolean +} + +// Key resolution order +async function resolveProviderKey( + ctx: TenantContext, + providerId: string +): Promise { + // 1. Check user BYOK + const userKey = await db.userProviderKeys.findFirst({ + where: { userId: ctx.userId, providerId }, + }) + if (userKey) { + return kms.decrypt(userKey.encryptedKey, userKey.keyId) + } + + // 2. Check org default + const orgConfig = await db.orgProviderConfigs.findFirst({ + where: { orgId: ctx.orgId, providerId }, + }) + if (orgConfig) { + return kms.decrypt(orgConfig.encryptedKey, orgConfig.keyId) + } + + throw new AuthError(`No API key for provider: ${providerId}`, "NO_PROVIDER_KEY") +} +``` + +## Session Management + +### Active Session Tracking + +```typescript +interface UserSession { + id: string + userId: string + tokenFamily: string + device: string + ip: string + location?: string + createdAt: Date + lastActiveAt: Date + expiresAt: Date +} + +// Track active sessions per user +async function createUserSession( + userId: string, + metadata: SessionMetadata +): Promise { + // Enforce max sessions per user + const activeSessions = await db.userSessions.count({ + where: { userId, expiresAt: { gt: new Date() } }, + }) + + if (activeSessions >= MAX_SESSIONS_PER_USER) { + // Revoke oldest session + const oldest = await db.userSessions.findFirst({ + where: { userId }, + orderBy: { lastActiveAt: "asc" }, + }) + if (oldest) { + await revokeSession(oldest.id) + } + } + + return db.userSessions.create({ + data: { + id: generateId(), + userId, + tokenFamily: generateId(), + device: metadata.device, + ip: metadata.ip, + createdAt: new Date(), + lastActiveAt: new Date(), + expiresAt: new Date(Date.now() + SESSION_TTL), + }, + }) +} +``` + +### Session Revocation + +```typescript +// Revoke specific session +async function revokeSession(sessionId: string): Promise { + const session = await db.userSessions.findById(sessionId) + if (!session) return + + // Revoke all tokens in family + await db.refreshTokens.updateMany({ + where: { familyId: session.tokenFamily }, + data: { revoked: true }, + }) + + // Delete session + await db.userSessions.delete({ id: sessionId }) + + // Publish revocation event + await redis.publish(`user:${session.userId}:revoke`, { + type: "session_revoked", + sessionId, + }) +} + +// Revoke all sessions for user +async function revokeAllSessions(userId: string): Promise { + const sessions = await db.userSessions.findMany({ + where: { userId }, + }) + + for (const session of sessions) { + await revokeSession(session.id) + } +} +``` + +## Security Controls + +### Rate Limiting + +```typescript +interface RateLimitConfig { + // Per-user limits + user: { + requests: number + window: number + burst?: number + } + // Per-organization limits + org: { + requests: number + window: number + } + // Per-endpoint limits + endpoints: { + [path: string]: { + requests: number + window: number + } + } +} + +// Example config +const rateLimitConfig: RateLimitConfig = { + user: { + requests: 100, + window: 60, // 100 req/min per user + burst: 20, // Allow burst of 20 + }, + org: { + requests: 10000, + window: 3600, // 10k req/hour per org + }, + endpoints: { + "POST /sessions/:id/messages": { + requests: 10, + window: 60, // 10 messages/min + }, + "POST /auth/login": { + requests: 5, + window: 300, // 5 attempts/5min + }, + }, +} +``` + +### Audit Logging + +```typescript +interface AuditLog { + id: string + timestamp: Date + userId: string + orgId: string + action: string + resource: string + resourceId?: string + metadata: Record + ip: string + userAgent: string + status: "success" | "failure" + errorCode?: string +} + +// Log security-sensitive actions +async function auditLog(entry: Omit): Promise { + await db.auditLogs.create({ + data: { + id: generateId(), + timestamp: new Date(), + ...entry, + }, + }) +} + +// Usage +await auditLog({ + userId: ctx.userId, + orgId: ctx.orgId, + action: "session.delete", + resource: "session", + resourceId: sessionId, + metadata: { reason: "user_request" }, + ip: ctx.metadata.ip, + userAgent: ctx.metadata.userAgent, + status: "success", +}) +``` + +### Brute Force Protection + +```typescript +// Failed login tracking +interface FailedAttempt { + identifier: string // email or IP + attempts: number + lastAttempt: Date + lockedUntil?: Date +} + +async function checkBruteForce(identifier: string): Promise { + const record = await redis.get(`failed:${identifier}`) + + if (record?.lockedUntil && record.lockedUntil > new Date()) { + const waitTime = Math.ceil((record.lockedUntil.getTime() - Date.now()) / 1000) + throw new AuthError( + `Too many attempts. Try again in ${waitTime}s`, + "RATE_LIMITED" + ) + } +} + +async function recordFailedAttempt(identifier: string): Promise { + const key = `failed:${identifier}` + const record = await redis.get(key) || { + identifier, + attempts: 0, + lastAttempt: new Date(), + } + + record.attempts++ + record.lastAttempt = new Date() + + // Progressive lockout + if (record.attempts >= 5) { + const lockoutMinutes = Math.min(Math.pow(2, record.attempts - 5), 60) + record.lockedUntil = new Date(Date.now() + lockoutMinutes * 60 * 1000) + } + + await redis.set(key, record, { ex: 3600 }) +} +``` + +## Implementation Checklist + +- [ ] OAuth 2.0 / OIDC integration +- [ ] API key generation and validation +- [ ] JWT issuance and validation +- [ ] Refresh token rotation +- [ ] Role-based access control +- [ ] Scope-based permissions +- [ ] Multi-tenant isolation +- [ ] Provider key management +- [ ] Session tracking +- [ ] Rate limiting +- [ ] Audit logging +- [ ] Brute force protection diff --git a/docs/design/server-side-deployment/scaling.md b/docs/design/server-side-deployment/scaling.md new file mode 100644 index 00000000000..de6b6162caa --- /dev/null +++ b/docs/design/server-side-deployment/scaling.md @@ -0,0 +1,866 @@ +# Scaling & Deployment + +## Overview + +This document covers horizontal scaling strategies, deployment patterns, and operational considerations for running OpenCode as a production web service. + +## Scaling Architecture + +### Horizontal Scaling Model + +``` + ┌─────────────────┐ + │ Global LB │ + │ (Cloudflare) │ + └────────┬────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐ + │ Region: US │ │ Region: EU │ │ Region: APAC │ + └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + ┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐ + │ K8s Cluster │ │ K8s Cluster │ │ K8s Cluster │ + │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ + │ │ API Pods │ │ │ │ API Pods │ │ │ │ API Pods │ │ + │ │ (3-20) │ │ │ │ (3-20) │ │ │ │ (3-20) │ │ + │ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ + │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ + │ │ Worker Pods │ │ │ │ Worker Pods │ │ │ │ Worker Pods │ │ + │ │ (2-10) │ │ │ │ (2-10) │ │ │ │ (2-10) │ │ + │ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ + └─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### Component Scaling Characteristics + +| Component | Scaling Type | Trigger | Min/Max | +|-----------|-------------|---------|---------| +| API Server | Horizontal | CPU/Memory | 3/50 | +| Tool Workers | Horizontal | Queue depth | 2/20 | +| WebSocket Handlers | Horizontal | Connection count | 2/20 | +| PostgreSQL | Vertical + Read Replicas | CPU/Connections | 1 primary | +| Redis | Cluster | Memory | 3 nodes | + +## Kubernetes Deployment + +### Namespace Structure + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: opencode + labels: + istio-injection: enabled +--- +apiVersion: v1 +kind: Namespace +metadata: + name: opencode-workers + labels: + istio-injection: enabled +``` + +### API Server Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: opencode-api + namespace: opencode +spec: + replicas: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app: opencode-api + template: + metadata: + labels: + app: opencode-api + version: v1 + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9090" + spec: + serviceAccountName: opencode-api + containers: + - name: api + image: ghcr.io/opencode/api:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 3000 + - name: metrics + containerPort: 9090 + env: + - name: NODE_ENV + value: "production" + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: opencode-secrets + key: database-url + - name: REDIS_URL + valueFrom: + secretKeyRef: + name: opencode-secrets + key: redis-url + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: opencode-secrets + key: jwt-secret + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "2Gi" + cpu: "2000m" + livenessProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "sleep 10"] + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: opencode-api + topologyKey: kubernetes.io/hostname + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app: opencode-api +``` + +### Horizontal Pod Autoscaler + +```yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: opencode-api-hpa + namespace: opencode +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: opencode-api + minReplicas: 3 + maxReplicas: 50 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 + - type: Pods + pods: + metric: + name: http_requests_per_second + target: + type: AverageValue + averageValue: "100" + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 10 + periodSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 100 + periodSeconds: 15 + - type: Pods + value: 4 + periodSeconds: 15 + selectPolicy: Max +``` + +### Tool Worker Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: opencode-worker + namespace: opencode-workers +spec: + replicas: 2 + selector: + matchLabels: + app: opencode-worker + template: + metadata: + labels: + app: opencode-worker + spec: + serviceAccountName: opencode-worker + containers: + - name: worker + image: ghcr.io/opencode/worker:latest + env: + - name: WORKER_TYPE + value: "tool-execution" + - name: REDIS_URL + valueFrom: + secretKeyRef: + name: opencode-secrets + key: redis-url + resources: + requests: + memory: "1Gi" + cpu: "1000m" + limits: + memory: "4Gi" + cpu: "4000m" + securityContext: + privileged: false + runAsNonRoot: true + readOnlyRootFilesystem: true + volumeMounts: + - name: workspace + mountPath: /workspace + - name: tmp + mountPath: /tmp + volumes: + - name: workspace + emptyDir: + sizeLimit: 10Gi + - name: tmp + emptyDir: + sizeLimit: 1Gi +``` + +### Service & Ingress + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: opencode-api + namespace: opencode +spec: + selector: + app: opencode-api + ports: + - name: http + port: 80 + targetPort: 3000 + type: ClusterIP +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: opencode-api + namespace: opencode + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: "100m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: + - api.opencode.io + secretName: opencode-tls + rules: + - host: api.opencode.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: opencode-api + port: + number: 80 +``` + +## Database Scaling + +### PostgreSQL High Availability + +```yaml +# Using CloudNativePG operator +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: opencode-postgres + namespace: opencode +spec: + instances: 3 + primaryUpdateStrategy: unsupervised + + postgresql: + parameters: + max_connections: "200" + shared_buffers: "2GB" + effective_cache_size: "6GB" + maintenance_work_mem: "512MB" + checkpoint_completion_target: "0.9" + wal_buffers: "64MB" + default_statistics_target: "100" + random_page_cost: "1.1" + effective_io_concurrency: "200" + work_mem: "10MB" + min_wal_size: "1GB" + max_wal_size: "4GB" + + storage: + size: 100Gi + storageClass: fast-ssd + + backup: + barmanObjectStore: + destinationPath: s3://opencode-backups/postgres + s3Credentials: + accessKeyId: + name: aws-creds + key: ACCESS_KEY_ID + secretAccessKey: + name: aws-creds + key: SECRET_ACCESS_KEY + wal: + compression: gzip + data: + compression: gzip + retentionPolicy: "30d" + + monitoring: + enablePodMonitor: true +``` + +### Read Replica Configuration + +```typescript +// Database client with read replica routing +const db = createDatabase({ + primary: { + connectionString: process.env.DATABASE_URL, + poolSize: 10, + }, + replicas: [ + { + connectionString: process.env.DATABASE_REPLICA_1_URL, + poolSize: 20, + }, + { + connectionString: process.env.DATABASE_REPLICA_2_URL, + poolSize: 20, + }, + ], + // Route read queries to replicas + router: (query) => { + if (query.type === "SELECT" && !query.inTransaction) { + return "replica" + } + return "primary" + }, +}) +``` + +### Connection Pooling with PgBouncer + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pgbouncer + namespace: opencode +spec: + replicas: 2 + selector: + matchLabels: + app: pgbouncer + template: + spec: + containers: + - name: pgbouncer + image: pgbouncer/pgbouncer:latest + ports: + - containerPort: 5432 + env: + - name: PGBOUNCER_POOL_MODE + value: "transaction" + - name: PGBOUNCER_MAX_CLIENT_CONN + value: "1000" + - name: PGBOUNCER_DEFAULT_POOL_SIZE + value: "20" + - name: PGBOUNCER_MIN_POOL_SIZE + value: "5" +``` + +## Redis Scaling + +### Redis Cluster + +```yaml +apiVersion: redis.redis.opstreelabs.in/v1beta1 +kind: RedisCluster +metadata: + name: opencode-redis + namespace: opencode +spec: + clusterSize: 3 + clusterVersion: v7 + persistenceEnabled: true + kubernetesConfig: + image: redis:7-alpine + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: 1000m + memory: 2Gi + storage: + volumeClaimTemplate: + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 10Gi + redisExporter: + enabled: true + image: oliver006/redis_exporter:latest +``` + +## Load Balancing + +### Global Load Balancing (Cloudflare) + +```typescript +// Cloudflare Worker for intelligent routing +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url) + + // Determine best region based on latency + const region = request.cf?.region || "us" + const backend = getBackendForRegion(region) + + // Add request tracing + const headers = new Headers(request.headers) + headers.set("x-request-id", crypto.randomUUID()) + headers.set("x-forwarded-region", region) + + return fetch(backend + url.pathname + url.search, { + method: request.method, + headers, + body: request.body, + }) + }, +} + +function getBackendForRegion(region: string): string { + const backends = { + us: "https://us.api.opencode.io", + eu: "https://eu.api.opencode.io", + apac: "https://apac.api.opencode.io", + } + return backends[region] || backends.us +} +``` + +### Internal Load Balancing + +```yaml +# Istio VirtualService for traffic management +apiVersion: networking.istio.io/v1beta1 +kind: VirtualService +metadata: + name: opencode-api + namespace: opencode +spec: + hosts: + - opencode-api + http: + - match: + - headers: + x-api-version: + exact: "v2" + route: + - destination: + host: opencode-api-v2 + port: + number: 80 + - route: + - destination: + host: opencode-api + port: + number: 80 + weight: 100 + retries: + attempts: 3 + perTryTimeout: 10s + retryOn: 5xx,reset,connect-failure + timeout: 300s +``` + +## SSE Connection Scaling + +### Sticky Sessions for SSE + +```yaml +# Nginx Ingress with sticky sessions +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: opencode-events + annotations: + nginx.ingress.kubernetes.io/affinity: "cookie" + nginx.ingress.kubernetes.io/session-cookie-name: "opencode-route" + nginx.ingress.kubernetes.io/session-cookie-expires: "172800" + nginx.ingress.kubernetes.io/session-cookie-max-age: "172800" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" +spec: + rules: + - host: events.opencode.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: opencode-api + port: + number: 80 +``` + +### Connection Draining + +```typescript +// Graceful shutdown with connection draining +const connections = new Set() + +async function gracefulShutdown(): Promise { + // Stop accepting new connections + server.close() + + // Notify existing connections + for (const conn of connections) { + conn.send({ type: "server.shutdown", reconnectIn: 5000 }) + } + + // Wait for connections to drain (max 30s) + const deadline = Date.now() + 30000 + while (connections.size > 0 && Date.now() < deadline) { + await sleep(1000) + } + + // Force close remaining + for (const conn of connections) { + conn.close() + } + + process.exit(0) +} + +process.on("SIGTERM", gracefulShutdown) +``` + +## Monitoring & Observability + +### Prometheus Metrics + +```typescript +import { Registry, Counter, Histogram, Gauge } from "prom-client" + +const registry = new Registry() + +// Request metrics +const httpRequestsTotal = new Counter({ + name: "http_requests_total", + help: "Total HTTP requests", + labelNames: ["method", "path", "status"], + registers: [registry], +}) + +const httpRequestDuration = new Histogram({ + name: "http_request_duration_seconds", + help: "HTTP request duration", + labelNames: ["method", "path"], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 5, 10], + registers: [registry], +}) + +// Business metrics +const activeSessions = new Gauge({ + name: "opencode_active_sessions", + help: "Number of active sessions", + registers: [registry], +}) + +const llmTokensTotal = new Counter({ + name: "opencode_llm_tokens_total", + help: "Total LLM tokens consumed", + labelNames: ["provider", "model", "type"], + registers: [registry], +}) + +const toolExecutionDuration = new Histogram({ + name: "opencode_tool_execution_seconds", + help: "Tool execution duration", + labelNames: ["tool"], + buckets: [0.1, 0.5, 1, 5, 10, 30, 60], + registers: [registry], +}) +``` + +### Grafana Dashboards + +```json +{ + "title": "OpenCode Overview", + "panels": [ + { + "title": "Request Rate", + "type": "graph", + "targets": [ + { + "expr": "sum(rate(http_requests_total[5m])) by (status)", + "legendFormat": "{{status}}" + } + ] + }, + { + "title": "P99 Latency", + "type": "graph", + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))" + } + ] + }, + { + "title": "Active Sessions", + "type": "stat", + "targets": [ + { + "expr": "sum(opencode_active_sessions)" + } + ] + }, + { + "title": "Token Usage", + "type": "graph", + "targets": [ + { + "expr": "sum(rate(opencode_llm_tokens_total[1h])) by (provider)", + "legendFormat": "{{provider}}" + } + ] + } + ] +} +``` + +### Distributed Tracing + +```typescript +import { trace, SpanKind } from "@opentelemetry/api" + +const tracer = trace.getTracer("opencode-api") + +async function handleChatRequest(ctx: Context): Promise { + return tracer.startActiveSpan( + "chat.request", + { kind: SpanKind.SERVER }, + async (span) => { + try { + span.setAttributes({ + "session.id": ctx.params.id, + "user.id": ctx.get("tenant").userId, + }) + + // Process request with child spans + const messages = await tracer.startActiveSpan( + "load.messages", + async (loadSpan) => { + const result = await loadMessages(ctx.params.id) + loadSpan.end() + return result + } + ) + + const response = await tracer.startActiveSpan( + "llm.request", + { kind: SpanKind.CLIENT }, + async (llmSpan) => { + llmSpan.setAttributes({ + "llm.provider": "anthropic", + "llm.model": "claude-3-sonnet", + }) + const result = await callLLM(messages) + llmSpan.setAttributes({ + "llm.tokens.input": result.tokens.input, + "llm.tokens.output": result.tokens.output, + }) + llmSpan.end() + return result + } + ) + + span.setStatus({ code: SpanStatusCode.OK }) + return ctx.json(response) + } catch (error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }) + throw error + } finally { + span.end() + } + } + ) +} +``` + +## Deployment Strategies + +### Blue-Green Deployment + +```yaml +# Argo Rollouts for blue-green deployment +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: opencode-api +spec: + replicas: 5 + strategy: + blueGreen: + activeService: opencode-api + previewService: opencode-api-preview + autoPromotionEnabled: false + scaleDownDelaySeconds: 30 + previewReplicaCount: 2 + prePromotionAnalysis: + templates: + - templateName: success-rate + args: + - name: service-name + value: opencode-api-preview +``` + +### Canary Deployment + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: opencode-api +spec: + strategy: + canary: + steps: + - setWeight: 5 + - pause: { duration: 5m } + - setWeight: 20 + - pause: { duration: 10m } + - setWeight: 50 + - pause: { duration: 10m } + - setWeight: 100 + analysis: + templates: + - templateName: success-rate + startingStep: 1 + canaryService: opencode-api-canary + stableService: opencode-api +``` + +## Disaster Recovery + +### Multi-Region Failover + +```typescript +// Health check and failover logic +interface RegionHealth { + region: string + healthy: boolean + latency: number + lastCheck: Date +} + +class RegionManager { + private regions: Map = new Map() + + async checkHealth(region: string): Promise { + const start = Date.now() + try { + const response = await fetch(`https://${region}.api.opencode.io/health`) + return { + region, + healthy: response.ok, + latency: Date.now() - start, + lastCheck: new Date(), + } + } catch { + return { + region, + healthy: false, + latency: -1, + lastCheck: new Date(), + } + } + } + + getBestRegion(): string { + const healthy = Array.from(this.regions.values()) + .filter((r) => r.healthy) + .sort((a, b) => a.latency - b.latency) + + return healthy[0]?.region || "us" // fallback + } +} +``` + +### RTO/RPO Targets + +| Scenario | RTO | RPO | +|----------|-----|-----| +| Single pod failure | 0 (auto-recovery) | 0 | +| Node failure | 2 minutes | 0 | +| AZ failure | 5 minutes | 0 | +| Region failure | 15 minutes | 1 minute | +| Complete outage | 1 hour | 5 minutes | diff --git a/docs/design/server-side-deployment/security.md b/docs/design/server-side-deployment/security.md new file mode 100644 index 00000000000..5583507433a --- /dev/null +++ b/docs/design/server-side-deployment/security.md @@ -0,0 +1,751 @@ +# Security + +## Overview + +This document outlines security controls, threat mitigations, and compliance requirements for the OpenCode server-side deployment. + +## Threat Model + +### Assets to Protect + +1. **User Data**: Sessions, messages, code, credentials +2. **Provider Keys**: API keys for LLM providers +3. **Infrastructure**: Servers, databases, networks +4. **Service Availability**: Protection against DoS + +### Threat Actors + +1. **External Attackers**: Unauthorized access attempts +2. **Malicious Users**: Abuse of legitimate access +3. **Compromised Accounts**: Stolen credentials +4. **Insider Threats**: Rogue employees/contractors + +### Attack Vectors + +| Vector | Risk | Mitigation | +|--------|------|------------| +| SQL Injection | High | Parameterized queries, ORM | +| XSS | Medium | Content Security Policy, sanitization | +| CSRF | Medium | SameSite cookies, CSRF tokens | +| Command Injection | Critical | Sandboxed execution | +| API Key Theft | High | Encryption at rest, KMS | +| Session Hijacking | High | Secure cookies, token rotation | +| DoS/DDoS | High | Rate limiting, CDN protection | + +## Network Security + +### Architecture + +``` +Internet + │ + ▼ +┌─────────────┐ +│ WAF/CDN │ ← DDoS protection, bot filtering +│ (Cloudflare)│ +└──────┬──────┘ + │ + ┌──▼──┐ + │ VPC │ + │ │ + │ ┌──┴──────────────────────┐ + │ │ Public Subnet │ + │ │ ┌─────────────────┐ │ + │ │ │ Load Balancer │ │ + │ │ └────────┬────────┘ │ + │ └───────────┼─────────────┘ + │ │ + │ ┌───────────▼─────────────┐ + │ │ Private Subnet │ + │ │ ┌─────────────────┐ │ + │ │ │ API Servers │ │ + │ │ └────────┬────────┘ │ + │ │ │ │ + │ │ ┌────────▼────────┐ │ + │ │ │ Database │ │ + │ │ └─────────────────┘ │ + │ └─────────────────────────┘ + └─────────────────────────────┘ +``` + +### Firewall Rules + +```yaml +# Network policies for Kubernetes +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: api-server-policy + namespace: opencode +spec: + podSelector: + matchLabels: + app: opencode-api + policyTypes: + - Ingress + - Egress + ingress: + - from: + - namespaceSelector: + matchLabels: + name: ingress-nginx + ports: + - port: 3000 + egress: + # Database + - to: + - podSelector: + matchLabels: + app: postgres + ports: + - port: 5432 + # Redis + - to: + - podSelector: + matchLabels: + app: redis + ports: + - port: 6379 + # External LLM APIs + - to: + - ipBlock: + cidr: 0.0.0.0/0 + ports: + - port: 443 +``` + +### TLS Configuration + +```typescript +// Minimum TLS 1.2, prefer 1.3 +const tlsConfig = { + minVersion: "TLSv1.2", + ciphers: [ + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256", + "TLS_AES_128_GCM_SHA256", + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-RSA-AES128-GCM-SHA256", + ].join(":"), + honorCipherOrder: true, +} +``` + +### mTLS for Internal Services + +```yaml +# Istio PeerAuthentication for mTLS +apiVersion: security.istio.io/v1beta1 +kind: PeerAuthentication +metadata: + name: default + namespace: opencode +spec: + mtls: + mode: STRICT +``` + +## Application Security + +### Input Validation + +```typescript +import { z } from "zod" + +// Strict input validation schemas +const CreateSessionSchema = z.object({ + title: z.string() + .min(1) + .max(500) + .regex(/^[\w\s\-.,!?]+$/), + projectId: z.string().uuid(), + model: z.object({ + providerId: z.enum(["anthropic", "openai", "google"]), + modelId: z.string().max(100), + }), +}) + +const MessageSchema = z.object({ + content: z.string().max(100000), // 100KB limit + files: z.array(z.object({ + name: z.string().max(255), + size: z.number().max(10 * 1024 * 1024), // 10MB + mimeType: z.string().regex(/^[\w\-]+\/[\w\-+.]+$/), + })).max(10).optional(), +}) + +// Middleware for validation +function validate(schema: z.ZodSchema) { + return async (c: Context, next: Next) => { + const result = schema.safeParse(await c.req.json()) + if (!result.success) { + throw new ValidationError(result.error) + } + c.set("body", result.data) + await next() + } +} +``` + +### Output Encoding + +```typescript +// Sanitize output for different contexts +import DOMPurify from "isomorphic-dompurify" + +function sanitizeForHtml(input: string): string { + return DOMPurify.sanitize(input, { + ALLOWED_TAGS: ["b", "i", "em", "strong", "code", "pre", "a"], + ALLOWED_ATTR: ["href"], + }) +} + +function sanitizeForJson(input: unknown): unknown { + // Remove any prototype pollution attempts + return JSON.parse(JSON.stringify(input, (key, value) => { + if (key === "__proto__" || key === "constructor" || key === "prototype") { + return undefined + } + return value + })) +} +``` + +### Content Security Policy + +```typescript +// CSP headers for web UI +const cspPolicy = { + "default-src": ["'self'"], + "script-src": ["'self'", "'wasm-unsafe-eval'"], + "style-src": ["'self'", "'unsafe-inline'"], + "img-src": ["'self'", "data:", "https:"], + "connect-src": [ + "'self'", + "https://api.anthropic.com", + "https://api.openai.com", + ], + "frame-ancestors": ["'none'"], + "form-action": ["'self'"], + "base-uri": ["'self'"], + "object-src": ["'none'"], +} + +app.use((c, next) => { + const csp = Object.entries(cspPolicy) + .map(([key, values]) => `${key} ${values.join(" ")}`) + .join("; ") + c.header("Content-Security-Policy", csp) + return next() +}) +``` + +### Security Headers + +```typescript +// Security headers middleware +app.use((c, next) => { + // Prevent MIME sniffing + c.header("X-Content-Type-Options", "nosniff") + + // Clickjacking protection + c.header("X-Frame-Options", "DENY") + + // XSS protection (legacy browsers) + c.header("X-XSS-Protection", "1; mode=block") + + // Referrer policy + c.header("Referrer-Policy", "strict-origin-when-cross-origin") + + // Permissions policy + c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + + // HSTS (1 year) + c.header( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload" + ) + + return next() +}) +``` + +## Sandboxed Code Execution + +### Isolation Strategy + +Tool execution (Bash, file operations) runs in isolated containers to prevent: +- Filesystem escape +- Network access to internal services +- Resource exhaustion +- Privilege escalation + +### Container Security + +```yaml +# Security context for worker pods +apiVersion: v1 +kind: Pod +spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: sandbox + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + limits: + cpu: "1" + memory: "512Mi" + ephemeral-storage: "1Gi" +``` + +### Firecracker/gVisor Integration + +```typescript +interface SandboxConfig { + // Firecracker microVM settings + firecracker: { + kernelPath: string + rootfsPath: string + vcpuCount: number + memSizeMib: number + networkInterface?: { + hostDevName: string + guestMac: string + } + } + // Or gVisor runtime + gvisor: { + platform: "ptrace" | "kvm" + network: "none" | "host" + } +} +``` + +### Command Filtering + +```typescript +// Block dangerous commands +const blockedCommands = [ + /\brm\s+-rf\s+\//, // rm -rf / + /\bmkfs\b/, + /\bdd\b.*of=\/dev/, + /\b(sudo|su)\b/, + /\bchmod\s+777/, + /\bcurl\b.*\|\s*(bash|sh)/, + /\bwget\b.*\|\s*(bash|sh)/, +] + +function validateCommand(cmd: string): boolean { + for (const pattern of blockedCommands) { + if (pattern.test(cmd)) { + return false + } + } + return true +} +``` + +## Data Protection + +### Encryption at Rest + +```typescript +// All sensitive data encrypted with AES-256-GCM +interface EncryptionConfig { + algorithm: "aes-256-gcm" + keyManagement: "aws-kms" | "hashicorp-vault" | "gcp-kms" + keyRotationDays: 90 +} + +// Encrypt provider API keys +async function encryptApiKey(key: string): Promise { + const kmsKeyId = process.env.KMS_KEY_ID + const { CiphertextBlob, KeyId } = await kms.encrypt({ + KeyId: kmsKeyId, + Plaintext: Buffer.from(key), + EncryptionContext: { + purpose: "provider-api-key", + }, + }) + + return { + ciphertext: CiphertextBlob.toString("base64"), + keyId: KeyId, + } +} +``` + +### Encryption in Transit + +- TLS 1.2+ for all external connections +- mTLS for internal service communication +- Certificate pinning for LLM provider connections + +### Data Classification + +| Classification | Examples | Controls | +|---------------|----------|----------| +| Public | Marketing content | None | +| Internal | Usage metrics | Access control | +| Confidential | User sessions | Encryption, audit logs | +| Restricted | API keys, PII | Encryption, KMS, strict access | + +### Key Management + +```typescript +// HashiCorp Vault integration +interface VaultConfig { + address: string + authMethod: "kubernetes" | "token" | "aws-iam" + secretEngine: "kv-v2" + transitEngine: "transit" +} + +class VaultClient { + // Get encryption key for data + async getDataKey(purpose: string): Promise { + const response = await this.client.write( + `transit/datakey/plaintext/${purpose}`, + { context: Buffer.from(purpose).toString("base64") } + ) + return Buffer.from(response.plaintext, "base64") + } + + // Encrypt with transit engine + async encrypt(plaintext: string, keyName: string): Promise { + const response = await this.client.write( + `transit/encrypt/${keyName}`, + { plaintext: Buffer.from(plaintext).toString("base64") } + ) + return response.ciphertext + } +} +``` + +## Secret Management + +### Secret Storage + +```yaml +# External Secrets Operator +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: opencode-secrets + namespace: opencode +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: vault-backend + target: + name: opencode-secrets + data: + - secretKey: database-url + remoteRef: + key: opencode/database + property: url + - secretKey: jwt-secret + remoteRef: + key: opencode/auth + property: jwt-secret + - secretKey: anthropic-api-key + remoteRef: + key: opencode/providers + property: anthropic-key +``` + +### Secret Rotation + +```typescript +// Automatic secret rotation +interface RotationConfig { + // Database credentials + database: { + rotationSchedule: "0 0 * * 0", // Weekly + maxAge: 90, // Days + }, + // API keys + apiKeys: { + rotationSchedule: "0 0 1 * *", // Monthly + maxAge: 365, + }, + // JWT signing keys + jwtKeys: { + rotationSchedule: "0 0 1 */3 *", // Quarterly + gracePeriod: 7, // Days to accept old key + }, +} +``` + +## Audit & Compliance + +### Audit Logging + +```typescript +// Comprehensive audit logging +interface AuditEvent { + id: string + timestamp: Date + actor: { + userId: string + orgId: string + ip: string + userAgent: string + } + action: string + resource: { + type: string + id: string + } + outcome: "success" | "failure" + metadata: Record +} + +// Log security-sensitive actions +const auditableActions = [ + "user.login", + "user.logout", + "user.mfa_enabled", + "user.mfa_disabled", + "user.password_changed", + "apikey.created", + "apikey.deleted", + "session.created", + "session.deleted", + "session.shared", + "provider.key_added", + "provider.key_removed", + "org.member_added", + "org.member_removed", + "org.settings_changed", +] +``` + +### Log Aggregation + +```yaml +# Fluent Bit for log collection +apiVersion: v1 +kind: ConfigMap +metadata: + name: fluent-bit-config +data: + fluent-bit.conf: | + [SERVICE] + Flush 5 + Log_Level info + Parsers_File parsers.conf + + [INPUT] + Name tail + Path /var/log/containers/opencode-*.log + Parser docker + Tag opencode.* + Mem_Buf_Limit 5MB + + [OUTPUT] + Name es + Match opencode.* + Host elasticsearch + Port 9200 + Index opencode-logs + Type _doc +``` + +### Compliance Controls + +#### SOC 2 Type II + +- [ ] Access control policies +- [ ] Encryption at rest and in transit +- [ ] Audit logging +- [ ] Incident response plan +- [ ] Vulnerability management +- [ ] Change management + +#### GDPR + +- [ ] Data processing agreements +- [ ] Right to erasure (data deletion) +- [ ] Data portability (export) +- [ ] Consent management +- [ ] Privacy policy +- [ ] DPO appointment + +#### HIPAA (if applicable) + +- [ ] BAA with customers +- [ ] PHI encryption +- [ ] Access controls +- [ ] Audit trails +- [ ] Breach notification + +## Vulnerability Management + +### Dependency Scanning + +```yaml +# GitHub Actions for dependency scanning +name: Security Scan +on: + push: + branches: [main] + schedule: + - cron: "0 0 * * *" + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + severity: 'CRITICAL,HIGH' + exit-code: '1' + + - name: Run Snyk + uses: snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} +``` + +### Container Image Scanning + +```yaml +# Scan images before deployment +- name: Scan container image + uses: aquasecurity/trivy-action@master + with: + image-ref: 'ghcr.io/opencode/api:${{ github.sha }}' + format: 'sarif' + output: 'trivy-results.sarif' + +- name: Upload scan results + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: 'trivy-results.sarif' +``` + +### Penetration Testing + +- Annual third-party penetration tests +- Quarterly internal security assessments +- Bug bounty program for external researchers + +## Incident Response + +### Incident Classification + +| Severity | Description | Response Time | +|----------|-------------|--------------| +| P1 - Critical | Data breach, complete outage | 15 minutes | +| P2 - High | Partial outage, security vulnerability | 1 hour | +| P3 - Medium | Degraded service, minor vulnerability | 4 hours | +| P4 - Low | Cosmetic issues, minor bugs | 24 hours | + +### Response Procedures + +```typescript +interface IncidentResponse { + // 1. Detection & Alerting + detection: { + source: "monitoring" | "user_report" | "automated_scan" + alertChannels: ["pagerduty", "slack", "email"] + } + + // 2. Triage & Classification + triage: { + severity: "P1" | "P2" | "P3" | "P4" + impactAssessment: string + affectedSystems: string[] + } + + // 3. Containment + containment: { + isolateAffectedSystems: boolean + preserveEvidence: boolean + communicateToStakeholders: boolean + } + + // 4. Eradication + eradication: { + rootCauseAnalysis: string + remediationSteps: string[] + } + + // 5. Recovery + recovery: { + restoreServices: boolean + verifyIntegrity: boolean + monitorForRecurrence: boolean + } + + // 6. Post-Incident + postIncident: { + incidentReport: string + lessonsLearned: string[] + preventiveMeasures: string[] + } +} +``` + +### Security Contacts + +```yaml +# PagerDuty escalation policy +escalation_policy: + name: "Security Incidents" + escalation_rules: + - escalation_delay_in_minutes: 5 + targets: + - type: "user_reference" + id: "security-oncall" + - escalation_delay_in_minutes: 15 + targets: + - type: "user_reference" + id: "security-lead" + - escalation_delay_in_minutes: 30 + targets: + - type: "user_reference" + id: "cto" +``` + +## Security Checklist + +### Pre-Deployment + +- [ ] Security review of architecture +- [ ] Threat modeling complete +- [ ] Penetration test passed +- [ ] Dependency vulnerabilities addressed +- [ ] Secrets rotated and secured +- [ ] Network policies configured +- [ ] TLS certificates valid +- [ ] Audit logging enabled +- [ ] Monitoring alerts configured +- [ ] Incident response plan tested + +### Ongoing + +- [ ] Weekly dependency updates +- [ ] Monthly security patches +- [ ] Quarterly access reviews +- [ ] Annual penetration tests +- [ ] Continuous vulnerability scanning +- [ ] Regular backup verification +- [ ] Incident response drills diff --git a/docs/design/server-side-deployment/storage-mysql.md b/docs/design/server-side-deployment/storage-mysql.md new file mode 100644 index 00000000000..00167870aab --- /dev/null +++ b/docs/design/server-side-deployment/storage-mysql.md @@ -0,0 +1,1005 @@ +# MySQL Storage Design + +## Overview + +This document describes an alternative storage design using MySQL optimized for high-scale deployments. The design avoids stored procedures, foreign keys, and triggers for maximum portability and performance, using efficient `BIGINT` primary keys instead of UUIDs. + +## Design Principles + +### Why These Constraints? + +| Constraint | Reason | +|------------|--------| +| No Foreign Keys | Eliminates FK checks on writes, enables easier sharding | +| No Stored Procedures | Application-level logic, better portability | +| No Triggers | Predictable performance, easier debugging | +| BIGINT Keys | 8 bytes vs 16 bytes (UUID), better index performance | + +### Trade-offs + +**Advantages**: +- 50% smaller primary key storage +- Faster index lookups (sequential vs random) +- No FK constraint overhead on inserts +- Easier horizontal sharding +- Better cache locality + +**Considerations**: +- Application must enforce referential integrity +- Need distributed ID generation strategy +- Orphan cleanup requires background jobs + +## ID Generation + +### Snowflake ID Structure + +Use Twitter Snowflake-style IDs for distributed, time-ordered, unique identifiers: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 63 bits total (signed BIGINT) │ +├─────────────────────┬──────────────┬────────────┬───────────────┤ +│ Timestamp (41 bits) │ Worker (10) │ Seq (12) │ Sign (1) │ +│ ~69 years │ 1024 workers │ 4096/ms │ Always 0 │ +└─────────────────────┴──────────────┴────────────┴───────────────┘ +``` + +### ID Generator Implementation + +```typescript +class SnowflakeGenerator { + private readonly epoch = 1704067200000n // 2024-01-01 00:00:00 UTC + private readonly workerIdBits = 10n + private readonly sequenceBits = 12n + + private readonly maxWorkerId = (1n << this.workerIdBits) - 1n + private readonly maxSequence = (1n << this.sequenceBits) - 1n + + private readonly workerIdShift = this.sequenceBits + private readonly timestampShift = this.sequenceBits + this.workerIdBits + + private workerId: bigint + private sequence = 0n + private lastTimestamp = -1n + + constructor(workerId: number) { + if (workerId < 0 || BigInt(workerId) > this.maxWorkerId) { + throw new Error(`Worker ID must be between 0 and ${this.maxWorkerId}`) + } + this.workerId = BigInt(workerId) + } + + nextId(): bigint { + let timestamp = BigInt(Date.now()) - this.epoch + + if (timestamp === this.lastTimestamp) { + this.sequence = (this.sequence + 1n) & this.maxSequence + if (this.sequence === 0n) { + // Wait for next millisecond + while (timestamp <= this.lastTimestamp) { + timestamp = BigInt(Date.now()) - this.epoch + } + } + } else { + this.sequence = 0n + } + + this.lastTimestamp = timestamp + + return ( + (timestamp << this.timestampShift) | + (this.workerId << this.workerIdShift) | + this.sequence + ) + } + + // Extract timestamp from ID + static getTimestamp(id: bigint): Date { + const epoch = 1704067200000n + const timestamp = (id >> 22n) + epoch + return new Date(Number(timestamp)) + } +} + +// Usage +const idGen = new SnowflakeGenerator(parseInt(process.env.WORKER_ID || "1")) +const sessionId = idGen.nextId() // 7159429562834944001n +``` + +### Worker ID Assignment + +```typescript +// Assign worker IDs via environment or coordination service +interface WorkerIdConfig { + // Static assignment via environment + static: { + workerId: number + } + // Dynamic assignment via Redis + redis: { + key: "workers:ids" + ttl: 60 // seconds, heartbeat interval + } + // Kubernetes pod ordinal + kubernetes: { + statefulSetName: string + // Pod name: opencode-api-3 → workerId: 3 + } +} + +// Redis-based dynamic assignment +async function acquireWorkerId(redis: Redis): Promise { + for (let id = 0; id < 1024; id++) { + const key = `worker:${id}` + const acquired = await redis.set(key, process.pid, { + nx: true, + ex: 60, + }) + if (acquired) { + // Start heartbeat + setInterval(() => redis.expire(key, 60), 30000) + return id + } + } + throw new Error("No available worker IDs") +} +``` + +## MySQL Schema + +### Core Tables + +```sql +-- Organizations (tenants) +CREATE TABLE organizations ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL, + plan VARCHAR(50) NOT NULL DEFAULT 'free', + settings JSON NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + + UNIQUE KEY uk_slug (slug), + KEY idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Users +CREATE TABLE users ( + id BIGINT NOT NULL PRIMARY KEY, + org_id BIGINT NOT NULL, + email VARCHAR(255) NOT NULL, + name VARCHAR(255), + avatar_url VARCHAR(500), + role VARCHAR(50) NOT NULL DEFAULT 'member', + preferences JSON NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + last_active_at TIMESTAMP(3) NULL, + + UNIQUE KEY uk_org_email (org_id, email), + KEY idx_org_id (org_id), + KEY idx_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Workspaces +CREATE TABLE workspaces ( + id BIGINT NOT NULL PRIMARY KEY, + org_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + git_config JSON, + settings JSON NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + + KEY idx_org_id (org_id), + KEY idx_org_name (org_id, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Projects +CREATE TABLE projects ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + path VARCHAR(1000) NOT NULL, + git_commit VARCHAR(40), + settings JSON NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + + KEY idx_workspace_id (workspace_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Sessions +CREATE TABLE sessions ( + id BIGINT NOT NULL PRIMARY KEY, + project_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + parent_id BIGINT NULL, + title VARCHAR(500) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'active', + model_provider VARCHAR(100) NOT NULL, + model_id VARCHAR(100) NOT NULL, + summary JSON, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + expires_at TIMESTAMP(3) NULL, + + KEY idx_project_id (project_id), + KEY idx_user_id (user_id), + KEY idx_user_created (user_id, created_at DESC), + KEY idx_status (status), + KEY idx_parent_id (parent_id), + KEY idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Messages +CREATE TABLE messages ( + id BIGINT NOT NULL PRIMARY KEY, + session_id BIGINT NOT NULL, + role VARCHAR(50) NOT NULL, + metadata JSON NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + completed_at TIMESTAMP(3) NULL, + + KEY idx_session_id (session_id), + KEY idx_session_created (session_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Message Parts +CREATE TABLE message_parts ( + id BIGINT NOT NULL PRIMARY KEY, + message_id BIGINT NOT NULL, + type VARCHAR(50) NOT NULL, + content JSON NOT NULL, + sort_order INT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + KEY idx_message_id (message_id), + KEY idx_message_order (message_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Session Diffs +CREATE TABLE session_diffs ( + id BIGINT NOT NULL PRIMARY KEY, + session_id BIGINT NOT NULL, + message_id BIGINT NOT NULL, + file_path VARCHAR(1000) NOT NULL, + diff_content MEDIUMTEXT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + KEY idx_session_id (session_id), + KEY idx_message_id (message_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### Authentication Tables + +```sql +-- API Keys +CREATE TABLE api_keys ( + id BIGINT NOT NULL PRIMARY KEY, + org_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + prefix VARCHAR(20) NOT NULL, + hash VARCHAR(255) NOT NULL, + scopes JSON NOT NULL, + rate_limit JSON, + expires_at TIMESTAMP(3) NULL, + last_used_at TIMESTAMP(3) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + KEY idx_org_id (org_id), + KEY idx_user_id (user_id), + KEY idx_prefix (prefix) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Refresh Tokens +CREATE TABLE refresh_tokens ( + id BIGINT NOT NULL PRIMARY KEY, + user_id BIGINT NOT NULL, + family_id BIGINT NOT NULL, + hash VARCHAR(255) NOT NULL, + used TINYINT(1) NOT NULL DEFAULT 0, + revoked TINYINT(1) NOT NULL DEFAULT 0, + expires_at TIMESTAMP(3) NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + KEY idx_user_id (user_id), + KEY idx_family_id (family_id), + KEY idx_hash (hash), + KEY idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- User Sessions (login sessions) +CREATE TABLE user_sessions ( + id BIGINT NOT NULL PRIMARY KEY, + user_id BIGINT NOT NULL, + token_family BIGINT NOT NULL, + device VARCHAR(255), + ip VARCHAR(45), + location VARCHAR(255), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + last_active_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + expires_at TIMESTAMP(3) NOT NULL, + + KEY idx_user_id (user_id), + KEY idx_token_family (token_family), + KEY idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- OAuth Connections +CREATE TABLE oauth_connections ( + id BIGINT NOT NULL PRIMARY KEY, + user_id BIGINT NOT NULL, + provider VARCHAR(50) NOT NULL, + provider_user_id VARCHAR(255) NOT NULL, + access_token_encrypted TEXT NOT NULL, + refresh_token_encrypted TEXT, + expires_at TIMESTAMP(3) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + + UNIQUE KEY uk_provider_user (provider, provider_user_id), + KEY idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### Provider & Usage Tables + +```sql +-- User Provider Keys (BYOK) +CREATE TABLE user_provider_keys ( + id BIGINT NOT NULL PRIMARY KEY, + user_id BIGINT NOT NULL, + provider_id VARCHAR(100) NOT NULL, + encrypted_key TEXT NOT NULL, + key_id VARCHAR(255) NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + last_used_at TIMESTAMP(3) NULL, + + UNIQUE KEY uk_user_provider (user_id, provider_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Organization Provider Config +CREATE TABLE org_provider_configs ( + id BIGINT NOT NULL PRIMARY KEY, + org_id BIGINT NOT NULL, + provider_id VARCHAR(100) NOT NULL, + encrypted_key TEXT NOT NULL, + key_id VARCHAR(255) NOT NULL, + rate_limit JSON, + allow_user_override TINYINT(1) NOT NULL DEFAULT 1, + usage_tracking TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + + UNIQUE KEY uk_org_provider (org_id, provider_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Usage Records +CREATE TABLE usage_records ( + id BIGINT NOT NULL PRIMARY KEY, + org_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + session_id BIGINT NULL, + provider_id VARCHAR(100) NOT NULL, + model_id VARCHAR(100) NOT NULL, + tokens_input INT NOT NULL, + tokens_output INT NOT NULL, + tokens_cache_read INT NOT NULL DEFAULT 0, + tokens_cache_write INT NOT NULL DEFAULT 0, + cost_cents INT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + KEY idx_org_created (org_id, created_at), + KEY idx_user_created (user_id, created_at), + KEY idx_session_id (session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Audit Logs +CREATE TABLE audit_logs ( + id BIGINT NOT NULL PRIMARY KEY, + org_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + action VARCHAR(100) NOT NULL, + resource VARCHAR(100) NOT NULL, + resource_id BIGINT NULL, + metadata JSON NOT NULL, + ip VARCHAR(45), + user_agent TEXT, + status VARCHAR(50) NOT NULL, + error_code VARCHAR(100), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + KEY idx_org_created (org_id, created_at), + KEY idx_user_created (user_id, created_at), + KEY idx_action (action), + KEY idx_resource (resource, resource_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +## Application-Level Referential Integrity + +### Validation on Insert/Update + +```typescript +// Validate parent exists before insert +class SessionRepository { + async create(input: CreateSessionInput): Promise { + // Validate project exists + const project = await this.db.query` + SELECT id, workspace_id FROM projects WHERE id = ${input.projectId} + `.first() + + if (!project) { + throw new NotFoundError("Project not found", "PROJECT_NOT_FOUND") + } + + // Validate workspace belongs to org (for tenant isolation) + const workspace = await this.db.query` + SELECT id FROM workspaces + WHERE id = ${project.workspaceId} AND org_id = ${this.ctx.orgId} + `.first() + + if (!workspace) { + throw new ForbiddenError("Access denied", "WORKSPACE_ACCESS_DENIED") + } + + // Insert session + const id = this.idGen.nextId() + await this.db.query` + INSERT INTO sessions (id, project_id, user_id, title, model_provider, model_id) + VALUES (${id}, ${input.projectId}, ${this.ctx.userId}, ${input.title}, + ${input.modelProvider}, ${input.modelId}) + ` + + return this.findById(id) + } +} +``` + +### Cascading Deletes + +```typescript +// Manual cascade delete (no FK constraints) +class SessionRepository { + async delete(id: bigint): Promise { + // Verify ownership + const session = await this.findById(id) + if (!session) { + throw new NotFoundError("Session not found") + } + + // Delete in order: parts → messages → diffs → session + // Use transaction for atomicity + await this.db.transaction(async (tx) => { + // Get all message IDs for this session + const messageIds = await tx.query<{ id: bigint }>` + SELECT id FROM messages WHERE session_id = ${id} + `.all() + + if (messageIds.length > 0) { + const ids = messageIds.map(m => m.id) + + // Delete parts for all messages + await tx.query` + DELETE FROM message_parts WHERE message_id IN (${ids}) + ` + + // Delete messages + await tx.query` + DELETE FROM messages WHERE session_id = ${id} + ` + } + + // Delete diffs + await tx.query` + DELETE FROM session_diffs WHERE session_id = ${id} + ` + + // Delete session + await tx.query` + DELETE FROM sessions WHERE id = ${id} + ` + }) + } +} +``` + +### Orphan Cleanup Job + +```typescript +// Background job to clean orphaned records +class OrphanCleanupJob { + async run(): Promise { + const result: CleanupResult = { + messageParts: 0, + messages: 0, + diffs: 0, + sessions: 0, + } + + // Find and delete orphaned message_parts + const orphanedParts = await this.db.query` + DELETE mp FROM message_parts mp + LEFT JOIN messages m ON mp.message_id = m.id + WHERE m.id IS NULL + ` + result.messageParts = orphanedParts.affectedRows + + // Find and delete orphaned messages + const orphanedMessages = await this.db.query` + DELETE m FROM messages m + LEFT JOIN sessions s ON m.session_id = s.id + WHERE s.id IS NULL + ` + result.messages = orphanedMessages.affectedRows + + // Find and delete orphaned session_diffs + const orphanedDiffs = await this.db.query` + DELETE sd FROM session_diffs sd + LEFT JOIN sessions s ON sd.session_id = s.id + WHERE s.id IS NULL + ` + result.diffs = orphanedDiffs.affectedRows + + // Find and delete orphaned sessions (no project) + const orphanedSessions = await this.db.query` + DELETE s FROM sessions s + LEFT JOIN projects p ON s.project_id = p.id + WHERE p.id IS NULL + ` + result.sessions = orphanedSessions.affectedRows + + return result + } +} + +// Schedule: run every hour +schedule.every("1 hour", () => orphanCleanupJob.run()) +``` + +## Query Patterns + +### Efficient Pagination with BIGINT + +```typescript +// Cursor-based pagination (efficient with BIGINT) +async function listSessions( + userId: bigint, + cursor?: bigint, + limit: number = 50 +): Promise> { + // Snowflake IDs are time-ordered, so we can use them directly + const sessions = await db.query` + SELECT * FROM sessions + WHERE user_id = ${userId} + ${cursor ? sql`AND id < ${cursor}` : sql``} + ORDER BY id DESC + LIMIT ${limit + 1} + `.all() + + const hasMore = sessions.length > limit + if (hasMore) sessions.pop() + + return { + data: sessions, + pagination: { + cursor: hasMore ? sessions[sessions.length - 1].id.toString() : undefined, + hasMore, + }, + } +} +``` + +### Batch Loading with IN Clause + +```typescript +// Efficient batch loading +async function getMessagesWithParts(sessionId: bigint): Promise { + // Load messages + const messages = await db.query` + SELECT * FROM messages + WHERE session_id = ${sessionId} + ORDER BY created_at ASC + `.all() + + if (messages.length === 0) return [] + + // Batch load all parts + const messageIds = messages.map(m => m.id) + const parts = await db.query` + SELECT * FROM message_parts + WHERE message_id IN (${messageIds}) + ORDER BY message_id, sort_order + `.all() + + // Group parts by message + const partsByMessage = new Map() + for (const part of parts) { + const list = partsByMessage.get(part.message_id) || [] + list.push(part) + partsByMessage.set(part.message_id, list) + } + + // Combine + return messages.map(msg => ({ + ...msg, + parts: partsByMessage.get(msg.id) || [], + })) +} +``` + +### Multi-Tenant Queries + +```typescript +// All queries scoped to organization +class TenantScopedRepository { + constructor( + protected db: Database, + protected ctx: TenantContext + ) {} + + // Helper to add org scope through joins + protected async withOrgScope( + table: string, + id: bigint + ): Promise { + // Different paths to org based on table + const scopeQueries: Record = { + sessions: ` + SELECT 1 FROM sessions s + JOIN projects p ON s.project_id = p.id + JOIN workspaces w ON p.workspace_id = w.id + WHERE s.id = ? AND w.org_id = ? + `, + messages: ` + SELECT 1 FROM messages m + JOIN sessions s ON m.session_id = s.id + JOIN projects p ON s.project_id = p.id + JOIN workspaces w ON p.workspace_id = w.id + WHERE m.id = ? AND w.org_id = ? + `, + projects: ` + SELECT 1 FROM projects p + JOIN workspaces w ON p.workspace_id = w.id + WHERE p.id = ? AND w.org_id = ? + `, + workspaces: ` + SELECT 1 FROM workspaces WHERE id = ? AND org_id = ? + `, + } + + const query = scopeQueries[table] + if (!query) { + throw new Error(`Unknown table: ${table}`) + } + + const result = await this.db.execute(query, [id, this.ctx.orgId]) + return result.length > 0 + } +} +``` + +## Index Optimization + +### Covering Indexes + +```sql +-- Covering index for common query patterns +-- Sessions by user with status filter +CREATE INDEX idx_sessions_user_status_created +ON sessions (user_id, status, created_at DESC, id, title, model_provider, model_id); + +-- Messages with metadata for listing +CREATE INDEX idx_messages_session_created +ON messages (session_id, created_at, id, role); +``` + +### JSON Indexing + +```sql +-- Virtual columns for JSON fields (MySQL 5.7+) +ALTER TABLE sessions +ADD COLUMN summary_files INT +GENERATED ALWAYS AS (JSON_EXTRACT(summary, '$.files')) VIRTUAL; + +CREATE INDEX idx_sessions_summary_files ON sessions (summary_files); + +-- Or use JSON_VALUE in MySQL 8.0+ +CREATE INDEX idx_sessions_plan +ON organizations ((CAST(JSON_VALUE(settings, '$.plan') AS CHAR(50)))); +``` + +### Composite Index Strategy + +```sql +-- Order matters: equality → range → sort +-- Good: WHERE user_id = ? AND status = ? ORDER BY created_at DESC +CREATE INDEX idx_sessions_user_status_created +ON sessions (user_id, status, created_at DESC); + +-- For time-range queries with org scope +CREATE INDEX idx_usage_org_created +ON usage_records (org_id, created_at); + +-- For prefix searches on API keys +CREATE INDEX idx_api_keys_prefix +ON api_keys (prefix(8)); +``` + +## Connection Management + +### Connection Pool Configuration + +```typescript +import mysql from "mysql2/promise" + +const pool = mysql.createPool({ + host: process.env.MYSQL_HOST, + port: parseInt(process.env.MYSQL_PORT || "3306"), + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE, + + // Pool settings + connectionLimit: 20, + queueLimit: 0, + waitForConnections: true, + + // Timeouts + connectTimeout: 10000, + acquireTimeout: 10000, + + // Keep-alive + enableKeepAlive: true, + keepAliveInitialDelay: 30000, + + // Character set + charset: "utf8mb4", + + // Timezone + timezone: "+00:00", + + // Named placeholders + namedPlaceholders: true, +}) + +// Health check +async function checkHealth(): Promise { + try { + const conn = await pool.getConnection() + await conn.ping() + conn.release() + return true + } catch { + return false + } +} +``` + +### Read/Write Splitting + +```typescript +interface DatabaseConfig { + writer: mysql.PoolOptions + readers: mysql.PoolOptions[] +} + +class ReadWritePool { + private writer: mysql.Pool + private readers: mysql.Pool[] + private readerIndex = 0 + + constructor(config: DatabaseConfig) { + this.writer = mysql.createPool(config.writer) + this.readers = config.readers.map(r => mysql.createPool(r)) + } + + // Get writer for INSERT/UPDATE/DELETE + getWriter(): mysql.Pool { + return this.writer + } + + // Round-robin reader selection + getReader(): mysql.Pool { + if (this.readers.length === 0) { + return this.writer + } + const reader = this.readers[this.readerIndex] + this.readerIndex = (this.readerIndex + 1) % this.readers.length + return reader + } + + // Smart routing based on query + async query(sql: string, params?: unknown[]): Promise { + const isWrite = /^\s*(INSERT|UPDATE|DELETE|REPLACE)/i.test(sql) + const pool = isWrite ? this.getWriter() : this.getReader() + const [rows] = await pool.execute(sql, params) + return rows as T[] + } +} +``` + +## Sharding Strategy + +### Shard Key Selection + +```typescript +// Shard by organization for tenant isolation +interface ShardConfig { + shardKey: "org_id" + shardCount: 16 + shardMap: Map // shard_id → connection +} + +function getShardId(orgId: bigint, shardCount: number): number { + // Consistent hashing + return Number(orgId % BigInt(shardCount)) +} + +class ShardedDatabase { + private shards: Map + + constructor(config: ShardConfig) { + this.shards = new Map() + for (const [shardId, dbConfig] of config.shardMap) { + this.shards.set(shardId, new ReadWritePool(dbConfig)) + } + } + + getPool(orgId: bigint): ReadWritePool { + const shardId = getShardId(orgId, this.shards.size) + const pool = this.shards.get(shardId) + if (!pool) { + throw new Error(`Shard ${shardId} not configured`) + } + return pool + } + + // Cross-shard query (fan-out) + async queryAll(sql: string, params?: unknown[]): Promise { + const results = await Promise.all( + Array.from(this.shards.values()).map(pool => + pool.query(sql, params) + ) + ) + return results.flat() + } +} +``` + +### Schema Per Shard + +```sql +-- Each shard has identical schema +-- Shard 0: opencode_shard_0 +-- Shard 1: opencode_shard_1 +-- ... + +-- Global tables (not sharded) in separate database +-- opencode_global: organizations, users, api_keys +``` + +## Migration from UUID + +### Migration Script + +```typescript +// Add bigint columns alongside UUID +async function migrationStep1(): Promise { + await db.query` + ALTER TABLE sessions + ADD COLUMN id_new BIGINT NULL AFTER id, + ADD COLUMN project_id_new BIGINT NULL AFTER project_id, + ADD COLUMN user_id_new BIGINT NULL AFTER user_id + ` +} + +// Populate bigint columns +async function migrationStep2(): Promise { + // Generate mapping: UUID → BIGINT + const idGen = new SnowflakeGenerator(0) + + // Process in batches + let cursor: string | null = null + while (true) { + const sessions = await db.query` + SELECT id, project_id, user_id FROM sessions + WHERE id_new IS NULL + ${cursor ? sql`AND id > ${cursor}` : sql``} + ORDER BY id + LIMIT 1000 + `.all() + + if (sessions.length === 0) break + + for (const session of sessions) { + const newId = idGen.nextId() + await db.query` + UPDATE sessions SET id_new = ${newId} + WHERE id = ${session.id} + ` + } + + cursor = sessions[sessions.length - 1].id + } +} + +// Swap columns +async function migrationStep3(): Promise { + await db.query` + ALTER TABLE sessions + DROP COLUMN id, + CHANGE COLUMN id_new id BIGINT NOT NULL, + ADD PRIMARY KEY (id) + ` +} +``` + +## Performance Considerations + +### Batch Inserts + +```typescript +// Bulk insert for message parts +async function insertParts(parts: MessagePart[]): Promise { + if (parts.length === 0) return + + const values = parts.map(p => [ + p.id, + p.message_id, + p.type, + JSON.stringify(p.content), + p.sort_order, + ]) + + await db.query` + INSERT INTO message_parts (id, message_id, type, content, sort_order) + VALUES ${values} + ` +} +``` + +### Query Optimization Tips + +```sql +-- Use STRAIGHT_JOIN to force join order when optimizer chooses poorly +SELECT STRAIGHT_JOIN s.* +FROM sessions s +JOIN projects p ON s.project_id = p.id +JOIN workspaces w ON p.workspace_id = w.id +WHERE w.org_id = ?; + +-- Use index hints if needed +SELECT * FROM sessions USE INDEX (idx_user_status_created) +WHERE user_id = ? AND status = 'active' +ORDER BY created_at DESC; + +-- Avoid SELECT * in production +SELECT id, title, status, created_at FROM sessions WHERE user_id = ?; +``` + +### Monitoring Queries + +```sql +-- Find slow queries +SELECT * FROM performance_schema.events_statements_summary_by_digest +ORDER BY SUM_TIMER_WAIT DESC +LIMIT 10; + +-- Check index usage +SELECT * FROM sys.schema_unused_indexes; + +-- Table sizes +SELECT + table_name, + ROUND(data_length / 1024 / 1024, 2) AS data_mb, + ROUND(index_length / 1024 / 1024, 2) AS index_mb +FROM information_schema.tables +WHERE table_schema = 'opencode' +ORDER BY data_length DESC; +``` diff --git a/docs/design/server-side-deployment/storage.md b/docs/design/server-side-deployment/storage.md new file mode 100644 index 00000000000..e1631b18d4f --- /dev/null +++ b/docs/design/server-side-deployment/storage.md @@ -0,0 +1,740 @@ +# Storage & Data Persistence + +## Overview + +The server-side deployment replaces the file-based storage system with a distributed storage architecture optimized for multi-tenancy, scalability, and reliability. + +## Storage Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Application Layer │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + ┌────────▼────────┐ ┌────▼────┐ ┌────────▼────────┐ + │ PostgreSQL │ │ Redis │ │ Object Store │ + │ (Primary DB) │ │ (Cache) │ │ (Files/Blobs) │ + └─────────────────┘ └─────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Replicas │ + │ (Read scaling) │ + └─────────────────┘ +``` + +## PostgreSQL Schema + +### Core Tables + +```sql +-- Organizations (tenants) +CREATE TABLE organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + plan VARCHAR(50) NOT NULL DEFAULT 'free', + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Users +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + name VARCHAR(255), + avatar_url VARCHAR(500), + role VARCHAR(50) NOT NULL DEFAULT 'member', + preferences JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_active_at TIMESTAMPTZ, + UNIQUE(org_id, email) +); + +-- Workspaces +CREATE TABLE workspaces ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + git_config JSONB, + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Projects +CREATE TABLE projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + path VARCHAR(1000) NOT NULL, + git_commit VARCHAR(40), + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Sessions +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id), + parent_id UUID REFERENCES sessions(id), + title VARCHAR(500) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'active', + model_provider VARCHAR(100) NOT NULL, + model_id VARCHAR(100) NOT NULL, + summary JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ +); + +-- Messages +CREATE TABLE messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +-- Message Parts (text, tools, files, etc.) +CREATE TABLE message_parts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + content JSONB NOT NULL, + sort_order INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Session Diffs (code changes) +CREATE TABLE session_diffs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + message_id UUID NOT NULL REFERENCES messages(id), + file_path VARCHAR(1000) NOT NULL, + diff_content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +### Authentication Tables + +```sql +-- API Keys +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + prefix VARCHAR(20) NOT NULL, + hash VARCHAR(255) NOT NULL, + scopes VARCHAR(50)[] NOT NULL DEFAULT '{}', + rate_limit JSONB, + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Refresh Tokens +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + family_id UUID NOT NULL, + hash VARCHAR(255) NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- User Sessions (login sessions) +CREATE TABLE user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_family UUID NOT NULL, + device VARCHAR(255), + ip INET, + location VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +-- OAuth Connections +CREATE TABLE oauth_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider VARCHAR(50) NOT NULL, + provider_user_id VARCHAR(255) NOT NULL, + access_token_encrypted TEXT NOT NULL, + refresh_token_encrypted TEXT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(provider, provider_user_id) +); +``` + +### Provider & Usage Tables + +```sql +-- User Provider Keys (BYOK) +CREATE TABLE user_provider_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider_id VARCHAR(100) NOT NULL, + encrypted_key TEXT NOT NULL, + key_id VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + UNIQUE(user_id, provider_id) +); + +-- Organization Provider Config +CREATE TABLE org_provider_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + provider_id VARCHAR(100) NOT NULL, + encrypted_key TEXT NOT NULL, + key_id VARCHAR(255) NOT NULL, + rate_limit JSONB, + allow_user_override BOOLEAN NOT NULL DEFAULT TRUE, + usage_tracking BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(org_id, provider_id) +); + +-- Usage Tracking +CREATE TABLE usage_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id), + user_id UUID NOT NULL REFERENCES users(id), + session_id UUID REFERENCES sessions(id), + provider_id VARCHAR(100) NOT NULL, + model_id VARCHAR(100) NOT NULL, + tokens_input INTEGER NOT NULL, + tokens_output INTEGER NOT NULL, + tokens_cache_read INTEGER DEFAULT 0, + tokens_cache_write INTEGER DEFAULT 0, + cost_cents INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Audit Logs +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL, + user_id UUID NOT NULL, + action VARCHAR(100) NOT NULL, + resource VARCHAR(100) NOT NULL, + resource_id UUID, + metadata JSONB NOT NULL DEFAULT '{}', + ip INET, + user_agent TEXT, + status VARCHAR(50) NOT NULL, + error_code VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +### Indexes + +```sql +-- Performance indexes +CREATE INDEX idx_sessions_project_id ON sessions(project_id); +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_created_at ON sessions(created_at DESC); +CREATE INDEX idx_messages_session_id ON messages(session_id); +CREATE INDEX idx_message_parts_message_id ON message_parts(message_id); +CREATE INDEX idx_session_diffs_session_id ON session_diffs(session_id); + +-- Multi-tenant indexes +CREATE INDEX idx_users_org_id ON users(org_id); +CREATE INDEX idx_workspaces_org_id ON workspaces(org_id); +CREATE INDEX idx_api_keys_prefix ON api_keys(prefix); + +-- Usage and audit indexes +CREATE INDEX idx_usage_records_org_id_created ON usage_records(org_id, created_at DESC); +CREATE INDEX idx_usage_records_user_id_created ON usage_records(user_id, created_at DESC); +CREATE INDEX idx_audit_logs_org_id_created ON audit_logs(org_id, created_at DESC); +CREATE INDEX idx_audit_logs_user_id_created ON audit_logs(user_id, created_at DESC); + +-- Full-text search +CREATE INDEX idx_sessions_title_fts ON sessions USING gin(to_tsvector('english', title)); +``` + +## Redis Data Structures + +### Caching Strategy + +```typescript +interface CacheConfig { + // Session metadata cache + session: { + key: (id: string) => `session:${id}`, + ttl: 3600, // 1 hour + }, + // User preferences cache + user: { + key: (id: string) => `user:${id}`, + ttl: 1800, // 30 minutes + }, + // Provider config cache + provider: { + key: (orgId: string, providerId: string) => `provider:${orgId}:${providerId}`, + ttl: 300, // 5 minutes + }, + // Rate limit counters + rateLimit: { + key: (id: string, window: string) => `rl:${id}:${window}`, + ttl: 60, // 1 minute + }, +} +``` + +### Real-time Data + +```typescript +// Active session tracking +interface ActiveSession { + key: `active:session:${sessionId}`, + value: { + userId: string + status: "idle" | "processing" | "streaming" + lastActivity: number + currentMessageId?: string + }, + ttl: 3600 +} + +// SSE connection tracking +interface SSEConnection { + key: `sse:user:${userId}`, + value: Set, + ttl: 86400 +} + +// Pub/Sub channels +const channels = { + session: (id: string) => `events:session:${id}`, + user: (id: string) => `events:user:${id}`, + workspace: (id: string) => `events:workspace:${id}`, +} +``` + +### Job Queue + +```typescript +// Background job queues using Redis Streams +interface JobQueue { + // Session compaction jobs + compaction: { + stream: "jobs:compaction", + group: "compaction-workers", + }, + // Usage aggregation + usage: { + stream: "jobs:usage", + group: "usage-workers", + }, + // Cleanup expired sessions + cleanup: { + stream: "jobs:cleanup", + group: "cleanup-workers", + }, +} +``` + +## Object Storage + +### File Organization + +``` +bucket/ +├── workspaces/ +│ └── {workspaceId}/ +│ └── {projectId}/ +│ ├── files/ # Project files +│ │ └── {hash} +│ └── snapshots/ # Git snapshots +│ └── {snapshotId} +├── sessions/ +│ └── {sessionId}/ +│ ├── attachments/ # User uploads +│ │ └── {attachmentId} +│ └── artifacts/ # Generated files +│ └── {artifactId} +├── exports/ +│ └── {exportId}/ # Session exports +│ └── export.zip +└── avatars/ + └── {userId}/ + └── avatar.{ext} +``` + +### Storage Operations + +```typescript +interface ObjectStorage { + // Upload file + upload(key: string, content: Buffer | Stream, options?: UploadOptions): Promise + + // Download file + download(key: string): Promise + + // Get signed URL for client-side download + getSignedUrl(key: string, expiresIn: number): Promise + + // Delete file + delete(key: string): Promise + + // List files by prefix + list(prefix: string): Promise +} + +interface UploadOptions { + contentType?: string + metadata?: Record + acl?: "private" | "public-read" +} +``` + +### Content-Addressable Storage + +```typescript +// Store files by content hash for deduplication +async function storeFile( + workspaceId: string, + projectId: string, + content: Buffer +): Promise { + const hash = crypto.createHash("sha256").update(content).digest("hex") + const key = `workspaces/${workspaceId}/${projectId}/files/${hash}` + + // Check if already exists + const exists = await storage.exists(key) + if (!exists) { + await storage.upload(key, content) + } + + return hash +} +``` + +## Data Access Layer + +### Repository Pattern + +```typescript +// Base repository with tenant scoping +abstract class BaseRepository { + constructor( + protected db: Database, + protected ctx: TenantContext + ) {} + + protected get orgId() { + return this.ctx.orgId + } + + protected get userId() { + return this.ctx.userId + } +} + +// Session repository +class SessionRepository extends BaseRepository { + async findById(id: string): Promise { + return this.db.query` + SELECT s.* + FROM sessions s + JOIN projects p ON s.project_id = p.id + JOIN workspaces w ON p.workspace_id = w.id + WHERE s.id = ${id} + AND w.org_id = ${this.orgId} + `.first() + } + + async create(input: CreateSessionInput): Promise { + return this.db.query` + INSERT INTO sessions ( + project_id, user_id, title, model_provider, model_id + ) VALUES ( + ${input.projectId}, + ${this.userId}, + ${input.title}, + ${input.modelProvider}, + ${input.modelId} + ) + RETURNING * + `.first() + } + + async listByUser(options: ListOptions): Promise { + return this.db.query` + SELECT s.* + FROM sessions s + JOIN projects p ON s.project_id = p.id + JOIN workspaces w ON p.workspace_id = w.id + WHERE w.org_id = ${this.orgId} + AND s.user_id = ${this.userId} + ORDER BY s.created_at DESC + LIMIT ${options.limit} + OFFSET ${options.offset} + `.all() + } +} +``` + +### Caching Layer + +```typescript +// Cache-aside pattern +class CachedSessionRepository { + constructor( + private repo: SessionRepository, + private cache: Redis, + private ctx: TenantContext + ) {} + + async findById(id: string): Promise { + const cacheKey = `session:${id}` + + // Try cache first + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + // Fetch from database + const session = await this.repo.findById(id) + if (session) { + await this.cache.set(cacheKey, session, { ex: 3600 }) + } + + return session + } + + async update(id: string, input: UpdateSessionInput): Promise { + const session = await this.repo.update(id, input) + + // Invalidate cache + await this.cache.del(`session:${id}`) + + // Publish update event + await this.cache.publish(`events:session:${id}`, { + type: "session.updated", + session, + }) + + return session + } +} +``` + +## Migration Strategy + +### From File-Based to Database + +```typescript +// Migration script for existing data +async function migrateFromFiles( + sourceDir: string, + targetDb: Database +): Promise { + const result: MigrationResult = { + sessions: 0, + messages: 0, + parts: 0, + errors: [], + } + + // Read existing sessions + const sessionFiles = await glob(`${sourceDir}/session/**/*.json`) + + for (const file of sessionFiles) { + try { + const data = JSON.parse(await fs.readFile(file, "utf-8")) + + // Map to new schema + const session = mapLegacySession(data) + await targetDb.sessions.create(session) + result.sessions++ + + // Migrate messages + const messageFiles = await glob(`${sourceDir}/message/${data.id}/*.json`) + for (const msgFile of messageFiles) { + const msgData = JSON.parse(await fs.readFile(msgFile, "utf-8")) + const message = mapLegacyMessage(msgData) + await targetDb.messages.create(message) + result.messages++ + + // Migrate parts + const partFiles = await glob(`${sourceDir}/part/${msgData.id}/*.json`) + for (const partFile of partFiles) { + const partData = JSON.parse(await fs.readFile(partFile, "utf-8")) + const part = mapLegacyPart(partData) + await targetDb.messageParts.create(part) + result.parts++ + } + } + } catch (error) { + result.errors.push({ file, error: error.message }) + } + } + + return result +} +``` + +## Backup & Recovery + +### Backup Strategy + +```typescript +interface BackupConfig { + // PostgreSQL backups + database: { + schedule: "0 */6 * * *", // Every 6 hours + retention: 30, // Days + method: "pg_dump" | "wal", + }, + // Object storage + objects: { + versioning: true, + retention: 90, // Days + replication: "cross-region", + }, +} +``` + +### Point-in-Time Recovery + +```sql +-- Enable WAL archiving for PITR +ALTER SYSTEM SET archive_mode = on; +ALTER SYSTEM SET archive_command = 'aws s3 cp %p s3://backups/wal/%f'; +ALTER SYSTEM SET wal_level = replica; +``` + +## Data Retention + +### Retention Policies + +```typescript +interface RetentionPolicy { + // Session data + sessions: { + active: "indefinite", + archived: 365, // Days + deleted: 30, // Soft delete grace period + }, + // Usage records + usage: { + detailed: 90, // Days + aggregated: 730, // 2 years + }, + // Audit logs + audit: { + security: 730, // 2 years + general: 90, // Days + }, +} +``` + +### Cleanup Jobs + +```typescript +// Scheduled cleanup job +async function cleanupExpiredData(): Promise { + const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + + // Delete soft-deleted sessions + await db.query` + DELETE FROM sessions + WHERE status = 'deleted' + AND updated_at < ${cutoff} + ` + + // Archive old usage records + await db.query` + INSERT INTO usage_records_archive + SELECT * FROM usage_records + WHERE created_at < ${cutoff} + ` + + await db.query` + DELETE FROM usage_records + WHERE created_at < ${cutoff} + ` + + // Clean up orphaned object storage + await cleanupOrphanedObjects() +} +``` + +## Performance Optimization + +### Query Optimization + +```typescript +// Efficient message loading with pagination +async function loadMessages( + sessionId: string, + cursor?: string, + limit: number = 50 +): Promise<{ messages: MessageWithParts[]; nextCursor?: string }> { + const messages = await db.query` + SELECT m.*, + json_agg( + json_build_object( + 'id', mp.id, + 'type', mp.type, + 'content', mp.content, + 'order', mp.sort_order + ) ORDER BY mp.sort_order + ) as parts + FROM messages m + LEFT JOIN message_parts mp ON mp.message_id = m.id + WHERE m.session_id = ${sessionId} + ${cursor ? sql`AND m.id < ${cursor}` : sql``} + GROUP BY m.id + ORDER BY m.created_at DESC + LIMIT ${limit + 1} + `.all() + + const hasMore = messages.length > limit + if (hasMore) messages.pop() + + return { + messages, + nextCursor: hasMore ? messages[messages.length - 1].id : undefined, + } +} +``` + +### Connection Pooling + +```typescript +// PostgreSQL connection pool config +const poolConfig = { + min: 5, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + // Read replicas for queries + replicas: [ + { host: "replica-1.db.internal", port: 5432 }, + { host: "replica-2.db.internal", port: 5432 }, + ], +} +``` diff --git a/docs/go-server-rewrite-evaluation.md b/docs/go-server-rewrite-evaluation.md new file mode 100644 index 00000000000..261faca4e58 --- /dev/null +++ b/docs/go-server-rewrite-evaluation.md @@ -0,0 +1,481 @@ +# OpenCode Server Go Rewrite: Feasibility Evaluation & Implementation Plan + +## Executive Summary + +**Feasibility: HIGH** - The rewrite is technically feasible and strategically sound. + +The OpenCode server can be rewritten in Go while maintaining full compatibility with the existing TUI client. The protocol (REST + SSE) is well-documented and standard. Existing Go code in the repository (go-memsh, OpenCode SDK) demonstrates the team's Go proficiency and provides reusable patterns. + +**Estimated Effort: 8-12 weeks** for a production-ready implementation with a small team (2-3 developers). + +--- + +## 1. Current Architecture Analysis + +### Server Stack (TypeScript/Bun) +| Component | Technology | Lines of Code | Complexity | +|-----------|------------|---------------|------------| +| HTTP Server | Hono.js + Bun.serve | ~2,000 | Medium | +| LLM Integration | Vercel AI SDK | ~1,500 | High | +| Tool System | Custom + Zod | ~3,500 | High | +| Session Management | Custom | ~2,000 | Medium | +| Storage | File-based JSON | ~500 | Low | +| Permission System | Custom | ~400 | Medium | +| LSP Integration | Custom | ~600 | Medium | +| MCP Support | @modelcontextprotocol/sdk | ~300 | Medium | +| **Total** | | **~10,800** | | + +### Protocol (TUI ↔ Server) +- **Transport**: HTTP REST + Server-Sent Events (SSE) +- **Format**: JSON +- **Endpoints**: 60+ REST endpoints +- **Streaming**: SSE for real-time events (`/event`, `/global/event`) +- **Authentication**: API key via headers + +### Existing Go Assets +1. **go-memsh**: Complete shell interpreter with HTTP API + WebSocket JSON-RPC +2. **OpenCode SDK for Go**: Comprehensive client SDK (~89KB session.go) +3. Both demonstrate Go patterns for similar problems + +--- + +## 2. Feasibility Assessment + +### ✅ Strong Feasibility Factors + +| Factor | Assessment | +|--------|------------| +| **Protocol Stability** | REST + SSE is standard; Go has excellent HTTP/SSE support | +| **Existing Go Code** | go-memsh and SDK provide patterns and reusable code | +| **LLM SDK Availability** | Go SDKs exist for all major providers (Anthropic, OpenAI, Google, etc.) | +| **Tool System** | Straightforward to port; Go has good process management | +| **Storage Layer** | Simple file-based JSON; trivial in Go | +| **Team Experience** | Codebase shows strong Go proficiency | + +### ⚠️ Challenges to Address + +| Challenge | Mitigation | +|-----------|------------| +| **Vercel AI SDK abstraction** | Build thin provider abstraction; each provider has native Go SDK | +| **Zod → Go validation** | Use go-playground/validator or custom validation | +| **TypeScript type inference** | Define explicit Go structs (more verbose but clearer) | +| **Hot module loading** | Use Go plugins or compile-time registration | +| **Tree-sitter bash parsing** | Use go-tree-sitter bindings or shell parser libraries | + +### 🚫 Non-Issues + +| Concern | Why It's Not a Problem | +|---------|------------------------| +| Protocol changes | None required; TUI client unchanged | +| Performance | Go typically faster than Bun for I/O-heavy workloads | +| Concurrency | Go's goroutines ideal for streaming + tool execution | +| Deployment | Single binary simplifies distribution | + +--- + +## 3. Benefits of Go Rewrite + +### Performance & Resource Efficiency +- **Lower memory footprint**: Go typically uses 3-5x less memory than Node.js/Bun +- **Faster startup**: Single binary, no runtime initialization +- **Better concurrency**: Native goroutines vs JavaScript event loop +- **Predictable latency**: No GC pauses like V8 + +### Operational Benefits +- **Single binary deployment**: No npm install, no node_modules +- **Cross-compilation**: Easy builds for all platforms +- **Static typing**: Catch errors at compile time +- **Smaller container images**: ~20MB vs ~200MB+ for Node.js + +### Developer Experience +- **Simpler debugging**: Standard tooling, no transpilation +- **Better IDE support**: gopls is excellent +- **Consistent formatting**: gofmt eliminates style debates + +--- + +## 4. Phased Implementation Plan + +### Phase 1: Foundation (Weeks 1-2) + +#### 1.1 Project Structure & Core Types +``` +opencode-server/ +├── cmd/ +│ └── opencode-server/ +│ └── main.go +├── internal/ +│ ├── server/ # HTTP server + routes +│ ├── session/ # Session management +│ ├── message/ # Message types + storage +│ ├── provider/ # LLM provider abstraction +│ ├── tool/ # Tool system +│ ├── permission/ # Permission checking +│ ├── storage/ # File-based storage +│ ├── config/ # Configuration loading +│ └── event/ # Event bus + SSE +├── pkg/ +│ └── types/ # Shared types (exported) +├── go.mod +└── go.sum +``` + +#### 1.2 Core Types (Port from TypeScript) +- [ ] Message types (User, Assistant, Parts) +- [ ] Session types +- [ ] Config schema +- [ ] Provider/Model types +- [ ] Tool definition types +- [ ] Permission types + +#### 1.3 Storage Layer +- [ ] File-based JSON storage (matching existing format) +- [ ] Session CRUD operations +- [ ] Message CRUD operations +- [ ] Part management +- [ ] File locking for concurrent access + +#### 1.4 Event System +- [ ] In-memory event bus +- [ ] SSE streaming implementation +- [ ] Event types matching TypeScript + +**Deliverable**: Core types, storage layer, event bus - all unit tested + +--- + +### Phase 2: HTTP Server & Basic Endpoints (Weeks 3-4) + +#### 2.1 HTTP Server Setup +- [ ] Chi or Gin router setup +- [ ] Middleware (CORS, logging, error handling) +- [ ] OpenAPI documentation (go-swagger or oapi-codegen) + +#### 2.2 Session Endpoints +```go +// Must match existing API exactly +GET /session // List sessions +POST /session // Create session +GET /session/:id // Get session +PATCH /session/:id // Update session +DELETE /session/:id // Delete session +POST /session/:id/abort // Abort session +POST /session/:id/fork // Fork session +POST /session/:id/revert // Revert message +``` + +#### 2.3 File Endpoints +```go +GET /file // List directory +GET /file/content // Read file +GET /file/status // Git status +GET /find // Grep search +GET /find/file // File search +``` + +#### 2.4 Config Endpoints +```go +GET /config // Get config +GET /config/providers // List providers +GET /provider // List available providers +GET /path // Get paths +``` + +#### 2.5 Event Streaming +```go +GET /event // Session SSE stream +GET /global/event // Global SSE stream +``` + +**Deliverable**: Working HTTP server with session/file/config endpoints + +--- + +### Phase 3: LLM Provider Integration (Weeks 5-6) + +#### 3.1 Provider Abstraction Layer +```go +type Provider interface { + ID() string + Name() string + Models() []Model + CreateCompletion(ctx context.Context, req CompletionRequest) (*CompletionStream, error) +} + +type CompletionStream interface { + Next() (StreamEvent, error) + Close() error +} +``` + +#### 3.2 Provider Implementations +Priority order based on usage: +- [ ] **Anthropic** (anthropic-go SDK) +- [ ] **OpenAI** (openai-go SDK) +- [ ] **Google Gemini** (google.golang.org/genai) +- [ ] **OpenRouter** (OpenAI-compatible) +- [ ] **Azure OpenAI** (azure-sdk-for-go) +- [ ] **Amazon Bedrock** (aws-sdk-go-v2) + +#### 3.3 Streaming Implementation +- [ ] Delta text streaming +- [ ] Tool call streaming +- [ ] Reasoning/thinking streaming +- [ ] Token counting + cost calculation +- [ ] Error handling + retries + +#### 3.4 Provider-Specific Transformations +- [ ] Message format normalization per provider +- [ ] Cache control headers (Anthropic) +- [ ] Temperature defaults per model +- [ ] Provider options mapping + +**Deliverable**: Working LLM completions with streaming for top 3 providers + +--- + +### Phase 4: Tool System (Weeks 7-8) + +#### 4.1 Tool Framework +```go +type Tool interface { + ID() string + Description() string + Parameters() json.RawMessage // JSON Schema + Execute(ctx context.Context, args json.RawMessage, toolCtx ToolContext) (*ToolResult, error) +} + +type ToolContext struct { + SessionID string + MessageID string + Agent string + Abort context.Context + Metadata func(title string, meta map[string]any) +} +``` + +#### 4.2 Core Tool Implementations +Priority order: +- [ ] **read** - File reading with line numbers +- [ ] **write** - File creation/overwriting +- [ ] **edit** - String replacement with fuzzy matching +- [ ] **bash** - Shell command execution +- [ ] **glob** - File pattern matching (via ripgrep) +- [ ] **grep** - Content search (via ripgrep) +- [ ] **list** - Directory listing +- [ ] **webfetch** - HTTP fetching +- [ ] **todowrite/todoread** - Task management + +#### 4.3 Edit Tool - Fuzzy Matching +- [ ] Exact string matching +- [ ] Levenshtein distance fallback +- [ ] Block anchor strategy +- [ ] Line normalization (CRLF/LF) + +#### 4.4 Bash Tool - Process Management +- [ ] Shell detection (bash/zsh) +- [ ] Process group management +- [ ] Timeout handling +- [ ] Output streaming + truncation +- [ ] Graceful termination (SIGTERM → SIGKILL) + +#### 4.5 Tool Registration +- [ ] Built-in tool registry +- [ ] Dynamic tool loading (Go plugins or config) +- [ ] Per-agent tool filtering + +**Deliverable**: All core tools working with proper validation + +--- + +### Phase 5: Permission & Security (Week 9) + +#### 5.1 Permission System +```go +type PermissionChecker interface { + Check(ctx context.Context, req PermissionRequest) (PermissionResult, error) + Approve(sessionID string, permType string, always bool) + Reject(sessionID string, permType string) +} +``` + +#### 5.2 Permission Types +- [ ] Edit permission (file modifications) +- [ ] Bash permission (command execution) +- [ ] WebFetch permission (external requests) +- [ ] External directory permission +- [ ] Doom loop detection + +#### 5.3 Bash Command Analysis +- [ ] Command parsing (go-shellwords or custom) +- [ ] Dangerous command detection +- [ ] Path extraction + validation +- [ ] Wildcard pattern matching + +#### 5.4 Directory Isolation +- [ ] Working directory scoping +- [ ] External path detection +- [ ] `.env` file blocking + +**Deliverable**: Full permission system with bash analysis + +--- + +### Phase 6: Session Processing Loop (Week 10) + +#### 6.1 Message Processing +- [ ] User message handling +- [ ] Assistant message creation +- [ ] Part management (text, reasoning, tool) +- [ ] Message history loading + +#### 6.2 Agentic Loop +```go +func (s *SessionProcessor) Loop(ctx context.Context, sessionID string) error { + for { + // 1. Load message history + // 2. Build system prompt + // 3. Resolve available tools + // 4. Call LLM with streaming + // 5. Process stream events + // 6. Execute tool calls + // 7. Continue if tool calls, stop if done + } +} +``` + +#### 6.3 Stream Event Processing +- [ ] text-delta → TextPart updates +- [ ] reasoning-delta → ReasoningPart updates +- [ ] tool-call-start/delta/end → ToolPart state machine +- [ ] finish → Cost calculation + token tracking + +#### 6.4 Message Endpoint +```go +POST /session/:id/message // Stream completion +``` + +**Deliverable**: Full agentic loop working end-to-end + +--- + +### Phase 7: Advanced Features (Weeks 11-12) + +#### 7.1 LSP Integration +- [ ] LSP client implementation +- [ ] TypeScript server support +- [ ] Diagnostics on file save +- [ ] Hover information + +#### 7.2 MCP Support +- [ ] MCP client (HTTP/SSE transport) +- [ ] MCP tool registration +- [ ] Remote tool execution + +#### 7.3 Agent System +- [ ] Agent definitions +- [ ] Agent-specific permissions +- [ ] Subagent spawning (Task tool) +- [ ] Agent context inheritance + +#### 7.4 Additional Endpoints +- [ ] OAuth flow endpoints +- [ ] TUI command endpoints +- [ ] Client tool registration +- [ ] Instance management + +#### 7.5 Testing & Documentation +- [ ] Integration tests with TUI client +- [ ] API documentation +- [ ] Migration guide +- [ ] Performance benchmarks + +**Deliverable**: Feature-complete server ready for production + +--- + +## 5. Risk Assessment + +### High Risk +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| Provider SDK differences | Medium | High | Build abstraction early; test all providers | +| Edit tool fuzzy matching accuracy | Medium | High | Port algorithm carefully; comprehensive tests | + +### Medium Risk +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| LSP integration complexity | Medium | Medium | Start with TypeScript only; expand later | +| Permission edge cases | Low | Medium | Port all test cases from TypeScript | + +### Low Risk +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| Protocol incompatibility | Low | High | Test with TUI client continuously | +| Performance regression | Very Low | Medium | Benchmark critical paths | + +--- + +## 6. Resource Requirements + +### Team +- **2-3 Go developers** with experience in: + - HTTP services + - Streaming/SSE + - Process management + - LLM APIs + +### Infrastructure +- CI/CD pipeline for multi-platform builds +- Integration test environment with TUI client +- Access to all LLM provider APIs for testing + +### Timeline +| Milestone | Week | Deliverable | +|-----------|------|-------------| +| Foundation | 2 | Core types, storage, events | +| HTTP Server | 4 | Basic endpoints working | +| LLM Integration | 6 | Streaming completions | +| Tool System | 8 | Core tools implemented | +| Security | 9 | Permission system complete | +| Processing Loop | 10 | End-to-end flow working | +| Polish | 12 | Production-ready release | + +--- + +## 7. Recommendation + +**Proceed with the rewrite.** The benefits outweigh the costs: + +1. **Strategic alignment**: Go fits the infrastructure/CLI tooling domain +2. **Operational simplicity**: Single binary deployment +3. **Performance gains**: Lower memory, faster startup +4. **Team capability**: Existing Go code demonstrates proficiency +5. **Protocol stability**: No client changes required + +### Suggested Approach +1. **Parallel development**: Keep TypeScript server running while building Go +2. **Incremental migration**: Route traffic gradually to Go server +3. **Feature flags**: Allow switching between implementations +4. **Comprehensive testing**: Integration tests with TUI client at every phase + +--- + +## 8. Appendix: Key File Mappings + +| TypeScript Source | Go Target | Priority | +|-------------------|-----------|----------| +| `server/server.ts` | `internal/server/` | P0 | +| `session/index.ts` | `internal/session/` | P0 | +| `session/message-v2.ts` | `internal/message/` | P0 | +| `provider/provider.ts` | `internal/provider/` | P0 | +| `tool/*.ts` | `internal/tool/` | P0 | +| `storage/storage.ts` | `internal/storage/` | P0 | +| `permission/index.ts` | `internal/permission/` | P1 | +| `lsp/` | `internal/lsp/` | P2 | +| `mcp/` | `internal/mcp/` | P2 | + +--- + +*Document generated: 2025-11-26* +*Author: Claude (Opus 4)* diff --git a/docs/testing-infrastructure.md b/docs/testing-infrastructure.md new file mode 100644 index 00000000000..cb2bf9f30d9 --- /dev/null +++ b/docs/testing-infrastructure.md @@ -0,0 +1,535 @@ +# Testing Infrastructure & Strategies Analysis + +**Date:** November 2024 +**Status:** Comprehensive Analysis +**Scope:** OpenCode Repository Testing Infrastructure + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Testing Infrastructure Overview](#testing-infrastructure-overview) +3. [Test Categories by Package](#test-categories-by-package) +4. [Testing Frameworks & Libraries](#testing-frameworks--libraries) +5. [Testing Patterns & Strategies](#testing-patterns--strategies) +6. [CI/CD Pipeline](#cicd-pipeline) +7. [Mock Infrastructure](#mock-infrastructure) +8. [Critical Gap Analysis: Real Model Testing](#critical-gap-analysis-real-model-testing) +9. [Recommendations](#recommendations) + +--- + +## Executive Summary + +The OpenCode repository employs a **comprehensive but purely synthetic testing strategy**. The testing infrastructure spans multiple languages (TypeScript, Go, Python) with appropriate unit and integration tests. However, there is a **critical gap**: the codebase does **not test against real AI models** and does **not include agent performance evaluation**. + +### Key Findings + +| Aspect | Status | Notes | +|--------|--------|-------| +| Unit Testing | ✅ Present | Good coverage across all SDKs | +| Integration Testing | ✅ Present | Server startup and API validation | +| Mock-based Testing | ✅ Comprehensive | HTTP transport mocking, LSP fakes | +| Real Model Testing | ❌ **Absent** | No tests against live AI APIs | +| Agent Performance Evaluation | ❌ **Absent** | No benchmarks, evals, or quality metrics | +| End-to-End AI Workflow Tests | ❌ **Absent** | No complete agent task execution tests | +| CI/CD Pipeline | ✅ Present | GitHub Actions with Turbo orchestration | + +--- + +## Testing Infrastructure Overview + +### Repository Structure + +``` +opencode/ +├── packages/ +│ ├── opencode/test/ # Core TypeScript tests (22 files) +│ │ ├── config/ # Configuration tests +│ │ ├── file/ # File handling tests +│ │ ├── fixture/ # Test utilities +│ │ ├── ide/ # IDE integration tests +│ │ ├── lsp/ # LSP client tests +│ │ ├── patch/ # Patch system tests +│ │ ├── project/ # Project management tests +│ │ ├── provider/ # Provider transform tests +│ │ ├── session/ # Session management tests +│ │ ├── snapshot/ # Snapshot system tests +│ │ ├── tool/ # Tool execution tests +│ │ └── util/ # Utility function tests +│ ├── sdk/ +│ │ ├── go/ # Go SDK tests (18 files) +│ │ │ ├── *_test.go # SDK entity tests +│ │ │ └── internal/ # API utilities tests +│ │ └── python/tests/ # Python SDK tests (2 files) +│ │ ├── test_wrapper.py # Unit tests with mock transport +│ │ └── test_integration.py # Integration tests +└── go-memsh/ # In-memory shell tests (9 files) + └── *_test.go # Shell parsing and execution tests +``` + +### Test File Count by Package + +| Package | Test Files | Test Type | +|---------|------------|-----------| +| `packages/opencode/test` | 22 | TypeScript (Bun) | +| `packages/sdk/go` | 18 | Go (native) | +| `packages/sdk/python/tests` | 2 | Python (pytest) | +| `go-memsh` | 9 | Go (native) | +| **Total** | **51** | - | + +--- + +## Test Categories by Package + +### TypeScript/Bun Tests (`packages/opencode/test/`) + +These tests focus on the core application logic and infrastructure: + +| Directory | Purpose | Test Focus | +|-----------|---------|------------| +| `config/` | Configuration management | YAML/JSON parsing, model config, agent colors | +| `file/` | File operations | .gitignore handling, file filtering | +| `fixture/` | Test utilities | Temporary directory creation, git initialization | +| `ide/` | IDE integration | IDE detection and integration | +| `lsp/` | Language Server Protocol | LSP client communication | +| `patch/` | Code patching | Diff/patch application | +| `project/` | Project management | Project initialization, directory handling | +| `provider/` | AI provider transforms | Token limits, provider-specific handling | +| `session/` | Session management | Session creation, events, retry logic | +| `snapshot/` | Git snapshot system | File tracking, revert, diff operations | +| `tool/` | Tool execution | Bash tool execution | +| `util/` | Utility functions | IIFE, lazy loading, timeouts, wildcards | + +### Go SDK Tests (`packages/sdk/go/`) + +Auto-generated tests from OpenAPI specification (Stainless): + +| File | Coverage | +|------|----------| +| `agent_test.go` | Agent list API | +| `client_test.go` | HTTP client, retries, User-Agent, context handling | +| `session_test.go` | Session CRUD operations | +| `config_test.go` | Configuration retrieval | +| `file_test.go` | File status API | +| `tui_test.go` | TUI SSE events | +| `usage_test.go` | Usage tracking | +| `internal/apiform/` | Form encoding | +| `internal/apijson/` | JSON serialization | +| `internal/apiquery/` | Query string building | + +### Python SDK Tests (`packages/sdk/python/tests/`) + +| File | Type | Description | +|------|------|-------------| +| `test_wrapper.py` | Unit | Mock HTTP transport testing | +| `test_integration.py` | Integration | Live server subprocess testing | + +### go-memsh Tests + +Shell implementation tests: + +| File | Coverage | +|------|----------| +| `sh_test.go` | Script execution | +| `shell_test.go` | Shell state management | +| `parser_test.go` | Command parsing | +| `posix_flags_test.go` | POSIX flag handling | +| `procsubst_test.go` | Process substitution | +| `httputils_test.go` | HTTP utilities | +| `textutils_test.go` | Text utilities | +| `import_export_test.go` | Environment handling | + +--- + +## Testing Frameworks & Libraries + +### TypeScript (Bun) + +```typescript +import { describe, expect, test } from "bun:test" +``` + +- **Framework:** Bun's native test framework +- **Test Runner:** `bun test` +- **Assertions:** Built-in `expect` API +- **Features:** Async/await support, snapshot testing + +### Go + +```go +import "testing" +``` + +- **Framework:** Standard `testing` package +- **Test Runner:** `go test` or `./scripts/test` +- **Mock Server:** Prism (OpenAPI mock server) +- **Dependencies:** + - `tidwall/gjson` - JSON parsing + - `tidwall/sjson` - JSON manipulation + - `spf13/afero` - Virtual filesystem + +### Python + +```python +import pytest +import httpx +``` + +- **Framework:** pytest with pytest-asyncio +- **Mock Transport:** `httpx.MockTransport` +- **SSE Testing:** `sseclient-py` +- **Test Runner:** `uv run --project packages/sdk/python pytest -q` + +--- + +## Testing Patterns & Strategies + +### 1. Mock-Based Unit Testing + +**Pattern:** Replace HTTP transport with mock handlers that return predefined responses. + +**Go Example (`client_test.go:19-45`):** +```go +type closureTransport struct { + fn func(req *http.Request) (*http.Response, error) +} + +func TestUserAgentHeader(t *testing.T) { + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + userAgent = req.Header.Get("User-Agent") + return &http.Response{StatusCode: http.StatusOK}, nil + }, + }, + }), + ) + // ... +} +``` + +**Python Example (`test_wrapper.py:29-54`):** +```python +def test_get_path_with_mock_transport() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={...}) + + transport = httpx.MockTransport(handler) + w = OpenCodeClient(base_url="http://test") + client = httpx.Client(base_url="http://test", transport=transport) + w.client.set_httpx_client(client) +``` + +### 2. Temporary Directory Isolation + +**Pattern:** Create isolated temporary directories for each test to prevent test pollution. + +**TypeScript Example (`fixture/fixture.ts`):** +```typescript +async function tmpdir(options?: TmpDirOptions) { + // Creates temporary directories + // Supports git initialization + // Automatic cleanup via Symbol.asyncDispose +} +``` + +### 3. Fake Server Implementation + +**Pattern:** Implement minimal fake servers for protocol testing. + +**Example:** `test/fixture/lsp/fake-lsp-server.js` - Minimal JSON-RPC LSP server for testing client communication. + +### 4. Integration Testing with Real Subprocesses + +**Pattern:** Start the actual server as a subprocess for integration tests. + +**Python Example (`test_integration.py:16-93`):** +```python +def test_integration_live_server_endpoints() -> None: + cmd = ["bun", "run", "./src/index.ts", "serve", "--port", "0"] + proc = subprocess.Popen(cmd, ...) + + # Wait for server URL + # Test actual API endpoints + # Test SSE streaming + + proc.terminate() +``` + +### 5. Event Bus Testing + +**Pattern:** Verify event emission order and payloads. + +**TypeScript Example (`session/session.test.ts`):** +```typescript +test("session.started event should be emitted before session.updated", async () => { + const events: string[] = [] + Bus.subscribe(Session.Event.Created, () => events.push("started")) + Bus.subscribe(Session.Event.Updated, () => events.push("updated")) + + await Session.create({}) + + expect(events.indexOf("started")).toBeLessThan(events.indexOf("updated")) +}) +``` + +### 6. Retry Logic Testing + +**Pattern:** Test exponential backoff and retry-after header handling. + +**TypeScript Example (`session/retry.test.ts`):** +```typescript +test("caps delay at 30 seconds when headers missing", () => { + const error = apiError() + const delays = Array.from({ length: 10 }, (_, i) => SessionRetry.delay(error, i + 1)) + expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, ...]) +}) +``` + +--- + +## CI/CD Pipeline + +### GitHub Actions Workflow (`.github/workflows/test.yml`) + +```yaml +name: test +on: + push: + branches-ignore: [production] + pull_request: + branches-ignore: [production] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-bun + - run: | + git config --global user.email "bot@opencode.ai" + git config --global user.name "opencode" + bun turbo typecheck + bun turbo test + env: + CI: true + - name: Check SDK is up to date + run: | + bun ./packages/sdk/js/script/build.ts + git diff --exit-code packages/sdk/js/src/gen packages/sdk/js/dist +``` + +### Turbo Configuration (`turbo.json`) + +```json +{ + "tasks": { + "typecheck": {}, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "opencode#test": { + "dependsOn": ["^build"], + "outputs": [] + } + } +} +``` + +### Pipeline Stages + +1. **Checkout** - Clone repository +2. **Setup Bun** - Install Bun runtime (v1.3.3+) +3. **Typecheck** - Run `bun turbo typecheck` (TypeScript validation) +4. **Test** - Run `bun turbo test` (all test suites) +5. **SDK Verification** - Ensure generated SDK is up-to-date + +--- + +## Mock Infrastructure + +### Prism Mock Server (Go SDK) + +The Go SDK tests rely on Prism to mock the OpenAPI specification: + +```bash +npx prism mock path/to/openapi.yml +``` + +**Configuration:** +- Default URL: `http://localhost:4010` +- Override: `TEST_API_BASE_URL` environment variable +- Skip tests: `SKIP_MOCK_TESTS=true` + +### HTTP Transport Mocking + +| Language | Library | Pattern | +|----------|---------|---------| +| Go | `http.RoundTripper` | Custom `closureTransport` struct | +| Python | `httpx.MockTransport` | Function-based request handler | +| TypeScript | Bun mocking | Direct module mocking (limited) | + +### Virtual Filesystem (go-memsh) + +```go +import "github.com/spf13/afero" + +fs := afero.NewMemMapFs() +``` + +Used for testing file operations without touching the real filesystem. + +--- + +## Critical Gap Analysis: Real Model Testing + +### What's Missing + +The OpenCode repository has **no testing against real AI models** and **no agent performance evaluation**. This is a significant gap for an AI-powered coding assistant. + +#### 1. No Real Model API Calls + +The tests mock all HTTP interactions. There are **zero tests** that: +- Make actual API calls to OpenAI, Anthropic, or other providers +- Validate model response parsing with real responses +- Test streaming behavior with real SSE from model providers + +#### 2. No Agent Performance Evaluation + +There are **no evaluation frameworks** that measure: +- Task completion accuracy +- Code quality of generated code +- Response latency and throughput +- Cost per task +- Tool selection accuracy +- Multi-turn conversation quality + +#### 3. No End-to-End Agent Workflow Tests + +Missing test scenarios: +- Complete task execution (prompt → tool calls → result) +- Error recovery in multi-step tasks +- Context window management with real conversations +- Model-specific behavior differences + +### Evidence from Codebase + +1. **Test search for "eval", "benchmark", "performance":** + - Returns only documentation files + - No actual evaluation code + +2. **Test search for API key handling:** + - Keys are only mentioned in environment type definitions + - No test infrastructure for authenticated API calls + +3. **Provider transform tests (`provider/transform.test.ts`):** + - Only tests token limit calculations + - No actual model interaction + +4. **Session tests (`session/session.test.ts`):** + - Tests event emission + - Does not test actual AI message generation + +### Comparison with Industry Standards + +| Feature | OpenCode | Claude Code | GitHub Copilot | +|---------|----------|-------------|----------------| +| Unit Tests | ✅ | ✅ | ✅ | +| Integration Tests | ✅ | ✅ | ✅ | +| Real Model Tests | ❌ | Unknown | Unknown | +| Eval Benchmarks | ❌ | Yes (SWE-bench) | Yes (HumanEval) | +| Performance Metrics | ❌ | Yes | Yes | + +--- + +## Recommendations + +### Short-term Improvements + +1. **Add Real API Integration Tests (Optional)** + ```typescript + // Mark as skip by default, run manually or in special CI jobs + test.skip("real model response parsing", async () => { + const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) + const response = await client.chat.completions.create({...}) + // Validate response structure + }) + ``` + +2. **Add Response Schema Validation** + - Validate that mock responses match real API schemas + - Use recorded real responses as fixtures + +### Medium-term Improvements + +1. **Implement Evaluation Framework** + ```typescript + interface EvalResult { + taskId: string + success: boolean + executionTime: number + tokenUsage: { input: number, output: number } + toolCalls: number + errorRecoveries: number + } + + async function runEval(task: EvalTask): Promise { + // Execute task with real model + // Measure success and metrics + } + ``` + +2. **Create Benchmark Suite** + - Code completion accuracy + - Bug fixing success rate + - Refactoring quality + - Documentation generation + +3. **Add Performance Regression Tests** + - Track response latency over releases + - Monitor token usage efficiency + - Alert on cost increases + +### Long-term Improvements + +1. **Continuous Evaluation Pipeline** + - Nightly eval runs against test repos + - Automated quality tracking dashboard + - A/B testing infrastructure for prompt changes + +2. **Model Comparison Framework** + - Compare GPT-4 vs Claude vs local models + - Identify optimal model for each task type + - Cost-performance optimization + +3. **User Simulation Testing** + - Synthetic user sessions + - Common workflow coverage + - Edge case discovery + +--- + +## Conclusion + +The OpenCode repository has a **solid foundation for traditional software testing** but **lacks AI-specific testing infrastructure**. The current tests validate: + +- ✅ API client behavior +- ✅ Configuration handling +- ✅ Session management +- ✅ Tool execution +- ✅ File operations + +But critically **do not validate**: + +- ❌ AI model integration quality +- ❌ Agent task completion accuracy +- ❌ Response quality and correctness +- ❌ Performance under real conditions +- ❌ Cost efficiency + +For an AI-powered coding assistant, **real model testing and evaluation benchmarks are essential** to ensure the product delivers value to users and to prevent regressions in AI behavior. diff --git a/docs/todo-and-task-tools-full.md b/docs/todo-and-task-tools-full.md new file mode 100644 index 00000000000..ceb78221060 --- /dev/null +++ b/docs/todo-and-task-tools-full.md @@ -0,0 +1,376 @@ +# OpenCode Todo and Task Tools - Comprehensive Documentation + +## Overview + +OpenCode provides two primary tool systems for task management and delegation: + +1. **Todo Tools** (`TodoWrite` and `TodoRead`) - For tracking and managing tasks within a session +2. **Task Tool** - For launching autonomous subagents to handle complex multi-step tasks + +--- + +## Table of Contents + +- [Todo Tools](#todo-tools) + - [Data Model](#data-model) + - [Tool Definitions](#tool-definitions) + - [Usage Guidelines](#usage-guidelines) + - [Storage Design](#storage-design) +- [Task Tool](#task-tool) + - [Definition & Architecture](#definition--architecture) + - [Usage Guidelines](#task-usage-guidelines) +- [System Integration](#system-integration) +- [File References](#file-references) + +--- + +## Todo Tools + +### Data Model + +**File**: `packages/opencode/src/session/todo.ts:6-14` + +```typescript +export const Info = z.object({ + content: z.string().describe("Brief description of the task"), + status: z.string().describe("pending, in_progress, completed, cancelled"), + priority: z.string().describe("high, medium, low"), + id: z.string().describe("Unique identifier"), +}) +``` + +### Tool Definitions + +#### TodoWriteTool (`packages/opencode/src/tool/todo.ts:6-24`) + +- **Parameters**: `todos` array with content, status, priority, id +- **Returns**: Count of incomplete todos, JSON output, metadata +- **Side Effects**: Persists to storage, publishes bus event + +#### TodoReadTool (`packages/opencode/src/tool/todo.ts:26-39`) + +- **Parameters**: None +- **Returns**: Current todo list +- **Side Effects**: None (read-only) + +### Usage Guidelines + +**Source**: `packages/opencode/src/tool/todowrite.txt` + +#### ✅ When to Use + +1. Complex multi-step tasks (3+ steps) +2. Non-trivial tasks requiring planning +3. User explicitly requests it +4. Multiple tasks provided by user +5. After receiving new instructions +6. After completing tasks (mark complete) +7. When starting work (mark in_progress) + +#### ❌ When NOT to Use + +1. Single straightforward task +2. Trivial task +3. <3 trivial steps +4. Purely conversational + +#### Task Management Rules + +1. **Status Tracking**: Update real-time, mark complete immediately +2. **Single Focus**: Only ONE task in_progress at a time +3. **Sequential Work**: Complete current before starting new + +### Storage Design + +**File**: `packages/opencode/src/session/todo.ts:26-35` + +```typescript +export async function update(input: { sessionID: string; todos: Info[] }) { + await Storage.write(["todo", input.sessionID], input.todos) + Bus.publish(Event.Updated, input) +} + +export async function get(sessionID: string) { + return Storage.read(["todo", sessionID]) + .then((x) => x || []) + .catch(() => []) +} +``` + +**Storage Location**: `~/.opencode/storage/todo/{sessionID}.json` + +--- + +## Task Tool + +### Definition & Architecture + +**File**: `packages/opencode/src/tool/task.ts` + +The Task tool spawns autonomous subagents for complex multi-step tasks. + +#### Key Implementation Details + +1. **Session Creation** (lines 38-42): + - Creates child session with parentID + - Title includes subagent name + - Can resume existing sessions via session_id parameter + +2. **Tool Restrictions** (lines 88-92): + ```typescript + tools: { + todowrite: false, // Prevent recursive nesting + todoread: false, + task: false, + ...agent.tools, + } + ``` + +3. **Progress Tracking** (lines 55-67): + - Subscribes to MessageV2.Event.PartUpdated + - Tracks tool calls in subagent + - Updates metadata with summary + +4. **Cancellation Support** (lines 74-78): + - Respects abort signals + - Cleans up listeners + +#### Parameters + +```typescript +{ + description: string // Short (3-5 words) description + prompt: string // Detailed task instructions + subagent_type: string // Agent type to use + session_id?: string // Optional: resume existing +} +``` + +#### Returns + +```typescript +{ + title: string // Task description + output: string // Agent response + metadata + metadata: { + summary: ToolPart[] // All tool calls + sessionId: string // Child session ID + } +} +``` + +### Task Usage Guidelines + +**Source**: `packages/opencode/src/tool/task.txt` + +#### When to Use + +- Execute custom slash commands +- Complex multi-step autonomous tasks matching agent descriptions + +#### When NOT to Use + +- Reading specific file paths (use Read/Glob) +- Searching for specific class definitions (use Glob) +- Searching within 2-3 specific files (use Read) +- Tasks not matching agent descriptions + +#### Best Practices + +1. **Concurrency**: Launch multiple agents in parallel when possible +2. **Detailed Prompts**: Provide highly detailed task descriptions +3. **Specify Intent**: Clearly state if agent should write code or just research +4. **Trust Results**: Agent outputs should generally be trusted +5. **User Communication**: Summarize results for user (agent output not visible to them) + +--- + +## System Integration + +### Event Bus Architecture + +**File**: `packages/opencode/src/session/todo.ts:16-24` + +```typescript +export const Event = { + Updated: Bus.event("todo.updated", z.object({ + sessionID: z.string(), + todos: z.array(Info), + })), +} +``` + +- Publishes on every TodoWrite +- Enables real-time UI updates +- Session-scoped events + +### Tool Registry + +**File**: `packages/opencode/src/tool/registry.ts` + +Tools are registered centrally and made available to all agents unless explicitly disabled. + +### UI Rendering + +**File**: `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1596-1622` + +```tsx + + {(todo) => ( + + [{todo.status === "completed" ? "✓" : " "}] {todo.content} + + )} + +``` + +Visual indicators: +- ✓ for completed +- Green color for in_progress +- Muted color for pending + +--- + +## File References + +### Core Files + +| File | Purpose | Lines | +|------|---------|-------| +| `packages/opencode/src/tool/todo.ts` | Tool definitions | 40 | +| `packages/opencode/src/session/todo.ts` | Data model & storage | 37 | +| `packages/opencode/src/tool/task.ts` | Task tool definition | 116 | +| `packages/opencode/src/storage/storage.ts` | File-based storage | 227 | + +### Prompt Files + +| File | Purpose | Size | +|------|---------|------| +| `packages/opencode/src/tool/todowrite.txt` | TodoWrite usage guidelines | 8,846 bytes | +| `packages/opencode/src/tool/todoread.txt` | TodoRead usage guidelines | 977 bytes | +| `packages/opencode/src/tool/task.txt` | Task tool guidelines | 3,506 bytes | + +### System Prompts + +| File | Todo Instructions | +|------|-------------------| +| `packages/opencode/src/session/prompt/anthropic.txt` | ✓ Full instructions | +| `packages/opencode/src/session/prompt/anthropic-20250930.txt` | ✓ Enhanced version | +| `packages/opencode/src/session/prompt/polaris.txt` | ✓ Similar instructions | + +--- + +## Data Flow Diagram + +``` +┌─────────────┐ +│ User Input │ +└──────┬──────┘ + │ + ▼ +┌─────────────────┐ +│ TodoWrite/Read │ +│ Tool Execution │ +└──────┬──────────┘ + │ + ├──────────────┐ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ Todo.update()│ │ Todo.get() │ +│ Todo.get() │ │ │ +└──────┬───────┘ └──────┬───────┘ + │ │ + ▼ ▼ +┌──────────────────────────────┐ +│ Storage.write/read() │ +│ ~/.opencode/storage/todo/ │ +│ {sessionID}.json │ +└──────┬───────────────────────┘ + │ + ▼ +┌──────────────────┐ +│ Bus.publish() │ +│ Event.Updated │ +└──────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ Tool Returns │ +│ { title, output, │ +│ metadata: todos } │ +└──────┬───────────────┘ + │ + ▼ +┌──────────────────────┐ +│ TUI Renders │ +│ with checkmarks ✓ │ +│ and color coding │ +└──────────────────────┘ +``` + +--- + +## Key Design Decisions + +### 1. Session-Scoped Storage +- Each session has independent todo list +- Stored at `~/.opencode/storage/todo/{sessionID}.json` +- Enables parallel sessions without conflicts + +### 2. Complete List Replacement +- TodoWrite replaces entire list (not incremental updates) +- Simplifies consistency and reduces edge cases +- Agent is responsible for managing complete state + +### 3. Task Tool Restrictions +- Subagents cannot use todowrite, todoread, or task tools +- Prevents recursive nesting and complexity +- Forces clear separation of concerns + +### 4. Event-Driven UI Updates +- Bus events enable real-time synchronization +- TUI subscribes to Event.Updated +- No polling required + +### 5. Single In-Progress Rule +- Only one task should be in_progress at a time +- Enforces sequential completion +- Prevents context-switching confusion + +--- + +## Common Patterns + +### Pattern 1: Multi-Step Task +```typescript +// 1. User provides complex request +// 2. Agent creates todo list with TodoWrite +// 3. Agent marks first task in_progress +// 4. Agent completes first task +// 5. Agent marks first completed, second in_progress +// 6. Repeat until all complete +``` + +### Pattern 2: Task Delegation +```typescript +// 1. Main agent identifies complex subtask +// 2. Launches Task tool with specific subagent +// 3. Subagent works independently (no nested todos/tasks) +// 4. Results returned to main agent +// 5. Main agent continues with results +``` + +### Pattern 3: Progress Checking +```typescript +// 1. Agent uses TodoRead at conversation start +// 2. Reviews pending/in_progress items +// 3. Continues where left off +// 4. Marks items completed as work progresses +``` + +--- + +*Generated from OpenCode source code analysis* +*Last updated: 2025-11-24* diff --git a/docs/tui-protocol-specification.md b/docs/tui-protocol-specification.md new file mode 100644 index 00000000000..e5c2c822e0f --- /dev/null +++ b/docs/tui-protocol-specification.md @@ -0,0 +1,1883 @@ +# OpenCode TUI Client-Server Protocol Specification + +**Version:** 1.1.0 +**Last Updated:** 2025-11-25 + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Transport Layer](#transport-layer) +- [Data Formats](#data-formats) +- [Communication Patterns](#communication-patterns) +- [API Endpoints](#api-endpoints) +- [Client Tools Protocol](#client-tools-protocol) +- [Event System](#event-system) +- [Error Handling](#error-handling) +- [Security Considerations](#security-considerations) +- [Examples](#examples) + +--- + +## Overview + +The OpenCode TUI (Text User Interface) uses a client-server architecture where the TUI client and server run as separate processes and communicate over HTTP. This document specifies the complete protocol for communication between these components. + +### Key Characteristics + +- **Process Model**: Client and server run in separate processes +- **Transport**: HTTP/1.1 over TCP +- **Data Format**: JSON +- **Event Streaming**: Server-Sent Events (SSE) +- **Communication Model**: Request-Response + Event Streaming + Bidirectional Queue + +--- + +## Architecture + +### Process Separation + +``` +┌─────────────────────┐ HTTP/SSE ┌─────────────────────┐ +│ TUI Client │◄──────────────────────────►│ HTTP Server │ +│ (UI Process) │ 127.0.0.1:PORT │ (Backend Process) │ +│ │ │ │ +│ - Renders UI │ │ - Business Logic │ +│ - Handles Input │ │ - Session Mgmt │ +│ - Makes HTTP Calls │ │ - File Operations │ +└─────────────────────┘ └─────────────────────┘ +``` + +### Startup Sequence + +1. **Server Process** starts and listens on `127.0.0.1:` +2. **TUI Client Process** spawns via `attach` command with server URL +3. Client establishes HTTP connection to server +4. Client subscribes to SSE event stream at `/event` +5. Communication begins + +**Implementation Reference:** +- Server startup: `packages/opencode/src/server/server.ts:2022-2029` +- Client spawn: `packages/opencode/src/cli/cmd/tui/spawn.ts:27-56` +- Client attach: `packages/opencode/src/cli/cmd/tui/attach.ts` + +--- + +## Transport Layer + +### HTTP Server + +- **Framework**: Hono (lightweight HTTP framework) +- **Protocol**: HTTP/1.1 +- **Host**: `127.0.0.1` (localhost only) +- **Port**: Dynamic (default 0, assigned by OS) +- **Timeout**: Disabled for long-running operations + +### Connection Parameters + +```typescript +{ + hostname: "127.0.0.1", + port: number, // Dynamically assigned + idleTimeout: 0 // No timeout +} +``` + +### HTTP Headers + +#### Request Headers + +| Header | Required | Description | Example | +|--------|----------|-------------|---------| +| `Content-Type` | Yes (POST/PUT/PATCH) | Request body format | `application/json` | +| `x-opencode-directory` | No | Working directory for operation | `/path/to/project` | +| `Accept` | No | Expected response format | `application/json` | + +#### Response Headers + +| Header | Description | Example | +|--------|-------------|---------| +| `Content-Type` | Response body format | `application/json` or `text/event-stream` | +| `Access-Control-Allow-Origin` | CORS header | `*` | + +--- + +## Data Formats + +### JSON Schema + +All request and response bodies use JSON format with schema validation via Zod. + +#### Standard Response Format + +```json +{ + "success": true, + "data": { /* response data */ } +} +``` + +#### Error Response Format + +```json +{ + "success": false, + "name": "ErrorName", + "data": { + "message": "Error description", + /* additional error-specific fields */ + }, + "errors": [ + { + "field": "error details" + } + ] +} +``` + +### Common Data Types + +#### Session Info + +```typescript +{ + id: string, + title: string, + agent: string, + time: { + created: number, + updated: number + }, + parent?: string, + shared?: { + url: string + } +} +``` + +#### Message Info + +```typescript +{ + id: string, + sessionID: string, + role: "user" | "assistant" | "system", + time: { + created: number, + updated: number + }, + status: "pending" | "streaming" | "completed" | "error", + agent?: string +} +``` + +#### Event Payload + +```typescript +{ + type: string, + properties: { + /* event-specific data */ + } +} +``` + +--- + +## Communication Patterns + +### 1. Request-Response Pattern + +Standard HTTP request-response for synchronous operations. + +``` +Client Server + │ │ + │──── POST /session ────────────────────► │ + │ { "title": "New Session" } │ + │ │ + │◄─── 200 OK ─────────────────────────── │ + │ { "id": "abc123", ... } │ + │ │ +``` + +### 2. Server-Sent Events (SSE) Pattern + +Unidirectional event streaming from server to client. + +``` +Client Server + │ │ + │──── GET /event ────────────────────────►│ + │ │ + │◄─── SSE Stream ────────────────────────│ + │ data: {"type":"server.connected"} │ + │◄───────────────────────────────────────│ + │ data: {"type":"session.created"} │ + │◄───────────────────────────────────────│ + │ data: {"type":"message.updated"} │ + │ ... │ +``` + +### 3. Bidirectional Queue Pattern + +For cases where server needs to "call back" to client (e.g., requesting user input). + +``` +Client Server + │ │ + │──── GET /tui/control/next ────────────►│ + │ (long-poll) │ + │ │ + │◄─── 200 OK ───────────────────────────│ + │ { │ + │ "path": "/some/endpoint", │ + │ "body": { ... } │ + │ } │ + │ │ + │──── POST /tui/control/response ───────►│ + │ { "result": "..." } │ + │ │ + │◄─── 200 OK ───────────────────────────│ + │ true │ +``` + +**Implementation Reference:** +- Queue mechanism: `packages/opencode/src/server/tui.ts:13-23` +- AsyncQueue implementation: `packages/opencode/src/util/queue.ts` + +### 4. Streaming Pattern (AI Response Generation) + +For AI response generation, streaming works through **SSE events, not HTTP response streaming**. + +``` +Client Server + │ │ + │──── POST /session/abc/message ────────►│ + │ { "text": "Explain this code" } │ + │ (HTTP request blocks) │ + │ │ + │◄─── SSE: message.updated ─────────────│ (status: streaming) + │◄─── SSE: message.part.updated ────────│ (text delta: "Let") + │◄─── SSE: message.part.updated ────────│ (text delta: " me") + │◄─── SSE: message.part.updated ────────│ (text delta: " explain") + │◄─── SSE: message.part.updated ────────│ (tool call: Read) + │◄─── SSE: message.part.updated ────────│ (tool result) + │◄─── SSE: message.part.updated ────────│ (text delta: "This") + │◄─── SSE: message.updated ─────────────│ (status: completed) + │ │ + │◄─── 200 OK ───────────────────────────│ + │ { /* complete message */ } │ +``` + +**Key Points:** +1. Client makes POST request to `/session/:id/message` +2. Server processes AI request using Vercel AI SDK's `streamText()` +3. As AI generates response, server publishes SSE events: + - `message.part.updated` with `delta` field for text chunks + - `message.part.updated` for tool calls and results + - `message.updated` for status changes +4. Client receives real-time updates via existing SSE connection +5. When AI completes, HTTP response returns with final message object + +**Why This Design?** +- Allows single SSE connection for all events (not just AI streaming) +- Maintains simple request-response semantics for HTTP API +- Enables multiple clients to observe same session in real-time +- Batches events efficiently (16ms batching window) + +**Implementation Reference:** +- Processor: `packages/opencode/src/session/processor.ts:49-328` +- Text delta handling: Line 296-305 (publishes `delta` field) +- Reasoning delta: Line 73-79 +- Tool call streaming: Line 97-227 + +--- + +## API Endpoints + +### Session Management + +#### List Sessions + +```http +GET /session?directory=/path/to/project +``` + +**Response:** +```json +[ + { + "id": "session-id", + "title": "Session Title", + "agent": "build", + "time": { + "created": 1700000000000, + "updated": 1700000001000 + } + } +] +``` + +#### Get Session + +```http +GET /session/:id?directory=/path/to/project +``` + +**Response:** Single Session object + +#### Create Session + +```http +POST /session?directory=/path/to/project +Content-Type: application/json + +{ + "title": "Optional Title", + "agent": "build", + "parent": "optional-parent-id" +} +``` + +**Response:** Created Session object + +#### Update Session + +```http +PATCH /session/:id?directory=/path/to/project +Content-Type: application/json + +{ + "title": "Updated Title" +} +``` + +#### Delete Session + +```http +DELETE /session/:id?directory=/path/to/project +``` + +**Response:** `true` + +#### Send Message + +```http +POST /session/:id/message?directory=/path/to/project +Content-Type: application/json + +{ + "text": "User message", + "agent": "build" +} +``` + +**Response:** Returns complete assistant message after processing + +**⚠️ Important - Streaming Behavior:** +While the HTTP response returns after completion, **real-time streaming updates are delivered via SSE events**. As the AI generates its response: + +1. Text deltas are sent via `message.part.updated` events with `delta` field +2. Tool calls are sent as they occur +3. Client receives incremental updates in real-time through the `/event` SSE stream +4. HTTP response waits for full completion, then returns final message + +See [Streaming Pattern](#streaming-pattern) for details. + +#### Abort Session + +```http +POST /session/:id/abort?directory=/path/to/project +``` + +**Response:** `true` + +### TUI-Specific Endpoints + +#### Append to Prompt + +```http +POST /tui/append-prompt?directory=/path/to/project +Content-Type: application/json + +{ + "text": "text to append" +} +``` + +#### Submit Prompt + +```http +POST /tui/submit-prompt?directory=/path/to/project +``` + +#### Clear Prompt + +```http +POST /tui/clear-prompt?directory=/path/to/project +``` + +#### Show Toast Notification + +```http +POST /tui/show-toast?directory=/path/to/project +Content-Type: application/json + +{ + "title": "Optional Title", + "message": "Toast message", + "variant": "info" | "success" | "warning" | "error", + "duration": 5000 +} +``` + +#### Execute Command + +```http +POST /tui/execute-command?directory=/path/to/project +Content-Type: application/json + +{ + "command": "session.new" | "session.list" | "agent.cycle" | ... +} +``` + +**Available Commands:** +- `session.list` - List all sessions +- `session.new` - Create new session +- `session.share` - Share current session +- `session.interrupt` - Interrupt current session +- `session.compact` - Compact session +- `session.page.up` - Scroll page up +- `session.page.down` - Scroll page down +- `session.half.page.up` - Scroll half page up +- `session.half.page.down` - Scroll half page down +- `session.first` - Go to first message +- `session.last` - Go to last message +- `prompt.clear` - Clear prompt +- `prompt.submit` - Submit prompt +- `agent.cycle` - Cycle through agents + +### TUI Control Queue + +#### Get Next Request + +```http +GET /tui/control/next +``` + +**Response:** +```json +{ + "path": "/some/path", + "body": { /* request data */ } +} +``` + +This endpoint blocks (long-polls) until a request is available. + +#### Submit Response + +```http +POST /tui/control/response +Content-Type: application/json + +{ /* response data */ } +``` + +**Response:** `true` + +### Configuration + +#### Get Config + +```http +GET /config?directory=/path/to/project +``` + +#### Update Config + +```http +PATCH /config?directory=/path/to/project +Content-Type: application/json + +{ + "tui": { + "theme": "dark", + "keybinds": { ... } + } +} +``` + +### File Operations + +#### List Files + +```http +GET /file?path=/relative/path&directory=/path/to/project +``` + +#### Read File + +```http +GET /file/content?path=/relative/path&directory=/path/to/project +``` + +#### Get File Status + +```http +GET /file/status?directory=/path/to/project +``` + +### Search Operations + +#### Find Text + +```http +GET /find?pattern=search_term&directory=/path/to/project +``` + +#### Find Files + +```http +GET /find/file?query=filename&directory=/path/to/project +``` + +### Provider Management + +#### List Providers + +```http +GET /provider?directory=/path/to/project +``` + +#### Get Provider Auth Methods + +```http +GET /provider/auth?directory=/path/to/project +``` + +--- + +## Client Tools Protocol + +Client-side tools allow SDK clients to register custom tools that execute on the client rather than the server. When the AI model calls a client tool, the server delegates execution to the originating client. + +### Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ SDK Client │ │ OpenCode │ │ AI Model │ +│ │ │ Server │ │ │ +│ ┌─────────────┐ │ 1. Register │ │ │ │ +│ │ Tool Defs │─┼───────────────────►│ ┌─────────────┐ │ │ │ +│ └─────────────┘ │ │ │Tool Registry│ │ │ │ +│ │ │ └─────────────┘ │ │ │ +│ │ │ │ 2. AI calls tool │ │ +│ │ │ │◄───────────────────│ │ +│ ┌─────────────┐ │ 3. Execute Req │ │ │ │ +│ │ Tool │◄├────────────────────┤ │ │ │ +│ │ Handler │ │ │ │ │ │ +│ └──────┬──────┘ │ 4. Result │ │ 5. Tool result │ │ +│ │ ├───────────────────►│ │───────────────────►│ │ +│ ▼ │ │ │ │ │ +│ ┌─────────────┐ │ │ │ │ │ +│ │ Local Exec │ │ │ │ │ │ +│ └─────────────┘ │ │ │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### Client Identity + +Each SDK client is assigned a unique `clientID` (UUID) for the lifetime of its connection. This ID: +- Scopes tool registrations to the client +- Routes tool execution requests to the correct client +- Enables cleanup when client disconnects + +### Data Types + +#### ClientToolDefinition + +```typescript +{ + id: string, // Tool identifier (unique per client) + description: string, // Human-readable description for AI + parameters: { // JSON Schema for tool parameters + type: "object", + properties: { ... }, + required?: string[] + } +} +``` + +#### ClientToolExecutionRequest + +Sent from server to client when AI calls a client tool. + +```typescript +{ + type: "client-tool-request", + requestID: string, // Unique request identifier + sessionID: string, // Session where tool was called + messageID: string, // Message containing tool call + callID: string, // Tool call identifier + tool: string, // Full tool ID (prefixed with client_) + input: Record // Tool parameters from AI +} +``` + +#### ClientToolResult + +```typescript +{ + status: "success", + title: string, // Display title for tool result + output: string, // Tool output (shown to AI) + metadata?: Record, + attachments?: FilePart[] // Optional file attachments +} +``` + +#### ClientToolError + +```typescript +{ + status: "error", + error: string // Error message +} +``` + +### API Endpoints + +#### Register Client Tools + +Register one or more tools for a client. + +```http +POST /client-tools/register +Content-Type: application/json + +{ + "clientID": "uuid-string", + "tools": [ + { + "id": "my_tool", + "description": "Does something useful", + "parameters": { + "type": "object", + "properties": { + "input": { "type": "string" } + }, + "required": ["input"] + } + } + ] +} +``` + +**Response:** + +```json +{ + "registered": ["client_uuid-string_my_tool"] +} +``` + +**Notes:** +- Tool IDs are prefixed with `client_{clientID}_` to avoid collisions +- Registering an existing tool ID overwrites the previous definition +- Tools are automatically unregistered when client disconnects + +#### Unregister Client Tools + +Remove tools for a client. + +```http +DELETE /client-tools/unregister +Content-Type: application/json + +{ + "clientID": "uuid-string", + "toolIDs": ["my_tool"] // Optional: if omitted, unregisters all +} +``` + +**Response:** + +```json +{ + "success": true +} +``` + +#### Submit Tool Result + +Submit the result of a tool execution. + +```http +POST /client-tools/result +Content-Type: application/json + +{ + "requestID": "req-uuid-string", + "result": { + "status": "success", + "title": "Tool completed", + "output": "Result data here" + } +} +``` + +**Response:** + +```json +{ + "success": true +} +``` + +**Error Response (unknown request):** + +```json +{ + "error": "Unknown request ID" +} +``` + +#### Stream Tool Requests (SSE) + +Long-lived SSE connection for receiving tool execution requests. + +```http +GET /client-tools/pending/:clientID +Accept: text/event-stream +``` + +**Response Stream:** + +``` +event: tool-request +data: {"type":"client-tool-request","requestID":"req-1","sessionID":"ses-1","messageID":"msg-1","callID":"call-1","tool":"client_uuid_my_tool","input":{"input":"hello"}} + +event: ping +data: + +event: tool-request +data: {"type":"client-tool-request","requestID":"req-2",...} +``` + +**Events:** +- `tool-request` - Server requests tool execution +- `ping` - Keep-alive (every 30 seconds) + +**Connection Lifecycle:** +- Client establishes SSE connection after registering tools +- Connection remains open for session lifetime +- On disconnect, all client tools are automatically unregistered +- Client should implement reconnection with exponential backoff + +### WebSocket Protocol (Alternative) + +For lower latency, clients may use WebSocket instead of SSE + HTTP. + +```http +GET /client-tools/ws/:clientID +Upgrade: websocket +``` + +#### Client → Server Messages + +```typescript +// Register tools +{ "type": "register", "tools": ClientToolDefinition[] } + +// Submit tool result +{ "type": "result", "requestID": string, "result": ClientToolResult | ClientToolError } + +// Unregister tools +{ "type": "unregister", "toolIDs"?: string[] } +``` + +#### Server → Client Messages + +```typescript +// Registration confirmed +{ "type": "registered", "toolIDs": string[] } + +// Tool execution request +{ "type": "request", "request": ClientToolExecutionRequest } + +// Error +{ "type": "error", "error": string } +``` + +### Communication Pattern + +The client tool execution pattern differs from standard request-response: + +``` +SDK Client Server AI Model + │ │ │ + │── POST /client-tools/register ──────►│ │ + │◄── { registered: [...] } ───────────│ │ + │ │ │ + │── GET /client-tools/pending/:id ────►│ │ + │ (SSE connection established) │ │ + │ │ │ + │ │◄── AI calls client tool ─────────│ + │ │ │ + │◄── SSE: tool-request ───────────────│ │ + │ │ │ + │ (client executes tool locally) │ │ + │ │ │ + │── POST /client-tools/result ────────►│ │ + │ │── tool result ───────────────────►│ + │ │ │ + │◄── SSE: tool-request ───────────────│◄── AI calls another tool ─────────│ + │ ... │ │ +``` + +### Timeout Handling + +- Server enforces a timeout for tool execution (default: 30 seconds) +- If client doesn't respond within timeout: + - Request is cancelled + - Error is returned to AI model + - `session.error` event may be emitted + +### Error Handling + +#### Client Disconnection + +When SSE/WebSocket connection drops: +1. All pending tool requests for that client are cancelled with error +2. All registered tools are unregistered +3. AI receives tool execution error + +#### Tool Execution Failure + +Client should return error result: + +```json +{ + "status": "error", + "error": "Detailed error message" +} +``` + +This is passed to the AI model, which may retry or handle the error. + +### Security Considerations + +1. **Client Scoping**: Tools are scoped to their registering client +2. **No Cross-Client Access**: Client A cannot execute Client B's tools +3. **Session Validation**: Tool execution requires valid session context +4. **Input Validation**: Tool parameters are validated against JSON Schema before sending to client +5. **Timeout Protection**: Prevents hung clients from blocking AI responses + +### Integration with Permission System + +Client tools integrate with the existing permission system: + +```typescript +// Agent permission configuration +{ + "permission": { + "client_tools": "allow" | "ask" | "deny" + } +} +``` + +- `allow` - Execute client tools without prompting +- `ask` - Request user permission before each execution +- `deny` - Never execute client tools + +--- + +## Event System + +### Event Stream Connection + +The client subscribes to server events via SSE: + +```http +GET /event?directory=/path/to/project +Accept: text/event-stream +``` + +The server responds with a continuous stream: + +```http +HTTP/1.1 200 OK +Content-Type: text/event-stream +Cache-Control: no-cache +Connection: keep-alive + +data: {"type":"server.connected","properties":{}} + +data: {"type":"session.created","properties":{"sessionID":"abc123"}} + +data: {"type":"message.updated","properties":{"sessionID":"abc123","messageID":"msg1","status":"streaming"}} +``` + +### Event Types + +#### Connection Events + +##### server.connected + +Sent immediately upon connection. + +```json +{ + "type": "server.connected", + "properties": {} +} +``` + +#### Installation Events + +##### installation.updated + +Installation status changed. + +```json +{ + "type": "installation.updated", + "properties": { + "status": "installing" | "installed" | "error" + } +} +``` + +##### installation.update.available + +Update available for OpenCode. + +```json +{ + "type": "installation.update.available", + "properties": { + "version": "1.2.3" + } +} +``` + +#### Session Events + +##### session.created + +New session was created. + +```json +{ + "type": "session.created", + "properties": { + "sessionID": "string" + } +} +``` + +##### session.updated + +Session metadata updated. + +```json +{ + "type": "session.updated", + "properties": { + "sessionID": "string", + "title": "string" + } +} +``` + +##### session.deleted + +Session was deleted. + +```json +{ + "type": "session.deleted", + "properties": { + "sessionID": "string" + } +} +``` + +##### session.status + +Session status changed. + +```json +{ + "type": "session.status", + "properties": { + "sessionID": "string", + "status": "pending" | "running" | "completed" | "error" + } +} +``` + +##### session.idle + +Session became idle (no active operations). + +```json +{ + "type": "session.idle", + "properties": { + "sessionID": "string" + } +} +``` + +##### session.compacted + +Session was compacted (history compressed). + +```json +{ + "type": "session.compacted", + "properties": { + "sessionID": "string" + } +} +``` + +##### session.diff + +Session diff calculated. + +```json +{ + "type": "session.diff", + "properties": { + "sessionID": "string", + "diff": [/* file diffs */] + } +} +``` + +##### session.error + +Session encountered an error. + +```json +{ + "type": "session.error", + "properties": { + "sessionID": "string", + "error": "string" + } +} +``` + +#### Message Events + +##### message.updated + +Message was created or updated. + +```json +{ + "type": "message.updated", + "properties": { + "sessionID": "string", + "messageID": "string", + "status": "pending" | "streaming" | "completed" | "error" + } +} +``` + +##### message.removed + +Message was deleted. + +```json +{ + "type": "message.removed", + "properties": { + "sessionID": "string", + "messageID": "string" + } +} +``` + +##### message.part.updated + +Message part (tool call, text block, etc.) updated. + +**For streaming text/reasoning**, includes `delta` field with incremental text chunk. + +```json +{ + "type": "message.part.updated", + "properties": { + "part": { + "id": "string", + "sessionID": "string", + "messageID": "string", + "type": "text" | "reasoning" | "tool" | ..., + "text": "accumulated text so far", + // ... other part-specific fields + }, + "delta": "incremental text chunk" // Only present during streaming + } +} +``` + +**Streaming vs Non-Streaming:** +- **With `delta`**: Real-time text generation (e.g., `delta: " me"`) +- **Without `delta`**: Part structure update (e.g., tool call status change) + +##### message.part.removed + +Message part was removed. + +```json +{ + "type": "message.part.removed", + "properties": { + "sessionID": "string", + "messageID": "string", + "partID": "string" + } +} +``` + +#### Permission Events + +##### permission.updated + +Permission request created or updated. + +```json +{ + "type": "permission.updated", + "properties": { + "sessionID": "string", + "permissionID": "string", + "tool": "string", + "status": "pending" | "approved" | "denied" + } +} +``` + +##### permission.replied + +Permission request was answered. + +```json +{ + "type": "permission.replied", + "properties": { + "sessionID": "string", + "permissionID": "string", + "response": "allow" | "deny" | "allow_all" + } +} +``` + +#### File Events + +##### file.edited + +File was modified. + +```json +{ + "type": "file.edited", + "properties": { + "sessionID": "string", + "messageID": "string", + "path": "string" + } +} +``` + +##### file.watcher.updated + +File watcher detected changes. + +```json +{ + "type": "file.watcher.updated", + "properties": { + "path": "string", + "event": "create" | "modify" | "delete" + } +} +``` + +#### Todo Events + +##### todo.updated + +Todo list updated. + +```json +{ + "type": "todo.updated", + "properties": { + "sessionID": "string", + "todos": [ + { + "content": "string", + "status": "pending" | "in_progress" | "completed", + "activeForm": "string" + } + ] + } +} +``` + +#### Command Events + +##### command.executed + +TUI command was executed. + +```json +{ + "type": "command.executed", + "properties": { + "command": "string" + } +} +``` + +#### TUI Events + +##### tui.prompt.append + +Text appended to TUI prompt. + +```json +{ + "type": "tui.prompt.append", + "properties": { + "text": "string" + } +} +``` + +##### tui.command.execute + +TUI command execution requested. + +```json +{ + "type": "tui.command.execute", + "properties": { + "command": "session.list" | "session.new" | ... + } +} +``` + +##### tui.toast.show + +Show toast notification in TUI. + +```json +{ + "type": "tui.toast.show", + "properties": { + "title": "string", + "message": "string", + "variant": "info" | "success" | "warning" | "error", + "duration": 5000 + } +} +``` + +#### LSP Events + +##### lsp.updated + +LSP server status changed. + +```json +{ + "type": "lsp.updated", + "properties": { + "language": "string", + "status": "starting" | "running" | "stopped" | "error" + } +} +``` + +##### lsp.client.diagnostics + +LSP diagnostics received. + +```json +{ + "type": "lsp.client.diagnostics", + "properties": { + "uri": "string", + "diagnostics": [ + { + "severity": "error" | "warning" | "info" | "hint", + "message": "string", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 10 } + } + } + ] + } +} +``` + +#### Client Tool Events + +##### client-tool.registered + +Client tools were registered. + +```json +{ + "type": "client-tool.registered", + "properties": { + "clientID": "string", + "toolIDs": ["string"] + } +} +``` + +##### client-tool.unregistered + +Client tools were unregistered. + +```json +{ + "type": "client-tool.unregistered", + "properties": { + "clientID": "string", + "toolIDs": ["string"] + } +} +``` + +##### client-tool.executing + +Client tool execution started. + +```json +{ + "type": "client-tool.executing", + "properties": { + "sessionID": "string", + "messageID": "string", + "callID": "string", + "tool": "string", + "clientID": "string" + } +} +``` + +##### client-tool.completed + +Client tool execution completed. + +```json +{ + "type": "client-tool.completed", + "properties": { + "sessionID": "string", + "messageID": "string", + "callID": "string", + "tool": "string", + "clientID": "string", + "success": true + } +} +``` + +##### client-tool.failed + +Client tool execution failed. + +```json +{ + "type": "client-tool.failed", + "properties": { + "sessionID": "string", + "messageID": "string", + "callID": "string", + "tool": "string", + "clientID": "string", + "error": "string" + } +} +``` + +--- + +## Error Handling + +### HTTP Status Codes + +| Code | Meaning | Usage | +|------|---------|-------| +| 200 | OK | Successful operation | +| 400 | Bad Request | Invalid request parameters or body | +| 404 | Not Found | Resource (session, file, etc.) not found | +| 500 | Internal Server Error | Unexpected server error | + +### Error Response Structure + +All errors follow the NamedError pattern: + +```typescript +{ + success: false, + name: string, // Error type identifier + data: { // Error-specific data + message: string, + // ... other fields + }, + errors?: Array<{ // Validation errors (optional) + field: string, + message: string + }> +} +``` + +### Error Types + +#### UnknownError + +Generic error for unexpected conditions. + +```json +{ + "name": "UnknownError", + "data": { + "message": "An unexpected error occurred" + } +} +``` + +#### NotFoundError + +Resource not found. + +```json +{ + "name": "NotFoundError", + "data": { + "resource": "session", + "id": "abc123" + } +} +``` + +#### ModelNotFoundError + +Requested AI model not available. + +```json +{ + "name": "ModelNotFoundError", + "data": { + "provider": "anthropic", + "model": "claude-3-opus" + } +} +``` + +#### ValidationError + +Request validation failed. + +```json +{ + "name": "ValidationError", + "data": { + "message": "Invalid request parameters" + }, + "errors": [ + { + "field": "title", + "message": "Title must be a string" + } + ] +} +``` + +### Error Handling Best Practices + +1. **Client Should:** + - Check HTTP status code first + - Parse error response for `name` and `data` fields + - Display user-friendly error messages based on error type + - Retry failed requests with exponential backoff for network errors + - Log errors for debugging + +2. **Server Will:** + - Return consistent error format across all endpoints + - Include stack traces only in development mode + - Log all errors with context + - Return appropriate HTTP status codes + +### SSE Error Handling + +If the SSE connection drops: + +1. Client receives connection close event +2. Client should attempt to reconnect with exponential backoff +3. Start with 1s delay, double on each failure, max 30s +4. On reconnect, sync state by fetching current session/message data + +--- + +## Security Considerations + +### Network Binding + +- Server binds only to `127.0.0.1` (localhost) +- Not accessible from external networks +- No authentication required (local-only access) + +### Directory Parameter + +- The `directory` query parameter specifies working directory +- Server validates directory exists and is accessible +- Prevents path traversal attacks +- All file operations are scoped to specified directory + +### CORS + +- CORS enabled with `Access-Control-Allow-Origin: *` +- Safe because server only listens on localhost + +### Timeout Configuration + +- No idle timeout on connections +- Allows long-running operations +- Client responsible for managing connection lifecycle + +--- + +## Examples + +### Complete Session Flow + +#### 1. Client Connects + +```http +GET /event HTTP/1.1 +Host: 127.0.0.1:12345 +Accept: text/event-stream +``` + +Server responds with SSE stream: + +``` +data: {"type":"server.connected","properties":{}} +``` + +#### 2. Create Session + +```http +POST /session?directory=/home/user/project HTTP/1.1 +Host: 127.0.0.1:12345 +Content-Type: application/json + +{ + "title": "Fix login bug", + "agent": "build" +} +``` + +Response: + +```json +{ + "id": "ses_abc123", + "title": "Fix login bug", + "agent": "build", + "time": { + "created": 1700000000000, + "updated": 1700000000000 + } +} +``` + +Event emitted: + +``` +data: {"type":"session.created","properties":{"sessionID":"ses_abc123"}} +``` + +#### 3. Send Message + +```http +POST /session/ses_abc123/message?directory=/home/user/project HTTP/1.1 +Host: 127.0.0.1:12345 +Content-Type: application/json + +{ + "text": "Please analyze the login.ts file and identify the bug", + "agent": "build" +} +``` + +Events emitted during processing (showing streaming): + +``` +data: {"type":"message.updated","properties":{"info":{"id":"msg_user1","status":"completed",...}}} + +data: {"type":"message.updated","properties":{"info":{"id":"msg_asst1","status":"streaming",...}}} + +data: {"type":"message.part.updated","properties":{"part":{"id":"text1","type":"text","text":"I"},"delta":"I"}} + +data: {"type":"message.part.updated","properties":{"part":{"id":"text1","type":"text","text":"I'll"},"delta":"'ll"}} + +data: {"type":"message.part.updated","properties":{"part":{"id":"text1","type":"text","text":"I'll analyze"},"delta":" analyze"}} + +data: {"type":"message.part.updated","properties":{"part":{"id":"tool1","type":"tool","tool":"Read","state":{"status":"running"}}}} + +data: {"type":"message.part.updated","properties":{"part":{"id":"tool1","type":"tool","tool":"Read","state":{"status":"completed","output":"..."}}}} + +data: {"type":"message.part.updated","properties":{"part":{"id":"text2","type":"text","text":"The"},"delta":"The"}} + +data: {"type":"message.part.updated","properties":{"part":{"id":"text2","type":"text","text":"The bug"},"delta":" bug"}} + +data: {"type":"message.updated","properties":{"info":{"id":"msg_asst1","status":"completed",...}}} + +data: {"type":"session.idle","properties":{"sessionID":"ses_abc123"}} +``` + +**Note:** Each `message.part.updated` event during text generation includes: +- `part.text`: Accumulated text so far +- `delta`: Just the new chunk (for efficient rendering) + +### TUI Command Example + +#### Show Toast Notification + +```http +POST /tui/show-toast?directory=/home/user/project HTTP/1.1 +Host: 127.0.0.1:12345 +Content-Type: application/json + +{ + "title": "Success", + "message": "Session created successfully", + "variant": "success", + "duration": 3000 +} +``` + +Event emitted: + +``` +data: {"type":"tui.toast.show","properties":{"title":"Success","message":"Session created successfully","variant":"success","duration":3000}} +``` + +### Bidirectional Queue Example + +Server needs client to execute a command: + +#### Server pushes to queue + +```typescript +// Server code +request.push({ + path: "/tui/execute-command", + body: { command: "session.list" } +}) +``` + +#### Client polls for request + +```http +GET /tui/control/next HTTP/1.1 +Host: 127.0.0.1:12345 +``` + +Response: + +```json +{ + "path": "/tui/execute-command", + "body": { + "command": "session.list" + } +} +``` + +#### Client executes and responds + +```http +POST /tui/control/response HTTP/1.1 +Host: 127.0.0.1:12345 +Content-Type: application/json + +{ + "success": true, + "result": "Sessions dialog opened" +} +``` + +--- + +## Implementation Notes + +### Client Implementation + +**Location:** `packages/opencode/src/cli/cmd/tui/` + +Key files: +- `app.tsx` - Main TUI application +- `context/sdk.tsx` - HTTP client setup +- `attach.ts` - Connection to server + +The client uses: +- `@opencode-ai/sdk` for typed API calls +- `fetch()` for HTTP requests +- EventSource API for SSE (handled by SDK) + +### Server Implementation + +**Location:** `packages/opencode/src/server/` + +Key files: +- `server.ts` - Main HTTP server and route definitions +- `tui.ts` - TUI-specific endpoints and queue + +The server uses: +- Hono framework for HTTP routing +- Bun.serve() for HTTP server +- AsyncQueue for bidirectional communication +- Zod for request/response validation + +### Event Bus Implementation + +**Location:** `packages/opencode/src/bus/` + +- `index.ts` - Event bus implementation +- `global.ts` - Global event emitter + +Events are: +- Type-safe via Zod schemas +- Published to all subscribers +- Streamed to SSE clients +- Batched for performance (16ms batching window) + +### Client Tool Execution Example + +Complete flow of registering and executing a client tool: + +#### 1. Client Registers Tool + +```http +POST /client-tools/register HTTP/1.1 +Host: 127.0.0.1:12345 +Content-Type: application/json + +{ + "clientID": "client-abc-123", + "tools": [ + { + "id": "get_local_time", + "description": "Get the current local time on the client machine", + "parameters": { + "type": "object", + "properties": { + "timezone": { + "type": "string", + "description": "Timezone (e.g., 'America/New_York')" + } + } + } + } + ] +} +``` + +Response: + +```json +{ + "registered": ["client_client-abc-123_get_local_time"] +} +``` + +#### 2. Client Connects to SSE Stream + +```http +GET /client-tools/pending/client-abc-123 HTTP/1.1 +Host: 127.0.0.1:12345 +Accept: text/event-stream +``` + +#### 3. AI Calls the Tool (via user prompt) + +User sends: "What time is it locally?" + +AI decides to call the `get_local_time` tool. Server sends SSE event: + +``` +event: tool-request +data: {"type":"client-tool-request","requestID":"req-xyz-789","sessionID":"ses_abc123","messageID":"msg_asst1","callID":"call_1","tool":"client_client-abc-123_get_local_time","input":{"timezone":"America/New_York"}} +``` + +#### 4. Client Executes and Submits Result + +```http +POST /client-tools/result HTTP/1.1 +Host: 127.0.0.1:12345 +Content-Type: application/json + +{ + "requestID": "req-xyz-789", + "result": { + "status": "success", + "title": "Local time (America/New_York)", + "output": "2025-11-25 14:30:45 EST" + } +} +``` + +Response: + +```json +{ + "success": true +} +``` + +#### 5. Events Emitted + +Main SSE stream (`/event`) receives: + +``` +data: {"type":"client-tool.executing","properties":{"sessionID":"ses_abc123","messageID":"msg_asst1","callID":"call_1","tool":"client_client-abc-123_get_local_time","clientID":"client-abc-123"}} + +data: {"type":"message.part.updated","properties":{"part":{"id":"tool1","type":"tool","tool":"get_local_time","state":{"status":"running"}}}} + +data: {"type":"client-tool.completed","properties":{"sessionID":"ses_abc123","messageID":"msg_asst1","callID":"call_1","tool":"client_client-abc-123_get_local_time","clientID":"client-abc-123","success":true}} + +data: {"type":"message.part.updated","properties":{"part":{"id":"tool1","type":"tool","tool":"get_local_time","state":{"status":"completed","output":"2025-11-25 14:30:45 EST"}}}} +``` + +--- + +## Version History + +- **1.1.0** (2025-11-25) - Added Client Tools Protocol section +- **1.0.0** (2025-11-24) - Initial protocol specification + +--- + +## References + +- Hono Documentation: https://hono.dev/ +- Server-Sent Events Specification: https://html.spec.whatwg.org/multipage/server-sent-events.html +- Zod Documentation: https://zod.dev/ +- OpenCode Repository: https://github.com/anthropics/opencode diff --git a/feature/custom-system-prompt.md b/feature/custom-system-prompt.md new file mode 100644 index 00000000000..8945f42346e --- /dev/null +++ b/feature/custom-system-prompt.md @@ -0,0 +1,1496 @@ +# Feature Plan: Custom System and Initial Prompt Templates Per Session + +## Executive Summary + +This document outlines the design and implementation plan for enabling custom system and initial instruction prompts on a per-session basis with template variable interpolation. This feature will allow users to create specialized agents (e.g., data analyst, Python expert, security auditor) by providing custom prompt templates with dynamic variables when starting a session. + +**Key Features:** +- Session-level custom prompt templates (file-based and inline) +- Template variable interpolation with 17 built-in variables +- Custom variables via session, config, or environment +- Auto-detection of primary programming language +- Git branch awareness +- Backward compatible with existing sessions + +**Status:** Planning +**Priority:** Medium +**Complexity:** Medium-High +**Estimated Files to Modify:** 4-6 +**Estimated LOC:** ~245 (including variable interpolation) + +--- + +## Table of Contents + +1. [Current Architecture](#current-architecture) +2. [Problem Statement](#problem-statement) +3. [Proposed Solution](#proposed-solution) +4. [Technical Design](#technical-design) +5. [Implementation Plan](#implementation-plan) +6. [API Changes](#api-changes) +7. [Backward Compatibility](#backward-compatibility) +8. [Testing Strategy](#testing-strategy) +9. [Future Enhancements](#future-enhancements) + +--- + +## Current Architecture + +### System Prompt Loading Mechanism + +**Location:** `/packages/opencode/src/session/prompt.ts:621-641` + +The `resolveSystemPrompt()` function assembles system prompts in the following **priority order**: + +```typescript +async function resolveSystemPrompt(input: { + system?: string // 1. Per-request override (highest priority) + agent: Agent.Info // 2. Agent-specific prompt + providerID: string + modelID: string +}) { + let system = SystemPrompt.header(providerID) // Provider-specific header + + system.push( + ...(() => { + if (input.system) return [input.system] // Step 1: Custom override + if (input.agent.prompt) return [input.agent.prompt] // Step 2: Agent prompt + return SystemPrompt.provider(modelID) // Step 3: Model-specific default + })() + ) + + system.push(...(await SystemPrompt.environment())) // Step 4: Environment context + system.push(...(await SystemPrompt.custom())) // Step 5: Custom instructions + + // Optimization: Combine into 2 messages for prompt caching + const [first, ...rest] = system + system = [first, rest.join("\n")] + return system +} +``` + +### Prompt Template Files + +**Location:** `/packages/opencode/src/session/prompt/*.txt` + +| Template File | Model Target | Size | Purpose | +|--------------|--------------|------|---------| +| `anthropic.txt` | Claude | 8.2 KB | General coding assistant | +| `beast.txt` | GPT-4/o1/o3 | 11 KB | Autonomous problem-solving | +| `gemini.txt` | Gemini | 15 KB | Gemini-specific instructions | +| `codex.txt` | GPT-5 | 24 KB | Detailed workflows | +| `qwen.txt` | Other | 9.7 KB | Minimal prompt | +| `polaris.txt` | Polaris-alpha | 8.3 KB | Polaris-specific | + +**Selection Logic:** `/packages/opencode/src/session/system.ts:27-34` + +```typescript +export function provider(modelID: string) { + if (modelID.includes("gpt-5")) return [PROMPT_CODEX] + if (modelID.includes("gpt-") || modelID.includes("o1") || modelID.includes("o3")) return [PROMPT_BEAST] + if (modelID.includes("gemini-")) return [PROMPT_GEMINI] + if (modelID.includes("claude")) return [PROMPT_ANTHROPIC] + if (modelID.includes("polaris-alpha")) return [PROMPT_POLARIS] + return [PROMPT_ANTHROPIC_WITHOUT_TODO] // Default +} +``` + +### Session Schema + +**Location:** `/packages/opencode/src/session/index.ts:37-75` + +```typescript +export const Info = z.object({ + id: Identifier.schema("session"), + projectID: z.string(), + directory: z.string(), + parentID: Identifier.schema("session").optional(), + summary: z.object({...}).optional(), + share: z.object({...}).optional(), + title: z.string(), + version: z.string(), + time: z.object({...}), + revert: z.object({...}).optional(), +}) +``` + +### Session Creation Flow + +**API Endpoint:** `POST /session` +**Handler:** `/packages/opencode/src/server/server.ts:516-521` + +```typescript +validator("json", Session.create.schema.optional()), +async (c) => { + const body = c.req.valid("json") ?? {} + const session = await Session.create(body) // Currently accepts: {parentID?, title?} + return c.json(session) +} +``` + +**Session.create Function:** `/packages/opencode/src/session/index.ts:122-135` + +```typescript +export const create = fn( + z.object({ + parentID: Identifier.schema("session").optional(), + title: z.string().optional(), + }).optional(), + async (input) => { + return createNext({ + parentID: input?.parentID, + directory: Instance.directory, + title: input?.title, + }) + } +) +``` + +--- + +## Problem Statement + +### Current Limitations + +1. **No Persistent Session-Level Customization** + - The `system` parameter in `PromptInput` must be passed on **every message request** + - No way to set a custom prompt once during session creation and have it persist + - Cumbersome for multi-turn conversations with specialized agents + +2. **Agent Configs Are Global** + - Agent configurations in `~/.opencode/agent/*.md` are project/user-wide + - Cannot create ephemeral, one-off specialized sessions without modifying configs + - No way to experiment with different prompts without file system changes + +3. **Template Reusability** + - Users cannot easily create and reference reusable prompt templates + - No mechanism to version or share prompt templates across teams + +### Use Cases + +1. **Data Analyst Agent** + ```bash + # User wants to start a session with data analysis focus + opencode --prompt templates/data-analyst.txt + ``` + +2. **Security Auditor** + ```bash + # Security-focused session for code review + opencode --prompt security-auditor + ``` + +3. **Domain-Specific Agents** + ```bash + # Medical records processing (HIPAA-compliant) + # Financial analysis (SOX-compliant) + # Legal document review + ``` + +4. **A/B Testing Prompts** + - Test different prompt variations without editing config files + - Compare agent behavior with different system prompts + +--- + +## Proposed Solution + +### Design Principles + +1. **Persistent but Optional:** Custom prompts stored in session metadata, falling back to existing behavior +2. **File-Based Templates:** Support loading prompts from files for reusability +3. **Inline Prompts:** Support inline prompt strings for quick experiments +4. **Backward Compatible:** Zero breaking changes to existing API +5. **Composable:** Custom prompts work with existing environment/instruction system + +### Solution Overview + +Add **session-level custom prompt templates** that: +- Are specified once during session creation +- Persist in session metadata +- Take precedence between agent prompts and model-specific defaults +- Support both file paths and inline strings + +### Priority Order (Updated) + +``` +1. Per-request `system` parameter (API override) +2. Agent-specific `agent.prompt` (from agent config) +3. ✨ NEW: Session-level `customPromptTemplate` (from session metadata) +4. Model-specific default (anthropic.txt, beast.txt, etc.) +5. Environment context (git status, file tree, etc.) +6. Custom instructions (AGENTS.md, CLAUDE.md, etc.) +``` + +--- + +## Technical Design + +### 1. Schema Changes + +#### Session.Info Schema Extension + +**File:** `/packages/opencode/src/session/index.ts` + +```typescript +export const Info = z.object({ + id: Identifier.schema("session"), + projectID: z.string(), + directory: z.string(), + parentID: Identifier.schema("session").optional(), + + // ✨ NEW: Custom prompt template + customPrompt: z.object({ + type: z.enum(["file", "inline"]), + value: z.string(), // File path or inline prompt text + loadedAt: z.number().optional(), // Timestamp for cache invalidation + variables: z.record(z.string(), z.string()).optional(), // Custom variables + }).optional(), + + summary: z.object({...}).optional(), + share: z.object({...}).optional(), + title: z.string(), + version: z.string(), + time: z.object({...}), + revert: z.object({...}).optional(), +}) +``` + +#### Session.create Schema Extension + +**File:** `/packages/opencode/src/session/index.ts` + +```typescript +export const create = fn( + z.object({ + parentID: Identifier.schema("session").optional(), + title: z.string().optional(), + + // ✨ NEW: Custom prompt options + customPrompt: z.union([ + z.string(), // Shorthand: file path or inline text (auto-detect) + z.object({ + type: z.enum(["file", "inline"]), + value: z.string(), + }), + ]).optional(), + }).optional(), + async (input) => { + // Implementation details below... + } +) +``` + +### 2. Prompt Loading Logic + +#### New Helper: `SystemPrompt.fromSession()` + +**File:** `/packages/opencode/src/session/system.ts` + +```typescript +export async function fromSession(sessionID: string): Promise { + const session = await Session.get(sessionID) + if (!session.customPrompt) return null + + if (session.customPrompt.type === "inline") { + return session.customPrompt.value + } + + if (session.customPrompt.type === "file") { + const filePath = resolveTemplatePath(session.customPrompt.value) + + // Cache check (optional optimization) + const fileStats = await Bun.file(filePath).stat() + if (session.customPrompt.loadedAt && fileStats.mtime.getTime() <= session.customPrompt.loadedAt) { + // File hasn't changed, could use cached version + } + + const content = await Bun.file(filePath).text() + return content + } + + return null +} + +function resolveTemplatePath(value: string): string { + // Priority order for file resolution: + // 1. Absolute path: /path/to/template.txt + // 2. Home directory: ~/templates/data-analyst.txt + // 3. Project .opencode/prompts/: template.txt → .opencode/prompts/template.txt + // 4. Global ~/.opencode/prompts/: template.txt → ~/.opencode/prompts/template.txt + + if (path.isAbsolute(value)) return value + if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2)) + + // Check project-level prompts + const projectPrompt = path.join(Instance.directory, ".opencode", "prompts", value) + if (Bun.file(projectPrompt).exists()) return projectPrompt + + // Check global prompts + const globalPrompt = path.join(Global.Path.config, "prompts", value) + if (Bun.file(globalPrompt).exists()) return globalPrompt + + // Fallback: treat as relative to cwd + return path.resolve(Instance.directory, value) +} +``` + +#### Updated `resolveSystemPrompt()` + +**File:** `/packages/opencode/src/session/prompt.ts` + +```typescript +async function resolveSystemPrompt(input: { + system?: string + agent: Agent.Info + providerID: string + modelID: string + sessionID: string // ✨ NEW: Need session ID to load custom prompt +}) { + let system = SystemPrompt.header(input.providerID) + + system.push( + ...(() => { + if (input.system) return [input.system] // 1. Per-request override + if (input.agent.prompt) return [input.agent.prompt] // 2. Agent prompt + + // ✨ NEW: 3. Session-level custom prompt + const sessionPrompt = await SystemPrompt.fromSession(input.sessionID) + if (sessionPrompt) return [sessionPrompt] + + return SystemPrompt.provider(input.modelID) // 4. Model default + })() + ) + + system.push(...(await SystemPrompt.environment())) // 5. Environment + system.push(...(await SystemPrompt.custom())) // 6. Custom instructions + + const [first, ...rest] = system + system = [first, rest.join("\n")] + return system +} +``` + +**Note:** Need to pass `sessionID` to `resolveSystemPrompt()` - already available in calling context at line 495. + +### 3. Template Variable Interpolation + +#### Variable Syntax + +Templates support **variable interpolation** using the syntax: `${VARIABLE_NAME}` + +**Supported variable formats:** +- `${VAR}` - Simple variable +- `${VAR:default}` - Variable with default value +- `${VAR|filter}` - Variable with filter (e.g., `uppercase`, `lowercase`, `capitalize`) + +#### Built-in Variables + +| Variable | Description | Example Value | +|----------|-------------|---------------| +| `${PROJECT_NAME}` | Project directory name | `opencode` | +| `${PROJECT_PATH}` | Absolute project path | `/home/user/opencode` | +| `${WORKING_DIR}` | Current working directory | `/home/user/opencode/packages` | +| `${GIT_BRANCH}` | Current git branch (if git repo) | `main`, `feature/xyz` | +| `${GIT_REPO}` | Is git repository? | `yes`, `no` | +| `${PRIMARY_LANGUAGE}` | Detected primary language | `typescript`, `python`, `go` | +| `${PLATFORM}` | Operating system | `linux`, `darwin`, `win32` | +| `${DATE}` | Current date | `2024-11-24` | +| `${TIME}` | Current time | `14:30:00` | +| `${DATETIME}` | Current date and time | `2024-11-24 14:30:00` | +| `${USER}` | Current user (if available) | `john` | +| `${HOSTNAME}` | Machine hostname (if available) | `dev-machine` | +| `${SESSION_ID}` | Current session ID | `session_abc123` | +| `${SESSION_TITLE}` | Session title | `Data Analysis Session` | +| `${AGENT_NAME}` | Agent name (if using agent) | `data-analyst` | +| `${MODEL_ID}` | LLM model being used | `claude-sonnet-4` | +| `${OPENCODE_VERSION}` | OpenCode version | `1.2.3` | + +#### Custom Variables + +Users can define custom variables via: + +**1. Session creation:** +```json +{ + "customPrompt": { + "type": "file", + "value": "analyst.txt", + "variables": { + "TEAM_NAME": "Data Science", + "PROJECT_DOMAIN": "Healthcare Analytics" + } + } +} +``` + +**2. Config file (`opencode.jsonc`):** +```jsonc +{ + "promptVariables": { + "COMPANY_NAME": "Acme Corp", + "CODING_STYLE": "functional", + "TESTING_FRAMEWORK": "jest" + } +} +``` + +**3. Environment variables (prefix: `OPENCODE_VAR_`):** +```bash +export OPENCODE_VAR_TEAM_NAME="Data Science" +export OPENCODE_VAR_DEPLOYMENT_ENV="production" +``` + +#### Variable Resolution Order + +1. Session-specific variables (highest priority) +2. Config file variables +3. Environment variables (`OPENCODE_VAR_*`) +4. Built-in variables +5. Default value (if specified in template) + +#### Implementation: `SystemPrompt.interpolateVariables()` + +**File:** `/packages/opencode/src/session/system.ts` + +```typescript +export async function interpolateVariables( + template: string, + context: { + sessionID: string + agent?: Agent.Info + model: { providerID: string; modelID: string } + customVars?: Record + } +): Promise { + const session = await Session.get(context.sessionID) + const config = await Config.get() + const project = Instance.project + + // Build variable map + const variables: Record = { + // Built-in variables + PROJECT_NAME: path.basename(Instance.worktree), + PROJECT_PATH: Instance.worktree, + WORKING_DIR: Instance.directory, + GIT_BRANCH: await getGitBranch().catch(() => "unknown"), + GIT_REPO: project.vcs === "git" ? "yes" : "no", + PRIMARY_LANGUAGE: await detectPrimaryLanguage(), + PLATFORM: process.platform, + DATE: new Date().toISOString().split("T")[0], + TIME: new Date().toTimeString().split(" ")[0], + DATETIME: new Date().toISOString().replace("T", " ").split(".")[0], + USER: process.env.USER || process.env.USERNAME || "unknown", + HOSTNAME: os.hostname(), + SESSION_ID: session.id, + SESSION_TITLE: session.title, + AGENT_NAME: context.agent?.name || "default", + MODEL_ID: context.model.modelID, + OPENCODE_VERSION: Installation.VERSION, + } + + // Merge in order of priority (later overrides earlier) + Object.assign( + variables, + extractEnvVariables(), // OPENCODE_VAR_* + config.promptVariables || {}, // Config file + session.customPrompt?.variables || {}, // Session-specific + context.customVars || {} // Inline custom vars + ) + + // Interpolate: ${VAR}, ${VAR:default}, ${VAR|filter} + return template.replace(/\$\{([A-Z_][A-Z0-9_]*)(:[^}]+)?(\|[^}]+)?\}/g, (match, varName, defaultValue, filter) => { + let value = variables[varName] + + // Use default if variable not found + if (value === undefined && defaultValue) { + value = defaultValue.slice(1) // Remove leading ':' + } + + // Return original if still not found + if (value === undefined) { + return match + } + + // Apply filter if specified + if (filter) { + value = applyFilter(value, filter.slice(1)) // Remove leading '|' + } + + return value + }) +} + +function extractEnvVariables(): Record { + const vars: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith("OPENCODE_VAR_")) { + const varName = key.replace("OPENCODE_VAR_", "") + vars[varName] = value || "" + } + } + return vars +} + +function applyFilter(value: string, filter: string): string { + switch (filter) { + case "uppercase": return value.toUpperCase() + case "lowercase": return value.toLowerCase() + case "capitalize": return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase() + default: return value + } +} + +async function detectPrimaryLanguage(): Promise { + // Count file extensions in project + const files = await Ripgrep.tree({ cwd: Instance.directory, limit: 500 }) + const extensions: Record = {} + + for (const line of files.split("\n")) { + const ext = path.extname(line).toLowerCase() + if (ext) extensions[ext] = (extensions[ext] || 0) + 1 + } + + // Map extensions to languages + const langMap: Record = { + ".ts": "typescript", ".tsx": "typescript", + ".js": "javascript", ".jsx": "javascript", + ".py": "python", + ".go": "go", + ".rs": "rust", + ".java": "java", + ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", + ".c": "c", + ".rb": "ruby", + ".php": "php", + ".cs": "csharp", + ".swift": "swift", + ".kt": "kotlin", + } + + // Find most common language + let maxCount = 0 + let primaryLang = "unknown" + for (const [ext, count] of Object.entries(extensions)) { + const lang = langMap[ext] + if (lang && count > maxCount) { + maxCount = count + primaryLang = lang + } + } + + return primaryLang +} + +async function getGitBranch(): Promise { + const result = await Bun.spawn(["git", "branch", "--show-current"], { + cwd: Instance.directory, + stdout: "pipe", + }) + const output = await new Response(result.stdout).text() + return output.trim() || "unknown" +} +``` + +#### Updated `fromSession()` with Interpolation + +```typescript +export async function fromSession( + sessionID: string, + context: { + agent?: Agent.Info + model: { providerID: string; modelID: string } + } +): Promise { + const session = await Session.get(sessionID) + if (!session.customPrompt) return null + + let content: string + + if (session.customPrompt.type === "inline") { + content = session.customPrompt.value + } else if (session.customPrompt.type === "file") { + const filePath = resolveTemplatePath(session.customPrompt.value) + content = await Bun.file(filePath).text() + } else { + return null + } + + // ✨ NEW: Interpolate variables + return await interpolateVariables(content, { + sessionID, + agent: context.agent, + model: context.model, + customVars: session.customPrompt.variables, + }) +} +``` + +### 4. Session Creation Logic + +#### Updated `createNext()` + +**File:** `/packages/opencode/src/session/index.ts` + +```typescript +export async function createNext(input: { + id?: string + title?: string + parentID?: string + directory: string + customPrompt?: { // ✨ NEW + type: "file" | "inline" + value: string + } +}) { + const result: Info = { + id: Identifier.descending("session", input.id), + version: Installation.VERSION, + projectID: Instance.project.id, + directory: input.directory, + parentID: input.parentID, + title: input.title ?? createDefaultTitle(!!input.parentID), + + // ✨ NEW: Store custom prompt metadata + customPrompt: input.customPrompt ? { + type: input.customPrompt.type, + value: input.customPrompt.value, + loadedAt: Date.now(), + } : undefined, + + time: { + created: Date.now(), + updated: Date.now(), + }, + } + + await Storage.write(["session", Instance.project.id, result.id], result) + // ... rest of existing logic + return result +} +``` + +### 4. Auto-Detection Logic + +**File:** `/packages/opencode/src/session/index.ts` + +```typescript +function parseCustomPromptInput(input: string | { type: string; value: string }) { + if (typeof input === "object") { + return input as { type: "file" | "inline"; value: string } + } + + // Auto-detect: if it looks like a file path, treat as file + // Otherwise, treat as inline prompt + + const isFilePath = + input.startsWith("/") || // Absolute path + input.startsWith("~/") || // Home directory + input.startsWith("./") || // Relative path + input.startsWith("../") || // Parent directory + input.endsWith(".txt") || // Common extension + input.endsWith(".md") || + !input.includes("\n") // Single line = likely a path + + return { + type: isFilePath ? "file" as const : "inline" as const, + value: input, + } +} + +export const create = fn( + z.object({ + parentID: Identifier.schema("session").optional(), + title: z.string().optional(), + customPrompt: z.union([ + z.string(), + z.object({ + type: z.enum(["file", "inline"]), + value: z.string(), + }), + ]).optional(), + }).optional(), + async (input) => { + const customPrompt = input?.customPrompt + ? parseCustomPromptInput(input.customPrompt) + : undefined + + return createNext({ + parentID: input?.parentID, + directory: Instance.directory, + title: input?.title, + customPrompt, + }) + } +) +``` + +--- + +## Implementation Plan + +### Phase 1: Core Implementation (Priority: High) + +#### Task 1.1: Extend Session Schema +**File:** `/packages/opencode/src/session/index.ts` + +- [ ] Add `customPrompt` field to `Session.Info` schema (lines 37-71) +- [ ] Add `customPrompt` parameter to `Session.create` schema (lines 122-135) +- [ ] Add `customPrompt` parameter to `createNext()` function (lines 175-208) +- [ ] Implement `parseCustomPromptInput()` helper function +- [ ] Update session storage to persist custom prompt metadata + +**Complexity:** Low +**Risk:** Low (additive change, backward compatible) + +#### Task 1.2: Implement Prompt Loading +**File:** `/packages/opencode/src/session/system.ts` + +- [ ] Add `fromSession()` function to load session-level prompts +- [ ] Implement `resolveTemplatePath()` helper for file resolution +- [ ] Add error handling for missing/invalid template files +- [ ] Add logging for prompt loading (debugging) + +**Complexity:** Medium +**Risk:** Medium (file I/O, path resolution edge cases) + +#### Task 1.3: Update Prompt Resolution +**File:** `/packages/opencode/src/session/prompt.ts` + +- [ ] Pass `sessionID` to `resolveSystemPrompt()` function (line 621) +- [ ] Call `SystemPrompt.fromSession()` in priority order (line 629-633) +- [ ] Update all call sites of `resolveSystemPrompt()` to include sessionID +- [ ] Verify prompt caching still works correctly + +**Complexity:** Low +**Risk:** Low (small change to existing function) + +#### Task 1.4: API Validation +**File:** `/packages/opencode/src/server/server.ts` + +- [ ] Verify OpenAPI schema includes new `customPrompt` field (line 516) +- [ ] Test API endpoint with new parameter +- [ ] Add validation for file path security (no directory traversal) + +**Complexity:** Low +**Risk:** Medium (security validation important) + +#### Task 1.5: Implement Variable Interpolation +**File:** `/packages/opencode/src/session/system.ts` + +- [ ] Add `interpolateVariables()` function for template variable substitution +- [ ] Implement built-in variable providers (PROJECT_NAME, GIT_BRANCH, etc.) +- [ ] Add `detectPrimaryLanguage()` helper function +- [ ] Add `getGitBranch()` helper function +- [ ] Implement `extractEnvVariables()` for OPENCODE_VAR_* support +- [ ] Add filter support (uppercase, lowercase, capitalize) +- [ ] Update `fromSession()` to call `interpolateVariables()` before returning +- [ ] Add config schema extension for `promptVariables` in `Config.get()` +- [ ] Test variable resolution priority order + +**Complexity:** Medium +**Risk:** Low (self-contained feature, no external dependencies) + +### Phase 2: CLI Integration (Priority: Medium) + +#### Task 2.1: Add CLI Flag +**File:** `/packages/opencode/src/cli/cmd/*.ts` (TBD - find CLI entry point) + +- [ ] Add `--prompt