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/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 2b44308f130..105894c0883 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,12 +1,10 @@ import { Config } from "../config/config" import z from "zod" import { Provider } from "../provider/provider" -import { generateObject, streamObject, type ModelMessage } from "ai" +import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { Truncate } from "../tool/truncation" -import { Auth } from "../auth" -import { ProviderTransform } from "../provider/transform" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -15,8 +13,6 @@ import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { PermissionNext } from "@/permission/next" import { mergeDeep, pipe, sortBy, values } from "remeda" -import { Global } from "@/global" -import path from "path" export namespace Agent { export const Info = z @@ -39,6 +35,7 @@ export namespace Agent { prompt: z.string().optional(), options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), + task_budget: z.number().int().nonnegative().optional(), }) .meta({ ref: "Agent", @@ -54,16 +51,13 @@ export namespace Agent { external_directory: { "*": "ask", [Truncate.DIR]: "allow", - [Truncate.GLOB]: "allow", }, question: "deny", - plan_enter: "deny", - plan_exit: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", - "*.env": "ask", - "*.env.*": "ask", + "*.env": "deny", + "*.env.*": "deny", "*.env.example": "allow", }, }) @@ -77,7 +71,6 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", - plan_enter: "allow", }), user, ), @@ -91,14 +84,9 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", - plan_exit: "allow", - external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", - }, edit: { "*": "deny", - [path.join(".opencode", "plans", "*.md")]: "allow", - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", + ".opencode/plan/*.md": "allow", }, }), user, @@ -137,7 +125,6 @@ export namespace Agent { read: "allow", external_directory: { [Truncate.DIR]: "allow", - [Truncate.GLOB]: "allow", }, }), user, @@ -220,6 +207,7 @@ export namespace Agent { item.hidden = value.hidden ?? item.hidden item.name = value.name ?? item.name item.steps = value.steps ?? item.steps + item.task_budget = value.task_budget ?? item.task_budget item.options = mergeDeep(item.options, value.options ?? {}) item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) } @@ -227,16 +215,14 @@ export namespace Agent { // Ensure Truncate.DIR is allowed unless explicitly configured for (const name in result) { const agent = result[name] - const explicit = agent.permission.some((r) => { - if (r.permission !== "external_directory") return false - if (r.action !== "deny") return false - return r.pattern === Truncate.DIR || r.pattern === Truncate.GLOB - }) + const explicit = agent.permission.some( + (r) => r.permission === "external_directory" && r.pattern === Truncate.DIR && r.action === "deny", + ) if (explicit) continue result[name].permission = PermissionNext.merge( result[name].permission, - PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow" } }), + PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow" } }), ) } @@ -257,20 +243,7 @@ export namespace Agent { } export async function defaultAgent() { - const cfg = await Config.get() - const agents = await state() - - if (cfg.default_agent) { - const agent = agents[cfg.default_agent] - if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`) - if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`) - if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`) - return agent.name - } - - const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) - if (!primaryVisible) throw new Error("no primary visible agent found") - return primaryVisible.name + return state().then((x) => Object.keys(x)[0]) } export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { @@ -278,12 +251,10 @@ export namespace Agent { const defaultModel = input.model ?? (await Provider.defaultModel()) const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID) const language = await Provider.getLanguage(model) - const system = SystemPrompt.header(defaultModel.providerID) system.push(PROMPT_GENERATE) const existing = await list() - - const params = { + const result = await generateObject({ experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, metadata: { @@ -309,24 +280,7 @@ export namespace Agent { whenToUse: z.string(), systemPrompt: z.string(), }), - } satisfies Parameters[0] - - if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") { - const result = streamObject({ - ...params, - providerOptions: ProviderTransform.providerOptions(model, { - instructions: SystemPrompt.instructions(), - store: false, - }), - onError: () => {}, - }) - for await (const part of result.fullStream) { - if (part.type === "error") throw part.error - } - return result.object - } - - const result = await generateObject(params) + }) return result.object } } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-session-tree.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-session-tree.tsx new file mode 100644 index 00000000000..b79d49d5609 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-session-tree.tsx @@ -0,0 +1,185 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { createMemo, onMount, type JSX } from "solid-js" +import { Locale } from "@/util/locale" +import { useTheme } from "../../context/theme" +import { useKV } from "../../context/kv" +import type { Session } from "@opencode-ai/sdk/v2" +import "opentui-spinner/solid" + +interface TreeOption { + title: string + value: string + prefix: string + footer: string + gutter: JSX.Element | undefined +} + +/** + * Find the root session by walking up the parentID chain + */ +function findRootSession( + currentSession: Session | undefined, + getSession: (id: string) => Session | undefined, +): Session | undefined { + let current = currentSession + while (current?.parentID) { + current = getSession(current.parentID) + } + return current +} + +/** + * Extract agent name from session title or agent field + * Session titles often contain "@agent-name" pattern + */ +function extractAgentName(session: Session): string { + // Try to extract from title pattern "... (@agent-name ...)" + const match = session.title?.match(/@([^\s)]+)/) + if (match) return match[1] + + // Fallback to first meaningful word of title, or "Session" + const firstWord = session.title?.split(" ")[0] + if (firstWord && firstWord.length > 0 && firstWord.length < 30) { + return firstWord + } + return "Session" +} + +/** + * Build flat array of tree options with visual prefixes using DFS traversal + */ +function buildTreeOptions( + sessions: Session[], + currentSessionId: string, + rootSession: Session | undefined, + sync: ReturnType, + theme: any, + animationsEnabled: boolean, +): TreeOption[] { + if (!rootSession) return [] + + const result: TreeOption[] = [] + const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + function getStatusIndicator(session: Session) { + // Current session indicator + if (session.id === currentSessionId) { + return + } + + // Permission awaiting indicator + const permission = sync.data.permission[session.id] + if (permission?.length) { + return + } + + // Busy session indicator (spinner) + const status = sync.data.session_status?.[session.id] + if (status?.type === "busy") { + if (animationsEnabled) { + return + } + return [⋯] + } + + return undefined + } + + function traverse(session: Session, depth: number, prefix: string, isLast: boolean) { + // Determine connector for this node + const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ " + // Determine prefix for children (continuation line or space) + const childPrefix = prefix + (depth === 0 ? "" : isLast ? " " : "│ ") + + const agentName = extractAgentName(session) + // For root, show full title; for children, show agent + truncated title + const displayTitle = + depth === 0 ? session.title || "Session" : `${agentName} "${session.title || ""}"` + + result.push({ + title: displayTitle, + value: session.id, + prefix: prefix + connector, + footer: Locale.time(session.time.updated), + gutter: getStatusIndicator(session), + }) + + // Get direct children and sort by id for consistent ordering + const children = sessions + .filter((s) => s.parentID === session.id) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + + children.forEach((child, i) => { + traverse(child, depth + 1, childPrefix, i === children.length - 1) + }) + } + + traverse(rootSession, 0, "", true) + return result +} + +export function DialogSessionTree() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const { theme } = useTheme() + const kv = useKV() + + const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + + const session = createMemo(() => { + const id = currentSessionID() + return id ? sync.session.get(id) : undefined + }) + + const rootSession = createMemo(() => { + return findRootSession(session(), (id) => sync.session.get(id)) + }) + + const animationsEnabled = kv.get("animations_enabled", true) + + const options = createMemo(() => { + const root = rootSession() + const currentId = currentSessionID() + if (!root || !currentId) return [] + + const treeOptions = buildTreeOptions( + sync.data.session, + currentId, + root, + sync, + theme, + animationsEnabled, + ) + + // Convert to DialogSelectOption format with custom rendering + return treeOptions.map((opt) => ({ + title: opt.prefix + opt.title, + value: opt.value, + footer: opt.footer, + gutter: opt.gutter, + })) + }) + + onMount(() => { + dialog.setSize("large") + }) + + return ( + { + route.navigate({ + type: "session", + sessionID: option.value, + }) + dialog.clear() + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 5e814c3d211..0345a58b306 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -1,4 +1,4 @@ -import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js" +import { type Accessor, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" import { useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { pipe, sumBy } from "remeda" @@ -8,6 +8,7 @@ import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" import { Installation } from "@/installation" +import { useRoute } from "@tui/context/route" import { useTerminalDimensions } from "@opentui/solid" const Title = (props: { session: Accessor }) => { @@ -32,6 +33,7 @@ const ContextInfo = (props: { context: Accessor; cost: Acces export function Header() { const route = useRouteData("session") + const { navigate } = useRoute() const sync = useSync() const session = createMemo(() => sync.session.get(route.sessionID)!) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) @@ -60,12 +62,113 @@ export function Header() { return result }) + // Build session path from root to current session + const sessionPath = createMemo(() => { + const path: Session[] = [] + let current: Session | undefined = session() + while (current) { + path.unshift(current) + current = current.parentID ? sync.session.get(current.parentID) : undefined + } + return path + }) + + // Current depth (0 = root, 1 = first child, etc.) + const depth = createMemo(() => sessionPath().length - 1) + + // Direct children of current session (for down navigation availability) + const directChildren = createMemo(() => { + const currentID = session()?.id + if (!currentID) return [] + return sync.data.session.filter((x) => x.parentID === currentID) + }) + + // Siblings at current level (for left/right navigation availability) + const siblings = createMemo(() => { + const currentParentID = session()?.parentID + if (!currentParentID) return [] + return sync.data.session.filter((x) => x.parentID === currentParentID) + }) + + // Navigation availability + const canGoUp = createMemo(() => !!session()?.parentID) + const canGoDown = createMemo(() => directChildren().length > 0) + const canCycleSiblings = createMemo(() => siblings().length > 1) + + // Get display name for a session + const getSessionDisplayName = (s: Session, isRoot: boolean) => { + if (isRoot) { + // Root session: show the title + return s.title || s.id.slice(0, 8) + } + // Child session: extract agent name from title like "Description (@agent-name subagent)" + const match = s.title?.match(/\(@([^)]+?)(?:\s+subagent)?\)/) + if (match) { + // Return just the agent name without @ and "subagent" + return match[1] + } + // Fallback to title or shortened ID + return s.title || s.id.slice(0, 8) + } + + // Get UP navigation label based on depth + const upLabel = createMemo(() => { + const d = depth() + if (d <= 0) return "" // Root has no parent + if (d === 1) return "Parent" // Depth 1 → Root + return `Child(L${d - 1})` // Depth N → Child(L{N-1}) + }) + + // Get DOWN navigation label based on depth + const downLabel = createMemo(() => { + const d = depth() + return `Child(L${d + 1})` // Depth N → Child(L{N+1}) + }) + const { theme } = useTheme() const keybind = useKeybind() const command = useCommandDialog() - const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) + const [hover, setHover] = createSignal<"parent" | "root" | "prev" | "next" | "down" | "breadcrumb" | null>(null) + const [hoverBreadcrumbIdx, setHoverBreadcrumbIdx] = createSignal(null) + + // Calculate breadcrumb text for a set of segments + const calcBreadcrumbLength = (segments: Session[], truncated: boolean) => { + let len = 0 + segments.forEach((s, i) => { + len += getSessionDisplayName(s, !s.parentID).length + if (i < segments.length - 1) { + len += truncated && i === 0 ? 9 : 3 // " > ... > " or " > " + } + }) + return len + } + + // Dynamic breadcrumb truncation based on available width + const breadcrumbSegments = createMemo(() => { + const path = sessionPath() + const availableWidth = dimensions().width - 40 // Reserve ~40 chars for right-side stats + + // Try full path first + const fullLength = calcBreadcrumbLength(path, false) + if (fullLength <= availableWidth || path.length <= 2) { + return { truncated: false, segments: path } + } + + // Truncate: show root + ... + last N segments that fit + // Start with root + last segment, add more if space allows + for (let keepLast = path.length - 1; keepLast >= 1; keepLast--) { + const segments = [path[0], ...path.slice(-keepLast)] + const len = calcBreadcrumbLength(segments, true) + if (len <= availableWidth || keepLast === 1) { + return { truncated: true, segments } + } + } + + // Fallback: root + last segment + return { truncated: true, segments: [path[0], path[path.length - 1]] } + }) return ( @@ -82,52 +185,125 @@ export function Header() { > - - - - Subagent session - + {/* Subagent session: 3-row layout */} + + {/* Row 1: Breadcrumb trail */} + + + {(segment, index) => ( + <> + { + setHover("breadcrumb") + setHoverBreadcrumbIdx(index()) + }} + onMouseOut={() => { + setHover(null) + setHoverBreadcrumbIdx(null) + }} + onMouseUp={() => { + navigate({ type: "session", sessionID: segment.id }) + }} + backgroundColor={ + hover() === "breadcrumb" && hoverBreadcrumbIdx() === index() + ? theme.backgroundElement + : theme.backgroundPanel + } + > + + + {getSessionDisplayName(segment, !segment.parentID)} + + + + + {/* Show "... >" after root when truncated */} + + {index() === 0 && breadcrumbSegments().truncated ? " > ... >" : " > "} + + + + )} + + + + {/* Row 2: Divider + stats */} + + + ──────────────────────────────────────── + v{Installation.VERSION} + + {/* Row 3: Navigation hints */} - setHover("parent")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.parent")} - backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} - > - - Parent {keybind.print("session_parent")} - - - setHover("prev")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.previous")} - backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} - > - - Prev {keybind.print("session_child_cycle_reverse")} - - - setHover("next")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.next")} - backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} - > - - Next {keybind.print("session_child_cycle")} - - + + setHover("parent")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.parent")} + backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} + > + + {upLabel()} {keybind.print("session_parent")} + + + + = 2}> + setHover("root")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.root")} + backgroundColor={hover() === "root" ? theme.backgroundElement : theme.backgroundPanel} + > + + Root {keybind.print("session_root")} + + + + + setHover("next")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.next")} + backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} + > + + Next {keybind.print("session_child_cycle")} + + + setHover("prev")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.previous")} + backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} + > + + Prev {keybind.print("session_child_cycle_reverse")} + + + + + setHover("down")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.down")} + backgroundColor={hover() === "down" ? theme.backgroundElement : theme.backgroundPanel} + > + + {downLabel()} {keybind.print("session_child_down")} + + + - + {/* Root session: responsive layout */} + <box flexDirection="row" gap={1} flexShrink={0}> <ContextInfo context={context} cost={cost} /> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1294ab849e9..ab34ba92d8d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -74,6 +74,7 @@ import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" +import { DialogSessionTree } from "./dialog-session-tree" addDefaultParsers(parsers.parsers) @@ -118,6 +119,25 @@ export function Session() { .filter((x) => x.parentID === parentID || x.id === parentID) .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) + // Siblings: sessions with the same direct parent (for left/right cycling) + const siblings = createMemo(() => { + const currentParentID = session()?.parentID + if (!currentParentID) { + // Root session: no siblings to cycle + return [session()!].filter(Boolean) + } + return sync.data.session + .filter((x) => x.parentID === currentParentID) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) + // Direct children: sessions whose parent is this session (for down navigation) + const directChildren = createMemo(() => { + const currentID = session()?.id + if (!currentID) return [] + return sync.data.session + .filter((x) => x.parentID === currentID) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => { if (session()?.parentID) return [] @@ -281,14 +301,37 @@ export function Session() { const local = useLocal() function moveChild(direction: number) { - if (children().length === 1) return - let next = children().findIndex((x) => x.id === session()?.id) + direction - if (next >= children().length) next = 0 - if (next < 0) next = children().length - 1 - if (children()[next]) { + if (siblings().length <= 1) return + let next = siblings().findIndex((x) => x.id === session()?.id) + direction + if (next >= siblings().length) next = 0 + if (next < 0) next = siblings().length - 1 + if (siblings()[next]) { + navigate({ + type: "session", + sessionID: siblings()[next].id, + }) + } + } + + function moveToFirstChild() { + const children = directChildren() + if (children.length === 0) return + navigate({ + type: "session", + sessionID: children[0].id, + }) + } + + function moveToRoot() { + // Traverse up to find root session (no parentID) + let current = session() + while (current?.parentID) { + current = sync.session.get(current.parentID) + } + if (current && current.id !== session()?.id) { navigate({ type: "session", - sessionID: children()[next].id, + sessionID: current.id, }) } } @@ -870,6 +913,17 @@ export function Session() { dialog.clear() }, }, + { + title: "Go to first child session", + value: "session.child.down", + keybind: "session_child_down", + category: "Session", + hidden: true, + onSelect: (dialog) => { + moveToFirstChild() + dialog.clear() + }, + }, { title: "Go to parent session", value: "session.parent", @@ -887,6 +941,26 @@ export function Session() { dialog.clear() }, }, + { + title: "Go to root session", + value: "session.root", + keybind: "session_root", + category: "Session", + hidden: true, + onSelect: (dialog) => { + moveToRoot() + dialog.clear() + }, + }, + { + title: "Session tree", + value: "session.tree", + keybind: "session_child_list", + category: "Session", + onSelect: (dialog) => { + dialog.replace(() => <DialogSessionTree />) + }, + }, ]) const revertInfo = createMemo(() => session()?.revert) @@ -1783,7 +1857,7 @@ function Task(props: ToolProps<typeof TaskTool>) { return ( <Switch> - <Match when={props.metadata.summary?.length}> + <Match when={props.metadata.sessionId}> <BlockTool title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"} onClick={ @@ -1805,7 +1879,7 @@ function Task(props: ToolProps<typeof TaskTool>) { </Show> </box> <text fg={theme.text}> - {keybind.print("session_child_cycle")} + {keybind.print("session_child_down")} <span style={{ fg: theme.textMuted }}> view subagents</span> </text> </BlockTool> diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cba8..5ef9b69cbbb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -224,19 +224,6 @@ export namespace Config { await BunProc.run(["install"], { cwd: dir }).catch(() => {}) } - function rel(item: string, patterns: string[]) { - for (const pattern of patterns) { - const index = item.indexOf(pattern) - if (index === -1) continue - return item.slice(index + pattern.length) - } - } - - function trim(file: string) { - const ext = path.extname(file) - return ext.length ? file.slice(0, -ext.length) : file - } - const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md") async function loadCommand(dir: string) { const result: Record<string, Command> = {} @@ -246,20 +233,19 @@ export namespace Config { dot: true, cwd: dir, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse command ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load command", { command: item, err }) - return undefined - }) - if (!md) continue + const md = await ConfigMarkdown.parse(item) + if (!md.data) continue - const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] - const file = rel(item, patterns) ?? path.basename(item) - const name = trim(file) + const name = (() => { + const patterns = ["/.opencode/command/", "/command/"] + const pattern = patterns.find((p) => item.includes(p)) + + if (pattern) { + const index = item.indexOf(pattern) + return item.slice(index + pattern.length, -3) + } + return path.basename(item, ".md") + })() const config = { name, @@ -286,20 +272,23 @@ export namespace Config { dot: true, cwd: dir, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse agent ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load agent", { agent: item, err }) - return undefined - }) - if (!md) continue - - const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] - const file = rel(item, patterns) ?? path.basename(item) - const agentName = trim(file) + const md = await ConfigMarkdown.parse(item) + if (!md.data) continue + + // Extract relative path from agent folder for nested agents + let agentName = path.basename(item, ".md") + const agentFolderPath = item.includes("/.opencode/agent/") + ? item.split("/.opencode/agent/")[1] + : item.includes("/agent/") + ? item.split("/agent/")[1] + : agentName + ".md" + + // If agent is in a subfolder, include folder path in name + if (agentFolderPath.includes("/")) { + const relativePath = agentFolderPath.replace(".md", "") + const pathParts = relativePath.split("/") + agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1] + } const config = { name: agentName, @@ -325,16 +314,8 @@ export namespace Config { dot: true, cwd: dir, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse mode ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load mode", { mode: item, err }) - return undefined - }) - if (!md) continue + const md = await ConfigMarkdown.parse(item) + if (!md.data) continue const config = { name: path.basename(item, ".md"), @@ -434,7 +415,9 @@ export namespace Config { .int() .positive() .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + .describe( + "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", + ), }) .strict() .meta({ @@ -473,7 +456,9 @@ export namespace Config { .int() .positive() .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + .describe( + "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", + ), }) .strict() .meta({ @@ -572,8 +557,16 @@ export namespace Config { hidden: z .boolean() .optional() - .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), - options: z.record(z.string(), z.any()).optional(), + .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), + task_budget: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Maximum task calls this agent can make per session when delegating to other subagents. Set to 0 to explicitly disable, omit to use default (disabled).", + ), + options: z.record(z.string(), z.any()).optional(), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format") @@ -599,6 +592,7 @@ export namespace Config { "top_p", "mode", "hidden", + "task_budget", "color", "steps", "maxSteps", @@ -650,29 +644,19 @@ export namespace Config { sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"), scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), - status_view: z.string().optional().default("<leader>s").describe("View status"), + status_view: z.string().optional().default("<leader>i").describe("View status"), session_export: z.string().optional().default("<leader>x").describe("Export session to editor"), session_new: z.string().optional().default("<leader>n").describe("Create a new session"), session_list: z.string().optional().default("<leader>l").describe("List all sessions"), session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"), session_fork: z.string().optional().default("none").describe("Fork session from message"), - session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), - session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), - stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), - model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), - model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), + session_rename: z.string().optional().default("none").describe("Rename session"), session_share: z.string().optional().default("none").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_compact: z.string().optional().default("<leader>c").describe("Compact the session"), - messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), - messages_page_down: z - .string() - .optional() - .default("pagedown,ctrl+alt+f") - .describe("Scroll messages down by one page"), - messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), - messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), + messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"), + messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"), messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), messages_half_page_down: z .string() @@ -786,9 +770,12 @@ export namespace Config { .describe("Delete word backward in input"), history_previous: z.string().optional().default("up").describe("Previous history item"), history_next: z.string().optional().default("down").describe("Next history item"), - session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"), - session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"), + session_child_cycle: z.string().optional().default("<leader>right").describe("Next sibling session"), + session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous sibling session"), session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"), + session_child_down: z.string().optional().default("<leader>down").describe("Go to first child session"), + session_root: z.string().optional().default("<leader>escape").describe("Go to root session"), + session_child_list: z.string().optional().default("<leader>s").describe("Open session tree dialog"), terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"), @@ -960,7 +947,7 @@ export namespace Config { }) .catchall(Agent) .optional() - .describe("Agent configuration, see https://opencode.ai/docs/agents"), + .describe("Agent configuration, see https://opencode.ai/docs/agent"), provider: z .record(z.string(), Provider) .optional() @@ -1086,6 +1073,15 @@ export namespace Config { .positive() .optional() .describe("Timeout in milliseconds for model context protocol (MCP) requests"), + level_limit: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Maximum depth for subagent session trees. Prevents infinite delegation loops. " + + "Default: 5. Set to 0 to disable (not recommended)." + ), }) .optional(), }) @@ -1135,7 +1131,6 @@ export namespace Config { } async function load(text: string, configFilepath: string) { - const original = text text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) @@ -1205,9 +1200,7 @@ export namespace Config { if (parsed.success) { if (!parsed.data.$schema) { parsed.data.$schema = "https://opencode.ai/config.json" - // Write the $schema to the original text to preserve variables like {env:VAR} - const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') - await Bun.write(configFilepath, updated).catch(() => {}) + await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)) } const data = parsed.data if (data.plugin) { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 170d4448088..50618034628 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -11,6 +11,41 @@ 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 +} + +/** + * Calculate session depth by walking up the parentID chain. + * Root session = depth 0, first child = depth 1, etc. + */ +async function getSessionDepth(sessionID: string): Promise<number> { + let depth = 0 + let currentID: string | undefined = sessionID + while (currentID) { + const session: Awaited<ReturnType<typeof Session.get>> | undefined = + await Session.get(currentID).catch(() => undefined) + if (!session?.parentID) break + currentID = session.parentID + depth++ + } + return depth +} const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -41,8 +76,13 @@ export const TaskTool = Tool.define("task", async (ctx) => { async execute(params: z.infer<typeof parameters>, ctx) { const config = await Config.get() + // Get caller's session to check if this is a subagent calling + const callerSession = await Session.get(ctx.sessionID) + const isSubagent = callerSession.parentID !== undefined + // Skip permission check when user explicitly invoked via @ or command subtask - if (!ctx.extra?.bypassAgentCheck) { + // BUT: always check permissions for subagent-to-subagent delegation + if (!ctx.extra?.bypassAgentCheck || isSubagent) { await ctx.ask({ permission: "task", patterns: [params.subagent_type], @@ -54,40 +94,91 @@ 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 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 session + const callerTaskBudget = callerAgentInfo?.task_budget ?? 0 + + // Get target's task_budget once (used for session permissions and tool availability) + const targetTaskBudget = targetAgent.task_budget ?? 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: 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.`, + ) + } + + // Check 3: Level limit not exceeded + const levelLimit = config.experimental?.level_limit ?? 5 // Default: 5 + if (levelLimit > 0) { + const currentDepth = await getSessionDepth(ctx.sessionID) + if (currentDepth >= levelLimit) { + throw new Error( + `Level limit reached (depth ${currentDepth}/${levelLimit}). ` + + `Cannot create deeper subagent sessions. Return control to caller.` + ) + } + } - const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") + // 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", - }, - ...(hasTaskPermission - ? [] - : [ - { - permission: "task" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), + ...sessionPermissions, ...(config.experimental?.primary_tools?.map((t) => ({ pattern: "*", action: "allow" as const, @@ -130,7 +221,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, } @@ -149,11 +240,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, - ...(hasTaskPermission ? {} : { 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..35084a9810f --- /dev/null +++ b/packages/opencode/test/task-delegation.test.ts @@ -0,0 +1,244 @@ +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 from config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + orchestrator: { + description: "Agent with high task budget", + mode: "subagent", + task_budget: 20, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["orchestrator"] + expect(agentConfig?.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?.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?.task_budget).toBeUndefined() + }, + }) + }) +}) + +describe("task_budget with permissions config", () => { + test("task_budget with permission rules for selective delegation", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + orchestrator: { + description: "Coordinates other subagents", + mode: "subagent", + task_budget: 20, + permission: { + task: { + "*": "deny", + "worker-a": "allow", + "worker-b": "allow", + }, + }, + }, + "worker-a": { + description: "Worker with medium budget", + mode: "subagent", + task_budget: 3, + permission: { + task: { + "*": "deny", + "worker-b": "allow", + }, + }, + }, + "worker-b": { + description: "Worker with minimal budget", + mode: "subagent", + task_budget: 1, + permission: { + task: { + "*": "deny", + "worker-a": "allow", + }, + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + + // Orchestrator: high budget + const orchestratorConfig = config.agent?.["orchestrator"] + expect(orchestratorConfig?.task_budget).toBe(20) + + // Verify permission rules + const orchestratorRuleset = PermissionNext.fromConfig(orchestratorConfig?.permission ?? {}) + expect(PermissionNext.evaluate("task", "worker-a", orchestratorRuleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "worker-b", orchestratorRuleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "orchestrator", orchestratorRuleset).action).toBe("deny") + + // Worker-A: medium budget + const workerAConfig = config.agent?.["worker-a"] + expect(workerAConfig?.task_budget).toBe(3) + + // Worker-B: minimal budget + const workerBConfig = config.agent?.["worker-b"] + expect(workerBConfig?.task_budget).toBe(1) + }, + }) + }) +}) + +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"] + + // Should be undefined/falsy = delegation disabled + const taskBudget = (agentConfig?.task_budget as number) ?? 0 + + expect(taskBudget).toBe(0) + }, + }) + }) + + 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?.task_budget ?? 0 + + expect(taskBudget).toBe(0) + }, + }) + }) +}) + +describe("level_limit configuration", () => { + test("level_limit is preserved from config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + level_limit: 8, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.level_limit).toBe(8) + }, + }) + }) + + test("level_limit defaults to undefined when not set (implementation defaults to 5)", async () => { + await using tmp = await tmpdir({ + git: true, + config: {}, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.level_limit).toBeUndefined() + }, + }) + }) + + test("level_limit of 0 is preserved (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + level_limit: 0, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.level_limit).toBe(0) + }, + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 38a52b325ad..09c94fedf34 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -47,13 +47,6 @@ export type EventProjectUpdated = { properties: Project } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string - } -} - export type EventServerConnected = { type: "server.connected" properties: { @@ -68,6 +61,13 @@ export type EventGlobalDisposed = { } } +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" + properties: { + directory: string + } +} + export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -885,9 +885,9 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventProjectUpdated - | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed + | EventServerInstanceDisposed | EventLspClientDiagnostics | EventLspUpdated | EventFileEdited @@ -1005,22 +1005,6 @@ export type KeybindsConfig = { * Rename session */ session_rename?: string - /** - * Delete session - */ - session_delete?: string - /** - * Delete stash entry - */ - stash_delete?: string - /** - * Open provider list from model dialog - */ - model_provider_list?: string - /** - * Toggle model favorite status - */ - model_favorite_toggle?: string /** * Share current session */ @@ -1045,14 +1029,6 @@ export type KeybindsConfig = { * Scroll messages down by one page */ messages_page_down?: string - /** - * Scroll messages up by one line - */ - messages_line_up?: string - /** - * Scroll messages down by one line - */ - messages_line_down?: string /** * Scroll messages up by half page */ @@ -1298,17 +1274,29 @@ export type KeybindsConfig = { */ history_next?: string /** - * Next child session + * Next sibling session */ session_child_cycle?: string /** - * Previous child session + * Previous sibling session */ session_child_cycle_reverse?: string /** * Go to parent session */ session_parent?: string + /** + * Go to first child session + */ + session_child_down?: string + /** + * Go to root session + */ + session_root?: string + /** + * Open session tree dialog + */ + session_child_list?: string /** * Suspend terminal */ @@ -1402,6 +1390,10 @@ export type AgentConfig = { * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) */ hidden?: boolean + /** + * Maximum task calls this agent can make per session when delegating to other subagents. Set to 0 to explicitly disable, omit to use default (disabled). + */ + task_budget?: number options?: { [key: string]: unknown } @@ -1429,6 +1421,7 @@ export type AgentConfig = { | "subagent" | "primary" | "all" + | number | { [key: string]: unknown } @@ -1546,7 +1539,7 @@ export type McpLocalConfig = { */ enabled?: boolean /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. + * Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified. */ timeout?: number } @@ -1590,7 +1583,7 @@ export type McpRemoteConfig = { */ oauth?: McpOAuthConfig | false /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. + * Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified. */ timeout?: number } @@ -1696,7 +1689,7 @@ export type Config = { [key: string]: AgentConfig | undefined } /** - * Agent configuration, see https://opencode.ai/docs/agents + * Agent configuration, see https://opencode.ai/docs/agent */ agent?: { plan?: AgentConfig @@ -1823,6 +1816,10 @@ export type Config = { * Timeout in milliseconds for model context protocol (MCP) requests */ mcp_timeout?: number + /** + * Maximum depth for subagent session trees. Prevents infinite delegation loops. Default: 5. Set to 0 to disable (not recommended). + */ + level_limit?: number } } @@ -2127,6 +2124,7 @@ export type Agent = { [key: string]: unknown } steps?: number + task_budget?: number } export type LspStatus = {