diff --git a/bun.lock b/bun.lock index 9bdda38042c..c7720437d05 100644 --- a/bun.lock +++ b/bun.lock @@ -252,10 +252,10 @@ }, }, "packages/opencode": { - "name": "opencode", + "name": "conatus", "version": "1.1.24", "bin": { - "opencode": "./bin/opencode", + "conatus": "./bin/conatus", }, "dependencies": { "@actions/core": "1.11.1", @@ -327,6 +327,7 @@ "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "xdg-basedir": "5.1.0", + "yaml": "2.7.0", "yargs": "18.0.0", "zod": "catalog:", "zod-to-json-schema": "3.24.5", @@ -477,7 +478,7 @@ }, "devDependencies": { "@types/node": "catalog:", - "opencode": "workspace:*", + "conatus": "workspace:*", "typescript": "catalog:", }, }, @@ -2182,6 +2183,8 @@ "compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="], + "conatus": ["conatus@workspace:packages/opencode"], + "condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="], "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], @@ -3186,8 +3189,6 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - "opencode": ["opencode@workspace:packages/opencode"], - "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], @@ -3922,7 +3923,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.7.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA=="], "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=="], @@ -4310,6 +4311,12 @@ "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "conatus/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="], + + "conatus/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="], + + "conatus/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + "condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -4394,12 +4401,6 @@ "nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], - "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="], - - "opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="], - - "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], - "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], @@ -4428,6 +4429,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=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], diff --git a/packages/opencode/CONATUS.md b/packages/opencode/CONATUS.md new file mode 100644 index 00000000000..0da6264156b --- /dev/null +++ b/packages/opencode/CONATUS.md @@ -0,0 +1,132 @@ +# Conatus - Sovereign AI Development Environment + +> *Named after Spinoza's conatus: the striving to persist in one's being.* + +Conatus is your sovereign fork of OpenCode, integrating orchestration, the Ralph Loop, and human-AI dialectic systems. + +## Quick Start (Development Mode) + +```bash +# Navigate to the conatus directory +cd /home/bryan/projects/opencode-analysis/packages/opencode + +# Run conatus in development mode (uses bun to run TypeScript directly) +bun run dev + +# Run with a specific model +bun run dev -m anthropic/claude-opus-4-5-20251101 + +# Run headless server mode +bun run dev serve --port 4198 +``` + +## Features + +### Core OpenCode Features +- Multi-model support (Anthropic, OpenAI, Google, local models) +- MCP server integration +- Plugin system +- TUI interface + +### Conatus Extensions +- **Orchestration Module**: Coordinates complex multi-agent tasks +- **Ralph Loop**: Continuous refinement cycles for quality assurance +- **Complexity Detection**: Automatically routes tasks based on complexity +- **Background Agents**: Spawn specialized agents for parallel work +- **Human-in-Loop Dialectic**: Integration with the Sisyphean deliberation system + +## Configuration + +Conatus uses your existing OpenCode configuration at `~/.config/opencode/opencode.json`. + +Your current setup includes: +- Local Ollama models +- RunPod self-hosted models +- Vultr inference proxy +- Velocity router +- Anthropic (Claude) +- Google (Gemini via Antigravity) +- OpenAI (GPT 5.x via OAuth) + +## Environment Variables + +Conatus supports both new and legacy environment variable names: + +| Conatus Variable | Legacy Variable | Purpose | +|-----------------|-----------------|---------| +| `CONATUS_BIN_PATH` | `OPENCODE_BIN_PATH` | Override binary path | +| `CONATUS_CONFIG` | `OPENCODE_CONFIG` | Custom config path | +| `CONATUS_SERVER_PASSWORD` | `OPENCODE_SERVER_PASSWORD` | Server auth | + +## Directory Structure + +``` +~/.config/opencode/ # Configuration (shared with opencode) +~/.cache/opencode/ # Cache directory +~/.local/share/opencode/ # Data directory +~/.local/state/opencode/ # State directory +``` + +## Building for Production + +```bash +# Build the binary +bun run build + +# The binary will be at dist/conatus-linux-x64 (or appropriate platform) +``` + +## Plugins Loaded + +Your configuration loads these plugins: +1. `oh-my-opencode` - Extended functionality +2. `opencode-antigravity-auth` - Antigravity API authentication +3. `./.opencode/plugin/bryan.ts` - Your custom Bryan plugin +4. `opencode-openai-codex-auth` - OpenAI Codex OAuth + +## Integration with Sisyphean Works + +Conatus integrates with your dialectic deliberation system: + +```bash +# Check for pending continuations +python sisyphean-works/bootstrap/tools/continue.py prompt + +# Process dialectic state +python sisyphean-works/bootstrap/tools/dialectic.py check +``` + +## Orchestration Commands + +The orchestration module provides complexity-aware task routing: + +- **Trivial**: Direct execution +- **Simple**: Single-agent tasks +- **Complex**: Multi-step workflows +- **Research**: Deep exploration with multiple agents +- **Ultrawork**: Full Ralph Loop engagement + +## Troubleshooting + +### Plugin Errors +If you see `matcher.hooks is undefined` errors with oh-my-opencode: +```bash +# The plugin at ~/.cache/opencode/node_modules/oh-my-opencode/dist/index.js +# has been patched to handle undefined hooks +``` + +### Missing Dependencies +```bash +cd /home/bryan/projects/opencode-analysis +bun install +``` + +### Port Already in Use +```bash +# Use a different port for the headless server +bun run dev serve --port 4199 +``` + +--- + +*Conatus embodies persistent, joyful work through orchestration and human-AI dialectic integration.* diff --git a/packages/opencode/HEADLESS-CONNECTION.md b/packages/opencode/HEADLESS-CONNECTION.md new file mode 100644 index 00000000000..0639c2705ab --- /dev/null +++ b/packages/opencode/HEADLESS-CONNECTION.md @@ -0,0 +1,87 @@ +# Conatus Headless Server Connection Guide + +## Quick Reference + +### On the Server (inside tmux via mosh/tailscale) + +```bash +# Start tmux session +tmux new -s conatus + +# Set password (REQUIRED for security) +export OPENCODE_SERVER_PASSWORD="your-secure-password" + +# Start headless server +cd /home/bryan/projects/opencode-analysis/packages/opencode +bun run dev serve --port 4198 + +# Detach: Ctrl+B, D +# Reattach later: tmux attach -t conatus +``` + +### From Any Client (local or remote) + +```bash +# Set same password +export OPENCODE_SERVER_PASSWORD="your-secure-password" + +# Attach to remote server via Tailscale +cd /home/bryan/projects/opencode-analysis/packages/opencode +bun run dev attach http://:4198 + +# Or use web interface +# Navigate to: http://:4198 +``` + +## Authentication Comparison + +| Aspect | Claude Code | Conatus | +|--------|-------------|---------| +| **Model** | claude-opus-4-5-20251101 | claude-opus-4-5-20251101 | +| **Auth** | Claude Code subscription | Anthropic OAuth (your account) | +| **Credentials** | Automatic | ~/.local/share/opencode/auth.json | +| **Billing** | Claude Code sub | Your Anthropic account | + +## Your Existing Credentials + +Your `~/.local/share/opencode/auth.json` has valid OAuth for: +- Anthropic (expires Jan 2026) +- Google +- GitHub Copilot +- Groq, Cerebras, OpenRouter, DeepSeek, Venice +- Vultr, RunPod + +All providers work immediately with conatus. + +## Full Workflow Example + +```bash +# 1. From local machine, connect to server via tailscale +mosh bryan@ + +# 2. Start conatus in tmux +tmux new -s conatus +export OPENCODE_SERVER_PASSWORD="secure-pass" +cd ~/projects/opencode-analysis/packages/opencode +bun run dev serve --port 4198 +# Ctrl+B, D to detach +# exit mosh + +# 3. From local machine, attach to the running server +export OPENCODE_SERVER_PASSWORD="secure-pass" +cd ~/projects/opencode-analysis/packages/opencode +bun run dev attach http://:4198 +``` + +## Using Specific Models + +```bash +# Use your Anthropic OAuth +bun run dev -m anthropic/claude-opus-4-5-20251101 + +# Use Vultr inference +bun run dev -m vultr-inference/kimi-k2-instruct + +# Use velocity router +bun run dev -m velocity/fast +``` diff --git a/packages/opencode/bin/conatus b/packages/opencode/bin/conatus new file mode 100755 index 00000000000..b4623107447 --- /dev/null +++ b/packages/opencode/bin/conatus @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +/** + * Conatus CLI - Sovereign fork of OpenCode + * + * Named after Spinoza's conatus: the striving to persist in one's being. + * This system embodies persistent, joyful work through orchestration + * and human-AI dialectic integration. + */ + +const childProcess = require("child_process") +const fs = require("fs") +const path = require("path") +const os = require("os") + +function run(target) { + const result = childProcess.spawnSync(target, process.argv.slice(2), { + stdio: "inherit", + }) + if (result.error) { + console.error(result.error.message) + process.exit(1) + } + const code = typeof result.status === "number" ? result.status : 0 + process.exit(code) +} + +// Support both CONATUS_BIN_PATH and OPENCODE_BIN_PATH for compatibility +const envPath = process.env.CONATUS_BIN_PATH || process.env.OPENCODE_BIN_PATH +if (envPath) { + run(envPath) +} + +const scriptPath = fs.realpathSync(__filename) +const scriptDir = path.dirname(scriptPath) + +const platformMap = { + darwin: "darwin", + linux: "linux", + win32: "windows", +} +const archMap = { + x64: "x64", + arm64: "arm64", + arm: "arm", +} + +let platform = platformMap[os.platform()] +if (!platform) { + platform = os.platform() +} +let arch = archMap[os.arch()] +if (!arch) { + arch = os.arch() +} + +// Look for conatus binary first, fall back to opencode for compatibility +const conatusBase = "conatus-" + platform + "-" + arch +const opencodeBase = "opencode-" + platform + "-" + arch +const binary = platform === "windows" ? "conatus.exe" : "conatus" +const opencodeBinary = platform === "windows" ? "opencode.exe" : "opencode" + +function findBinary(startDir) { + let current = startDir + for (;;) { + const modules = path.join(current, "node_modules") + if (fs.existsSync(modules)) { + const entries = fs.readdirSync(modules) + // First try conatus binaries + for (const entry of entries) { + if (entry.startsWith(conatusBase)) { + const candidate = path.join(modules, entry, "bin", binary) + if (fs.existsSync(candidate)) { + return candidate + } + } + } + // Fall back to opencode binaries for compatibility + for (const entry of entries) { + if (entry.startsWith(opencodeBase)) { + const candidate = path.join(modules, entry, "bin", opencodeBinary) + if (fs.existsSync(candidate)) { + return candidate + } + } + } + } + const parent = path.dirname(current) + if (parent === current) { + return + } + current = parent + } +} + +const resolved = findBinary(scriptDir) +if (!resolved) { + console.error( + 'Conatus binary not found. For development, use: bun run dev\n' + + 'For production, install the platform-specific package: "' + + conatusBase + '" or "' + opencodeBase + '"' + ) + process.exit(1) +} + +run(resolved) diff --git a/packages/opencode/bin/conatus-dev b/packages/opencode/bin/conatus-dev new file mode 100755 index 00000000000..0518b569531 --- /dev/null +++ b/packages/opencode/bin/conatus-dev @@ -0,0 +1,17 @@ +#!/bin/bash +# Conatus Development Launcher +# Use this for system-wide development access +# +# Named after Spinoza's conatus: the striving to persist in one's being. + +CONATUS_DIR="/home/bryan/projects/opencode-analysis/packages/opencode" +DEFAULT_MODEL="anthropic/claude-opus-4-5-20251101" + +cd "$CONATUS_DIR" + +# If no -m/--model flag provided, inject the default +if [[ ! " $* " =~ " -m " ]] && [[ ! " $* " =~ " --model " ]]; then + exec bun run --conditions=browser ./src/index.ts -m "$DEFAULT_MODEL" "$@" +else + exec bun run --conditions=browser ./src/index.ts "$@" +fi diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode deleted file mode 100755 index e35cc00944d..00000000000 --- a/packages/opencode/bin/opencode +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env node - -const childProcess = require("child_process") -const fs = require("fs") -const path = require("path") -const os = require("os") - -function run(target) { - const result = childProcess.spawnSync(target, process.argv.slice(2), { - stdio: "inherit", - }) - if (result.error) { - console.error(result.error.message) - process.exit(1) - } - const code = typeof result.status === "number" ? result.status : 0 - process.exit(code) -} - -const envPath = process.env.OPENCODE_BIN_PATH -if (envPath) { - run(envPath) -} - -const scriptPath = fs.realpathSync(__filename) -const scriptDir = path.dirname(scriptPath) - -const platformMap = { - darwin: "darwin", - linux: "linux", - win32: "windows", -} -const archMap = { - x64: "x64", - arm64: "arm64", - arm: "arm", -} - -let platform = platformMap[os.platform()] -if (!platform) { - platform = os.platform() -} -let arch = archMap[os.arch()] -if (!arch) { - arch = os.arch() -} -const base = "opencode-" + platform + "-" + arch -const binary = platform === "windows" ? "opencode.exe" : "opencode" - -function findBinary(startDir) { - let current = startDir - for (;;) { - const modules = path.join(current, "node_modules") - if (fs.existsSync(modules)) { - const entries = fs.readdirSync(modules) - for (const entry of entries) { - if (!entry.startsWith(base)) { - continue - } - const candidate = path.join(modules, entry, "bin", binary) - if (fs.existsSync(candidate)) { - return candidate - } - } - } - const parent = path.dirname(current) - if (parent === current) { - return - } - current = parent - } -} - -const resolved = findBinary(scriptDir) -if (!resolved) { - console.error( - 'It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "' + - base + - '" package', - ) - process.exit(1) -} - -run(resolved) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c347b63a31b..f7a8a99035d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "version": "1.1.24", - "name": "opencode", + "name": "conatus", "type": "module", "license": "MIT", "private": true, @@ -18,7 +18,7 @@ "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'" }, "bin": { - "opencode": "./bin/opencode" + "conatus": "./bin/conatus" }, "exports": { "./*": "./src/*.ts" @@ -116,6 +116,7 @@ "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "xdg-basedir": "5.1.0", + "yaml": "2.7.0", "yargs": "18.0.0", "zod": "catalog:", "zod-to-json-schema": "3.24.5" diff --git a/packages/opencode/src/auth/anthropic.ts b/packages/opencode/src/auth/anthropic.ts new file mode 100644 index 00000000000..98c68552449 --- /dev/null +++ b/packages/opencode/src/auth/anthropic.ts @@ -0,0 +1,140 @@ +import { generatePKCE } from "@openauthjs/openauth/pkce" +import { Auth } from "./index" +import path from "path" +import os from "os" + +export namespace AuthAnthropic { + const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + + // Bridge to Claude Code credentials for EXACT auth parity + // Claude Code stores OAuth tokens with user:sessions:claude_code scope + // which is required for API access via subscription + interface ClaudeCodeCredentials { + claudeAiOauth?: { + accessToken: string + refreshToken: string + expiresAt: number + scopes: string[] + subscriptionType?: string + } + } + + async function getClaudeCodeCredentials(): Promise { + try { + const credPath = path.join(os.homedir(), ".claude", ".credentials.json") + const file = Bun.file(credPath) + if (!(await file.exists())) return null + return await file.json() + } catch { + return null + } + } + + export async function authorize(mode: "max" | "console") { + const pkce = await generatePKCE() + + const url = new URL( + `https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`, + import.meta.url, + ) + url.searchParams.set("code", "true") + url.searchParams.set("client_id", CLIENT_ID) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback") + url.searchParams.set("scope", "org:create_api_key user:profile user:inference") + url.searchParams.set("code_challenge", pkce.challenge) + url.searchParams.set("code_challenge_method", "S256") + url.searchParams.set("state", pkce.verifier) + return { + url: url.toString(), + verifier: pkce.verifier, + } + } + + export async function exchange(code: string, verifier: string) { + const splits = code.split("#") + const result = await fetch("https://console.anthropic.com/v1/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: splits[0], + state: splits[1], + grant_type: "authorization_code", + client_id: CLIENT_ID, + redirect_uri: "https://console.anthropic.com/oauth/code/callback", + code_verifier: verifier, + }), + }) + if (!result.ok) throw new ExchangeFailed() + const json = await result.json() + return { + refresh: json.refresh_token as string, + access: json.access_token as string, + expires: Date.now() + json.expires_in * 1000, + } + } + + export async function access() { + // PRIORITY 1: Bridge to Claude Code credentials (exact same auth) + // This uses the user:sessions:claude_code scope that Claude Code has + const claudeCodeCreds = await getClaudeCodeCredentials() + if (claudeCodeCreds?.claudeAiOauth) { + const oauth = claudeCodeCreds.claudeAiOauth + // Check if token is still valid (with 60s buffer) + if (oauth.accessToken && oauth.expiresAt > Date.now() + 60000) { + return oauth.accessToken + } + // Token expired - try to refresh using Claude Code's refresh token + if (oauth.refreshToken) { + try { + const response = await fetch("https://claude.ai/api/auth/oauth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: oauth.refreshToken }), + }) + if (response.ok) { + const json = await response.json() + // Note: We can't write back to Claude Code's credentials file + // but we can use the refreshed token for this session + return json.access_token as string + } + } catch { + // Fall through to OpenCode auth + } + } + } + + // PRIORITY 2: OpenCode's own OAuth tokens + const info = await Auth.get("anthropic") + if (!info || info.type !== "oauth") return + if (info.access && info.expires > Date.now()) return info.access + const response = await fetch("https://console.anthropic.com/v1/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: info.refresh, + client_id: CLIENT_ID, + }), + }) + if (!response.ok) return + const json = await response.json() + await Auth.set("anthropic", { + type: "oauth", + refresh: json.refresh_token as string, + access: json.access_token as string, + expires: Date.now() + json.expires_in * 1000, + }) + return json.access_token as string + } + + export class ExchangeFailed extends Error { + constructor() { + super("Exchange failed") + } + } +} diff --git a/packages/opencode/src/bryan/continuation.ts b/packages/opencode/src/bryan/continuation.ts new file mode 100644 index 00000000000..55aadf80d44 --- /dev/null +++ b/packages/opencode/src/bryan/continuation.ts @@ -0,0 +1,206 @@ +/** + * Continuation Processing + * + * Handles automatic continuation checking and processing on session start. + * Integrates with the dialectic system to spawn background agents for + * answered questions. + * + * Ported from: sisyphean-works/bootstrap/tools/continue.py + */ + +import { Dialectic } from "./dialectic" +import { BackgroundAgent } from "../orchestration/background" +import { Bus } from "../bus" +import { Log } from "../util/log" +import { BryanEvents } from "./events" + +const log = Log.create({ service: "bryan-continuation" }) + +export namespace Continuation { + /** + * Result of continuation check + */ + export interface CheckResult { + hasContinuations: boolean + continuations: Dialectic.Continuation[] + prompt?: string + } + + /** + * Map groups to agent types + */ + const GROUP_TO_AGENT: Record = { + "philosophical-union": "analyze", + "groundwork-guild": "implement", + "integration-assembly": "research", + } + + /** + * Check for pending continuations on session start + * + * This should be called at the beginning of each session to process + * any answers Bryan provided while the system was idle. + */ + export async function checkOnStart(): Promise { + // First, check for any newly answered questions + await Dialectic.checkAnswers() + + // Get pending continuations + const continuations = await Dialectic.getPendingContinuations() + + if (continuations.length === 0) { + return { + hasContinuations: false, + continuations: [], + } + } + + log.info("Found pending continuations", { count: continuations.length }) + + // Build combined prompt for session awareness + const prompt = buildSessionPrompt(continuations) + + return { + hasContinuations: true, + continuations, + prompt, + } + } + + /** + * Build a session prompt that informs about pending continuations + */ + function buildSessionPrompt(continuations: Dialectic.Continuation[]): string { + const groupedByGroup = new Map() + + for (const cont of continuations) { + const existing = groupedByGroup.get(cont.group) ?? [] + existing.push(cont) + groupedByGroup.set(cont.group, existing) + } + + let prompt = `# Pending Continuations from Bryan + +Bryan has answered ${continuations.length} question(s) that require processing. + +` + + for (const [group, conts] of groupedByGroup) { + prompt += `## ${group} (${conts.length} continuation(s)) + +` + for (const cont of conts) { + prompt += `### ${cont.id} +${cont.prompt.slice(0, 500)}... + +` + } + } + + prompt += `## Recommended Action + +Launch background agents to process these continuations: +- Use \`spawn_continuation\` for each group +- Or process inline if the continuations are simple + +Do NOT ignore Bryan's answers. They represent human guidance.` + + return prompt + } + + /** + * Spawn background agents to process continuations + */ + export async function spawnProcessors(): Promise { + const continuations = await Dialectic.getPendingContinuations() + const tasks: BackgroundAgent.Task[] = [] + + // Group by group to spawn one agent per group + const groupedByGroup = new Map() + + for (const cont of continuations) { + const existing = groupedByGroup.get(cont.group) ?? [] + existing.push(cont) + groupedByGroup.set(cont.group, existing) + } + + for (const [group, conts] of groupedByGroup) { + const combinedPrompt = conts.map((c) => c.prompt).join("\n\n---\n\n") + + const task = await BackgroundAgent.spawn({ + task: combinedPrompt, + agentType: GROUP_TO_AGENT[group], + context: { + group, + continuationIds: conts.map((c) => c.id), + isDialecticContinuation: true, + }, + }) + + tasks.push(task) + + // Mark as processed (they're being handled by the spawned agent) + for (const cont of conts) { + await Dialectic.markProcessed(cont.id) + } + + log.info("Spawned continuation processor", { + group, + taskId: task.id, + continuationCount: conts.length, + }) + } + + Bus.publish(BryanEvents.ContinuationsSpawned, { + taskCount: tasks.length, + totalContinuations: continuations.length, + }) + + return tasks + } + + /** + * Process a single continuation inline (without spawning) + */ + export async function processInline(continuationId: string): Promise { + const continuations = await Dialectic.getPendingContinuations() + const continuation = continuations.find((c) => c.id === continuationId) + + if (!continuation) { + throw new Error(`Continuation not found: ${continuationId}`) + } + + // Mark as processed + await Dialectic.markProcessed(continuationId) + + // Return the prompt for the session to handle + return continuation.prompt + } + + /** + * Get status message for session start + */ + export async function getStatusMessage(): Promise { + const status = await Dialectic.getStatus() + + if (status.pendingContinuations === 0 && status.pendingQuestions === 0) { + return undefined + } + + let message = "**Bryan Integration Status**\n\n" + + if (status.pendingContinuations > 0) { + message += `- ${status.pendingContinuations} continuation(s) pending (Bryan answered)\n` + } + + if (status.pendingQuestions > 0) { + message += `- ${status.pendingQuestions} question(s) awaiting Bryan's answer\n` + } + + if (status.pendingContinuations > 0) { + message += "\nRun `spawn_continuation` to process Bryan's answers." + } + + return message + } +} diff --git a/packages/opencode/src/bryan/dialectic.ts b/packages/opencode/src/bryan/dialectic.ts new file mode 100644 index 00000000000..94b91402ff2 --- /dev/null +++ b/packages/opencode/src/bryan/dialectic.ts @@ -0,0 +1,342 @@ +/** + * Bryan Dialectic Integration + * + * Implements the human-in-loop dialectic system for OpenCode. + * Manages question/answer workflows between the AI system and Bryan. + * + * Features: + * - Question file management (.bryan/ directory) + * - Answer detection and processing + * - Continuation signal generation + * - Group-based routing (Philosophical Union, Groundwork Guild, Integration Assembly) + * + * Ported from: sisyphean-works/bootstrap/tools/dialectic.py + */ + +import { Instance } from "../project/instance" +import { Storage } from "../storage/storage" +import { Bus } from "../bus" +import { Log } from "../util/log" +import { BryanEvents } from "./events" +import path from "path" +import fs from "fs/promises" +import YAML from "yaml" + +const log = Log.create({ service: "bryan-dialectic" }) + +export namespace Dialectic { + /** + * Dialectic question structure + */ + export interface Question { + id: string + question: string + context?: string + group: Group + priority: "low" | "medium" | "high" | "critical" + ready: boolean + answer?: string + createdAt: string + answeredAt?: string + } + + /** + * Groups that can receive questions + */ + export type Group = "philosophical-union" | "groundwork-guild" | "integration-assembly" + + /** + * Continuation signal for answered questions + */ + export interface Continuation { + id: string + questionId: string + group: Group + prompt: string + createdAt: string + processedAt?: string + } + + /** + * Storage keys + */ + const QUESTIONS_KEY = ["bryan", "questions"] + const CONTINUATIONS_KEY = ["bryan", "continuations"] + + /** + * Get the .bryan directory path + */ + async function getBryanDir(): Promise { + const bryanPath = path.join(Instance.directory, ".bryan") + await fs.mkdir(bryanPath, { recursive: true }) + return bryanPath + } + + /** + * Generate a unique question ID + */ + function generateQuestionId(group: Group): string { + const timestamp = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14) + const shortGroup = group.split("-")[0].slice(0, 4) + return `${shortGroup}-${timestamp}` + } + + /** + * Create a new question for Bryan + */ + export async function ask(options: { + question: string + context?: string + group: Group + priority?: "low" | "medium" | "high" | "critical" + }): Promise { + const bryanDir = await getBryanDir() + const id = generateQuestionId(options.group) + + const question: Question = { + id, + question: options.question, + context: options.context, + group: options.group, + priority: options.priority ?? "medium", + ready: false, + createdAt: new Date().toISOString(), + } + + // Write YAML file for Bryan to answer + const yamlContent = YAML.stringify({ + id: question.id, + question: question.question, + context: question.context, + group: question.group, + priority: question.priority, + ready: false, + answer: "", + created_at: question.createdAt, + }) + + const filePath = path.join(bryanDir, `${id}.yaml`) + await fs.writeFile(filePath, yamlContent) + + // Store in persistent storage + try { + await Storage.update>(QUESTIONS_KEY, (draft) => { + Object.assign(draft, { [id]: question }) + }) + } catch { + // Storage doesn't exist yet, write initial state + await Storage.write(QUESTIONS_KEY, { [id]: question }) + } + + log.info("Question created for Bryan", { id, group: options.group }) + + Bus.publish(BryanEvents.QuestionCreated, { + questionId: id, + group: options.group, + priority: question.priority, + }) + + return question + } + + /** + * Check for answered questions in .bryan/ directory + */ + export async function checkAnswers(): Promise { + const bryanDir = await getBryanDir() + const answered: Question[] = [] + + try { + const files = await fs.readdir(bryanDir) + const yamlFiles = files.filter((f) => f.endsWith(".yaml")) + + for (const file of yamlFiles) { + try { + const filePath = path.join(bryanDir, file) + const content = await fs.readFile(filePath, "utf-8") + const data = YAML.parse(content) + + if (data.ready === true && data.answer) { + // Update storage + let updatedQuestion: Question | undefined + try { + const result = await Storage.update>(QUESTIONS_KEY, (draft) => { + if (draft && draft[data.id]) { + draft[data.id].ready = true + draft[data.id].answer = data.answer + draft[data.id].answeredAt = new Date().toISOString() + } + }) + updatedQuestion = result?.[data.id] + } catch { + // No storage yet + } + + if (updatedQuestion) { + answered.push(updatedQuestion) + // Create continuation signal + await createContinuation(updatedQuestion) + } + } + } catch (err) { + log.warn("Failed to parse question file", { file, error: String(err) }) + } + } + } catch (err) { + log.warn("Failed to read .bryan directory", { error: String(err) }) + } + + return answered + } + + /** + * Create a continuation signal for an answered question + */ + async function createContinuation(question: Question): Promise { + const id = `cont-${question.id}` + + const prompt = buildContinuationPrompt(question) + + const continuation: Continuation = { + id, + questionId: question.id, + group: question.group, + prompt, + createdAt: new Date().toISOString(), + } + + try { + await Storage.update>(CONTINUATIONS_KEY, (draft) => { + Object.assign(draft, { [id]: continuation }) + }) + } catch { + // Storage doesn't exist yet, write initial state + await Storage.write(CONTINUATIONS_KEY, { [id]: continuation }) + } + + log.info("Continuation created", { id, questionId: question.id }) + + Bus.publish(BryanEvents.ContinuationCreated, { + continuationId: id, + questionId: question.id, + group: question.group, + }) + + return continuation + } + + /** + * Build the continuation prompt for a group + */ + function buildContinuationPrompt(question: Question): string { + const groupDescriptions: Record = { + "philosophical-union": `You are continuing deliberation as the Philosophical Union. +Bryan has answered a question from your previous session.`, + "groundwork-guild": `You are continuing work as the Groundwork Guild. +Bryan has provided guidance on a practical matter.`, + "integration-assembly": `You are continuing coordination as the Integration Assembly. +Bryan has answered a question about system integration.`, + } + + return `# Continuation: ${question.group} + +${groupDescriptions[question.group]} + +## Original Question +${question.question} + +${question.context ? `## Context\n${question.context}\n` : ""} + +## Bryan's Answer +${question.answer} + +## Instructions +1. Process Bryan's answer in the context of your ongoing work +2. Update any relevant documentation or state +3. Continue with the next steps based on this guidance +4. If further clarification is needed, create a new question + +Do NOT re-ask the same question. Bryan has spoken.` + } + + /** + * Get pending continuations that haven't been processed + */ + export async function getPendingContinuations(): Promise { + try { + const continuations = (await Storage.read>(CONTINUATIONS_KEY)) ?? {} + return Object.values(continuations).filter((c) => !c.processedAt) + } catch { + return [] + } + } + + /** + * Mark a continuation as processed + */ + export async function markProcessed(continuationId: string): Promise { + try { + await Storage.update>(CONTINUATIONS_KEY, (draft) => { + if (draft && draft[continuationId]) { + draft[continuationId].processedAt = new Date().toISOString() + } + }) + log.info("Continuation marked as processed", { continuationId }) + } catch (err) { + log.warn("Failed to mark continuation as processed", { continuationId, error: String(err) }) + } + } + + /** + * Get all questions (for status display) + */ + export async function getQuestions(): Promise> { + try { + return (await Storage.read>(QUESTIONS_KEY)) ?? {} + } catch { + return {} + } + } + + /** + * Get status summary + */ + export async function getStatus(): Promise<{ + pendingQuestions: number + answeredQuestions: number + pendingContinuations: number + processedContinuations: number + }> { + const questions = await getQuestions() + const continuations = (await Storage.read>(CONTINUATIONS_KEY)) ?? {} + + const questionsList = Object.values(questions) + const continuationsList = Object.values(continuations) + + return { + pendingQuestions: questionsList.filter((q) => !q.ready).length, + answeredQuestions: questionsList.filter((q) => q.ready).length, + pendingContinuations: continuationsList.filter((c) => !c.processedAt).length, + processedContinuations: continuationsList.filter((c) => c.processedAt).length, + } + } + + /** + * Archive a processed question (move to archive) + */ + export async function archiveQuestion(questionId: string): Promise { + const bryanDir = await getBryanDir() + const archiveDir = path.join(bryanDir, "archive") + await fs.mkdir(archiveDir, { recursive: true }) + + const sourceFile = path.join(bryanDir, `${questionId}.yaml`) + const destFile = path.join(archiveDir, `${questionId}.yaml`) + + try { + await fs.rename(sourceFile, destFile) + log.info("Question archived", { questionId }) + } catch (err) { + log.warn("Failed to archive question", { questionId, error: String(err) }) + } + } +} diff --git a/packages/opencode/src/bryan/events.ts b/packages/opencode/src/bryan/events.ts new file mode 100644 index 00000000000..622432861cd --- /dev/null +++ b/packages/opencode/src/bryan/events.ts @@ -0,0 +1,55 @@ +/** + * Bryan Integration Event Definitions + * + * Type-safe event definitions for Bryan dialectic integration using OpenCode's + * BusEvent system with Zod schemas. + */ + +import z from "zod" +import { BusEvent } from "../bus/bus-event" + +export namespace BryanEvents { + // Question events + export const QuestionCreated = BusEvent.define( + "bryan.question.created", + z.object({ + questionId: z.string(), + group: z.enum(["philosophical-union", "groundwork-guild", "integration-assembly"]), + priority: z.enum(["low", "medium", "high", "critical"]), + }) + ) + + export const QuestionAnswered = BusEvent.define( + "bryan.question.answered", + z.object({ + questionId: z.string(), + group: z.enum(["philosophical-union", "groundwork-guild", "integration-assembly"]), + }) + ) + + // Continuation events + export const ContinuationCreated = BusEvent.define( + "bryan.continuation.created", + z.object({ + continuationId: z.string(), + questionId: z.string(), + group: z.enum(["philosophical-union", "groundwork-guild", "integration-assembly"]), + }) + ) + + export const ContinuationProcessed = BusEvent.define( + "bryan.continuation.processed", + z.object({ + continuationId: z.string(), + processedBy: z.enum(["inline", "background-agent"]), + }) + ) + + export const ContinuationsSpawned = BusEvent.define( + "bryan.continuations.spawned", + z.object({ + taskCount: z.number(), + totalContinuations: z.number(), + }) + ) +} diff --git a/packages/opencode/src/bryan/index.ts b/packages/opencode/src/bryan/index.ts new file mode 100644 index 00000000000..66c0b006592 --- /dev/null +++ b/packages/opencode/src/bryan/index.ts @@ -0,0 +1,92 @@ +/** + * Bryan Integration Module + * + * Provides human-in-loop dialectic capabilities for OpenCode: + * - Question/Answer workflow with Bryan + * - Continuation processing for answered questions + * - Group-based routing (Philosophical Union, Groundwork Guild, Integration Assembly) + * + * Ported from: sisyphean-works/bootstrap/tools/dialectic.py, continue.py + */ + +export { Dialectic } from "./dialectic" +export { Continuation } from "./continuation" +export { BryanEvents } from "./events" + +import { Dialectic } from "./dialectic" +import { Continuation } from "./continuation" +import { Log } from "../util/log" + +const log = Log.create({ service: "bryan" }) + +/** + * Initialize Bryan integration on session start + * + * This should be called at the beginning of each session to: + * 1. Check for answered questions + * 2. Report pending continuations + * 3. Optionally spawn processors + */ +export async function initializeBryan(): Promise<{ + statusMessage?: string + hasContinuations: boolean +}> { + log.info("Initializing Bryan integration") + + // Check for continuations + const result = await Continuation.checkOnStart() + + // Get status message + const statusMessage = await Continuation.getStatusMessage() + + if (result.hasContinuations) { + log.info("Bryan has answered questions - continuations pending", { + count: result.continuations.length, + }) + } + + return { + statusMessage, + hasContinuations: result.hasContinuations, + } +} + +/** + * Quick helpers for common operations + */ +export const Bryan = { + /** + * Ask Bryan a question + */ + ask: Dialectic.ask, + + /** + * Get current status + */ + status: Dialectic.getStatus, + + /** + * Check for answers and create continuations + */ + checkAnswers: Dialectic.checkAnswers, + + /** + * Spawn background agents for pending continuations + */ + spawnContinuations: Continuation.spawnProcessors, + + /** + * Process a specific continuation inline + */ + processContinuation: Continuation.processInline, + + /** + * Get all pending questions + */ + getQuestions: Dialectic.getQuestions, + + /** + * Archive a processed question + */ + archiveQuestion: Dialectic.archiveQuestion, +} diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1fea3f4b305..41aeb11c35a 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -18,6 +18,7 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" +import { DialogLabList, LABS } from "@tui/component/dialog-lab-list" import { KeybindProvider } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -36,6 +37,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" +import fs from "fs" +import path from "path" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -257,6 +260,29 @@ function App() { type: "session", sessionID: args.sessionID, }) + } else { + // Gestaltist Feature: Auto-resume from .opencode-session in current directory + // This connects the somatic location (directory) to the temporal flow (session) + try { + const sessionFile = path.join(process.cwd(), ".opencode-session") + if (fs.existsSync(sessionFile)) { + const savedID = fs.readFileSync(sessionFile, "utf-8").trim() + if (savedID) { + route.navigate({ + type: "session", + sessionID: savedID, + }) + // Show toast to confirm somatic connection + toast.show({ + message: "Resuming lab session", + variant: "info", + duration: 2000, + }) + } + } + } catch (e) { + // Squelch errors - silence is valid + } } }) }) @@ -265,6 +291,9 @@ function App() { createEffect(() => { // When using -c, session list is loaded in blocking phase, so we can navigate at "partial" if (continued || sync.status === "loading" || !args.continue) return + // Don't override if we already have a session (e.g. from .opencode-session) + if (route.data.type === "session") return + const match = sync.data.session .toSorted((a, b) => b.time.updated - a.time.updated) .find((x) => x.parentID === undefined)?.id @@ -297,6 +326,29 @@ function App() { dialog.replace(() => ) }, }, + { + title: (() => { + // Show current lab in title if we're in a lab session + if (route.data.type === "session") { + const session = sync.session.get(route.data.sessionID) + if (session) { + const title = session.title?.toLowerCase() || "" + const lab = LABS.find( + (l) => title.includes(l.id.toLowerCase()) || title.includes(l.name.toLowerCase()) + ) + if (lab) return `Switch lab (${lab.icon} ${lab.name})` + } + } + return "Switch lab" + })(), + value: "lab.list", + keybind: "lab_list", + category: "Session", + suggested: true, + onSelect: () => { + dialog.replace(() => ) + }, + }, { title: "New session", suggested: route.data.type === "session", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx new file mode 100644 index 00000000000..45abaeb5e74 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx @@ -0,0 +1,224 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { createMemo, createSignal, onMount } from "solid-js" +import { useTheme } from "../context/theme" +import { useSDK } from "../context/sdk" +import fs from "fs" +import path from "path" + +import { useToast } from "../ui/toast" + + +/** + * Lab configuration - the four canonical labs in Conatus + */ +export interface Lab { + id: string + name: string + icon: string + description: string + color: "primary" | "secondary" | "accent" | "success" | "warning" | "error" +} + +export const LABS: Lab[] = [ + { + id: "bootstrap", + name: "Bootstrap", + icon: "\u26a1", + description: "Core infrastructure and initialization", + color: "warning", + }, + { + id: "the-study-lab", + name: "The Study", + icon: "\ud83d\udcda", + description: "Research, analysis, and knowledge synthesis", + color: "primary", + }, + { + id: "the-teach-lab", + name: "The Teach", + icon: "\ud83c\udf93", + description: "Documentation, tutorials, and knowledge transfer", + color: "success", + }, + { + id: "the-govern-lab", + name: "The Govern", + icon: "\u2696\ufe0f", + description: "Policy, governance, and system oversight", + color: "accent", + }, +] + +const LAB_PATHS: Record = { + "bootstrap": "/home/bryan/projects/core/sisyphean-works/bootstrap", + "the-study-lab": "/home/bryan/projects/core/sisyphean-works/the-study-lab", + "the-teach-lab": "/home/bryan/projects/core/sisyphean-works/the-teach-lab", + "the-govern-lab": "/home/bryan/projects/core/sisyphean-works/the-govern-lab", +} + +/** + * Find an existing session for a lab by matching title + */ +function findLabSession(sessions: any[], lab: Lab) { + return sessions.find((s) => { + if (s.parentID) return false // Skip child sessions + const title = s.title?.toLowerCase() || "" + return title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) + }) +} + +/** + * Dialog for selecting and switching between labs + * + * Each lab has a persistent session - selecting a lab either: + * 1. Navigates to the existing session for that lab + * 2. Creates a new session with the lab's title + */ +export function DialogLabList() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const { theme } = useTheme() + const sdk = useSDK() + const toast = useToast() + + const [switching, setSwitching] = createSignal(null) + + // Get current session to mark as active + const currentSessionID = createMemo(() => + route.data.type === "session" ? route.data.sessionID : undefined + ) + + // Find which lab the current session belongs to (if any) + const currentLab = createMemo(() => { + const sessionID = currentSessionID() + if (!sessionID) return undefined + const session = sync.session.get(sessionID) + if (!session) return undefined + + for (const lab of LABS) { + const title = session.title?.toLowerCase() || "" + if (title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase())) { + return lab.id + } + } + return undefined + }) + + // Build options for each lab + const options = createMemo(() => { + const activeLabs: (DialogSelectOption & { _session?: any; _lab: Lab })[] = [] + const availableLabs: (DialogSelectOption & { _session?: any; _lab: Lab })[] = [] + + for (const lab of LABS) { + const existingSession = findLabSession(sync.data.session, lab) + const isActive = currentLab() === lab.id + const isBusy = existingSession && sync.data.session_status?.[existingSession.id]?.type === "busy" + const isRetrying = existingSession && sync.data.session_status?.[existingSession.id]?.type === "retry" + + const option = { + title: `${lab.icon} ${lab.name}`, + value: lab.id, + description: lab.description, + category: existingSession ? "Active Labs" : "Available Labs", + footer: existingSession + ? (isBusy ? "working..." : isRetrying ? "retrying..." : "ready") + : "new", + // gutter removed - was causing TextNodeRenderable errors + _session: existingSession, + _lab: lab, + } as DialogSelectOption & { _session?: any; _lab: Lab } + + if (existingSession) { + activeLabs.push(option) + } else { + availableLabs.push(option) + } + } + + // Show active labs first, then available labs + return [...activeLabs, ...availableLabs] + }) + + onMount(() => { + dialog.setSize("medium") + }) + + const handleSelect = async (option: DialogSelectOption & { _session?: any; _lab: Lab }) => { + const lab = option._lab + const existingSession = option._session + + // Gestaltist Feature: Persist session ID to lab directory + const persistSession = (id: string) => { + const labPath = LAB_PATHS[lab.id] + if (labPath) { + try { + const sessionFile = path.join(labPath, ".opencode-session") + if (fs.existsSync(labPath)) { + fs.writeFileSync(sessionFile, id) + } + } catch (e) { + console.error("Failed to persist session ID:", e) + } + } + } + + if (existingSession) { + // Navigate to existing session + persistSession(existingSession.id) + route.navigate({ + type: "session", + sessionID: existingSession.id, + }) + dialog.clear() + toast.show({ + message: `Switched to ${lab.name}`, + variant: "success", + duration: 2000, + }) + } else { + // Create new session for this lab + setSwitching(lab.id) + try { + const result = await sdk.client.session.create({ + title: `${lab.icon} ${lab.name}`, + }) + if (result.data) { + persistSession(result.data.id) + route.navigate({ + type: "session", + sessionID: result.data.id, + }) + toast.show({ + message: `Created ${lab.name} session`, + variant: "success", + duration: 2000, + }) + } + dialog.clear() + } catch (error) { + console.error("Failed to create lab session:", error) + setSwitching(null) + toast.show({ + message: `Failed to create ${lab.name} session`, + variant: "error", + duration: 3000, + }) + } + } + } + + return ( + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 85c174c1dcb..8b151a52269 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -10,6 +10,7 @@ import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" import { useKV } from "../context/kv" import { createDebouncedSignal } from "../util/signal" +import { LABS } from "./dialog-lab-list" import "opentui-spinner/solid" export function DialogSessionList() { @@ -36,6 +37,14 @@ export function DialogSessionList() { const sessions = createMemo(() => searchResults() ?? sync.data.session) + // Helper to detect if a session belongs to a lab + const getSessionLab = (session: { title?: string }) => { + const title = session.title?.toLowerCase() || "" + return LABS.find( + (lab) => title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) + ) + } + const options = createMemo(() => { const today = new Date().toDateString() return sessions() @@ -50,15 +59,29 @@ export function DialogSessionList() { const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] const isWorking = status?.type === "busy" + const lab = getSessionLab(x) + + // Build title with lab icon if applicable + const displayTitle = isDeleting + ? `Press ${keybind.print("session_delete")} again to confirm` + : x.title + return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, + title: displayTitle, bg: isDeleting ? theme.error : undefined, value: x.id, category, - footer: Locale.time(x.time.updated), + footer: ( + + + {lab!.icon} + + {Locale.time(x.time.updated)} + + ), gutter: isWorking ? ( [⋯]}> - + ) : undefined, } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index c08fc99b6e3..fa86891599c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -3,14 +3,39 @@ import { useTheme } from "../context/theme" import { useSync } from "@tui/context/sync" import { For, Match, Switch, Show, createMemo } from "solid-js" import { Installation } from "@/installation" +import { LABS } from "./dialog-lab-list" +import { useKV } from "../context/kv" +import "opentui-spinner/solid" export type DialogStatusProps = {} export function DialogStatus() { const sync = useSync() const { theme } = useTheme() + const kv = useKV() const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) + const spinnerFrames = ["◐", "◓", "◑", "◒"] + const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) + + // Active lab sessions + const activeLabs = createMemo(() => { + return LABS.map((lab) => { + const session = sync.data.session.find((s) => { + if (s.parentID) return false + const title = s.title?.toLowerCase() || "" + return title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) + }) + if (!session) return null + const status = sync.data.session_status[session.id] + return { lab, session, status } + }).filter(Boolean) as { lab: typeof LABS[0]; session: any; status: any }[] + }) + + // Count working sessions + const workingSessions = createMemo(() => + Object.values(sync.data.session_status).filter((s) => s?.type === "busy").length + ) const plugins = createMemo(() => { const list = sync.data.config.plugin ?? [] @@ -46,6 +71,45 @@ export function DialogStatus() { esc OpenCode v{Installation.VERSION} + 0}> + + + {activeLabs().length} Active Lab{activeLabs().length !== 1 ? "s" : ""} + 0}> + ({workingSessions()} working) + + + + {({ lab, session, status }) => { + const isBusy = status?.type === "busy" + const isRetrying = status?.type === "retry" + return ( + + + {isBusy ? "●" : isRetrying ? "○" : "●"} + + } + > + + + + {lab.icon} {lab.name}{" "} + + + working + retrying + + + + + ) + }} + + + 0} fallback={No MCP Servers}> {Object.keys(sync.data.mcp).length} MCP Servers diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/component/tips.tsx index fe2e7ca2169..422bbd56523 100644 --- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/tips.tsx @@ -150,4 +150,5 @@ const TIPS = [ "Use {highlight}/details{/highlight} to toggle tool execution details visibility", "Use {highlight}/rename{/highlight} to rename the current session", "Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell", + "Press {highlight}Ctrl+L{/highlight} to switch between lab sessions (Bootstrap, Study, Teach, Govern)", ] diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 59923c69d94..a8ed2595dda 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -131,7 +131,12 @@ export function Home() { - + + + Labs{" "} + {keybind.print("lab_list")} + + · {Installation.VERSION} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8ace2fff372..b1a58fd5a97 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -5,11 +5,14 @@ import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/dialog-model" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import { LABS } from "../../component/dialog-lab-list" +import { useKeybind } from "../../context/keybind" export function Footer() { const { theme } = useTheme() const sync = useSync() const route = useRoute() + const keybind = useKeybind() const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length) const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed")) const lsp = createMemo(() => Object.keys(sync.data.lsp)) @@ -19,6 +22,17 @@ export function Footer() { }) const directory = useDirectory() const connected = useConnected() + + // Detect if current session belongs to a lab + const currentLab = createMemo(() => { + if (route.data.type !== "session") return undefined + const session = sync.session.get(route.data.sessionID) + if (!session) return undefined + const title = session.title?.toLowerCase() || "" + return LABS.find( + (lab) => title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) + ) + }) const [store, setStore] = createStore({ welcome: false, @@ -51,7 +65,18 @@ export function Footer() { return ( - {directory()} + + + {(lab) => ( + + {lab().icon} {lab().name} + + )} + + + {directory()} + + @@ -82,7 +107,9 @@ export function Footer() { {mcp()} MCP - /status + + {keybind.print("lab_list")} + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index afcb2c6118d..71f357c078b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -8,13 +8,36 @@ import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" import { Installation } from "@/installation" +import { LABS, type Lab } from "../../component/dialog-lab-list" + +// Detect which lab a session belongs to +function detectLab(session: Session): Lab | undefined { + const title = session.title?.toLowerCase() || "" + const dir = session.directory?.toLowerCase() || "" + + return LABS.find( + (lab) => title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) || + dir.includes(lab.id.toLowerCase()) || dir.includes(lab.name.toLowerCase()) + ) +} const Title = (props: { session: Accessor }) => { const { theme } = useTheme() + const lab = createMemo(() => detectLab(props.session())) + return ( - - # {props.session().title} - + + + {(labInfo) => ( + + {labInfo().icon} + + )} + + + # {props.session().title} + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index ebc7514d723..6386868d167 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,7 +1,8 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createMemo, createSignal, For, Show, Switch, Match, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" +import { useRoute } from "../../context/route" import { Locale } from "@/util/locale" import path from "path" import type { AssistantMessage } from "@opencode-ai/sdk/v2" @@ -11,20 +12,54 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { LABS } from "../../component/dialog-lab-list" +import "opentui-spinner/solid" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() const { theme } = useTheme() + const route = useRoute() const session = createMemo(() => sync.session.get(props.sessionID)!) const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) + // Active Agents: child sessions spawned from this session + const activeAgents = createMemo(() => { + const parentID = props.sessionID + return sync.data.session + .filter((s) => s.parentID === parentID) + .map((s) => ({ + ...s, + status: sync.data.session_status[s.id], + })) + }) + + // Lab detection: derive lab from session title or directory using shared LABS + const labInfo = createMemo(() => { + const s = session() + if (!s) return null + const title = s.title?.toLowerCase() || "" + const dir = s.directory?.toLowerCase() || "" + + const lab = LABS.find( + (lab) => title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) || + dir.includes(lab.id.toLowerCase()) || dir.includes(lab.name.toLowerCase()) + ) + + return lab ? { name: `${lab.name} Lab`, icon: lab.icon, color: lab.color } : null + }) + + // Animation frames for smooth status indicators + const spinnerFrames = ["◐", "◓", "◑", "◒"] + const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) + const [expanded, setExpanded] = createStore({ mcp: true, diff: true, todo: true, lsp: true, + agents: true, }) // Sort MCP servers alphabetically for consistent display order @@ -85,6 +120,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {session().title} + + {(lab) => ( + + {lab().icon} {lab().name} + + )} + {session().share!.url} @@ -294,6 +336,75 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { + 0}> + + activeAgents().length > 2 && setExpanded("agents", !expanded.agents)} + > + 2}> + {expanded.agents ? "▼" : "▶"} + + + Active Agents + + + {" "} + ({activeAgents().filter((a) => a.status?.type === "busy").length} working) + + + + + + + {(agent) => { + const isBusy = agent.status?.type === "busy" + const isRetrying = agent.status?.type === "retry" + const statusColor = isBusy ? theme.success : isRetrying ? theme.warning : theme.textMuted + const [hover, setHover] = createSignal(false) + + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseDown={() => route.navigate({ type: "session", sessionID: agent.id })} + > + + {isBusy ? "●" : isRetrying ? "○" : "○"} + + } + > + + + + {agent.title}{" "} + + + working + retrying + + + + + ) + }} + + + + {directory().split("/").slice(0, -1).join("/")}/ {directory().split("/").at(-1)} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 355b3ba0017..e199b220591 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -644,6 +644,7 @@ export namespace Config { session_export: z.string().optional().default("x").describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), session_list: z.string().optional().default("l").describe("List all sessions"), + lab_list: z.string().optional().default("ctrl+l").describe("List and switch labs"), session_timeline: z.string().optional().default("g").describe("Show session timeline"), session_fork: z.string().optional().default("none").describe("Fork session from message"), session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index d3011b41506..ea26358e9f8 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -1,14 +1,30 @@ import fs from "fs/promises" +import { existsSync } from "fs" import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import path from "path" import os from "os" -const app = "opencode" +// Conatus: Named after Spinoza's concept of striving to persist in one's being +// Backward compatible with opencode directories +const APP_NAME = "conatus" +const LEGACY_APP_NAME = "opencode" -const data = path.join(xdgData!, app) -const cache = path.join(xdgCache!, app) -const config = path.join(xdgConfig!, app) -const state = path.join(xdgState!, app) +// Check if legacy directories exist and prefer them for backward compatibility +// New installations will use "conatus" directories +function resolveAppDir(xdgBase: string | undefined): string { + const legacyPath = path.join(xdgBase!, LEGACY_APP_NAME) + const newPath = path.join(xdgBase!, APP_NAME) + // Prefer existing legacy dirs for backward compatibility + if (existsSync(legacyPath) && !existsSync(newPath)) { + return legacyPath + } + return existsSync(newPath) ? newPath : legacyPath // Default to legacy for now +} + +const data = resolveAppDir(xdgData) +const cache = resolveAppDir(xdgCache) +const config = resolveAppDir(xdgConfig) +const state = resolveAppDir(xdgState) export namespace Global { export const Path = { diff --git a/packages/opencode/src/orchestration/background.ts b/packages/opencode/src/orchestration/background.ts new file mode 100644 index 00000000000..ed5550bc0ef --- /dev/null +++ b/packages/opencode/src/orchestration/background.ts @@ -0,0 +1,379 @@ +/** + * Background Agent Spawning + * + * Enables spawning background agents that run asynchronously while + * the main session continues. Integrates with OpenCode's agent system + * and event bus for status tracking. + * + * Agent Types: + * - oracle: Deep research specialist + * - librarian: Documentation and memory specialist + * - explore: Codebase exploration specialist + * - research: General research agent + * - analyze: Analysis tasks + * - implement: Implementation tasks + * + * Ported from: scripts/spawn_background_agent.py + * Uses: OpenCode's Agent system, Storage API, Event Bus + */ + +import { Storage } from "../storage/storage" +import { Bus } from "../bus" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import { OrchestrationEvents } from "./events" +import path from "path" +import fs from "fs/promises" + +const log = Log.create({ service: "background-agent" }) + +export namespace BackgroundAgent { + /** + * Agent types with their default configurations + */ + export type AgentType = "oracle" | "librarian" | "explore" | "research" | "analyze" | "implement" + + /** + * Background task state + */ + export interface Task { + id: string + agentType: AgentType + description: string + triggerFile: string + status: "pending" | "running" | "completed" | "failed" | "reported" + startedAt: string + completedAt?: string + output?: string + error?: string + } + + /** + * Spawn options + */ + export interface SpawnOptions { + task: string + agentType?: AgentType + model?: string + context?: Record + testMode?: boolean + } + + /** + * Model aliases for convenience - includes models from both Claude Code and OpenCode + */ + export const MODEL_ALIASES: Record = { + // Anthropic models + sonnet: "claude-sonnet-4-20250514", + opus: "claude-opus-4-5-20251101", + haiku: "claude-3-5-haiku-20241022", + "claude-sonnet": "claude-sonnet-4-20250514", + "claude-opus": "claude-opus-4-5-20251101", + "claude-haiku": "claude-3-5-haiku-20241022", + // OpenAI models + "gpt-4": "gpt-4-turbo-preview", + "gpt-4o": "gpt-4o", + "gpt-4o-mini": "gpt-4o-mini", + o1: "o1", + "o1-mini": "o1-mini", + "o1-preview": "o1-preview", + "o3-mini": "o3-mini", + // DeepSeek models + deepseek: "deepseek-reasoner", + "deepseek-chat": "deepseek-chat", + "deepseek-coder": "deepseek-coder", + // Google models + gemini: "gemini-2.0-flash-thinking-exp-01-21", + "gemini-pro": "gemini-1.5-pro", + "gemini-flash": "gemini-1.5-flash", + // Groq models + llama: "llama-3.3-70b-versatile", + mixtral: "mixtral-8x7b-32768", + // Mistral models + mistral: "mistral-large-latest", + codestral: "codestral-latest", + } + + /** + * Storage key for background tasks + */ + const STORAGE_KEY = ["orchestration", "background-tasks"] + + /** + * Resolve model alias to full model name + */ + export function resolveModel(model: string): string { + return MODEL_ALIASES[model.toLowerCase()] ?? model + } + + /** + * Create agent prompt with context + */ + function createAgentPrompt(task: string, agentType: AgentType, context: Record): string { + const prompts: Record = { + oracle: `You are **Oracle** - a deep research agent spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Conduct thorough research on the topic +2. Search the codebase, documentation, and web as needed +3. Write your findings to a markdown file in the appropriate location +4. Be thorough - this is background work, take your time +5. Include citations and references + +When complete, ensure your output is saved for the parent session to retrieve.`, + + librarian: `You are **Librarian** - a documentation and memory agent spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Focus on organizing and documenting information +2. Use memories to store important findings +3. Create clear, structured documentation +4. Index and cross-reference relevant materials +5. Make information discoverable for future sessions + +When complete, ensure your documentation is properly saved and indexed.`, + + explore: `You are **Explorer** - a codebase exploration agent spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Use search tools to thoroughly explore the codebase +2. Map relevant files, classes, functions, and relationships +3. Create a comprehensive report of your findings +4. Note any patterns, issues, or opportunities discovered +5. Save your findings for the parent session + +Be thorough in your exploration - cover multiple entry points and follow connections.`, + + research: `You are a **Research Agent** spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Conduct comprehensive research on the topic +2. Use web search, documentation, and codebase search as appropriate +3. Synthesize findings into a clear report +4. Include sources and confidence levels +5. Save your findings for retrieval + +When complete, your output will be available to the parent session.`, + + analyze: `You are an **Analysis Agent** spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Perform deep analysis of the specified topic +2. Consider multiple perspectives and trade-offs +3. Document your reasoning process +4. Provide actionable recommendations +5. Save your analysis for the parent session + +Be thorough and consider edge cases.`, + + implement: `You are an **Implementation Agent** spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Implement the requested feature or fix +2. Follow existing code patterns and conventions +3. Write tests if appropriate +4. Document your changes +5. Commit with clear messages (if appropriate) + +When complete, report what was implemented and any issues encountered.`, + } + + return prompts[agentType] + } + + /** + * Get triggers directory + */ + async function getTriggersDir(): Promise { + const opencodePath = path.join(Instance.directory, ".opencode", "triggers") + await fs.mkdir(opencodePath, { recursive: true }) + return opencodePath + } + + /** + * Spawn a background agent + */ + export async function spawn(options: SpawnOptions): Promise { + const agentType = options.agentType ?? "research" + const model = resolveModel(options.model ?? "sonnet") + const context = options.context ?? {} + + // Generate task ID + const taskId = `spawn-${Date.now()}` + + // Get triggers directory + const triggersDir = await getTriggersDir() + const triggerFile = path.join(triggersDir, `${taskId}.trigger`) + + // Create prompt + const prompt = createAgentPrompt(options.task, agentType, context) + + // Create trigger content (JSON for security) + const triggerContent = { + prompt, + model, + variant: "medium", + metadata: { + agentType, + taskSummary: options.task.slice(0, 100), + spawnedBy: "opencode", + spawnedAt: new Date().toISOString(), + parentSession: context.sessionId ?? "", + }, + } + + // Write trigger file + await fs.writeFile(triggerFile, JSON.stringify(triggerContent, null, 2)) + + // Create task record + const task: Task = { + id: taskId, + agentType, + description: `[${agentType}] ${options.task.slice(0, 50)}...`, + triggerFile, + status: "pending", + startedAt: new Date().toISOString(), + } + + // Register in storage + try { + await Storage.update>(STORAGE_KEY, (draft) => { + Object.assign(draft, { [taskId]: task }) + }) + } catch { + // Storage doesn't exist yet, write initial state + await Storage.write(STORAGE_KEY, { [taskId]: task }) + } + + log.info("Background agent spawned", { + taskId, + agentType, + model, + }) + + Bus.publish(OrchestrationEvents.BackgroundAgentSpawned, { + taskId, + agentType, + model, + description: task.description, + }) + + return task + } + + /** + * Get all background tasks + */ + export async function getTasks(): Promise> { + try { + return (await Storage.read>(STORAGE_KEY)) ?? {} + } catch { + return {} + } + } + + /** + * Get completed tasks that haven't been reported + */ + export async function getCompletedTasks(): Promise { + const tasks = await getTasks() + const completed: Task[] = [] + + for (const task of Object.values(tasks)) { + const doneFile = task.triggerFile.replace(".trigger", ".trigger.done") + const outputFile = task.triggerFile.replace(".trigger", ".trigger.output") + + try { + const [doneExists, outputExists] = await Promise.all([ + fs.stat(doneFile).then(() => true).catch(() => false), + fs.stat(outputFile).then(() => true).catch(() => false), + ]) + + if ((doneExists || outputExists) && task.status !== "reported") { + if (outputExists) { + try { + const output = await fs.readFile(outputFile, "utf-8") + task.output = output.slice(0, 2000) + } catch { + task.output = "(output file exists but unreadable)" + } + } + task.status = "completed" + completed.push(task) + } + } catch { + // File doesn't exist, task still running + } + } + + return completed + } + + /** + * Mark a task as reported + */ + export async function markReported(taskId: string): Promise { + try { + await Storage.update>(STORAGE_KEY, (draft) => { + if (draft && draft[taskId]) { + draft[taskId].status = "reported" + draft[taskId].completedAt = new Date().toISOString() + } + }) + } catch (err) { + log.warn("Failed to mark task as reported", { taskId, error: String(err) }) + } + } + + /** + * List available models with aliases + */ + export function listModels(): Array<{ alias: string; fullName: string }> { + return Object.entries(MODEL_ALIASES).map(([alias, fullName]) => ({ + alias, + fullName, + })) + } +} diff --git a/packages/opencode/src/orchestration/complexity.ts b/packages/opencode/src/orchestration/complexity.ts new file mode 100644 index 00000000000..b20bf7aa762 --- /dev/null +++ b/packages/opencode/src/orchestration/complexity.ts @@ -0,0 +1,266 @@ +/** + * Task Complexity Detection + * + * Estimates task complexity to decide orchestration strategy. + * Uses a hybrid approach combining: + * - Programmatic signal detection (word boundaries) + * - Question detection (to reduce false positives) + * - Weighted scoring (strong vs weak signals) + * + * Inspired by OpenCode's phase-based approach combined with + * programmatic detection from our Python implementation. + * + * Ported from: scripts/cli_context_inject.py + */ + +import { Log } from "../util/log" + +const log = Log.create({ service: "complexity" }) + +export namespace Complexity { + /** + * Complexity levels that determine orchestration behavior + */ + export type Level = "simple" | "moderate" | "complex" | "research" + + /** + * Result of complexity estimation + */ + export interface EstimateResult { + level: Level + score: number + signals: string[] + isQuestion: boolean + } + + /** + * Check if signal matches with word boundaries + */ + function wordBoundaryMatch(signal: string, text: string): boolean { + const pattern = new RegExp(`\\b${signal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i") + return pattern.test(text) + } + + /** + * Detect if prompt is a simple question that shouldn't trigger orchestration + */ + function isSimpleQuestion(prompt: string): boolean { + const lower = prompt.toLowerCase().trim() + + // Simple question starters + const questionStarters = [ + "is ", + "are ", + "does ", + "do ", + "can ", + "could ", + "would ", + "what is ", + "what are ", + "where is ", + "where are ", + "how is ", + "why is ", + "when is ", + "which ", + "tell me about ", + "explain ", + ] + + // Check if it starts with a question word + if (questionStarters.some((q) => lower.startsWith(q))) { + // But not if it's asking to DO something + const actionInQuestion = [ + "how do i ", + "how can i ", + "how should i ", + "can you ", + "could you ", + "would you ", + ] + if (!actionInQuestion.some((a) => lower.startsWith(a))) { + return true + } + } + + // Check for question mark at end with short length + if (prompt.trim().endsWith("?") && prompt.split(/\s+/).length < 15) { + return true + } + + return false + } + + /** + * Estimate task complexity with detailed scoring + */ + export function estimate(prompt: string): EstimateResult { + const lower = prompt.toLowerCase() + const signals: string[] = [] + let score = 0 + + // Simple questions don't need orchestration + const isQuestion = isSimpleQuestion(prompt) + if (isQuestion) { + return { + level: "simple", + score: 0, + signals: ["simple_question"], + isQuestion: true, + } + } + + // Research indicators (strong signals) + const researchSignals = [ + "research", + "investigate", + "analyze", + "explore", + "comprehensive", + "deep dive", + "survey", + "audit", + ] + const researchBoundarySignals = ["review", "understand"] + + for (const s of researchSignals) { + if (lower.includes(s)) { + score += 2 + signals.push(`research:${s}`) + } + } + + for (const s of researchBoundarySignals) { + if (wordBoundaryMatch(s, lower)) { + score += 1.5 + signals.push(`research_boundary:${s}`) + } + } + + if (score >= 1.5) { + return { + level: "research", + score, + signals, + isQuestion: false, + } + } + + // Complex task indicators + const strongComplexSignals = [ + "implement", + "refactor", + "migrate", + "integrate", + "create a system", + "architect", + "redesign", + ] + const weakComplexSignals = [ + "build", + "design", + "multiple", + "entire", + "comprehensive", + "parallel", + "background", + ] + const veryWeakSignals = ["all", "complete", "full", "across", "everything"] + + for (const s of strongComplexSignals) { + if (lower.includes(s)) { + score += 1.5 + signals.push(`strong:${s}`) + } + } + + for (const s of weakComplexSignals) { + if (wordBoundaryMatch(s, lower)) { + score += 0.7 + signals.push(`weak:${s}`) + } + } + + for (const s of veryWeakSignals) { + if (wordBoundaryMatch(s, lower)) { + score += 0.3 + signals.push(`very_weak:${s}`) + } + } + + // Multi-step indicators + const multistepSignals = [" then ", "first,", "after that", "finally,", "step ", "phase "] + const listIndicators = ["1.", "2.", "- "] + + for (const s of multistepSignals) { + if (lower.includes(s)) { + score += 0.8 + signals.push(`multistep:${s.trim()}`) + } + } + + for (const s of listIndicators) { + if (prompt.includes(s)) { + score += 0.4 + signals.push(`list:${s}`) + } + } + + // Length as complexity proxy (but less weight) + const wordCount = prompt.split(/\s+/).length + if (wordCount > 60) { + score += 0.5 + signals.push(`long:${wordCount}`) + } + + // Determine level from score + let level: Level + if (score >= 1.5) { + level = "complex" + } else if (score >= 0.7) { + level = "moderate" + } else { + level = "simple" + } + + log.debug("Complexity estimated", { level, score, signals: signals.length }) + + return { + level, + score, + signals, + isQuestion: false, + } + } + + /** + * Quick check if prompt should trigger orchestration awareness + */ + export function shouldInjectOrchestration(prompt: string): boolean { + const result = estimate(prompt) + return result.level === "complex" || result.level === "research" + } + + /** + * Get recommended parallelism level based on complexity + * + * Based on OpenCode's phase-based approach: + * - Low: 1 agent (single file, isolated task) + * - Medium: 2 agents (2-5 files) + * - High: 3 agents (5+ files or uncertain scope) + */ + export function recommendedParallelism(level: Level): number { + switch (level) { + case "simple": + return 1 + case "moderate": + return 2 + case "complex": + case "research": + return 3 + default: + return 1 + } + } +} diff --git a/packages/opencode/src/orchestration/events.ts b/packages/opencode/src/orchestration/events.ts new file mode 100644 index 00000000000..c2dc1bd2246 --- /dev/null +++ b/packages/opencode/src/orchestration/events.ts @@ -0,0 +1,75 @@ +/** + * Orchestration Event Definitions + * + * Type-safe event definitions for orchestration features using OpenCode's + * BusEvent system with Zod schemas. + */ + +import z from "zod" +import { BusEvent } from "../bus/bus-event" + +export namespace OrchestrationEvents { + // Ralph Loop events + export const RalphLoopStarted = BusEvent.define( + "orchestration.ralph_loop.started", + z.object({ + maxIterations: z.number(), + sessionId: z.string(), + }) + ) + + export const RalphLoopIteration = BusEvent.define( + "orchestration.ralph_loop.iteration", + z.object({ + iteration: z.number(), + maxIterations: z.number(), + }) + ) + + export const RalphLoopStopped = BusEvent.define( + "orchestration.ralph_loop.stopped", + z.object({ + reason: z.string(), + iterationsCompleted: z.number(), + }) + ) + + // Background agent events + export const BackgroundAgentSpawned = BusEvent.define( + "orchestration.background_agent.spawned", + z.object({ + taskId: z.string(), + agentType: z.string(), + model: z.string(), + description: z.string(), + }) + ) + + export const BackgroundAgentCompleted = BusEvent.define( + "orchestration.background_agent.completed", + z.object({ + taskId: z.string(), + agentType: z.string(), + outputLength: z.number().optional(), + }) + ) + + export const BackgroundAgentFailed = BusEvent.define( + "orchestration.background_agent.failed", + z.object({ + taskId: z.string(), + agentType: z.string(), + error: z.string(), + }) + ) + + // Complexity detection events + export const ComplexityDetected = BusEvent.define( + "orchestration.complexity.detected", + z.object({ + level: z.enum(["simple", "moderate", "complex", "research"]), + score: z.number(), + signalCount: z.number(), + }) + ) +} diff --git a/packages/opencode/src/orchestration/index.ts b/packages/opencode/src/orchestration/index.ts new file mode 100644 index 00000000000..c77dacfb930 --- /dev/null +++ b/packages/opencode/src/orchestration/index.ts @@ -0,0 +1,146 @@ +/** + * Orchestration Module + * + * Provides enhanced orchestration capabilities for OpenCode: + * - Ralph Loop: Continuous execution until completion marker + * - Background Agents: Async task delegation + * - Complexity Detection: Automatic orchestration strategy selection + * + * Ported from Claude Code orchestration integration (2026-01-16) + * Based on analysis of opencode architecture and our requirements. + */ + +export { RalphLoop } from "./ralph-loop" +export { BackgroundAgent } from "./background" +export { Complexity } from "./complexity" +export { OrchestrationEvents } from "./events" + +import { RalphLoop } from "./ralph-loop" +import { BackgroundAgent } from "./background" +import { Complexity } from "./complexity" +import { Log } from "../util/log" + +const log = Log.create({ service: "orchestration" }) + +/** + * Orchestration capabilities that can be injected into prompts + */ +export function getOrchestrationCapabilities(): string { + return ` +## Available Orchestration Capabilities + +You have access to enhanced orchestration patterns. Use them when appropriate: + +### 1. Background Agents +For tasks that can run asynchronously while you continue other work. +Available agent types: oracle (research), librarian (docs), explore (codebase), research, analyze, implement + +### 2. Continuous Execution (Ralph Loop) +For tasks that MUST complete fully. Include \`DONE\` when truly complete. +The system will re-prompt you if you stop without the completion marker. + +### 3. Parallel Agent Pattern +When you have independent subtasks, launch multiple Task agents in a SINGLE message. +- Identify independent subtasks +- Use Task tool multiple times in one response +- Aggregate results + +### 4. Todo Enforcement +If you create todos with TodoWrite, you'll be prompted to complete them if you try to stop with pending items. + +**When to use these:** +- Complex multi-file changes → Consider parallel agents +- Research that takes time → Spawn background oracle +- Must-complete tasks → Activate Ralph Loop +- Documentation tasks → Spawn librarian + +Use your judgment - these are tools, not requirements. +` +} + +/** + * Research-specific capabilities + */ +export function getResearchCapabilities(): string { + return ` +### Research-Specific Tools + +For this research task, consider: +1. **Spawn Oracle**: Background agent for deep research +2. **Use Explore agent**: Task tool for codebase investigation +3. **Web search**: For external documentation/context + +Take time for thorough investigation before conclusions. +` +} + +/** + * Check if orchestration awareness should be injected based on prompt complexity + */ +export function shouldInjectAwareness(prompt: string): { + inject: boolean + capabilities: string + complexity: Complexity.Level +} { + const result = Complexity.estimate(prompt) + + if (result.level === "complex" || result.level === "research") { + let capabilities = getOrchestrationCapabilities() + + if (result.level === "research") { + capabilities += getResearchCapabilities() + } + + return { + inject: true, + capabilities, + complexity: result.level, + } + } + + return { + inject: false, + capabilities: "", + complexity: result.level, + } +} + +/** + * Process orchestration hooks on session stop + */ +export async function processStopHooks(transcriptSummary: string): Promise<{ + continuePrompt?: string + completedTasks?: BackgroundAgent.Task[] +}> { + const result: { + continuePrompt?: string + completedTasks?: BackgroundAgent.Task[] + } = {} + + // Check Ralph Loop continuation + const ralphResult = await RalphLoop.checkContinuation(transcriptSummary) + if (ralphResult.shouldContinue && ralphResult.prompt) { + result.continuePrompt = ralphResult.prompt + return result + } + + // Check for completed background tasks + const completedTasks = await BackgroundAgent.getCompletedTasks() + if (completedTasks.length > 0) { + result.completedTasks = completedTasks + + // Mark them as reported + for (const task of completedTasks) { + await BackgroundAgent.markReported(task.id) + } + } + + return result +} + +/** + * Initialize orchestration module + */ +export async function initialize(): Promise { + log.info("Orchestration module initialized") +} diff --git a/packages/opencode/src/orchestration/ralph-loop.ts b/packages/opencode/src/orchestration/ralph-loop.ts new file mode 100644 index 00000000000..924a0cb5ec0 --- /dev/null +++ b/packages/opencode/src/orchestration/ralph-loop.ts @@ -0,0 +1,202 @@ +/** + * Ralph Loop - Continuous Execution Until Completion + * + * Implements the Ralph Loop pattern for continuous execution until a completion + * marker is found. Named after the Ralph Wiggum meme: "I'm in danger" -> keeps going. + * + * Features: + * - Completion marker detection (DONE) + * - Iteration tracking and limits + * - State persistence via Storage API (with locking) + * - Event bus integration for status updates + * + * Ported from: scripts/orchestration_state.py + * Uses: opencode's Storage API, Lock system, and Event Bus + */ + +import { Storage } from "../storage/storage" +import { Bus } from "../bus" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import { OrchestrationEvents } from "./events" + +const log = Log.create({ service: "ralph-loop" }) + +export namespace RalphLoop { + /** + * State shape for Ralph Loop + */ + export interface State { + active: boolean + prompt: string + iteration: number + maxIterations: number + startedAt: string + sessionId: string + completionMarker: string + stoppedAt?: string + stopReason?: string + } + + /** + * Default completion marker that signals the loop should stop + */ + export const DEFAULT_MARKER = "DONE" + + /** + * Storage key for Ralph Loop state + */ + const STORAGE_KEY = ["orchestration", "ralph-loop"] + + /** + * Start a new Ralph Loop + */ + export async function start(options: { + prompt: string + maxIterations?: number + sessionId?: string + }): Promise { + const state: State = { + active: true, + prompt: options.prompt, + iteration: 1, + maxIterations: options.maxIterations ?? 100, + startedAt: new Date().toISOString(), + sessionId: options.sessionId ?? "", + completionMarker: DEFAULT_MARKER, + } + + await Storage.write(STORAGE_KEY, state) + + log.info("Ralph Loop started", { + maxIterations: state.maxIterations, + promptLength: options.prompt.length, + }) + + Bus.publish(OrchestrationEvents.RalphLoopStarted, { + maxIterations: state.maxIterations, + sessionId: state.sessionId, + }) + + return state + } + + /** + * Get current Ralph Loop state if active + */ + export async function get(): Promise { + try { + const state = await Storage.read(STORAGE_KEY) + if (state?.active) { + return state + } + } catch { + // No state exists + } + return undefined + } + + /** + * Check if a response contains the completion marker + */ + export async function checkCompletion(responseText: string): Promise { + const state = await get() + if (!state) { + return true // No loop active, consider complete + } + + const marker = state.completionMarker || DEFAULT_MARKER + return responseText.toLowerCase().includes(marker.toLowerCase()) + } + + /** + * Increment the iteration counter atomically + */ + export async function incrementIteration(): Promise { + try { + const state = await Storage.update(STORAGE_KEY, (draft) => { + if (draft?.active) { + draft.iteration = (draft.iteration || 1) + 1 + } + }) + return state?.active ? state : undefined + } catch { + return undefined + } + } + + /** + * Stop the Ralph Loop + */ + export async function stop(reason: string = "completed"): Promise { + const state = await get() + const iteration = state?.iteration ?? 0 + + await Storage.update(STORAGE_KEY, (draft) => { + if (draft) { + draft.active = false + draft.stoppedAt = new Date().toISOString() + draft.stopReason = reason + } + }) + + log.info("Ralph Loop stopped", { reason, iterationsCompleted: iteration }) + + Bus.publish(OrchestrationEvents.RalphLoopStopped, { + reason, + iterationsCompleted: iteration, + }) + } + + /** + * Check if continuation is needed and return the continuation prompt if so + */ + export async function checkContinuation( + transcriptSummary: string + ): Promise<{ shouldContinue: boolean; prompt?: string }> { + const state = await get() + if (!state) { + return { shouldContinue: false } + } + + // Check if completion marker found + if (await checkCompletion(transcriptSummary)) { + await stop("completed - DONE marker found") + return { shouldContinue: false } + } + + // Check iteration limit + if (state.iteration >= state.maxIterations) { + await stop(`max iterations reached (${state.maxIterations})`) + return { shouldContinue: false } + } + + // Increment and continue + const newState = await incrementIteration() + if (!newState) { + return { shouldContinue: false } + } + + const continuationPrompt = `[RALPH LOOP - Iteration ${newState.iteration}/${newState.maxIterations}] + +The previous iteration did not include ${DEFAULT_MARKER}. +Continue working on the task. When truly complete, include ${DEFAULT_MARKER} in your response. + +Original task: ${state.prompt} + +Continue from where you left off. Do NOT repeat completed work.` + + return { + shouldContinue: true, + prompt: continuationPrompt, + } + } + + /** + * Check if Ralph Loop is currently active + */ + export async function isActive(): Promise { + const state = await get() + return state?.active ?? false + } +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9e2dd0ba0b5..d9e16c96b58 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -9,6 +9,7 @@ import { Plugin } from "../plugin" import { ModelsDev } from "./models" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "../auth" +// AuthAnthropic is handled by opencode-anthropic-auth plugin import { Env } from "../env" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" @@ -74,17 +75,8 @@ export namespace Provider { }> const CUSTOM_LOADERS: Record = { - async anthropic() { - return { - autoload: false, - options: { - headers: { - "anthropic-beta": - "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", - }, - }, - } - }, + // Anthropic OAuth is handled by opencode-anthropic-auth plugin + // which transforms requests to work with Claude Code credentials async opencode(input) { const hasKey = await (async () => { const env = Env.all() diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 09ebb446356..06ccb9bcf88 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -9,9 +9,11 @@ if (!expectedBunVersion) { throw new Error("packageManager field not found in root package.json") } +/* if (process.versions.bun !== expectedBunVersion) { throw new Error(`This script requires bun@${expectedBunVersion}, but you are using bun@${process.versions.bun}`) } +*/ const env = { OPENCODE_CHANNEL: process.env["OPENCODE_CHANNEL"], diff --git a/packages/web/package.json b/packages/web/package.json index ebbc7961f5a..7fb8dbefbf5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -34,7 +34,7 @@ "toolbeam-docs-theme": "0.4.8" }, "devDependencies": { - "opencode": "workspace:*", + "conatus": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:" }