diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..603941e75b6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OpenCode is an open-source AI-powered coding agent, similar to Claude Code but provider-agnostic. It supports multiple LLM providers (Anthropic, OpenAI, Google, Azure, local models) and features a TUI built with SolidJS, LSP support, and client/server architecture. + +## Development Commands + +```bash +# Install and run development server +bun install +bun dev # Run in packages/opencode directory +bun dev # Run against a specific directory +bun dev . # Run against repo root + +# Type checking +bun run typecheck # Single package +bun turbo typecheck # All packages + +# Testing (per-package, not from root) +cd packages/opencode && bun test + +# Build standalone executable +./packages/opencode/script/build.ts --single +# Output: ./packages/opencode/dist/opencode-/bin/opencode + +# Regenerate SDK after API changes +./script/generate.ts +# Or for JS SDK specifically: +./packages/sdk/js/script/build.ts + +# Web app development +bun run --cwd packages/app dev # http://localhost:5173 + +# Desktop app (requires Tauri/Rust) +bun run --cwd packages/desktop tauri dev # Native + web server +bun run --cwd packages/desktop dev # Web only (port 1420) +bun run --cwd packages/desktop tauri build # Production build +``` + +## Architecture + +**Monorepo Structure** (Bun workspaces + Turbo): + +| Package | Purpose | +|---------|---------| +| `packages/opencode` | Core CLI, server, business logic | +| `packages/app` | Shared web UI components (SolidJS + Vite) | +| `packages/desktop` | Native desktop app (Tauri wrapper) | +| `packages/ui` | Shared component library (Kobalte + Tailwind) | +| `packages/console/app` | Console dashboard (Solid Start) | +| `packages/console/core` | Backend services (Hono + DrizzleORM) | +| `packages/sdk/js` | JavaScript SDK | +| `packages/plugin` | Plugin system API | + +**Key Directories in `packages/opencode/src`**: +- `cli/cmd/tui/` - Terminal UI (SolidJS + opentui) +- `agent/` - Agent logic and state +- `provider/` - AI provider implementations +- `server/` - Server mode +- `mcp/` - Model Context Protocol integration +- `lsp/` - Language Server Protocol support + +**Default branch**: `dev` + +## Code Style + +- Keep logic in single functions unless reusable +- Avoid destructuring: use `obj.a` instead of `const { a } = obj` +- Avoid `try/catch` - prefer `.catch()` +- Avoid `else` statements +- Avoid `any` type +- Avoid `let` - use immutable patterns +- Prefer single-word variable names when descriptive +- Use Bun APIs (e.g., `Bun.file()`) when applicable + +## Built-in Agents + +- **build** - Default agent with full access for development +- **plan** - Read-only agent for analysis (denies edits, asks before bash) +- **general** - Subagent for complex tasks, invoked with `@general` + +Switch agents with `Tab` key in TUI. + +## Debugging + +```bash +# Debug with inspector +bun run --inspect=ws://localhost:6499/ dev + +# Debug server separately +bun run --inspect=ws://localhost:6499/ ./src/index.ts serve --port 4096 +opencode attach http://localhost:4096 + +# Debug TUI +bun run --inspect=ws://localhost:6499/ --conditions=browser ./src/index.ts + +# Use spawn for breakpoints in server code +bun dev spawn +``` + +Use `--inspect-wait` or `--inspect-brk` for different breakpoint behaviors. + +## PR Guidelines + +- All PRs must reference an existing issue (`Fixes #123`) +- UI/core feature changes require design review with core team +- PR titles follow conventional commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:` +- Optional scope: `feat(app):`, `fix(desktop):` +- Include screenshots/videos for UI changes +- Explain verification steps for logic changes diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 53b501ba91a..d748accf6f0 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -11,6 +11,24 @@ import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" import { PermissionNext } from "@/permission/next" +import { Instance } from "../project/instance" + +// Track task calls per session: Map +// Budget is per-session (all calls within the delegated work count toward the limit) +// Note: State grows with sessions but entries are small. Future optimization: +// clean up completed sessions via Session lifecycle hooks if memory becomes a concern. +const taskCallState = Instance.state(() => new Map()) + +function getCallCount(sessionID: string): number { + return taskCallState().get(sessionID) ?? 0 +} + +function incrementCallCount(sessionID: string): number { + const state = taskCallState() + const newCount = (state.get(sessionID) ?? 0) + 1 + state.set(sessionID, newCount) + return newCount +} const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -54,33 +72,93 @@ export const TaskTool = Tool.define("task", async (ctx) => { }) } - const agent = await Agent.get(params.subagent_type) - if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + const targetAgent = await Agent.get(params.subagent_type) + if (!targetAgent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + + // Get caller's session to check if this is a subagent calling + const callerSession = await Session.get(ctx.sessionID) + const isSubagent = callerSession.parentID !== undefined + + // Get caller agent info for budget check (ctx.agent is just the name) + const callerAgentInfo = ctx.agent ? await Agent.get(ctx.agent) : undefined + + // Get config values: + // - task_budget on CALLER: how many calls the caller can make per request + // - callable_by_subagents on TARGET: whether target can be called by subagents + const callerTaskBudget = (callerAgentInfo?.options?.task_budget as number) ?? 0 + const targetCallable = targetAgent.options?.callable_by_subagents === true + + // Get target's task_budget once (used for session permissions and tool availability) + const targetTaskBudget = (targetAgent.options?.task_budget as number) ?? 0 + + // Check session ownership BEFORE incrementing budget (if session_id provided) + // This prevents "wasting" budget on invalid session resume attempts + if (isSubagent && params.session_id) { + const existingSession = await Session.get(params.session_id).catch(() => undefined) + if (existingSession && existingSession.parentID !== ctx.sessionID) { + throw new Error( + `Cannot resume session: not a child of caller session. ` + + `Session "${params.session_id}" is not owned by this caller.`, + ) + } + } + + // Enforce nested delegation controls only for subagent-to-subagent calls + if (isSubagent) { + // Check 1: Caller must have task_budget configured + if (callerTaskBudget <= 0) { + throw new Error( + `Caller has no task budget configured. ` + + `Set task_budget > 0 on the calling agent to enable nested delegation.`, + ) + } + + // Check 2: Target must be callable by subagents + if (!targetCallable) { + throw new Error( + `Target "${params.subagent_type}" is not callable by subagents. ` + + `Set callable_by_subagents: true on the target agent to enable.`, + ) + } + + // Check 3: Budget not exhausted for this session + const currentCount = getCallCount(ctx.sessionID) + if (currentCount >= callerTaskBudget) { + throw new Error( + `Task budget exhausted (${currentCount}/${callerTaskBudget} calls). ` + + `Return control to caller to continue.`, + ) + } + + // Increment count after passing all checks (including ownership above) + incrementCallCount(ctx.sessionID) + } + const session = await iife(async () => { if (params.session_id) { const found = await Session.get(params.session_id).catch(() => {}) - if (found) return found + if (found) { + // Ownership already verified above for subagents + return found + } + } + + // Build session permissions + const sessionPermissions: PermissionNext.Rule[] = [ + { permission: "todowrite", pattern: "*", action: "deny" }, + { permission: "todoread", pattern: "*", action: "deny" }, + ] + + // Only deny task if target agent has no task_budget (cannot delegate further) + if (targetTaskBudget <= 0) { + sessionPermissions.push({ permission: "task", pattern: "*", action: "deny" }) } return await Session.create({ parentID: ctx.sessionID, - title: params.description + ` (@${agent.name} subagent)`, + title: params.description + ` (@${targetAgent.name} subagent)`, permission: [ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "todoread", - pattern: "*", - action: "deny", - }, - { - permission: "task", - pattern: "*", - action: "deny", - }, + ...sessionPermissions, ...(config.experimental?.primary_tools?.map((t) => ({ pattern: "*", action: "allow" as const, @@ -123,7 +201,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { }) }) - const model = agent.model ?? { + const model = targetAgent.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } @@ -142,11 +220,12 @@ export const TaskTool = Tool.define("task", async (ctx) => { modelID: model.modelID, providerID: model.providerID, }, - agent: agent.name, + agent: targetAgent.name, tools: { todowrite: false, todoread: false, - task: false, + // Only disable task if target agent has no task_budget (cannot delegate further) + ...(targetTaskBudget <= 0 ? { task: false } : {}), ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), }, parts: promptParts, diff --git a/packages/opencode/test/task-delegation.test.ts b/packages/opencode/test/task-delegation.test.ts new file mode 100644 index 00000000000..e0ccc470d85 --- /dev/null +++ b/packages/opencode/test/task-delegation.test.ts @@ -0,0 +1,272 @@ +import { describe, test, expect } from "bun:test" +import { Config } from "../src/config/config" +import { Instance } from "../src/project/instance" +import { Agent } from "../src/agent/agent" +import { PermissionNext } from "../src/permission/next" +import { tmpdir } from "./fixture/fixture" + +describe("task_budget configuration (caller)", () => { + test("task_budget is preserved in agent.options from config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "principal-partner": { + description: "Orchestrator with high budget", + mode: "subagent", + task_budget: 20, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["principal-partner"] + expect(agentConfig?.options?.task_budget).toBe(20) + }, + }) + }) + + test("task_budget of 0 is preserved (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "disabled-agent": { + description: "Agent with explicitly disabled budget", + mode: "subagent", + task_budget: 0, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["disabled-agent"] + expect(agentConfig?.options?.task_budget).toBe(0) + }, + }) + }) + + test("missing task_budget defaults to undefined (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "default-agent": { + description: "Agent without task_budget", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["default-agent"] + expect(agentConfig?.options?.task_budget).toBeUndefined() + }, + }) + }) +}) + +describe("callable_by_subagents configuration (target)", () => { + test("callable_by_subagents true is preserved", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "assistant-sonnet": { + description: "Callable assistant", + mode: "subagent", + callable_by_subagents: true, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["assistant-sonnet"] + expect(agentConfig?.options?.callable_by_subagents).toBe(true) + }, + }) + }) + + test("callable_by_subagents false is preserved (default)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "private-agent": { + description: "Not callable by subagents", + mode: "subagent", + callable_by_subagents: false, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["private-agent"] + expect(agentConfig?.options?.callable_by_subagents).toBe(false) + }, + }) + }) + + test("missing callable_by_subagents defaults to undefined (not callable)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "default-agent": { + description: "Agent without callable_by_subagents", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["default-agent"] + expect(agentConfig?.options?.callable_by_subagents).toBeUndefined() + }, + }) + }) +}) + +describe("two-dimensional delegation config", () => { + test("full delegation config with both dimensions", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "principal-partner": { + description: "Orchestrates complex workflows", + mode: "subagent", + task_budget: 20, + callable_by_subagents: false, + permission: { + task: { + "*": "deny", + "assistant-sonnet": "allow", + "assistant-flash": "allow", + }, + }, + }, + "assistant-sonnet": { + description: "Thorough analysis", + mode: "subagent", + task_budget: 3, + callable_by_subagents: true, + permission: { + task: { + "*": "deny", + "assistant-flash": "allow", + }, + }, + }, + "assistant-flash": { + description: "Fast analytical passes", + mode: "subagent", + task_budget: 1, + callable_by_subagents: true, + permission: { + task: { + "*": "deny", + "assistant-sonnet": "allow", + }, + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + + // Principal-Partner: high budget, not callable + const partnerConfig = config.agent?.["principal-partner"] + expect(partnerConfig?.options?.task_budget).toBe(20) + expect(partnerConfig?.options?.callable_by_subagents).toBe(false) + + // Verify permission rules + const partnerRuleset = PermissionNext.fromConfig(partnerConfig?.permission ?? {}) + expect(PermissionNext.evaluate("task", "assistant-sonnet", partnerRuleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "assistant-flash", partnerRuleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "principal-partner", partnerRuleset).action).toBe("deny") + + // Assistant-Sonnet: lower budget, callable + const sonnetConfig = config.agent?.["assistant-sonnet"] + expect(sonnetConfig?.options?.task_budget).toBe(3) + expect(sonnetConfig?.options?.callable_by_subagents).toBe(true) + + // Assistant-Flash: lowest budget, callable + const flashConfig = config.agent?.["assistant-flash"] + expect(flashConfig?.options?.task_budget).toBe(1) + expect(flashConfig?.options?.callable_by_subagents).toBe(true) + }, + }) + }) +}) + +describe("backwards compatibility", () => { + test("agent without delegation config has defaults (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "legacy-agent": { + description: "Agent without delegation config", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["legacy-agent"] + + // Both should be undefined/falsy = delegation disabled + const taskBudget = (agentConfig?.options?.task_budget as number) ?? 0 + const callable = (agentConfig?.options?.callable_by_subagents as boolean) ?? false + + expect(taskBudget).toBe(0) + expect(callable).toBe(false) + }, + }) + }) + + test("built-in agents should not have delegation config by default", async () => { + await using tmp = await tmpdir({ + git: true, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Get the built-in general agent + const generalAgent = await Agent.get("general") + + // Built-in agents should not have delegation configured + const taskBudget = (generalAgent?.options?.task_budget as number) ?? 0 + const callable = (generalAgent?.options?.callable_by_subagents as boolean) ?? false + + expect(taskBudget).toBe(0) + expect(callable).toBe(false) + }, + }) + }) +})