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 c53ca04e238..ea75795ad55 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" @@ -42,6 +40,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", @@ -61,13 +60,11 @@ export namespace Agent { ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "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", }, }) @@ -82,7 +79,6 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", - plan_enter: "allow", }), user, ), @@ -97,14 +93,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, @@ -227,6 +218,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 ?? {})) } @@ -264,20 +256,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 } }) { @@ -289,8 +268,7 @@ export namespace Agent { const system = [PROMPT_GENERATE] await Plugin.trigger("experimental.chat.system.transform", { model }, { system }) const existing = await list() - - const params = { + const result = await generateObject({ experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, metadata: { @@ -316,24 +294,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/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8ace2fff372..737ca86e8ff 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -15,7 +15,19 @@ export function Footer() { const lsp = createMemo(() => Object.keys(sync.data.lsp)) const permissions = createMemo(() => { if (route.data.type !== "session") return [] - return sync.data.permission[route.data.sessionID] ?? [] + // Collect permissions from all descendant sessions (full tree) + const rootID = route.data.sessionID + const ids: string[] = [rootID] + const queue = [rootID] + while (queue.length > 0) { + const parentID = queue.pop()! + for (const s of sync.data.session) { + if (s.parentID !== parentID) continue + ids.push(s.id) + queue.push(s.id) + } + } + return ids.flatMap((id) => sync.data.permission[id] ?? []) }) const directory = useDirectory() const connected = useConnected() 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 0c5ea9a8572..218018e9a91 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -1,5 +1,5 @@ -import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js" -import { useRouteData } from "@tui/context/route" +import { type Accessor, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { pipe, sumBy } from "remeda" import { useTheme } from "@tui/context/theme" @@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border" 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 { useTerminalDimensions } from "@opentui/solid" const Title = (props: { session: Accessor }) => { @@ -31,6 +32,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] ?? []) @@ -59,12 +61,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 ( @@ -81,49 +184,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 ? " > ... >" : " > "} + + + + )} + - - 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")} - + + {/* Row 2: Divider + stats */} + + + ──────────────────────────────────────── - setHover("next")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.next")} - backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} - > - - Next {keybind.print("session_child_cycle")} - + + + v{Installation.VERSION} + + {/* Row 3: Navigation hints */} + + + 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 */} + <ContextInfo context={context} cost={cost} /> </box> 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 31401836766..2a7b4769e8b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -80,6 +80,7 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { DialogSessionTree } from "./dialog-session-tree" addDefaultParsers(parsers.parsers) @@ -127,14 +128,47 @@ 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] ?? []) + // Collect all descendant session IDs (full tree) for permission/question aggregation + const descendants = createMemo(() => { + const rootID = session()?.id + if (!rootID || session()?.parentID) return [] + const ids: string[] = [rootID] + const queue = [rootID] + while (queue.length > 0) { + const parentID = queue.pop()! + for (const s of sync.data.session) { + if (s.parentID !== parentID) continue + ids.push(s.id) + queue.push(s.id) + } + } + return ids + }) const permissions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.permission[x.id] ?? []) + return descendants().flatMap((id) => sync.data.permission[id] ?? []) }) const questions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + return descendants().flatMap((id) => sync.data.question[id] ?? []) }) const pending = createMemo(() => { @@ -315,29 +349,40 @@ export function Session() { const local = useLocal() - function moveFirstChild() { - if (children().length === 1) return - const next = children().find((x) => !!x.parentID) - if (next) { + function moveChild(direction: number) { + // Use siblings for cycling (sessions with same parentID) + const sibs = siblings() + if (sibs.length <= 1) return + let next = sibs.findIndex((x) => x.id === session()?.id) + direction + if (next >= sibs.length) next = 0 + if (next < 0) next = sibs.length - 1 + if (sibs[next]) { navigate({ type: "session", - sessionID: next.id, + sessionID: sibs[next].id, }) } } - function moveChild(direction: number) { - if (children().length === 1) return - - const sessions = children().filter((x) => !!x.parentID) - let next = sessions.findIndex((x) => x.id === session()?.id) + direction + function moveToFirstChild() { + const children = directChildren() + if (children.length === 0) return + navigate({ + type: "session", + sessionID: children[0].id, + }) + } - if (next >= sessions.length) next = 0 - if (next < 0) next = sessions.length - 1 - if (sessions[next]) { + 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: sessions[next].id, + sessionID: current.id, }) } } @@ -908,13 +953,13 @@ export function Session() { }, }, { - title: "Go to child session", - value: "session.child.first", - keybind: "session_child_first", + title: "Go to first child session", + value: "session.child.down", + keybind: "session_child_down", category: "Session", hidden: true, onSelect: (dialog) => { - moveFirstChild() + moveToFirstChild() dialog.clear() }, }, @@ -957,6 +1002,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) @@ -1937,7 +2002,7 @@ function Task(props: ToolProps<typeof TaskTool>) { return ( <Switch> - <Match when={props.input.description || props.input.subagent_type}> + <Match when={props.metadata.sessionId}> <BlockTool title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"} onClick={ @@ -1963,12 +2028,10 @@ function Task(props: ToolProps<typeof TaskTool>) { }} </Show> </box> - <Show when={props.metadata.sessionId}> - <text fg={theme.text}> - {keybind.print("session_child_first")} - <span style={{ fg: theme.textMuted }}> view subagents</span> - </text> - </Show> + <text fg={theme.text}> + {keybind.print("session_child_down")} + <span style={{ fg: theme.textMuted }}> view subagents</span> + </text> </BlockTool> </Match> <Match when={true}> diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 141f6156985..90e528f1279 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -342,20 +342,19 @@ export namespace Config { dot: true, symlink: true, })) { - 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 name = (() => { + const patterns = ["/.opencode/command/", "/command/"] + const pattern = patterns.find((p) => item.includes(p)) - const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] - const file = rel(item, patterns) ?? path.basename(item) - const name = trim(file) + if (pattern) { + const index = item.indexOf(pattern) + return item.slice(index + pattern.length, -3) + } + return path.basename(item, ".md") + })() const config = { name, @@ -381,20 +380,23 @@ export namespace Config { dot: true, symlink: true, })) { - 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, @@ -419,16 +421,8 @@ export namespace Config { dot: true, symlink: true, })) { - 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"), @@ -527,7 +521,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({ @@ -566,7 +562,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({ @@ -679,8 +677,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 .union([ z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"), @@ -709,6 +715,7 @@ export namespace Config { "top_p", "mode", "hidden", + "task_budget", "color", "steps", "maxSteps", @@ -760,29 +767,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() @@ -896,10 +893,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_first: z.string().optional().default("<leader>down").describe("Go to first child session"), - session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), - session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), - session_parent: z.string().optional().default("up").describe("Go to parent 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"), @@ -1056,7 +1055,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() @@ -1165,6 +1164,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(), }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 8c8cf827aba..a5adc2e6080 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,6 +10,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"), @@ -45,8 +80,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], @@ -58,40 +98,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 task_id provided) + // This prevents "wasting" budget on invalid session resume attempts + if (isSubagent && params.task_id) { + const existingSession = await Session.get(params.task_id).catch(() => undefined) + if (existingSession && existingSession.parentID !== ctx.sessionID) { + throw new Error( + `Cannot resume session: not a child of caller session. ` + + `Session "${params.task_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.task_id) { const found = await Session.get(params.task_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, @@ -103,7 +194,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") - const model = agent.model ?? { + const model = targetAgent.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } @@ -132,11 +223,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) + }, + }) + }) +})