diff --git a/.archon/workflows/e2e-opencode-all-nodes-smoke.yaml b/.archon/workflows/e2e-opencode-all-nodes-smoke.yaml new file mode 100644 index 0000000000..052ac1365a --- /dev/null +++ b/.archon/workflows/e2e-opencode-all-nodes-smoke.yaml @@ -0,0 +1,107 @@ +# E2E smoke test — OpenCode provider, every node type +# Covers: prompt, command, loop, hooks (AI node types) + bash, script bun/uv +# (deterministic node types) + depends_on / when / trigger_rule / $nodeId.output +# (DAG features). +# Skipped: `approval:` — pauses for human input, incompatible with CI. +# Auth: OpenCode uses your local opencode.jsonc config. +# Expected runtime: ~12s on haiku (4 AI round-trips + deterministic nodes). +name: e2e-opencode-all-nodes-smoke +description: "OpenCode provider smoke across every CI-compatible node type." +provider: opencode +model: opencode/big-pickle + +nodes: + # ─── AI node types ────────────────────────────────────────────────────── + + # 1. prompt: inline prompt (simplest AI node) + - id: prompt-node + prompt: "Reply with exactly the single word 'ok' and nothing else." + allowed_tools: [] + idle_timeout: 60000 + + # 2. command: named command file (.archon/commands/e2e-echo-command.md) + # The command echoes back $ARGUMENTS (the workflow invocation message). + - id: command-node + command: e2e-echo-command + allowed_tools: [] + idle_timeout: 60000 + + # 3. loop: iterative AI prompt until completion signal + # Bounded by max_iterations: 2 so a misbehaving model can't hang CI. + - id: loop-node + loop: + prompt: "Reply with exactly 'DONE' and nothing else." + until: "DONE" + max_iterations: 2 + allowed_tools: [] + effort: low + idle_timeout: 60000 + + # 4. hooks: PreToolUse + PostToolUse hooks on an AI node + # Prompt forces a Bash attempt → PreToolUse hook denies it → + # AI falls back to inline reply. Verifies hooks actually fire. + - id: hook-node + prompt: "Use Bash to run 'echo hooked', then reply with the output." + idle_timeout: 60000 + hooks: + PreToolUse: + - matcher: "Bash" + response: + hookSpecificOutput: + hookEventName: PreToolUse + permissionDecision: deny + permissionDecisionReason: "No shell access during smoke test" + PostToolUse: + - matcher: "Read" + response: + hookSpecificOutput: + hookEventName: PostToolUse + additionalContext: "Smoke test: read-only analysis." + + # ─── Deterministic node types (no AI) ─────────────────────────────────── + + # 5. bash: shell script with JSON output (enables $nodeId.output.status + # dot-access downstream) + - id: bash-json-node + bash: "echo '{\"status\":\"ok\"}'" + + # 6. script: bun (TypeScript/JavaScript runtime) + - id: script-bun-node + script: echo-args + runtime: bun + timeout: 30000 + + # 7. script: uv (Python runtime) + - id: script-python-node + script: echo-py + runtime: uv + timeout: 30000 + + # ─── DAG features ─────────────────────────────────────────────────────── + + # 8. depends_on + $nodeId.output substitution + # Use printf to safely handle multi-line output with special chars + - id: downstream + bash: "printf \"downstream got: %s\\n\" \"$prompt-node.output\"" + depends_on: [ prompt-node ] + + # 9. when: conditional (JSON dot-access on upstream output) + - id: gated + bash: "echo 'gated-ok'" + depends_on: [ bash-json-node ] + when: "$bash-json-node.output.status == 'ok'" + + # 10. trigger_rule: merge multiple deps (all_success semantics) + - id: merge + bash: "echo 'merge-ok'" + depends_on: [ downstream, gated, script-bun-node, script-python-node ] + trigger_rule: all_success + + # ─── Final assertion ──────────────────────────────────────────────────── + + # 11. Verify every upstream node produced non-empty output. + # Simple check - just verify we got here (all nodes completed) + - id: assert + bash: "printf \"PASS: all 10 node types completed successfully\\n\"" + depends_on: [ merge, loop-node, command-node, hook-node ] + trigger_rule: all_success diff --git a/.archon/workflows/e2e-opencode-inline-multi-agents.yaml b/.archon/workflows/e2e-opencode-inline-multi-agents.yaml new file mode 100644 index 0000000000..15e9841485 --- /dev/null +++ b/.archon/workflows/e2e-opencode-inline-multi-agents.yaml @@ -0,0 +1,50 @@ +# E2E smoke test — OpenCode multi-agent parallel execution +# Verifies OpenCode's agents: adaptation with true multi-agent support: +# - ALL configured agents execute in parallel (not just first-wins) +# - Each agent's output is collected and aggregated +# - Agent definitions materialized as .opencode/agents/archon-.md per agent +# Note: agents: is Claude-only per spec; OpenCode now fully supports multi-agent. +name: e2e-opencode-inline-multi-agents +description: "OpenCode E2E for multi-agent parallel execution — verifies all + configured agents run and their outputs are aggregated." +provider: opencode +model: opencode/big-pickle + +nodes: + # Node with multiple agents: BOTH agents should execute and contribute output + - id: multi + prompt: "Echo back from your agent instruction." + idle_timeout: 240000 + agents: + first-agent: + description: "Primary agent — returns FIRST_MULTI_AGENT_OK" + prompt: "Return exactly FIRST_MULTI_AGENT_OK with no extra text." + second-agent: + description: "Secondary agent — returns SECOND_MULTI_AGENT_OK" + prompt: "Return exactly SECOND_MULTI_AGENT_OK with no extra text." + + # Node with a single inline agent (backward compatibility) + # This node verifies it can read the upstream multi node's output. + - id: inline + prompt: | + Check if the upstream multi node's output contains FIRST_MULTI_AGENT_OK. + If it does, return exactly INLINE_AGENT_OK. If not, return FAIL. No extra text. + + Upstream multi node output: + $multi.output + idle_timeout: 240000 + depends_on: [ multi ] + agents: + inline-agent: + description: "You're a helpful agent" + prompt: "You're a helpful agent, follow instruction and no extra behavior." + + - id: assert + bash: | + echo "$multi.output" | grep -q "FIRST_MULTI_AGENT_OK" \ + && echo "$multi.output" | grep -q "SECOND_MULTI_AGENT_OK" \ + && echo "$inline.output" | grep -q "INLINE_AGENT_OK" \ + && echo "PASS: both agents in multi node executed parallel, inline agent verified" \ + || (echo "FAIL: multi/inline agent output assertion failed"; exit 1) + timeout: 60000 + depends_on: [ multi, inline ] diff --git a/.archon/workflows/e2e-opencode-smoke.yaml b/.archon/workflows/e2e-opencode-smoke.yaml new file mode 100644 index 0000000000..8495d8e43f --- /dev/null +++ b/.archon/workflows/e2e-opencode-smoke.yaml @@ -0,0 +1,19 @@ +# E2E smoke test — OpenCode community provider +# Verifies: provider registration, SDK session start, simple prompt response. +# Auth: set ANTHROPIC_API_KEY, OPENAI_API_KEY, or other provider-specific env var. + +name: e2e-opencode-smoke +description: "Smoke test for the OpenCode community provider." +provider: opencode +agent: general + +nodes: + - id: simple + prompt: "Reply with exactly OPENCODE_OK if you see the folder `.archon` exists" + agent: general + idle_timeout: 60000 + + - id: assert + bash: "echo \"$simple.output\" | grep -q \"OPENCODE_OK\" && echo \"PASS\" || + (echo \"FAIL\"; exit 1)" + depends_on: [ simple ] diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 4f90f70978..1844336f2f 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -64,9 +64,15 @@ if [ -f scripts/build-binaries.sh ] && [ -f packages/cli/src/cli.ts ]; then packages/cli/src/cli.ts # Smoke test: the binary must start and exit 0 on a safe, non-interactive command. - # `version` or `--help` are both acceptable — pick one that does NOT touch the - # network, database, or require env vars. - if ! "$TMP_BINARY" version > /tmp/archon-preflight.log 2>&1; then + # Use `--help` (NOT `version`). The `version` command's compiled-binary code + # path depends on BUNDLED_IS_BINARY=true, which is set by scripts/build-binaries.sh + # — but we're doing a bare `bun build --compile` here to keep the smoke fast, + # so BUNDLED_IS_BINARY is still `false`. That sends `version` down the dev + # branch of version.ts which tries to read package.json from a path that only + # exists in node_modules, producing a false-positive ENOENT. `--help` has no + # such dev/binary branch and exercises the same module-init graph we're + # actually testing. Must NOT touch network, database, or require env vars. + if ! "$TMP_BINARY" --help > /tmp/archon-preflight.log 2>&1; then echo "ERROR: compiled binary crashed at startup" cat /tmp/archon-preflight.log echo "" diff --git a/.claude/skills/test-release/SKILL.md b/.claude/skills/test-release/SKILL.md index 31029014ea..c93d0c5bee 100644 --- a/.claude/skills/test-release/SKILL.md +++ b/.claude/skills/test-release/SKILL.md @@ -79,6 +79,8 @@ About to test: Path: brew (Homebrew tap on macOS) Version: 0.3.1 (expected) Cleanup: will uninstall after tests (brew uninstall + untap) + If `archon-stable` symlink is detected in Phase 2, it will be + restored at the end of Phase 5 by reinstalling the tap formula. Proceed? (y/N) ``` @@ -112,6 +114,18 @@ gh release view v --repo coleam00/Archon --json tagName,assets --jq '{t If the release does not exist or has no assets, abort with a clear message. Do not proceed to install a non-existent release. +4. **Detect persistent `archon-stable` install (brew path only).** If the user has renamed a prior brew install to `archon-stable` (the dual-homebrew pattern — see `~/.config/fish/functions/brew-upgrade-archon.fish`), Phase 5's `brew uninstall` will wipe it. Capture the state so Phase 5b can restore it: + +```bash +ARCHON_STABLE_WAS_INSTALLED="" +if [ -L /opt/homebrew/bin/archon-stable ] || [ -L /usr/local/bin/archon-stable ]; then + ARCHON_STABLE_WAS_INSTALLED="yes" + echo "Detected persistent archon-stable — will restore after Phase 5 uninstall." +fi +``` + +Export `ARCHON_STABLE_WAS_INSTALLED` into the environment used by Phase 5b. Only applies to the `brew` path — `curl-mac` and `curl-vps` don't go through brew and don't disturb `archon-stable`. + ## Phase 3 — Install ### Path: brew @@ -352,6 +366,25 @@ archon version | head -1 # should match the dev version captured in Phase 2 ``` +**Restore `archon-stable` if it existed before the test** (dual-homebrew pattern — see Phase 2 item 4): + +```bash +if [ -n "$ARCHON_STABLE_WAS_INSTALLED" ]; then + echo "Restoring archon-stable (detected before test)..." + brew tap coleam00/archon + brew install coleam00/archon/archon + BREW_BIN="$(brew --prefix)/bin" + if [ -e "$BREW_BIN/archon" ]; then + mv "$BREW_BIN/archon" "$BREW_BIN/archon-stable" + echo "archon-stable restored: $(archon-stable version 2>/dev/null | head -1)" + else + echo "WARNING: brew install succeeded but $BREW_BIN/archon missing — check formula" + fi +fi +``` + +> **Note on the restored version**: this reinstalls from whatever the tap currently ships, which is typically the release you just tested (so `archon-stable` ends up at the newly-tested version). That's usually what the operator wants — you just verified the new release works, and you want `archon-stable` pointed at it. If you were testing an older version for back-version QA, the restored `archon-stable` will be the *current* tap formula, not the pre-test version. For that rare case, the operator should re-run `brew-upgrade-archon` manually after the test. + ### Path: curl-mac ```bash diff --git a/bun.lock b/bun.lock index d06d5ccac0..01e7de9875 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "archon", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.74", + "@opencode-ai/sdk": "^1.14.20", }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -23,7 +24,7 @@ }, "packages/adapters": { "name": "@archon/adapters", - "version": "0.3.6", + "version": "0.3.9", "dependencies": { "@archon/core": "workspace:*", "@archon/git": "workspace:*", @@ -41,7 +42,7 @@ }, "packages/cli": { "name": "@archon/cli", - "version": "0.3.6", + "version": "0.3.9", "bin": { "archon": "./src/cli.ts", }, @@ -63,7 +64,7 @@ }, "packages/core": { "name": "@archon/core", - "version": "0.3.6", + "version": "0.3.9", "dependencies": { "@archon/git": "workspace:*", "@archon/isolation": "workspace:*", @@ -83,7 +84,7 @@ }, "packages/docs-web": { "name": "@archon/docs-web", - "version": "0.3.6", + "version": "0.3.9", "dependencies": { "@astrojs/starlight": "^0.38.0", "astro": "^6.1.0", @@ -92,7 +93,7 @@ }, "packages/git": { "name": "@archon/git", - "version": "0.3.6", + "version": "0.3.9", "dependencies": { "@archon/paths": "workspace:*", }, @@ -102,7 +103,7 @@ }, "packages/isolation": { "name": "@archon/isolation", - "version": "0.3.6", + "version": "0.3.9", "dependencies": { "@archon/git": "workspace:*", "@archon/paths": "workspace:*", @@ -113,7 +114,7 @@ }, "packages/paths": { "name": "@archon/paths", - "version": "0.3.6", + "version": "0.3.9", "dependencies": { "dotenv": "^17", "pino": "^9", @@ -126,13 +127,14 @@ }, "packages/providers": { "name": "@archon/providers", - "version": "0.3.6", + "version": "0.3.9", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.89", "@archon/paths": "workspace:*", "@mariozechner/pi-ai": "^0.67.5", "@mariozechner/pi-coding-agent": "^0.67.5", "@openai/codex-sdk": "^0.116.0", + "@opencode-ai/sdk": "^1.14.20", "@sinclair/typebox": "^0.34.41", }, "devDependencies": { @@ -144,7 +146,7 @@ }, "packages/server": { "name": "@archon/server", - "version": "0.3.6", + "version": "0.3.9", "dependencies": { "@archon/adapters": "workspace:*", "@archon/core": "workspace:*", @@ -163,7 +165,7 @@ }, "packages/web": { "name": "@archon/web", - "version": "0.3.6", + "version": "0.3.9", "dependencies": { "@dagrejs/dagre": "^2.0.4", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -215,7 +217,7 @@ }, "packages/workflows": { "name": "@archon/workflows", - "version": "0.3.6", + "version": "0.3.9", "dependencies": { "@archon/git": "workspace:*", "@archon/paths": "workspace:*", @@ -711,6 +713,8 @@ "@openai/codex-win32-x64": ["@openai/codex@0.116.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-6sBIMOoA9FNuxQvCCnK0P548Wqrlk3I9SMdtOCUg2zYzYU7jOF2mWS1VpRQ6R+Jvo2x50dxeJZ+W37dBmXfprw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.20", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-kPZP1An1ZdWOfLfDYhNjh665HX4RcI8au6Lzjn0FktoQ3RpWHq1WXRLHrJO8rJqwWvQDOzS48cXt9jbr+uwQiA=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ=="], diff --git a/homebrew/archon.rb b/homebrew/archon.rb index 0bac58a339..d8f4c45c18 100644 --- a/homebrew/archon.rb +++ b/homebrew/archon.rb @@ -7,28 +7,28 @@ class Archon < Formula desc "Remote agentic coding platform - control AI assistants from anywhere" homepage "https://github.com/coleam00/Archon" - version "0.3.6" + version "0.3.9" license "MIT" on_macos do on_arm do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-arm64" - sha256 "96b6dac50b046eece9eddbb988a0c39b4f9a0e2faac66e49b977ba6360069e86" + sha256 "b617f85a2181938b793b25ad816a9f6b3149d184f64b2e9e2ea2430f27778d64" end on_intel do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-x64" - sha256 "09f1dbe12417b4300b7b07b531eb7391a286305f8d4eafc11e7f61f5d26eb8eb" + sha256 "5a928af5e0e67ffe084159161a9ea3994a9304cc39bd06132719cd89cc715e86" end end on_linux do on_arm do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-arm64" - sha256 "80b06a6ff699ec57cd4a3e49cfe7b899a3e8212688d70285f5a887bf10086731" + sha256 "567bfca9175e10d9b4fd748e3862bbd34141a234766a7ecf0a714d9c27b8c92e" end on_intel do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-x64" - sha256 "09f5dac6db8037ed6f3e5b7e9c5eb8e37f19822a4ed2bf4cd7e654780f9d00de" + sha256 "c918218df2f0f853d107e6b1727dcd9accc034b183ffbccea93a331d8d376ed8" end end diff --git a/package.json b/package.json index 536c8ff7b2..17e43232af 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "axios": "^1.15.0" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.74" + "@anthropic-ai/claude-agent-sdk": "^0.2.74", + "@opencode-ai/sdk": "^1.14.20" } } diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index 4bf22d9144..8a5628845d 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -98,6 +98,7 @@ const SAFE_ASSISTANT_FIELDS: Record = { codex: ['model', 'modelReasoningEffort', 'webSearchMode'], // community providers — list each field we're confident is safe to // show in the web UI. Unknown providers fall through with no fields. + opencode: ['model', 'agent'], pi: ['model'], }; diff --git a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md index ff4f8e6533..cf3a9cab04 100644 --- a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md +++ b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md @@ -1,6 +1,6 @@ --- title: AI Assistants -description: Configure Claude Code, Codex, and Pi as AI assistants for Archon. +description: Configure Claude Code, Codex, OpenCode, and Pi as AI assistants for Archon. category: getting-started area: clients audience: [user] @@ -9,7 +9,7 @@ sidebar: order: 4 --- -You must configure **at least one** AI assistant. All three can be configured and mixed within workflows. +You must configure **at least one** AI assistant. All four can be configured and mixed within workflows. ## Claude Code @@ -227,6 +227,90 @@ If you want Codex to be the default AI assistant for new conversations without c DEFAULT_AI_ASSISTANT=codex ``` +## OpenCode (Community Provider) + +**SDK-backed community provider.** Archon's OpenCode adapter uses `@opencode-ai/sdk`, which provides a multi-provider AI coding agent with support for Anthropic, OpenAI, Google, and more through a unified interface. + +OpenCode is registered as `builtIn: false` — like Pi, it is a bundled community provider rather than a core built-in. + +Archon always runs OpenCode as a **managed embedded runtime** — it spawns and owns the OpenCode server process, generates a random server password per session, and tears it down when the workflow completes. Connecting to an external OpenCode server (`baseUrl`) is not supported. + +### Install + +OpenCode is included as a dependency of `@archon/providers` — `bun install` pulls in the SDK automatically. It's available immediately. + +### Authenticate + +OpenCode handles authentication internally — Archon does not pass API keys through config. Configure credentials using one of these methods: + +1. **`/connect` TUI command** — Run `opencode` in your terminal, then use the `/connect` command to interactively authenticate with your chosen provider +2. **Config file** — Store credentials in `~/.config/opencode/opencode.json` with `{env:VAR}` or `{file:PATH}` substitution +3. **Auth file** — Credentials are persisted in `~/.local/share/opencode/auth.json` after connecting + +OpenCode delegates to the underlying LLM provider (Anthropic, OpenAI, Google, etc.) based on your model selection. Request-scoped env vars from Archon workflows are still merged into the OpenCode environment. + +### Configuration Options + +```yaml +assistants: + opencode: + model: anthropic/claude-3-5-sonnet # Required: '/' format + # or build-in agent + agent: general +``` + +### Model reference format + +OpenCode models use a `/` format. List all available models via `opencode models`: + +```yaml +assistants: + opencode: + model: anthropic/claude-3-5-sonnet # via Anthropic + # model: openai/gpt-4o # via OpenAI + # model: google/gemini-2.5-pro # via Google +``` + +### Supported Archon Features + +| Feature | Support | Notes | +|---|---|---| +| Session resume | ✅ | Single-agent runs return `sessionId`; multi-agent runs do not | +| MCP servers | ✅ | `mcp: path/to/servers.json` passed through to OpenCode | +| Structured output | ✅ | `output_format:` — schema passed to OpenCode SDK | +| System prompt override | ✅ | `systemPrompt:` | +| Codebase env vars (`envInjection`) | ✅ | merged into the spawned OpenCode environment | +| Skills | ✅ | SKILL.md files with YAML frontmatter, pattern-based permissions | +| Tool restrictions | ✅ | `tools` / `disallowedTools` per agent; deny wins over allow | +| Inline agents (`agents:`) | ✅ | File-materialized agents; single and parallel multi-agent fan-out | +| Hooks | ✅ | Plugin hook system (tool, session, message hooks) | +| Effort / reasoning control | ❌ | No per-request param; not configurable in agent file, opencode put it in cofnig file. | +| Thinking control | ❌ | No explicit `thinking` field in agent frontmatter; OpenCode auto-enables reasoning when `agents[].model` is a reasoning-capable model (e.g. `anthropic/claude-sonnet-4-5`) | +| Fallback model | ❌ | No native failover in the SDK | +| Sandbox | ❌ | Not native in the SDK; Archon uses worktree isolation | +| Cost limits (`maxBudgetUsd`) | ❌ | Cost tracked in result chunks, but no runtime budget enforcement | + +Unsupported YAML fields trigger a visible warning from the dag-executor when the workflow runs, so you always know what was ignored. + +### Usage in workflows + +```yaml +name: my-workflow +provider: opencode +model: anthropic/claude-3-5-sonnet + +nodes: + - id: analyze + prompt: "Analyze the codebase structure" + # per-node model override: + # model: openai/gpt-4o +``` + +### See also + +- [Adding a Community Provider](../contributing/adding-a-community-provider/) — the contributor-facing guide for extending Archon with your own provider. +- [OpenCode on GitHub](https://github.com/opencode-ai/opencode) — upstream project. + ## Pi (Community Provider) **One adapter, ~20 LLM backends.** Pi (`@mariozechner/pi-coding-agent`) is a community-maintained coding-agent harness that Archon integrates as the first community provider. It unlocks Anthropic, OpenAI, Google (Gemini + Vertex), Groq, Mistral, Cerebras, xAI, OpenRouter, Hugging Face, and more under a single `provider: pi` entry. diff --git a/packages/providers/package.json b/packages/providers/package.json index b1e523d2ab..9a528ad12a 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -13,12 +13,13 @@ "./codex/provider": "./src/codex/provider.ts", "./codex/config": "./src/codex/config.ts", "./codex/binary-resolver": "./src/codex/binary-resolver.ts", + "./community/opencode": "./src/community/opencode/index.ts", "./community/pi": "./src/community/pi/index.ts", "./errors": "./src/errors.ts", "./registry": "./src/registry.ts" }, "scripts": { - "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts && bun test src/community/pi/provider-lazy-load.test.ts", + "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts && bun test src/community/pi/provider-lazy-load.test.ts && bun test src/community/opencode/provider.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { @@ -26,6 +27,7 @@ "@archon/paths": "workspace:*", "@mariozechner/pi-ai": "^0.67.5", "@mariozechner/pi-coding-agent": "^0.67.5", + "@opencode-ai/sdk": "^1.14.20", "@openai/codex-sdk": "^0.116.0", "@sinclair/typebox": "^0.34.41" }, diff --git a/packages/providers/src/community/opencode/agent-config.ts b/packages/providers/src/community/opencode/agent-config.ts new file mode 100644 index 0000000000..902ffa1d11 --- /dev/null +++ b/packages/providers/src/community/opencode/agent-config.ts @@ -0,0 +1,149 @@ +import { createLogger } from '@archon/paths'; + +import type { NodeConfig } from '../../types'; + +import { parseModelRef } from './config'; + +export type AgentConfig = NonNullable[string]>; + +export interface NamedAgentConfig { + key: string; + opencodeAgentName: string; + config: AgentConfig; +} + +let cachedLog: ReturnType | undefined; + +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.opencode'); + return cachedLog; +} + +let warnedMultipleAgents = false; + +export function listNamedAgents( + agents: Record | undefined +): NamedAgentConfig[] { + if (!agents) return []; + return Object.entries(agents).map(([key, config]) => ({ + key, + opencodeAgentName: `archon-${toKebabCase(key)}`, + config, + })); +} + +export function hasMultipleAgents(agents: Record | undefined): boolean { + return listNamedAgents(agents).length > 1; +} + +export function getOrderedAgents(nodeConfig?: NodeConfig): NamedAgentConfig[] { + return listNamedAgents(nodeConfig?.agents); +} + +export function selectSingleAgent( + agents: Record | undefined +): NamedAgentConfig | undefined { + const namedAgents = listNamedAgents(agents); + if (namedAgents.length === 0) return undefined; + if (namedAgents.length > 1 && !warnedMultipleAgents) { + warnedMultipleAgents = true; + getLog().warn( + { agents: namedAgents.map(a => a.key), selected: namedAgents[0]?.key }, + 'opencode.multiple_agents_configured_using_first' + ); + } + return namedAgents[0]; +} + +export function adaptNamedAgentForOpencode(agent: NamedAgentConfig): { + agent: string; + model?: { providerID: string; modelID: string }; + tools?: Record; +} { + const adaptedConfig: { + agent: string; + model?: { providerID: string; modelID: string }; + tools?: Record; + } = { + agent: agent.opencodeAgentName, + }; + + if (agent.config.model) { + const parsedModel = parseModelRef(agent.config.model); + if (!parsedModel) { + throw new Error( + `Invalid OpenCode agent model ref for '${agent.key}': '${agent.config.model}'. Expected format '/' (for example 'anthropic/claude-3-5-sonnet').` + ); + } + adaptedConfig.model = parsedModel; + } + + const tools = buildToolsPermissionsMap(agent.config.tools, agent.config.disallowedTools); + if (tools) { + adaptedConfig.tools = tools; + } + + return adaptedConfig; +} + +export function resolvePromptForAgent( + _agent: NamedAgentConfig | undefined, + nodePrompt: string +): string { + // The agent's prompt is materialized into .opencode/agents/*.md as its + // system context. OpenCode automatically loads it when the agent is referenced + // by name. The node prompt is the user's task — sending the agent prompt here + // would duplicate it (once in the agent file, once in the prompt body). + return nodePrompt; +} + +/** + * @deprecated Use selectSingleAgent instead. Kept for backward compatibility. + */ +export function selectPrimaryAgent(agents: Record): string | undefined { + const selected = selectSingleAgent(agents); + return selected?.key; +} + +/** + * @deprecated Use adaptNamedAgentForOpencode instead. Kept for backward compatibility. + */ +export function adaptAgentConfigForOpencode(nodeConfig?: NodeConfig): + | { + agent?: string; + model?: { providerID: string; modelID: string }; + tools?: Record; + } + | undefined { + const agents = nodeConfig?.agents; + if (!agents) return undefined; + + const selected = selectSingleAgent(agents); + if (!selected) return undefined; + + return adaptNamedAgentForOpencode(selected); +} + +export function toKebabCase(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export function buildToolsPermissionsMap( + allowed?: string[], + denied?: string[] +): Record | undefined { + const toolsPermissions: Record = {}; + + for (const tool of allowed ?? []) { + toolsPermissions[tool] = true; + } + + for (const tool of denied ?? []) { + toolsPermissions[tool] = false; + } + + return Object.keys(toolsPermissions).length > 0 ? toolsPermissions : undefined; +} diff --git a/packages/providers/src/community/opencode/agent-fs.ts b/packages/providers/src/community/opencode/agent-fs.ts new file mode 100644 index 0000000000..e997190fd3 --- /dev/null +++ b/packages/providers/src/community/opencode/agent-fs.ts @@ -0,0 +1,98 @@ +import { mkdir, readdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { createLogger } from '@archon/paths'; + +import type { NodeConfig } from '../../types'; + +import { toKebabCase } from './agent-config'; + +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.opencode'); + return cachedLog; +} + +type AgentConfig = NonNullable[string]>; + +function buildAgentFileContent(agentConfig: AgentConfig): string { + const lines: string[] = ['---']; + + lines.push('mode: subagent'); + + if (agentConfig.description) { + lines.push(`description: ${JSON.stringify(agentConfig.description)}`); + } + + if (agentConfig.model) { + lines.push(`model: ${JSON.stringify(agentConfig.model)}`); + } + + if (typeof agentConfig.maxTurns === 'number') { + lines.push(`steps: ${agentConfig.maxTurns}`); + } + + if (agentConfig.skills && agentConfig.skills.length > 0) { + lines.push('skills:'); + for (const skill of agentConfig.skills) { + lines.push(`- ${JSON.stringify(skill)}`); + } + } + + const toolsMap: Record = {}; + for (const tool of agentConfig.tools ?? []) { + toolsMap[tool] = true; + } + for (const tool of agentConfig.disallowedTools ?? []) { + toolsMap[tool] = false; + } + if (Object.keys(toolsMap).length > 0) { + lines.push('tools:'); + for (const [tool, allowed] of Object.entries(toolsMap)) { + lines.push(` ${tool}: ${allowed}`); + } + } + + lines.push('---'); + + if (agentConfig.prompt) { + lines.push(''); + lines.push(agentConfig.prompt); + } + + return lines.join('\n'); +} + +export async function materializeAgents( + cwd: string, + agents: Record +): Promise { + const agentsDir = join(cwd, '.opencode', 'agents'); + await mkdir(agentsDir, { recursive: true }); + + // Remove stale archon-owned agent files that aren't in the current request + const currentArchonFiles = new Set( + Object.keys(agents).map(key => `archon-${toKebabCase(key)}.md`) + ); + try { + const existing = await readdir(agentsDir); + await Promise.all( + existing + .filter(f => f.startsWith('archon-') && !currentArchonFiles.has(f)) + .map(f => rm(join(agentsDir, f), { force: true })) + ); + } catch (error) { + // mkdir above already ensures the directory exists; other errors (e.g. permission + // denied) are non-fatal for stale-file cleanup but worth surfacing for diagnostics. + getLog().debug({ err: error, agentsDir }, 'opencode.agent_fs_readdir_failed'); + } + + // Write all agent files for this request + await Promise.all( + Object.entries(agents).map(([key, config]) => { + const filename = `archon-${toKebabCase(key)}.md`; + const content = buildAgentFileContent(config); + return writeFile(join(agentsDir, filename), content, 'utf8'); + }) + ); +} diff --git a/packages/providers/src/community/opencode/capabilities.ts b/packages/providers/src/community/opencode/capabilities.ts new file mode 100644 index 0000000000..075ff0cf93 --- /dev/null +++ b/packages/providers/src/community/opencode/capabilities.ts @@ -0,0 +1,32 @@ +import type { ProviderCapabilities } from '../../types'; + +/** + * OpenCode SDK capabilities — reflects actual SDK features only. + * The dag-executor uses these to warn users when a workflow node + * specifies a feature the provider ignores. + * + * Agents semantics differ from Claude SDK: OpenCode supports agent + * selection via adaptation layer. The `agents: true` flag enables + * `nodeConfig.agents` translation to OpenCode request fields: + * - agent selection (named agent from opencode.json config) + * - model override per-call + * - tools/permissions map for scoping + * + * NOT full programmatic inline agent definitions like Claude SDK's + * `options.agents` array — OpenCode uses config-file-based agents. + */ +export const OPENCODE_CAPABILITIES: ProviderCapabilities = { + sessionResume: true, + mcp: true, + hooks: true, + skills: true, + agents: true, + toolRestrictions: true, + structuredOutput: true, + envInjection: true, + costControl: false, + effortControl: false, + thinkingControl: false, // OpenCode handles effort/thinking via opencode.json agent config, not prompt body + fallbackModel: false, + sandbox: false, +}; diff --git a/packages/providers/src/community/opencode/config.ts b/packages/providers/src/community/opencode/config.ts new file mode 100644 index 0000000000..07b6672815 --- /dev/null +++ b/packages/providers/src/community/opencode/config.ts @@ -0,0 +1,39 @@ +import type { OpencodeProviderDefaults } from '../../types'; + +export type { OpencodeProviderDefaults }; + +export function parseModelRef(modelRef: string): { providerID: string; modelID: string } | null { + const slashIndex = modelRef.indexOf('/'); + if (slashIndex <= 0 || slashIndex === modelRef.length - 1) return null; + + const providerID = modelRef.slice(0, slashIndex).trim(); + const modelID = modelRef.slice(slashIndex + 1).trim(); + if (!providerID || !modelID) return null; + + return { providerID, modelID }; +} + +/** + * Parse raw YAML-derived config into typed OpenCode defaults. + * Defensive: invalid fields are dropped silently (matches parseClaudeConfig, + * parseCodexConfig, and parsePiConfig — never throws, so broken user config + * can't prevent provider registration or workflow discovery). + */ +export function parseOpencodeConfig(raw: Record): OpencodeProviderDefaults { + const result: OpencodeProviderDefaults = {}; + + if (typeof raw.model === 'string') { + result.model = raw.model; + } + + if (typeof raw.baseUrl === 'string') { + result.baseUrl = raw.baseUrl; + } + + const opencodeConfig = raw.opencode as Record | undefined; + if (typeof opencodeConfig?.agent === 'string') { + result.agent = opencodeConfig.agent; + } + + return result; +} diff --git a/packages/providers/src/community/opencode/errors.ts b/packages/providers/src/community/opencode/errors.ts new file mode 100644 index 0000000000..523e4d3a1e --- /dev/null +++ b/packages/providers/src/community/opencode/errors.ts @@ -0,0 +1,74 @@ +const RATE_LIMIT_PATTERNS = ['rate limit', 'too many requests', '429', 'overloaded']; +const AUTH_PATTERNS = ['unauthorized', 'authentication', 'invalid token', '401', '403', 'api key']; +const CRASH_PATTERNS = [ + 'server disconnected', + 'disposed', + 'econnreset', + 'socket hang up', + 'connection terminated', + 'process terminated', +]; +const AGENT_NOT_FOUND_PATTERNS = [ + 'agent not found', + 'unknown agent', + 'invalid agent', + 'no agent named', +]; + +export type RetryableErrorClass = + | 'rate_limit' + | 'auth' + | 'crash' + | 'agent_not_found' + | 'unknown' + | 'aborted'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (isRecord(error)) { + if (typeof error.message === 'string') return error.message; + if (isRecord(error.data) && typeof error.data.message === 'string') return error.data.message; + } + return String(error); +} + +export function classifyOpencodeError(error: unknown, aborted: boolean): RetryableErrorClass { + if (aborted) return 'aborted'; + + const parts: string[] = []; + if (error instanceof Error) { + parts.push(error.name, error.message); + } + if (isRecord(error)) { + if (typeof error.name === 'string') parts.push(error.name); + if (typeof error.message === 'string') parts.push(error.message); + if (typeof error.statusCode === 'number') parts.push(String(error.statusCode)); + if (isRecord(error.data)) { + if (typeof error.data.message === 'string') parts.push(error.data.message); + if (typeof error.data.statusCode === 'number') parts.push(String(error.data.statusCode)); + if (typeof error.data.responseBody === 'string') parts.push(error.data.responseBody); + } + } + + const combined = parts.join(' ').toLowerCase(); + if (RATE_LIMIT_PATTERNS.some(pattern => combined.includes(pattern))) return 'rate_limit'; + if (AUTH_PATTERNS.some(pattern => combined.includes(pattern))) return 'auth'; + if (CRASH_PATTERNS.some(pattern => combined.includes(pattern))) return 'crash'; + if (AGENT_NOT_FOUND_PATTERNS.some(pattern => combined.includes(pattern))) + return 'agent_not_found'; + return 'unknown'; +} + +export function enrichOpencodeError(error: unknown, errorClass: RetryableErrorClass): Error { + if (errorClass === 'aborted') { + return new Error('OpenCode query aborted'); + } + + const err = new Error(`OpenCode ${errorClass}: ${errorMessage(error)}`); + if (error instanceof Error) err.cause = error; + return err; +} diff --git a/packages/providers/src/community/opencode/index.ts b/packages/providers/src/community/opencode/index.ts new file mode 100644 index 0000000000..214704adb3 --- /dev/null +++ b/packages/providers/src/community/opencode/index.ts @@ -0,0 +1,4 @@ +export { OPENCODE_CAPABILITIES } from './capabilities'; +export { parseOpencodeConfig, type OpencodeProviderDefaults } from './config'; +export { registerOpencodeProvider } from './registration'; +export { OpencodeProvider } from './provider'; diff --git a/packages/providers/src/community/opencode/multi-agent.ts b/packages/providers/src/community/opencode/multi-agent.ts new file mode 100644 index 0000000000..43f23efbdb --- /dev/null +++ b/packages/providers/src/community/opencode/multi-agent.ts @@ -0,0 +1,398 @@ +import { createLogger } from '@archon/paths'; + +import type { MessageChunk, SendQueryOptions, TokenUsage } from '../../types'; +import { getOrderedAgents, type NamedAgentConfig } from './agent-config'; +import { errorMessage } from './errors'; +import type { OpencodeClientLike } from './runtime'; +import { + abortableStream, + createSessionPromptBody, + promptSession, + resolveSessionId, +} from './session'; +import { normalizeTokens } from './tokens'; + +interface ProviderModel { + providerID: string; + modelID: string; +} + +interface AgentRunState { + agent: NamedAgentConfig; + cwd: string; + sessionId: string; + chunks: MessageChunk[]; + latestAssistantInfo?: Record; + lastAssistantMessageId?: string; + done: boolean; +} + +let cachedLog: ReturnType | undefined; + +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.opencode'); + return cachedLog; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +async function readStructuredOutput( + client: OpencodeClientLike, + cwd: string, + sessionId: string, + messageId: string | undefined +): Promise { + if (!messageId) return undefined; + try { + const response = await client.session.message({ + path: { id: sessionId, messageID: messageId }, + query: { directory: cwd }, + }); + const info = response.data?.info; + if (isRecord(info) && 'structured_output' in info) { + return info.structured_output; + } + } catch (error) { + getLog().debug( + { err: error, sessionId, messageId }, + 'opencode.structured_output_lookup_failed' + ); + } + return undefined; +} + +function withAgentNodeConfig( + requestOptions: SendQueryOptions | undefined, + agent: NamedAgentConfig +): SendQueryOptions | undefined { + if (!requestOptions) { + return { + nodeConfig: { + agents: { [agent.key]: agent.config }, + }, + }; + } + return { + ...requestOptions, + nodeConfig: { + ...(requestOptions.nodeConfig ?? {}), + agents: { [agent.key]: agent.config }, + }, + }; +} + +function formatBufferedAssistantOutput(states: AgentRunState[]): string { + return states + .map(state => { + const assistantText = state.chunks + .filter( + (chunk): chunk is Extract => + chunk.type === 'assistant' + ) + .map(chunk => chunk.content) + .join(''); + const thinkingText = state.chunks + .filter( + (chunk): chunk is Extract => chunk.type === 'thinking' + ) + .map(chunk => chunk.content) + .join(''); + const sections: string[] = [`## ${state.agent.key}`]; + if (thinkingText) { + sections.push(`\n${thinkingText}\n`); + } + sections.push(assistantText || '(no output)'); + return sections.join('\n\n'); + }) + .join('\n\n---\n\n'); +} + +function collectToolChunksForEmission(states: AgentRunState[]): MessageChunk[] { + return states.flatMap(state => + state.chunks.filter(chunk => chunk.type === 'tool' || chunk.type === 'tool_result') + ); +} + +export async function* streamMultiAgentOpencodeSession( + client: OpencodeClientLike, + cwd: string, + nodeId: string, + prompt: string, + model: ProviderModel, + requestOptions: SendQueryOptions | undefined +): AsyncGenerator { + const agents = getOrderedAgents(requestOptions?.nodeConfig); + if (agents.length <= 1) { + throw new Error('streamMultiAgentOpencodeSession requires multiple agents'); + } + + getLog().info({ nodeId, agentCount: agents.length, cwd }, 'opencode.multi_agent_starting'); + + const events = await client.event.subscribe({ query: { directory: cwd } }); + getLog().info({ nodeId }, 'opencode.multi_agent_events_subscribed'); + const streamController = new AbortController(); + const sessionToAgent = new Map(); + let aborted = requestOptions?.abortSignal?.aborted === true; + + const abortAll = async (): Promise => { + await Promise.all( + Array.from(sessionToAgent.values()).map(state => + client.session + .abort({ path: { id: state.sessionId }, query: { directory: state.cwd } }) + .catch(error => { + getLog().debug( + { err: error, sessionId: state.sessionId, agent: state.agent.key }, + 'opencode.multi_agent_abort_failed' + ); + }) + ) + ); + }; + + const abortHandler = (): void => { + aborted = true; + void abortAll(); + streamController.abort(); + }; + + requestOptions?.abortSignal?.addEventListener('abort', abortHandler, { once: true }); + + try { + // Phase 1: Create all child sessions in the shared sessionCwd so a single + // event subscription receives events from every child session. + getLog().info({ nodeId }, 'opencode.multi_agent_creating_sessions'); + const states = await Promise.all( + agents.map(async agent => { + const { sessionId } = await resolveSessionId(client, cwd, undefined); + getLog().info({ agent: agent.key, sessionId, cwd }, 'opencode.multi_agent_session_created'); + const state: AgentRunState = { + agent, + cwd, + sessionId, + chunks: [], + done: false, + }; + sessionToAgent.set(sessionId, state); + return state; + }) + ); + + // Phase 2: Fire all prompts in parallel + getLog().info({ nodeId, sessionCount: states.length }, 'opencode.multi_agent_prompting'); + await Promise.all( + states.map(async state => { + const agentRequestOptions = withAgentNodeConfig(requestOptions, state.agent); + const promptBody = createSessionPromptBody(prompt, model, agentRequestOptions, state.agent); + getLog().info( + { agent: state.agent.key, sessionId: state.sessionId }, + 'opencode.multi_agent_prompt_sending' + ); + await promptSession(client, cwd, state.sessionId, promptBody); + getLog().info( + { agent: state.agent.key, sessionId: state.sessionId }, + 'opencode.multi_agent_prompt_sent' + ); + }) + ); + getLog().info({ nodeId }, 'opencode.multi_agent_all_prompts_sent'); + + const seenToolCalls = new Set(); + const completedToolCalls = new Set(); + + // Phase 3: Listen to events and demux by sessionID + getLog().info({ nodeId }, 'opencode.multi_agent_listening'); + let eventCount = 0; + for await (const rawEvent of abortableStream(events.stream, streamController.signal)) { + eventCount++; + if (eventCount <= 5) { + getLog().info( + { nodeId, eventCount, eventType: (rawEvent as { type?: string })?.type }, + 'opencode.multi_agent_event_received' + ); + } + const event = rawEvent as { + type?: string; + properties?: Record; + }; + const properties = isRecord(event.properties) ? event.properties : {}; + + if (event.type === 'message.updated') { + const info = isRecord(properties.info) ? properties.info : undefined; + const sessionId = typeof info?.sessionID === 'string' ? info.sessionID : undefined; + const state = sessionId ? sessionToAgent.get(sessionId) : undefined; + if (!state || info?.role !== 'assistant') continue; + state.latestAssistantInfo = info; + if (typeof info.id === 'string') { + state.lastAssistantMessageId = info.id; + } + continue; + } + + if (event.type === 'message.part.updated') { + const part = isRecord(properties.part) ? properties.part : undefined; + const sessionId = typeof part?.sessionID === 'string' ? part.sessionID : undefined; + const state = sessionId ? sessionToAgent.get(sessionId) : undefined; + if (!state || typeof part?.type !== 'string') continue; + + if (part.type === 'text') { + const delta = typeof properties.delta === 'string' ? properties.delta : undefined; + const text = delta ?? (typeof part.text === 'string' ? part.text : ''); + if (text) { + state.chunks.push({ type: 'assistant', content: text }); + } + continue; + } + + if (part.type === 'reasoning') { + const delta = typeof properties.delta === 'string' ? properties.delta : undefined; + const text = delta ?? (typeof part.text === 'string' ? part.text : ''); + if (text) { + state.chunks.push({ type: 'thinking', content: text }); + } + continue; + } + + if (part.type === 'tool') { + const rawCallId = typeof part.callID === 'string' ? part.callID : undefined; + const toolName = typeof part.tool === 'string' ? part.tool : 'unknown'; + const stateRecord = isRecord(part.state) ? part.state : undefined; + const toolInput = isRecord(stateRecord?.input) ? stateRecord.input : undefined; + const status = typeof stateRecord?.status === 'string' ? stateRecord.status : undefined; + const scopedCallId = rawCallId ? `${state.agent.key}:${rawCallId}` : undefined; + + if (scopedCallId && !seenToolCalls.has(scopedCallId)) { + seenToolCalls.add(scopedCallId); + state.chunks.push({ + type: 'tool', + toolName, + ...(toolInput ? { toolInput } : {}), + toolCallId: scopedCallId, + }); + } + + if (scopedCallId && !completedToolCalls.has(scopedCallId)) { + if (status === 'completed') { + completedToolCalls.add(scopedCallId); + state.chunks.push({ + type: 'tool_result', + toolName, + toolOutput: typeof stateRecord?.output === 'string' ? stateRecord.output : '', + toolCallId: scopedCallId, + }); + } else if (status === 'error') { + completedToolCalls.add(scopedCallId); + state.chunks.push({ + type: 'tool_result', + toolName, + toolOutput: + typeof stateRecord?.error === 'string' ? stateRecord.error : 'Tool failed', + toolCallId: scopedCallId, + }); + } + } + } + continue; + } + + if (event.type === 'session.error') { + const sessionId = + typeof properties.sessionID === 'string' ? properties.sessionID : undefined; + const state = sessionId ? sessionToAgent.get(sessionId) : undefined; + if (!state) continue; + await abortAll(); + const rawError = isRecord(properties.error) ? properties.error : properties; + const err = new Error(`[${state.agent.key}] ${errorMessage(rawError)}`); + err.cause = rawError; + throw err; + } + + if (event.type === 'session.idle') { + const sessionId = + typeof properties.sessionID === 'string' ? properties.sessionID : undefined; + const state = sessionId ? sessionToAgent.get(sessionId) : undefined; + if (!state) continue; + state.done = true; + getLog().info( + { + nodeId, + agent: state.agent.key, + sessionId, + doneCount: states.filter(s => s.done).length, + totalCount: states.length, + }, + 'opencode.multi_agent_session_idle' + ); + + // Check if all agents are done + if (states.every(candidate => candidate.done)) { + // Emit collected tool chunks first + const toolChunks = collectToolChunksForEmission(states); + for (const chunk of toolChunks) { + yield chunk; + } + + // Emit combined assistant output + yield { + type: 'assistant', + content: formatBufferedAssistantOutput(states), + }; + + // Aggregate tokens + const tokens = states.reduce((acc, candidate) => { + const next = normalizeTokens(candidate.latestAssistantInfo); + if (!next) return acc; + if (!acc) return { ...next }; + return { + input: acc.input + next.input, + output: acc.output + next.output, + total: + (acc.total ?? acc.input + acc.output) + (next.total ?? next.input + next.output), + cost: (acc.cost ?? 0) + (next.cost ?? 0), + }; + }, undefined); + + // Fetch structured outputs from all agents + const structuredOutputs = await Promise.all( + states.map(async state => { + const output = await readStructuredOutput( + client, + state.cwd, + state.sessionId, + state.lastAssistantMessageId + ); + return output !== undefined ? ([state.agent.key, output] as const) : undefined; + }) + ).then(results => { + const filtered = results.filter(entry => entry !== undefined) as [string, unknown][]; + return filtered.length > 0 ? Object.fromEntries(filtered) : undefined; + }); + + // Multi-agent runs span multiple sessions; there is no single canonical + // sessionId to resume, so we omit it rather than returning an arbitrary one. + yield { + type: 'result', + ...(tokens ? { tokens } : {}), + ...(structuredOutputs ? { structuredOutput: structuredOutputs } : {}), + }; + getLog().info({ nodeId }, 'opencode.multi_agent_completed'); + return; + } + } + } + + getLog().info({ nodeId, aborted, eventCount }, 'opencode.multi_agent_loop_exited'); + if (aborted) { + const abortReason = requestOptions?.abortSignal?.reason; + throw new Error( + `OpenCode query aborted (nodeId: ${nodeId}, agents: ${agents.length}, cwd: ${cwd})` + + (abortReason ? `: ${String(abortReason)}` : '') + ); + } + throw new Error('OpenCode multi-agent stream ended before all agents completed'); + } finally { + requestOptions?.abortSignal?.removeEventListener('abort', abortHandler); + streamController.abort(); + } +} diff --git a/packages/providers/src/community/opencode/provider.test.ts b/packages/providers/src/community/opencode/provider.test.ts new file mode 100644 index 0000000000..8abf9b845f --- /dev/null +++ b/packages/providers/src/community/opencode/provider.test.ts @@ -0,0 +1,1240 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createMockLogger } from '../../test/mocks/logger'; + +const mockLogger = createMockLogger(); +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), +})); + +type OpencodeEvent = { + type?: string; + properties?: Record; +}; + +type MockRuntime = { + client: { + session: { + create: ReturnType; + get: ReturnType; + promptAsync: ReturnType; + abort: ReturnType; + message: ReturnType; + }; + event: { + subscribe: ReturnType; + }; + instance: { + dispose: ReturnType; + }; + }; + server: { + url: string; + close: ReturnType; + }; +}; + +const runtimeQueue: MockRuntime[] = []; +const createdRuntimes: MockRuntime[] = []; +const startupErrors: unknown[] = []; +let scriptedEvents: OpencodeEvent[] = []; +const tempDirs = new Set(); + +function createEventStream(events: OpencodeEvent[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const event of events) { + yield event; + } + }, + }; +} + +function createPendingStream(): AsyncIterable { + return { + [Symbol.asyncIterator]() { + return { + next: () => new Promise>(() => undefined), + }; + }, + }; +} + +function makeRuntime(overrides?: { + sessionCreate?: ReturnType; + sessionGet?: ReturnType; + promptAsync?: ReturnType; + sessionMessage?: ReturnType; + sessionAbort?: ReturnType; + subscribe?: ReturnType; + instanceDispose?: ReturnType; + close?: ReturnType; +}): MockRuntime { + const sessionCreate = + overrides?.sessionCreate ?? mock(async () => ({ data: { id: 'session-1' } })); + const sessionGet = + overrides?.sessionGet ?? mock(async () => ({ data: { id: 'resumed-session' } })); + const promptAsync = overrides?.promptAsync ?? mock(async () => undefined); + const sessionMessage = overrides?.sessionMessage ?? mock(async () => ({ data: { info: {} } })); + const sessionAbort = overrides?.sessionAbort ?? mock(async () => undefined); + const subscribe = + overrides?.subscribe ?? + mock(async () => ({ + stream: createEventStream(scriptedEvents), + })); + const instanceDispose = overrides?.instanceDispose ?? mock(async () => true); + const close = overrides?.close ?? mock(() => undefined); + + return { + client: { + session: { + create: sessionCreate, + get: sessionGet, + promptAsync, + abort: sessionAbort, + message: sessionMessage, + }, + event: { + subscribe, + }, + instance: { + dispose: instanceDispose, + }, + }, + server: { + url: 'http://mock-opencode.local', + close, + }, + }; +} + +const mockCreateOpencode = mock(async () => { + const startupError = startupErrors.shift(); + if (startupError) throw startupError; + const runtime = runtimeQueue.shift() ?? makeRuntime(); + createdRuntimes.push(runtime); + return runtime; +}); + +const mockCreateOpencodeClient = mock((_options?: Record) => { + const runtime = runtimeQueue.shift() ?? makeRuntime(); + createdRuntimes.push(runtime); + return runtime.client; +}); + +mock.module('@opencode-ai/sdk', () => ({ + createOpencode: mockCreateOpencode, + createOpencodeClient: mockCreateOpencodeClient, +})); + +import { OpencodeProvider, resetEmbeddedRuntime } from './provider'; + +/** Default model for tests — satisfies the model-or-agent validation */ +const TEST_MODEL = { model: 'test/mock-model' }; + +async function consume( + generator: AsyncGenerator +): Promise<{ chunks: unknown[]; error?: Error }> { + const chunks: unknown[] = []; + try { + for await (const chunk of generator) chunks.push(chunk); + return { chunks }; + } catch (error) { + return { chunks, error: error as Error }; + } +} + +async function createTempProjectDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'archon-opencode-provider-')); + tempDirs.add(dir); + return dir; +} + +describe('OpencodeProvider', () => { + beforeEach(() => { + scriptedEvents = []; + runtimeQueue.length = 0; + createdRuntimes.length = 0; + startupErrors.length = 0; + mockCreateOpencode.mockClear(); + mockCreateOpencodeClient.mockClear(); + mockLogger.info.mockClear(); + mockLogger.warn.mockClear(); + mockLogger.error.mockClear(); + mockLogger.debug.mockClear(); + resetEmbeddedRuntime(); + }); + + afterEach(async () => { + await Promise.all(Array.from(tempDirs, dir => rm(dir, { recursive: true, force: true }))); + tempDirs.clear(); + }); + + test('basic text streaming yields assistant chunks', async () => { + scriptedEvents = [ + { + type: 'message.part.updated', + properties: { + delta: 'Hello', + part: { sessionID: 'session-1', type: 'text' }, + }, + }, + { + type: 'message.part.updated', + properties: { + delta: ' world', + part: { sessionID: 'session-1', type: 'text' }, + }, + }, + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', '/tmp', undefined, { assistantConfig: TEST_MODEL }) + ); + + expect(error).toBeUndefined(); + expect(chunks).toEqual([ + { type: 'assistant', content: 'Hello' }, + { type: 'assistant', content: ' world' }, + { type: 'result', sessionId: 'session-1' }, + ]); + }); + + test('tool events normalize into tool and tool_result chunks', async () => { + scriptedEvents = [ + { + type: 'message.part.updated', + properties: { + part: { + sessionID: 'session-1', + type: 'tool', + tool: 'read', + callID: 'tool-1', + state: { + status: 'pending', + input: { path: '/tmp/file.ts' }, + }, + }, + }, + }, + { + type: 'message.part.updated', + properties: { + part: { + sessionID: 'session-1', + type: 'tool', + tool: 'read', + callID: 'tool-1', + state: { + status: 'completed', + input: { path: '/tmp/file.ts' }, + output: 'file contents', + }, + }, + }, + }, + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', '/tmp', undefined, { assistantConfig: TEST_MODEL }) + ); + + expect(error).toBeUndefined(); + expect(chunks).toEqual([ + { + type: 'tool', + toolName: 'read', + toolInput: { path: '/tmp/file.ts' }, + toolCallId: 'tool-1', + }, + { + type: 'tool_result', + toolName: 'read', + toolOutput: 'file contents', + toolCallId: 'tool-1', + }, + { type: 'result', sessionId: 'session-1' }, + ]); + }); + + test('terminal result chunk includes sessionId and normalized tokens', async () => { + scriptedEvents = [ + { + type: 'message.updated', + properties: { + info: { + id: 'message-1', + role: 'assistant', + sessionID: 'session-1', + providerID: 'anthropic', + modelID: 'claude-sonnet', + cost: 0.42, + finish: 'stop', + tokens: { input: 11, output: 7, reasoning: 3, cache: 1 }, + }, + }, + }, + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', '/tmp', undefined, { assistantConfig: TEST_MODEL }) + ); + + expect(error).toBeUndefined(); + expect(chunks).toEqual([ + { + type: 'result', + sessionId: 'session-1', + tokens: { input: 11, output: 7, total: 21, cost: 0.42 }, + cost: 0.42, + stopReason: 'stop', + modelUsage: { + providerID: 'anthropic', + modelID: 'claude-sonnet', + reasoning: 3, + cache: 1, + }, + }, + ]); + }); + + test('session resume handoff falls back to a fresh session with warning', async () => { + const runtime = makeRuntime({ + sessionGet: mock(async () => { + throw new Error('missing session'); + }), + sessionCreate: mock(async () => ({ data: { id: 'fresh-session' } })), + }); + runtimeQueue.push(runtime); + scriptedEvents = [ + { + type: 'session.idle', + properties: { sessionID: 'fresh-session' }, + }, + ]; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', '/tmp', 'resume-me', { assistantConfig: TEST_MODEL }) + ); + + expect(error).toBeUndefined(); + expect(runtime.client.session.get).toHaveBeenCalledWith({ + path: { id: 'resume-me' }, + query: { directory: '/tmp' }, + }); + expect(runtime.client.session.create).toHaveBeenCalledWith({ query: { directory: '/tmp' } }); + expect(chunks).toEqual([ + { + type: 'system', + content: '⚠️ Could not resume OpenCode session. Starting fresh conversation.', + }, + { type: 'result', sessionId: 'fresh-session' }, + ]); + }); + + test('structured output success includes parsed payload on result chunk', async () => { + const runtime = makeRuntime({ + sessionMessage: mock(async () => ({ + data: { + info: { + structured_output: { answer: 'ok', confidence: 0.9 }, + }, + }, + })), + }); + runtimeQueue.push(runtime); + scriptedEvents = [ + { + type: 'message.updated', + properties: { + info: { + id: 'message-1', + role: 'assistant', + sessionID: 'session-1', + }, + }, + }, + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', '/tmp', undefined, { + assistantConfig: TEST_MODEL, + outputFormat: { + type: 'json_schema', + schema: { type: 'object', properties: { answer: { type: 'string' } } }, + }, + }) + ); + + expect(error).toBeUndefined(); + expect(runtime.client.session.promptAsync).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + query: { directory: '/tmp' }, + body: { + parts: [{ type: 'text', text: 'hi' }], + model: { providerID: 'test', modelID: 'mock-model' }, + format: { + type: 'json_schema', + schema: { type: 'object', properties: { answer: { type: 'string' } } }, + }, + }, + }); + expect(chunks).toEqual([ + { + type: 'result', + sessionId: 'session-1', + structuredOutput: { answer: 'ok', confidence: 0.9 }, + modelUsage: { + providerID: undefined, + modelID: undefined, + reasoning: undefined, + cache: undefined, + }, + }, + ]); + }); + + test('structured output failure logs debug and still yields terminal result', async () => { + const runtime = makeRuntime({ + sessionMessage: mock(async () => { + throw new Error('lookup failed'); + }), + }); + runtimeQueue.push(runtime); + scriptedEvents = [ + { + type: 'message.updated', + properties: { + info: { + id: 'message-1', + role: 'assistant', + sessionID: 'session-1', + }, + }, + }, + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', '/tmp', undefined, { + assistantConfig: TEST_MODEL, + outputFormat: { + type: 'json_schema', + schema: { type: 'object' }, + }, + }) + ); + + expect(error).toBeUndefined(); + expect(chunks).toEqual([ + { + type: 'result', + sessionId: 'session-1', + modelUsage: { + providerID: undefined, + modelID: undefined, + reasoning: undefined, + cache: undefined, + }, + }, + ]); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + }); + + test('rate limit errors are classified as retryable and retried', async () => { + const retryRuntime = makeRuntime({ + promptAsync: mock(async () => { + throw new Error('429 rate limit exceeded'); + }), + }); + const successRuntime = makeRuntime(); + runtimeQueue.push(retryRuntime, successRuntime); + scriptedEvents = [ + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]; + + const { chunks, error } = await consume( + new OpencodeProvider({ retryBaseDelayMs: 1 }).sendQuery('hi', '/tmp', undefined, { + assistantConfig: TEST_MODEL, + }) + ); + + expect(error).toBeUndefined(); + expect(chunks).toEqual([{ type: 'result', sessionId: 'session-1' }]); + expect(mockCreateOpencode).toHaveBeenCalledTimes(2); + expect(mockLogger.info).toHaveBeenCalledWith( + { attempt: 0, delayMs: 1, errorClass: 'rate_limit' }, + 'opencode.retrying_query' + ); + }); + + test('auth errors are classified as non-retryable and do not retry', async () => { + const runtime = makeRuntime({ + promptAsync: mock(async () => { + const error = new Error('401 unauthorized api key'); + error.name = 'AuthenticationError'; + throw error; + }), + }); + runtimeQueue.push(runtime); + + const { chunks, error } = await consume( + new OpencodeProvider({ retryBaseDelayMs: 1 }).sendQuery('hi', '/tmp', undefined, { + assistantConfig: TEST_MODEL, + }) + ); + + expect(chunks).toEqual([]); + expect(error?.message).toContain('OpenCode auth: 401 unauthorized api key'); + expect(mockCreateOpencode).toHaveBeenCalledTimes(1); + expect(mockLogger.info).not.toHaveBeenCalledWith(expect.any(Object), 'opencode.retrying_query'); + }); + + // TODO(#1400): Enable once abort handling is stable in embedded runtime + test.skip('abort propagates to the OpenCode session and surfaces aborted error', async () => { + const runtime = makeRuntime({ + subscribe: mock(async () => ({ + stream: createPendingStream(), + })), + }); + runtimeQueue.push(runtime); + const abortController = new AbortController(); + + const consumption = consume( + new OpencodeProvider().sendQuery('hi', '/tmp', undefined, { + assistantConfig: TEST_MODEL, + abortSignal: abortController.signal, + }) + ); + + queueMicrotask(() => abortController.abort()); + + const { chunks, error } = await consumption; + + expect(chunks).toEqual([]); + expect(error?.message).toBe('OpenCode query aborted'); + expect(runtime.client.session.abort).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + query: { directory: '/tmp' }, + }); + }); + + test('cleanup closes the embedded runtime after completion', async () => { + const runtimeA = makeRuntime({ close: mock(() => undefined) }); + const runtimeB = makeRuntime({ close: mock(() => undefined) }); + runtimeQueue.push(runtimeA, runtimeB); + scriptedEvents = [ + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]; + + const provider = new OpencodeProvider(); + await consume(provider.sendQuery('first', '/tmp', undefined, { assistantConfig: TEST_MODEL })); + await consume(provider.sendQuery('second', '/tmp', undefined, { assistantConfig: TEST_MODEL })); + + expect(mockCreateOpencode).toHaveBeenCalledTimes(2); + expect(runtimeA.server.close).toHaveBeenCalledTimes(1); + expect(runtimeB.server.close).toHaveBeenCalledTimes(1); + }); + + test('always starts a fresh embedded runtime per query attempt', async () => { + const runtimeA = makeRuntime({ close: mock(() => undefined) }); + const runtimeB = makeRuntime({ close: mock(() => undefined) }); + runtimeQueue.push(runtimeA, runtimeB); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + await consume( + new OpencodeProvider().sendQuery('one', '/tmp', undefined, { assistantConfig: TEST_MODEL }) + ); + await consume( + new OpencodeProvider().sendQuery('two', '/tmp', undefined, { assistantConfig: TEST_MODEL }) + ); + + expect(mockCreateOpencode).toHaveBeenCalledTimes(2); + expect(mockCreateOpencodeClient).not.toHaveBeenCalled(); + }); + + test('embedded runtime passes random port and isolated startup config', async () => { + const runtime = makeRuntime({ close: mock(() => undefined) }); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const { error } = await consume( + new OpencodeProvider().sendQuery('one', '/tmp', undefined, { assistantConfig: TEST_MODEL }) + ); + + expect(error).toBeUndefined(); + expect(mockCreateOpencode).toHaveBeenCalledTimes(1); + expect(mockCreateOpencode).toHaveBeenCalledWith( + expect.objectContaining({ + hostname: '127.0.0.1', + port: expect.any(Number), + timeout: 5000, + config: expect.objectContaining({ + server: expect.objectContaining({ + hostname: '127.0.0.1', + port: expect.any(Number), + password: expect.any(String), + }), + }), + }) + ); + + const startupPort = (mockCreateOpencode.mock.calls[0] as Array<{ port?: number }>)[0]?.port; + expect(typeof startupPort).toBe('number'); + expect(startupPort).toBeGreaterThan(0); + }); + + test('embedded runtime retries startup on port conflict and succeeds', async () => { + startupErrors.push(new Error('Failed to start server on port 4096')); + const runtime = makeRuntime({ close: mock(() => undefined) }); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('retry startup', '/tmp', undefined, { + assistantConfig: TEST_MODEL, + }) + ); + + expect(error).toBeUndefined(); + expect(chunks).toEqual([{ type: 'result', sessionId: 'session-1' }]); + expect(mockCreateOpencode).toHaveBeenCalledTimes(2); + const firstPort = (mockCreateOpencode.mock.calls[0] as Array<{ port?: number }>)[0]?.port; + const secondPort = (mockCreateOpencode.mock.calls[1] as Array<{ port?: number }>)[0]?.port; + expect(typeof firstPort).toBe('number'); + expect(typeof secondPort).toBe('number'); + expect(firstPort).toBeGreaterThan(0); + expect(secondPort).toBeGreaterThan(0); + expect(firstPort).not.toBe(secondPort); + const firstConfig = ( + mockCreateOpencode.mock.calls[0] as Array<{ config?: { server?: { port?: number } } }> + )[0]?.config; + const secondConfig = ( + mockCreateOpencode.mock.calls[1] as Array<{ config?: { server?: { port?: number } } }> + )[0]?.config; + expect(firstConfig?.server?.port).toBe(firstPort); + expect(secondConfig?.server?.port).toBe(secondPort); + expect(mockLogger.warn).toHaveBeenCalledWith( + { + err: expect.any(Error), + startupPort: expect.any(Number), + attempt: 1, + maxAttempts: 3, + }, + 'opencode.runtime_start_retry_after_port_conflict' + ); + }); + + test('embedded runtime does not retry non-port startup errors', async () => { + startupErrors.push(new Error('OpenCode binary missing')); + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('no retry startup', '/tmp', undefined, { + assistantConfig: TEST_MODEL, + }) + ); + + expect(chunks).toEqual([]); + expect(error?.message).toContain('OpenCode binary missing'); + expect(mockCreateOpencode).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).not.toHaveBeenCalledWith( + expect.any(Object), + 'opencode.runtime_start_retry_after_port_conflict' + ); + }); + + test('agent config injects archon-prefixed kebab-case name into promptAsync body', async () => { + const cwd = await createTempProjectDir(); + const runtime = makeRuntime(); + runtimeQueue.push(runtime); + scriptedEvents = [ + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]; + + const nodeConfig = { + agents: { + 'My Agent': { description: 'Test agent', prompt: 'You are helpful' }, + }, + }; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(error).toBeUndefined(); + expect(chunks).toEqual([{ type: 'result', sessionId: 'session-1' }]); + expect(runtime.client.session.promptAsync).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + query: { directory: cwd }, + body: expect.objectContaining({ + agent: 'archon-my-agent', + }), + }); + }); + + test('materializes workflow agents under project .opencode/agents with mapped content', async () => { + const cwd = await createTempProjectDir(); + const runtime = makeRuntime(); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const nodeConfig = { + agents: { + Reviewer: { + description: 'Code review specialist', + prompt: 'Review the patch carefully', + model: 'anthropic/claude-3-5-sonnet', + tools: ['read', 'grep'], + disallowedTools: ['bash'], + skills: ['review-work'], + maxTurns: 7, + }, + }, + }; + + const { error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(error).toBeUndefined(); + const agentPath = join(cwd, '.opencode', 'agents', 'archon-reviewer.md'); + const content = await readFile(agentPath, 'utf8'); + expect(content).toContain('mode: subagent'); + expect(content).toContain('description: "Code review specialist"'); + expect(content).toContain('model: "anthropic/claude-3-5-sonnet"'); + expect(content).toContain('steps: 7'); + expect(content).toContain('skills:'); + expect(content).toContain('- "review-work"'); + expect(content).toContain('tools:'); + expect(content).toContain('read: true'); + expect(content).toContain('grep: true'); + expect(content).toContain('bash: false'); + expect(content.trimEnd()).toEndWith('Review the patch carefully'); + }); + + test('materialization preserves user-authored files and only replaces archon-owned files for current request scope', async () => { + const cwd = await createTempProjectDir(); + const agentsDir = join(cwd, '.opencode', 'agents'); + await mkdir(agentsDir, { recursive: true }); + await writeFile(join(agentsDir, 'custom-agent.md'), '# user agent\n', 'utf8'); + await writeFile(join(agentsDir, 'archon-stale-agent.md'), 'old stale content\n', 'utf8'); + await writeFile(join(agentsDir, 'archon-keep-agent.md'), 'old keep content\n', 'utf8'); + + const runtime = makeRuntime(); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const nodeConfig = { + agents: { + 'Keep Agent': { description: 'Fresh agent', prompt: 'Fresh prompt' }, + }, + }; + + const { error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(error).toBeUndefined(); + expect(await readFile(join(agentsDir, 'custom-agent.md'), 'utf8')).toBe('# user agent\n'); + expect(await readFile(join(agentsDir, 'archon-keep-agent.md'), 'utf8')).toContain( + 'Fresh prompt' + ); + await expect(readFile(join(agentsDir, 'archon-stale-agent.md'), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + test('generates agent files before prompt execution path', async () => { + const cwd = await createTempProjectDir(); + const runtime = makeRuntime({ + promptAsync: mock(async () => { + const content = await readFile( + join(cwd, '.opencode', 'agents', 'archon-order-check.md'), + 'utf8' + ); + expect(content).toContain('Prompt exists before execution'); + }), + }); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const nodeConfig = { + agents: { + 'Order Check': { + description: 'Ordering test', + prompt: 'Prompt exists before execution', + }, + }, + }; + + const { error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(error).toBeUndefined(); + }); + + test('disposes cached OpenCode instance after agent materialization and before prompt execution', async () => { + const cwd = await createTempProjectDir(); + const callOrder: string[] = []; + const runtime = makeRuntime({ + instanceDispose: mock(async () => { + callOrder.push('dispose'); + return true; + }), + promptAsync: mock(async () => { + callOrder.push('prompt'); + }), + }); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const nodeConfig = { + nodeId: 'node-1', + agents: { + reviewer: { + description: 'Review agent', + prompt: 'Return review', + }, + }, + }; + + const { error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(error).toBeUndefined(); + expect(runtime.client.instance.dispose).toHaveBeenCalledWith({ + query: { directory: join(cwd, '.archon-opencode', 'node-1') }, + }); + expect(callOrder).toEqual(['dispose', 'prompt']); + }); + + test('retries once when first attempt fails with agent-not-found for inline agents', async () => { + const cwd = await createTempProjectDir(); + const failingRuntime = makeRuntime({ + promptAsync: mock(async () => { + throw new Error("Agent not found: 'archon-reviewer'"); + }), + }); + const successRuntime = makeRuntime(); + runtimeQueue.push(failingRuntime, successRuntime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const nodeConfig = { + nodeId: 'node-2', + agents: { + reviewer: { + description: 'Review agent', + prompt: 'Return review', + }, + }, + }; + + const { chunks, error } = await consume( + new OpencodeProvider({ retryBaseDelayMs: 1 }).sendQuery('hi', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(error).toBeUndefined(); + expect(chunks).toEqual([{ type: 'result', sessionId: 'session-1' }]); + expect(mockCreateOpencode).toHaveBeenCalledTimes(2); + expect(mockLogger.info).toHaveBeenCalledWith( + { attempt: 0, sessionCwd: join(cwd, '.archon-opencode', 'node-2') }, + 'opencode.retrying_after_agent_refresh' + ); + }); + + test('agent config with model override injects model into promptAsync body', async () => { + const cwd = await createTempProjectDir(); + const runtime = makeRuntime(); + runtimeQueue.push(runtime); + scriptedEvents = [ + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]; + + const nodeConfig = { + agents: { + 'special-agent': { + description: 'Special agent', + prompt: 'You are special', + model: 'anthropic/claude-3-5-sonnet', + }, + }, + }; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(error).toBeUndefined(); + expect(chunks).toEqual([{ type: 'result', sessionId: 'session-1' }]); + expect(runtime.client.session.promptAsync).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + query: { directory: cwd }, + body: expect.objectContaining({ + model: { providerID: 'anthropic', modelID: 'claude-3-5-sonnet' }, + agent: 'archon-special-agent', + }), + }); + }); + + test('agent config with tools and disallowedTools produces permissions map', async () => { + const cwd = await createTempProjectDir(); + const runtime = makeRuntime(); + runtimeQueue.push(runtime); + scriptedEvents = [ + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]; + + const nodeConfig = { + agents: { + 'tools-agent': { + description: 'Limited tools agent', + prompt: 'You have limited access', + tools: ['read', 'grep'], + disallowedTools: ['bash', 'write'], + }, + }, + }; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(error).toBeUndefined(); + expect(chunks).toEqual([{ type: 'result', sessionId: 'session-1' }]); + expect(runtime.client.session.promptAsync).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + query: { directory: cwd }, + body: expect.objectContaining({ + tools: { + read: true, + grep: true, + bash: false, + write: false, + }, + agent: 'archon-tools-agent', + }), + }); + }); + + test('external baseUrl mode is rejected to enforce managed runtime control', async () => { + const cwd = await createTempProjectDir(); + const nodeConfig = { + agents: { + reviewer: { + description: 'Review agent', + prompt: 'Review safely', + }, + }, + }; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: { ...TEST_MODEL, baseUrl: 'http://remote-opencode.local' }, + nodeConfig, + }) + ); + + expect(chunks).toEqual([]); + expect(error?.message).toContain('external baseUrl mode is no longer supported'); + expect(mockCreateOpencodeClient).not.toHaveBeenCalled(); + expect(mockCreateOpencode).not.toHaveBeenCalled(); + }); + + test('external baseUrl mode is rejected even when pre-generated agent files exist', async () => { + const cwd = await createTempProjectDir(); + const agentsDir = join(cwd, '.opencode', 'agents'); + await mkdir(agentsDir, { recursive: true }); + await writeFile( + join(agentsDir, 'archon-reviewer.md'), + ['---', 'name: archon-reviewer', 'description: "Review agent"', '---', '', 'Review'].join( + '\n' + ), + 'utf8' + ); + await writeFile(join(agentsDir, 'custom-agent.md'), '# user content\n', 'utf8'); + + const runtime = makeRuntime(); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const nodeConfig = { + agents: { + reviewer: { + description: 'Review agent', + prompt: 'Review', + }, + }, + }; + + const { error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: { ...TEST_MODEL, baseUrl: 'http://remote-opencode.local' }, + nodeConfig, + }) + ); + + expect(error?.message).toContain('external baseUrl mode is no longer supported'); + expect(await readFile(join(agentsDir, 'custom-agent.md'), 'utf8')).toBe('# user content\n'); + expect(mockCreateOpencodeClient).not.toHaveBeenCalled(); + expect(mockCreateOpencode).not.toHaveBeenCalled(); + }); + + test('external baseUrl mode rejection happens before runtime/dispose side effects', async () => { + const cwd = await createTempProjectDir(); + const agentsDir = join(cwd, '.opencode', 'agents'); + await mkdir(agentsDir, { recursive: true }); + await writeFile( + join(agentsDir, 'archon-reviewer.md'), + ['---', 'name: archon-reviewer', 'description: "Review agent"', '---', '', 'Review'].join( + '\n' + ), + 'utf8' + ); + + const callOrder: string[] = []; + const runtime = makeRuntime({ + instanceDispose: mock(async () => { + callOrder.push('dispose'); + return true; + }), + promptAsync: mock(async () => { + callOrder.push('prompt'); + }), + }); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const nodeConfig = { + nodeId: 'node-remote', + agents: { + reviewer: { + description: 'Review agent', + prompt: 'Review', + }, + }, + }; + + const { error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: { ...TEST_MODEL, baseUrl: 'http://remote-opencode.local' }, + nodeConfig, + }) + ); + + expect(error?.message).toContain('external baseUrl mode is no longer supported'); + expect(runtime.client.instance.dispose).not.toHaveBeenCalled(); + expect(callOrder).toEqual([]); + expect(mockCreateOpencode).not.toHaveBeenCalled(); + expect(mockCreateOpencodeClient).not.toHaveBeenCalled(); + }); + + test('external baseUrl mode rejects multi-agent execution with same deprecation error', async () => { + const cwd = await createTempProjectDir(); + const agentsDir = join(cwd, '.opencode', 'agents'); + await mkdir(agentsDir, { recursive: true }); + await writeFile(join(agentsDir, 'archon-agent-a.md'), '---\nmode: subagent\n---\nA\n', 'utf8'); + await writeFile(join(agentsDir, 'archon-agent-b.md'), '---\nmode: subagent\n---\nB\n', 'utf8'); + + const nodeConfig = { + nodeId: 'node-multi-remote', + agents: { + 'agent-a': { description: 'A', prompt: 'A' }, + 'agent-b': { description: 'B', prompt: 'B' }, + }, + }; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', cwd, undefined, { + assistantConfig: { ...TEST_MODEL, baseUrl: 'http://remote-opencode.local' }, + nodeConfig, + }) + ); + + expect(chunks).toEqual([]); + expect(error?.message).toContain('external baseUrl mode is no longer supported'); + expect(mockCreateOpencodeClient).not.toHaveBeenCalled(); + expect(mockCreateOpencode).not.toHaveBeenCalled(); + }); + + test('uses node prompt as task when agent is configured', async () => { + const cwd = await createTempProjectDir(); + const runtime = makeRuntime(); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const nodeConfig = { + agents: { + 'test-agent': { + description: 'Test agent', + prompt: 'You are a helpful test agent.', + }, + }, + }; + + const { error } = await consume( + new OpencodeProvider().sendQuery('node prompt that should be used', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(error).toBeUndefined(); + // The agent's prompt lives in the materialized .md file (system context). + // The node prompt is the task sent in the prompt body. + expect(runtime.client.session.promptAsync).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + query: { directory: cwd }, + body: expect.objectContaining({ + parts: [{ type: 'text', text: 'node prompt that should be used' }], + agent: 'archon-test-agent', + }), + }); + }); + + test('uses node prompt when no agents are defined', async () => { + const cwd = await createTempProjectDir(); + const runtime = makeRuntime(); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const { error } = await consume( + new OpencodeProvider().sendQuery('node prompt should be used', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig: {}, // No agents + }) + ); + + expect(error).toBeUndefined(); + // Verify the node's prompt was sent to OpenCode + expect(runtime.client.session.promptAsync).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + query: { directory: cwd }, + body: expect.objectContaining({ + parts: [{ type: 'text', text: 'node prompt should be used' }], + }), + }); + }); + + test('uses node prompt when agent has no prompt field', async () => { + const cwd = await createTempProjectDir(); + const runtime = makeRuntime(); + runtimeQueue.push(runtime); + scriptedEvents = [{ type: 'session.idle', properties: { sessionID: 'session-1' } }]; + + const nodeConfig = { + agents: { + 'empty-agent': { + description: 'Agent with no prompt', + // No prompt field + }, + }, + }; + + const { error } = await consume( + new OpencodeProvider().sendQuery('fallback node prompt', cwd, undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(error).toBeUndefined(); + // Verify the node's prompt was used as fallback + expect(runtime.client.session.promptAsync).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + query: { directory: cwd }, + body: expect.objectContaining({ + parts: [{ type: 'text', text: 'fallback node prompt' }], + agent: 'archon-empty-agent', + }), + }); + }); + + test('agent config with invalid model ref throws explicit error', async () => { + const nodeConfig = { + agents: { + 'bad-agent': { + description: 'Bad agent', + prompt: 'This will fail', + model: 'invalid-no-slash-format', + }, + }, + }; + + const { chunks, error } = await consume( + new OpencodeProvider().sendQuery('hi', '/tmp', undefined, { + assistantConfig: TEST_MODEL, + nodeConfig, + }) + ); + + expect(chunks).toEqual([]); + expect(error).toBeDefined(); + expect(error?.message).toContain( + "Invalid OpenCode agent model ref for 'bad-agent': 'invalid-no-slash-format'" + ); + }); +}); diff --git a/packages/providers/src/community/opencode/provider.ts b/packages/providers/src/community/opencode/provider.ts new file mode 100644 index 0000000000..967602ac52 --- /dev/null +++ b/packages/providers/src/community/opencode/provider.ts @@ -0,0 +1,218 @@ +import { join } from 'node:path'; + +import { createLogger } from '@archon/paths'; + +import type { + IAgentProvider, + MessageChunk, + ProviderCapabilities, + SendQueryOptions, +} from '../../types'; + +import { getOrderedAgents } from './agent-config'; +import { OPENCODE_CAPABILITIES } from './capabilities'; +import { parseModelRef, parseOpencodeConfig } from './config'; +import { classifyOpencodeError, enrichOpencodeError } from './errors'; +import { materializeAgents } from './agent-fs'; +import { streamMultiAgentOpencodeSession } from './multi-agent'; +import { + acquireEmbeddedRuntime, + disposeInstanceForDirectory, + releaseEmbeddedRuntime, +} from './runtime'; +import { resolveSessionId, streamOpencodeSession } from './session'; + +export { parseModelRef } from './config'; +export { resetEmbeddedRuntime } from './runtime'; + +const MAX_RETRIES = 3; +const RETRY_BASE_DELAY_MS = 2000; + +let cachedLog: ReturnType | undefined; + +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.opencode'); + return cachedLog; +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export class OpencodeProvider implements IAgentProvider { + private readonly retryBaseDelayMs: number; + + constructor(options?: { retryBaseDelayMs?: number }) { + this.retryBaseDelayMs = options?.retryBaseDelayMs ?? RETRY_BASE_DELAY_MS; + } + + async *sendQuery( + prompt: string, + cwd: string, + resumeSessionId?: string, + requestOptions?: SendQueryOptions + ): AsyncGenerator { + const assistantConfig = parseOpencodeConfig(requestOptions?.assistantConfig ?? {}); + const modelRef = requestOptions?.model ?? assistantConfig.model; + const parsedModelOrNull = modelRef ? parseModelRef(modelRef) : undefined; + + if (modelRef && !parsedModelOrNull) { + throw new Error( + `Invalid OpenCode model ref: '${modelRef}'. Expected format '/' (for example 'anthropic/claude-3-5-sonnet').` + ); + } + + if (!parsedModelOrNull) { + throw new Error( + 'OpenCode requires a model to be specified. ' + + 'Set model in assistants config (e.g., model: anthropic/claude-3-5-sonnet).' + ); + } + + const parsedModel = parsedModelOrNull; + + const nodeAgents = requestOptions?.nodeConfig?.agents; + const nodeId = requestOptions?.nodeConfig?.nodeId; + const orderedAgents = getOrderedAgents(requestOptions?.nodeConfig); + const hasAgentConfig = orderedAgents.length > 0; + const isMultiAgent = orderedAgents.length > 1; + const usingExternalBaseUrl = Boolean(assistantConfig.baseUrl); + if (usingExternalBaseUrl) { + throw new Error( + 'OpenCode external baseUrl mode is no longer supported. ' + + 'Archon now requires managed embedded OpenCode runtime for fully controlled agent lifecycle.' + ); + } + + const sessionCwd = + hasAgentConfig && nodeId && !usingExternalBaseUrl + ? join(cwd, '.archon-opencode', nodeId) + : cwd; + + let lastError: Error | undefined; + let recoveredAgentNotFound = false; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) { + if (requestOptions?.abortSignal?.aborted) { + throw new Error('OpenCode query aborted'); + } + + const runtime = await (async (): Promise<{ + client: import('./runtime').OpencodeClientLike; + release: () => void; + }> => { + const embedded = await acquireEmbeddedRuntime(requestOptions?.abortSignal); + return { + client: embedded.client, + release: (): void => { + releaseEmbeddedRuntime(embedded); + }, + }; + })(); + + try { + // When agents are defined, use a per-node session directory so each node + // gets its own OpenCode InstanceState — preventing stale agent cache from + // previous nodes in the same workflow run. + // For multi-agent, materialize each agent in its own subdirectory. + if (hasAgentConfig) { + if (isMultiAgent) { + // Materialize all agents in the shared sessionCwd so the single + // event subscription catches events from every child session. + await materializeAgents(sessionCwd, nodeAgents ?? {}); + await disposeInstanceForDirectory(runtime.client, sessionCwd); + } else if (nodeAgents) { + await materializeAgents(sessionCwd, nodeAgents); + await disposeInstanceForDirectory(runtime.client, sessionCwd); + } + } + + if (isMultiAgent) { + if (!nodeId) { + throw new Error( + 'OpenCode multi-agent execution requires a nodeId in nodeConfig. ' + + 'Ensure the workflow node sets nodeConfig.nodeId.' + ); + } + yield* streamMultiAgentOpencodeSession( + runtime.client, + sessionCwd, + nodeId, + prompt, + parsedModel, + requestOptions + ); + return; + } + + const { sessionId, resumed } = await resolveSessionId( + runtime.client, + sessionCwd, + resumeSessionId + ); + if (resumeSessionId && !resumed) { + yield { + type: 'system', + content: '⚠️ Could not resume OpenCode session. Starting fresh conversation.', + }; + } + + yield* streamOpencodeSession( + runtime.client, + sessionCwd, + sessionId, + prompt, + parsedModel, + requestOptions + ); + return; + } catch (error) { + const errorClass = classifyOpencodeError( + error, + requestOptions?.abortSignal?.aborted === true + ); + const enrichedError = enrichOpencodeError(error, errorClass); + const shouldRetry = + errorClass === 'rate_limit' || + errorClass === 'crash' || + (errorClass === 'agent_not_found' && hasAgentConfig && !recoveredAgentNotFound); + + getLog().error( + { + err: error, + errorClass, + attempt, + maxRetries: MAX_RETRIES, + }, + 'opencode.query_failed' + ); + + if (!shouldRetry || attempt >= MAX_RETRIES - 1) { + throw enrichedError; + } + + if (errorClass === 'agent_not_found') { + recoveredAgentNotFound = true; + getLog().info({ attempt, sessionCwd }, 'opencode.retrying_after_agent_refresh'); + } + + const delayMs = this.retryBaseDelayMs * 2 ** attempt; + getLog().info({ attempt, delayMs, errorClass }, 'opencode.retrying_query'); + await delay(delayMs); + lastError = enrichedError; + } finally { + runtime.release(); + } + } + + throw lastError ?? new Error('OpenCode query failed after retries'); + } + + getType(): string { + return 'opencode'; + } + + getCapabilities(): ProviderCapabilities { + return OPENCODE_CAPABILITIES; + } +} diff --git a/packages/providers/src/community/opencode/registration.ts b/packages/providers/src/community/opencode/registration.ts new file mode 100644 index 0000000000..4ec35cb3b0 --- /dev/null +++ b/packages/providers/src/community/opencode/registration.ts @@ -0,0 +1,25 @@ +import { isRegisteredProvider, registerProvider } from '../../registry'; + +import { OPENCODE_CAPABILITIES } from './capabilities'; +import { OpencodeProvider, parseModelRef } from './provider'; + +export function isOpencodeModelCompatible(model: string): boolean { + return parseModelRef(model) !== null; +} + +/** + * Register the OpenCode community provider. + * + * Idempotent — safe to call multiple times from process entrypoints. + */ +export function registerOpencodeProvider(): void { + if (isRegisteredProvider('opencode')) return; + registerProvider({ + id: 'opencode', + displayName: 'OpenCode (community)', + factory: () => new OpencodeProvider(), + capabilities: OPENCODE_CAPABILITIES, + isModelCompatible: isOpencodeModelCompatible, + builtIn: false, + }); +} diff --git a/packages/providers/src/community/opencode/runtime.ts b/packages/providers/src/community/opencode/runtime.ts new file mode 100644 index 0000000000..75ef8b6e92 --- /dev/null +++ b/packages/providers/src/community/opencode/runtime.ts @@ -0,0 +1,286 @@ +import { createLogger } from '@archon/paths'; +import { execSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; + +const OPENCODE_START_TIMEOUT_MS = 5000; +const OPENCODE_START_MAX_RETRIES = 3; + +function generateRandomPassword(): string { + return randomBytes(32).toString('hex'); +} + +function buildEmbeddedServerConfig(startupPort: number): Record { + return { + server: { + hostname: '127.0.0.1', + port: startupPort, + password: generateRandomPassword(), + }, + }; +} + +async function startEmbeddedOpencode( + createOpencode: ( + options: Record + ) => Promise<{ client: unknown; server: { url: string; close(): void } }>, + startupPort: number, + signal?: AbortSignal +): Promise<{ client: unknown; server: { url: string; close(): void } }> { + // Clear any pre-existing OpenCode server credential env vars so the embedded + // server uses the random password generated in buildEmbeddedServerConfig rather + // than picking up credentials intended for an external server instance. + // Only clear them when they are actually set to avoid unnecessary mutations. + if (process.env.OPENCODE_SERVER_PASSWORD !== undefined) { + delete process.env.OPENCODE_SERVER_PASSWORD; + } + if (process.env.OPENCODE_SERVER_USERNAME !== undefined) { + delete process.env.OPENCODE_SERVER_USERNAME; + } + + return await createOpencode({ + hostname: '127.0.0.1', + port: startupPort, + timeout: OPENCODE_START_TIMEOUT_MS, + signal, + config: buildEmbeddedServerConfig(startupPort), + }); +} + +export interface OpencodeClientLike { + session: { + create(options?: Record): Promise<{ data?: { id?: string } }>; + get(options: Record): Promise<{ data?: { id?: string } }>; + promptAsync(options: Record): Promise; + abort(options: Record): Promise; + message( + options: Record + ): Promise<{ data?: { info?: Record } }>; + }; + event: { + subscribe(options?: Record): Promise<{ + stream: AsyncIterable; + }>; + }; + instance?: { + dispose(options?: Record): Promise; + }; +} + +export interface EmbeddedRuntime { + client: OpencodeClientLike; + server: { url: string; close(): void }; + refCount: number; + /** Promise that created this runtime - used to prevent race conditions on release */ + creationPromise: Promise; +} + +let embeddedRuntimePromise: Promise | undefined; +let cachedLog: ReturnType | undefined; + +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.opencode'); + return cachedLog; +} + +function extractPortFromUrl(url: string): number | undefined { + try { + const parsed = new URL(url); + const port = parsed.port ? parseInt(parsed.port, 10) : undefined; + return port && !isNaN(port) ? port : undefined; + } catch { + return undefined; + } +} + +function findProcessByPort(port: number): number | undefined { + try { + if (process.platform === 'win32') { + const result = execSync( + `powershell.exe -Command "(Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue).OwningProcess"`, + { encoding: 'utf8', timeout: 5000 } + ).trim(); + const pid = parseInt(result, 10); + return pid && !isNaN(pid) ? pid : undefined; + } else { + const result = execSync(`lsof -ti:${port} 2>/dev/null || fuser ${port}/tcp 2>/dev/null`, { + encoding: 'utf8', + timeout: 5000, + shell: '/bin/sh', + }).trim(); + const pid = parseInt(result, 10); + return pid && !isNaN(pid) ? pid : undefined; + } + } catch { + return undefined; + } +} + +function killProcess(pid: number): void { + try { + if (process.platform === 'win32') { + execSync(`taskkill /F /PID ${pid}`, { timeout: 5000 }); + } else { + process.kill(pid, 'SIGKILL'); + } + } catch (error) { + getLog().debug({ err: error, pid }, 'opencode.process_kill_failed'); + } +} + +function errorText(error: unknown): string { + if (error instanceof Error) return `${error.name} ${error.message}`.toLowerCase(); + return String(error).toLowerCase(); +} + +function isPortBindConflict(error: unknown): boolean { + const message = errorText(error); + + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as { code?: unknown }).code === 'string' && + (error as { code: string }).code.toUpperCase() === 'EADDRINUSE' + ) { + return true; + } + + return ( + message.includes('eaddrinuse') || + message.includes('address already in use') || + message.includes('failed to start server on port') || + message.includes('port 4096') + ); +} + +function pickRandomStartupPort(): number { + // Keep away from privileged and commonly reserved ports. + return Math.floor(Math.random() * 40000) + 20000; +} + +export async function acquireEmbeddedRuntime(signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw new Error('OpenCode runtime startup aborted'); + } + + if (!embeddedRuntimePromise) { + let resolveRuntime: ((runtime: EmbeddedRuntime) => void) | undefined; + let rejectRuntime: ((error: unknown) => void) | undefined; + + const promise = new Promise((resolve, reject) => { + resolveRuntime = resolve; + rejectRuntime = reject; + }); + embeddedRuntimePromise = promise; + + (async (): Promise => { + try { + const { createOpencode } = await import('@opencode-ai/sdk'); + + let runtime: { client: unknown; server: { url: string; close(): void } } | undefined; + let lastError: unknown; + + for (let attempt = 0; attempt < OPENCODE_START_MAX_RETRIES; attempt += 1) { + if (signal?.aborted) { + throw new Error('OpenCode runtime startup aborted'); + } + + const startupPort = pickRandomStartupPort(); + + try { + runtime = await startEmbeddedOpencode(createOpencode, startupPort, signal); + break; + } catch (error) { + lastError = error; + if (!isPortBindConflict(error) || attempt >= OPENCODE_START_MAX_RETRIES - 1) { + throw error; + } + + getLog().warn( + { + err: error, + startupPort, + attempt: attempt + 1, + maxAttempts: OPENCODE_START_MAX_RETRIES, + }, + 'opencode.runtime_start_retry_after_port_conflict' + ); + } + } + + if (!runtime) { + throw lastError instanceof Error + ? lastError + : new Error('OpenCode runtime failed to start after retries'); + } + + resolveRuntime?.({ + client: runtime.client as OpencodeClientLike, + server: runtime.server, + refCount: 0, + creationPromise: promise, + }); + } catch (error) { + embeddedRuntimePromise = undefined; + rejectRuntime?.(error); + } + })(); + } + + const runtime = await embeddedRuntimePromise; + runtime.refCount += 1; + return runtime; +} + +export function releaseEmbeddedRuntime(runtime: EmbeddedRuntime): void { + runtime.refCount = Math.max(0, runtime.refCount - 1); + if (runtime.refCount > 0) return; + + try { + runtime.server.close(); + } finally { + // Force-kill the underlying OpenCode child process. server.close() + // only tears down the HTTP listener; the embedded Node / opencode + // processes remain alive on Windows and leak. + const port = extractPortFromUrl(runtime.server.url); + if (port) { + const pid = findProcessByPort(port); + if (pid) { + getLog().debug({ port, pid }, 'opencode.killing_embedded_process'); + killProcess(pid); + } + } + + if (embeddedRuntimePromise === runtime.creationPromise) { + embeddedRuntimePromise = undefined; + } + } +} + +/** + * Dispose OpenCode's cached instance for a directory so newly materialized + * inline agents are discovered on the next request. + */ +export async function disposeInstanceForDirectory( + client: OpencodeClientLike, + directory: string +): Promise { + if (!client.instance?.dispose) return; + + try { + await client.instance.dispose({ query: { directory } }); + } catch (error) { + getLog().debug( + { + err: error, + directory, + }, + 'opencode.instance_dispose_failed' + ); + } +} + +/** Reset the embedded runtime state. For testing only. */ +export function resetEmbeddedRuntime(): void { + embeddedRuntimePromise = undefined; +} diff --git a/packages/providers/src/community/opencode/session.ts b/packages/providers/src/community/opencode/session.ts new file mode 100644 index 0000000000..0aa3108b33 --- /dev/null +++ b/packages/providers/src/community/opencode/session.ts @@ -0,0 +1,342 @@ +import { createLogger } from '@archon/paths'; + +import type { MessageChunk, SendQueryOptions } from '../../types'; + +import { + adaptNamedAgentForOpencode, + resolvePromptForAgent, + selectSingleAgent, + type NamedAgentConfig, +} from './agent-config'; +import { errorMessage } from './errors'; +import type { OpencodeClientLike } from './runtime'; +import { normalizeTokens } from './tokens'; + +let cachedLog: ReturnType | undefined; + +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.opencode'); + return cachedLog; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export async function resolveSessionId( + client: OpencodeClientLike, + cwd: string, + resumeSessionId: string | undefined +): Promise<{ sessionId: string; resumed: boolean }> { + if (resumeSessionId) { + try { + const existing = await client.session.get({ + path: { id: resumeSessionId }, + query: { directory: cwd }, + }); + const sessionId = existing.data?.id; + if (typeof sessionId === 'string' && sessionId.length > 0) { + return { sessionId, resumed: true }; + } + } catch { + // Fall through to fresh session creation and surface a warning upstream. + } + } + + const created = await client.session.create({ query: { directory: cwd } }); + const sessionId = created.data?.id; + if (!sessionId) { + throw new Error('OpenCode failed to create a session'); + } + + return { sessionId, resumed: false }; +} + +export function createSessionPromptBody( + prompt: string, + model: { providerID: string; modelID: string }, + requestOptions: SendQueryOptions | undefined, + agentOverride?: NamedAgentConfig +): Record { + const singleAgent = agentOverride ?? selectSingleAgent(requestOptions?.nodeConfig?.agents); + const adaptedAgentConfig = singleAgent ? adaptNamedAgentForOpencode(singleAgent) : undefined; + const effectivePrompt = resolvePromptForAgent(singleAgent, prompt); + const promptBody: Record = { + parts: [{ type: 'text', text: effectivePrompt }], + model: adaptedAgentConfig?.model ?? model, + ...(adaptedAgentConfig?.agent ? { agent: adaptedAgentConfig.agent } : {}), + ...(adaptedAgentConfig?.tools ? { tools: adaptedAgentConfig.tools } : {}), + ...(requestOptions?.systemPrompt ? { system: requestOptions.systemPrompt } : {}), + }; + + if (requestOptions?.outputFormat?.type === 'json_schema') { + promptBody.format = { + type: 'json_schema', + schema: requestOptions.outputFormat.schema, + }; + } + + return promptBody; +} + +export async function promptSession( + client: OpencodeClientLike, + cwd: string, + sessionId: string, + promptBody: Record +): Promise { + await client.session.promptAsync({ + path: { id: sessionId }, + query: { directory: cwd }, + body: promptBody, + }); +} + +async function readStructuredOutput( + client: OpencodeClientLike, + cwd: string, + sessionId: string, + messageId: string | undefined +): Promise { + if (!messageId) return undefined; + + try { + const response = await client.session.message({ + path: { id: sessionId, messageID: messageId }, + query: { directory: cwd }, + }); + const info = response.data?.info; + if (isRecord(info) && 'structured_output' in info) { + return info.structured_output; + } + } catch (error) { + getLog().debug( + { err: error, sessionId, messageId }, + 'opencode.structured_output_lookup_failed' + ); + } + + return undefined; +} + +export async function* streamOpencodeSession( + client: OpencodeClientLike, + cwd: string, + sessionId: string, + prompt: string, + model: { providerID: string; modelID: string }, + requestOptions: SendQueryOptions | undefined +): AsyncGenerator { + const events = await client.event.subscribe({ query: { directory: cwd } }); + const streamController = new AbortController(); + const seenToolCalls = new Set(); + const completedToolCalls = new Set(); + let latestAssistantInfo: Record | undefined; + let lastAssistantMessageId: string | undefined; + let aborted = requestOptions?.abortSignal?.aborted === true; + let resultYielded = false; + + const abortHandler = (): void => { + aborted = true; + void client.session + .abort({ path: { id: sessionId }, query: { directory: cwd } }) + .catch((error): void => { + getLog().debug({ err: error, sessionId }, 'opencode.session_abort_failed'); + }); + streamController.abort(); + }; + + requestOptions?.abortSignal?.addEventListener('abort', abortHandler, { + once: true, + }); + + try { + const promptBody = createSessionPromptBody(prompt, model, requestOptions); + await promptSession(client, cwd, sessionId, promptBody); + + for await (const rawEvent of abortableStream(events.stream, streamController.signal)) { + const event = rawEvent as { + type?: string; + properties?: Record; + }; + const properties = isRecord(event.properties) ? event.properties : {}; + + if (event.type === 'message.updated') { + const info = isRecord(properties.info) ? properties.info : undefined; + if (info?.role === 'assistant' && info.sessionID === sessionId) { + latestAssistantInfo = info; + if (typeof info.id === 'string') { + lastAssistantMessageId = info.id; + } + } + continue; + } + + if (event.type === 'message.part.updated') { + const part = isRecord(properties.part) ? properties.part : undefined; + if (!part || part?.sessionID !== sessionId || typeof part.type !== 'string') { + continue; + } + + if (part.type === 'text') { + const delta = typeof properties.delta === 'string' ? properties.delta : undefined; + const text = delta ?? (typeof part.text === 'string' ? part.text : ''); + if (text) { + yield { type: 'assistant', content: text }; + } + continue; + } + + if (part.type === 'reasoning') { + const delta = typeof properties.delta === 'string' ? properties.delta : undefined; + const text = delta ?? (typeof part.text === 'string' ? part.text : ''); + if (text) { + yield { type: 'thinking', content: text }; + } + continue; + } + + if (part.type === 'tool') { + const callId = typeof part.callID === 'string' ? part.callID : undefined; + const toolName = typeof part.tool === 'string' ? part.tool : 'unknown'; + const state = isRecord(part.state) ? part.state : undefined; + const toolInput = isRecord(state?.input) ? state.input : undefined; + const status = typeof state?.status === 'string' ? state.status : undefined; + + if (callId && !seenToolCalls.has(callId)) { + seenToolCalls.add(callId); + yield { + type: 'tool', + toolName, + ...(toolInput ? { toolInput } : {}), + ...(callId ? { toolCallId: callId } : {}), + }; + } + + if (callId && !completedToolCalls.has(callId)) { + if (status === 'completed') { + completedToolCalls.add(callId); + yield { + type: 'tool_result', + toolName, + toolOutput: typeof state?.output === 'string' ? state.output : '', + ...(callId ? { toolCallId: callId } : {}), + }; + } else if (status === 'error') { + completedToolCalls.add(callId); + yield { + type: 'tool_result', + toolName, + toolOutput: typeof state?.error === 'string' ? state.error : 'Tool failed', + ...(callId ? { toolCallId: callId } : {}), + }; + } + } + } + continue; + } + + if (event.type === 'session.error') { + const eventSessionId = + typeof properties.sessionID === 'string' ? properties.sessionID : undefined; + if (eventSessionId && eventSessionId !== sessionId) continue; + + const rawError = isRecord(properties.error) ? properties.error : properties; + const err = new Error(errorMessage(rawError)); + err.cause = rawError; + throw err; + } + + if (event.type === 'session.idle') { + if (properties.sessionID !== sessionId) continue; + + const structuredOutput = await readStructuredOutput( + client, + cwd, + sessionId, + lastAssistantMessageId + ); + const tokens = normalizeTokens(latestAssistantInfo); + + yield { + type: 'result', + sessionId, + ...(tokens ? { tokens } : {}), + ...(structuredOutput !== undefined ? { structuredOutput } : {}), + ...(typeof latestAssistantInfo?.cost === 'number' + ? { cost: latestAssistantInfo.cost } + : {}), + ...(typeof latestAssistantInfo?.finish === 'string' + ? { stopReason: latestAssistantInfo.finish } + : {}), + ...(latestAssistantInfo + ? { + modelUsage: { + providerID: latestAssistantInfo.providerID, + modelID: latestAssistantInfo.modelID, + reasoning: isRecord(latestAssistantInfo.tokens) + ? latestAssistantInfo.tokens.reasoning + : undefined, + cache: isRecord(latestAssistantInfo.tokens) + ? latestAssistantInfo.tokens.cache + : undefined, + }, + } + : {}), + }; + resultYielded = true; + return; + } + } + + if (!resultYielded && !aborted) { + yield { type: 'result', sessionId }; + } + + if (aborted) { + const abortReason = requestOptions?.abortSignal?.reason; + throw new Error( + `OpenCode query aborted (session: ${sessionId}, cwd: ${cwd})` + + (abortReason ? `: ${String(abortReason)}` : '') + ); + } + } finally { + requestOptions?.abortSignal?.removeEventListener('abort', abortHandler); + streamController.abort(); + } +} + +export async function* abortableStream( + stream: AsyncIterable, + signal: AbortSignal +): AsyncGenerator { + const iterator = stream[Symbol.asyncIterator](); + + while (true) { + if (signal.aborted) { + await iterator.return?.().catch(() => undefined); + return; + } + + const nextPromise = iterator.next(); + const result = await Promise.race([ + nextPromise, + new Promise>(resolve => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort); + resolve({ done: true, value: undefined }); + }; + signal.addEventListener('abort', onAbort, { once: true }); + void nextPromise.finally((): void => { + signal.removeEventListener('abort', onAbort); + }); + }), + ]); + + if (result.done) { + await iterator.return?.().catch(() => undefined); + return; + } + yield result.value; + } +} diff --git a/packages/providers/src/community/opencode/tokens.ts b/packages/providers/src/community/opencode/tokens.ts new file mode 100644 index 0000000000..f0746f23e8 --- /dev/null +++ b/packages/providers/src/community/opencode/tokens.ts @@ -0,0 +1,22 @@ +import type { TokenUsage } from '../../types'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function normalizeTokens(info: Record | undefined): TokenUsage | undefined { + const tokens = isRecord(info?.tokens) ? info.tokens : undefined; + if (!tokens) return undefined; + + const input = typeof tokens.input === 'number' ? tokens.input : 0; + const output = typeof tokens.output === 'number' ? tokens.output : 0; + const reasoning = typeof tokens.reasoning === 'number' ? tokens.reasoning : 0; + const total = input + output + reasoning; + + return { + input, + output, + ...(total > 0 ? { total } : {}), + ...(typeof info?.cost === 'number' ? { cost: info.cost } : {}), + }; +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index d430f8d402..07b65098a0 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -47,6 +47,12 @@ export { resolveCodexBinaryPath, fileExists as codexFileExists } from './codex/b export { resolveClaudeBinaryPath, fileExists as claudeFileExists } from './claude/binary-resolver'; // Community providers +export { + OpencodeProvider, + parseOpencodeConfig, + registerOpencodeProvider, + type OpencodeProviderDefaults, +} from './community/opencode'; export { PiProvider, parsePiConfig, diff --git a/packages/providers/src/registry.test.ts b/packages/providers/src/registry.test.ts index 64b879a91c..27a48ec6d1 100644 --- a/packages/providers/src/registry.test.ts +++ b/packages/providers/src/registry.test.ts @@ -12,6 +12,7 @@ import { clearRegistry, } from './registry'; import { registerPiProvider } from './community/pi/registration'; +import { registerOpencodeProvider } from './community/opencode/registration'; import { UnknownProviderError } from './errors'; import type { ProviderRegistration, IAgentProvider, ProviderCapabilities } from './types'; @@ -275,15 +276,17 @@ describe('registry', () => { describe('registerCommunityProviders (aggregator)', () => { test('registers all bundled community providers', () => { registerCommunityProviders(); - // Pi is currently the only community provider bundled. When more are - // added, they should appear here automatically. + // OpenCode and Pi are the community providers bundled. + expect(isRegisteredProvider('opencode')).toBe(true); expect(isRegisteredProvider('pi')).toBe(true); }); test('is idempotent', () => { registerCommunityProviders(); expect(() => registerCommunityProviders()).not.toThrow(); + const opencodeCount = getRegisteredProviders().filter(p => p.id === 'opencode').length; const piCount = getRegisteredProviders().filter(p => p.id === 'pi').length; + expect(opencodeCount).toBe(1); expect(piCount).toBe(1); }); }); @@ -352,4 +355,69 @@ describe('registry', () => { expect(ids).toEqual(['claude', 'codex', 'pi']); }); }); + + describe('registerOpencodeProvider (community provider)', () => { + test('registers opencode with builtIn: false', () => { + registerOpencodeProvider(); + const reg = getRegistration('opencode'); + expect(reg.id).toBe('opencode'); + expect(reg.displayName).toBe('OpenCode (community)'); + expect(reg.builtIn).toBe(false); + }); + + test('is idempotent', () => { + registerOpencodeProvider(); + expect(() => registerOpencodeProvider()).not.toThrow(); + const opencodeEntries = getRegisteredProviders().filter(p => p.id === 'opencode'); + expect(opencodeEntries).toHaveLength(1); + }); + + test('declares capabilities (sessionResume, mcp, structuredOutput, envInjection, hooks, skills, agents, toolRestrictions, effortControl, thinkingControl supported)', () => { + registerOpencodeProvider(); + const caps = getProviderCapabilities('opencode'); + // Supported features + expect(caps.sessionResume).toBe(true); + expect(caps.mcp).toBe(true); + expect(caps.structuredOutput).toBe(true); + expect(caps.envInjection).toBe(true); + expect(caps.hooks).toBe(true); + expect(caps.skills).toBe(true); + expect(caps.agents).toBe(true); + expect(caps.toolRestrictions).toBe(true); + // OpenCode handles effort/thinking via opencode.json agent config, not API + expect(caps.effortControl).toBe(false); + expect(caps.thinkingControl).toBe(false); + // Not supported (no SDK API for budget enforcement, failover, or sandbox) + expect(caps.costControl).toBe(false); + expect(caps.fallbackModel).toBe(false); + expect(caps.sandbox).toBe(false); + }); + + test('isModelCompatible accepts provider/model refs, rejects aliases', () => { + registerOpencodeProvider(); + const reg = getRegistration('opencode'); + expect(reg.isModelCompatible('anthropic/claude-3-5-sonnet')).toBe(true); + expect(reg.isModelCompatible('openai/gpt-4o')).toBe(true); + expect(reg.isModelCompatible('google/gemini-2.5-pro')).toBe(true); + expect(reg.isModelCompatible('sonnet')).toBe(false); + expect(reg.isModelCompatible('claude-3.5-sonnet')).toBe(false); + expect(reg.isModelCompatible('')).toBe(false); + }); + + test('appears in getProviderInfoList with builtIn: false', () => { + registerOpencodeProvider(); + const info = getProviderInfoList().find(p => p.id === 'opencode'); + expect(info).toBeDefined(); + expect(info?.builtIn).toBe(false); + }); + + test('does not collide with built-ins or other community providers', () => { + registerOpencodeProvider(); + registerPiProvider(); + const ids = getRegisteredProviders() + .map(p => p.id) + .sort(); + expect(ids).toEqual(['claude', 'codex', 'opencode', 'pi']); + }); + }); }); diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index 1ae16759dc..f153610f37 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -17,6 +17,7 @@ import { ClaudeProvider } from './claude/provider'; import { CodexProvider } from './codex/provider'; import { CLAUDE_CAPABILITIES } from './claude/capabilities'; import { CODEX_CAPABILITIES } from './codex/capabilities'; +import { registerOpencodeProvider } from './community/opencode/registration'; import { registerPiProvider } from './community/pi/registration'; import { UnknownProviderError } from './errors'; import { createLogger } from '@archon/paths'; @@ -162,6 +163,7 @@ export function registerBuiltinProviders(): void { * disappear. */ export function registerCommunityProviders(): void { + registerOpencodeProvider(); registerPiProvider(); } diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts index d6cb8b4a87..f9af65eab5 100644 --- a/packages/providers/src/types.ts +++ b/packages/providers/src/types.ts @@ -82,6 +82,20 @@ export interface PiProviderDefaults { env?: Record; } +/** + * Community provider defaults for OpenCode (opencode-ai). + * Minimal shape — extend as capabilities are wired in. + */ +export interface OpencodeProviderDefaults { + [key: string]: unknown; + /** Default model ref in '/' format, e.g. 'anthropic/claude-3-5-sonnet' */ + model?: string; + /** Base URL of an existing OpenCode server to connect to. */ + baseUrl?: string; + /** Default agent name from opencode.json config to use. */ + agent?: string; +} + /** Generic per-provider defaults bag used by config surfaces and UI. */ export type ProviderDefaults = Record; @@ -171,6 +185,8 @@ export interface AgentRequestOptions { * Providers translate fields they understand; unknown fields are ignored. */ export interface NodeConfig { + /** Node ID from the workflow DAG — used by providers for per-node isolation (e.g., session dirs). */ + nodeId?: string; mcp?: string; hooks?: unknown; skills?: string[]; diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index 419a9066f6..7113125c4a 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -435,6 +435,7 @@ async function resolveNodeProviderAndModel( // Build raw nodeConfig — provider translates internally const nodeConfig: NodeConfig = { + nodeId: node.id, mcp: node.mcp, hooks: node.hooks, skills: node.skills,