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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <directory> # 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-<platform>/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
123 changes: 101 additions & 22 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<sessionID, count>
// 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<string, number>())

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"),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
Expand All @@ -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,
Expand Down
Loading