From d667e7b42051695b037b3edda8b86f6e854d9e13 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Sat, 10 Jan 2026 17:09:43 -0600 Subject: [PATCH 01/10] feat(task): add subagent-to-subagent task delegation with configurable limits Enable nested task delegation between subagents with two-dimensional configuration: - task_budget (CALLER): max task calls per request (messageID) - callable_by_subagents (TARGET): whether agent can be called by subagents Key changes: - Add budget tracking per (sessionID, messageID) for per-request limits - Check caller's task_budget before allowing delegation - Check target's callable_by_subagents before allowing calls - Validate session ownership before resuming with session_id - Primary agents bypass all nested delegation controls - Conditionally enable/disable task tool based on target's task_budget Backwards compatible: missing config = delegation disabled (current behavior) Co-Authored-By: Claude Opus 4.5 --- packages/opencode/src/tool/task.ts | 135 +++++++-- .../opencode/test/task-delegation.test.ts | 272 ++++++++++++++++++ 2 files changed, 380 insertions(+), 27 deletions(-) create mode 100644 packages/opencode/test/task-delegation.test.ts diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 170d4448088..719de86aa9f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -11,6 +11,30 @@ 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 request: Map> +// Budget is per-request (one "work assignment" within a session), resets on new messageID +// Note: State grows with sessions/messages but entries are small. Future optimization: +// clean up completed sessions via Session lifecycle hooks if memory becomes a concern. +const taskCallState = Instance.state(() => new Map>()) + +function getCallCount(sessionID: string, messageID: string): number { + const sessionCounts = taskCallState().get(sessionID) + return sessionCounts?.get(messageID) ?? 0 +} + +function incrementCallCount(sessionID: string, messageID: string): number { + const state = taskCallState() + let sessionCounts = state.get(sessionID) + if (!sessionCounts) { + sessionCounts = new Map() + state.set(sessionID, sessionCounts) + } + const newCount = (sessionCounts.get(messageID) ?? 0) + 1 + sessionCounts.set(messageID, newCount) + return newCount +} const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -54,40 +78,96 @@ 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`) + + // Check if target agent has task permission configured + const hasTaskPermission = targetAgent.permission.some((rule) => rule.permission === "task") + + // Get caller's session to check if this is a subagent calling + const callerSession = await Session.get(ctx.sessionID) + const isSubagent = callerSession.parentID !== undefined + + // Get caller agent info for budget check (ctx.agent is just the name) + const callerAgentInfo = ctx.agent ? await Agent.get(ctx.agent) : undefined + + // Get config values: + // - task_budget on CALLER: how many calls the caller can make per request + // - callable_by_subagents on TARGET: whether target can be called by subagents + const callerTaskBudget = (callerAgentInfo?.options?.task_budget as number) ?? 0 + const targetCallable = (targetAgent.options?.callable_by_subagents as boolean) ?? false + + // Get target's task_budget once (used for session permissions and tool availability) + const targetTaskBudget = (targetAgent.options?.task_budget as number) ?? 0 + + // Check session ownership BEFORE incrementing budget (if session_id provided) + // This prevents "wasting" budget on invalid session resume attempts + if (isSubagent && params.session_id) { + const existingSession = await Session.get(params.session_id).catch(() => undefined) + if (existingSession && existingSession.parentID !== ctx.sessionID) { + throw new Error( + `Cannot resume session: not a child of caller session. ` + + `Session "${params.session_id}" is not owned by this caller.`, + ) + } + } + + // Enforce nested delegation controls only for subagent-to-subagent calls + if (isSubagent) { + // Check 1: Caller must have task_budget configured + if (callerTaskBudget <= 0) { + throw new Error( + `Caller has no task budget configured. ` + + `Set task_budget > 0 on the calling agent to enable nested delegation.`, + ) + } + + // Check 2: Target must be callable by subagents + if (!targetCallable) { + throw new Error( + `Target "${params.subagent_type}" is not callable by subagents. ` + + `Set callable_by_subagents: true on the target agent to enable.`, + ) + } + + // Check 3: Budget not exhausted for this request (messageID) + const currentCount = getCallCount(ctx.sessionID, ctx.messageID) + if (currentCount >= callerTaskBudget) { + throw new Error( + `Task budget exhausted (${currentCount}/${callerTaskBudget} calls). ` + + `Return control to caller to continue.`, + ) + } - const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") + // Increment count after passing all checks (including ownership above) + incrementCallCount(ctx.sessionID, ctx.messageID) + } 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" }, + ] + + // Deny task if: (1) target has no task_budget, OR (2) target has no task permission + if (targetTaskBudget <= 0 || !hasTaskPermission) { + 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 +210,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 +229,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 }), + // Disable task if: (1) target has no task_budget, OR (2) target has no task permission + ...(targetTaskBudget <= 0 || !hasTaskPermission ? { task: false } : {}), ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), }, parts: promptParts, diff --git a/packages/opencode/test/task-delegation.test.ts b/packages/opencode/test/task-delegation.test.ts new file mode 100644 index 00000000000..e0ccc470d85 --- /dev/null +++ b/packages/opencode/test/task-delegation.test.ts @@ -0,0 +1,272 @@ +import { describe, test, expect } from "bun:test" +import { Config } from "../src/config/config" +import { Instance } from "../src/project/instance" +import { Agent } from "../src/agent/agent" +import { PermissionNext } from "../src/permission/next" +import { tmpdir } from "./fixture/fixture" + +describe("task_budget configuration (caller)", () => { + test("task_budget is preserved in agent.options from config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "principal-partner": { + description: "Orchestrator with high budget", + mode: "subagent", + task_budget: 20, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["principal-partner"] + expect(agentConfig?.options?.task_budget).toBe(20) + }, + }) + }) + + test("task_budget of 0 is preserved (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "disabled-agent": { + description: "Agent with explicitly disabled budget", + mode: "subagent", + task_budget: 0, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["disabled-agent"] + expect(agentConfig?.options?.task_budget).toBe(0) + }, + }) + }) + + test("missing task_budget defaults to undefined (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "default-agent": { + description: "Agent without task_budget", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["default-agent"] + expect(agentConfig?.options?.task_budget).toBeUndefined() + }, + }) + }) +}) + +describe("callable_by_subagents configuration (target)", () => { + test("callable_by_subagents true is preserved", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "assistant-sonnet": { + description: "Callable assistant", + mode: "subagent", + callable_by_subagents: true, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["assistant-sonnet"] + expect(agentConfig?.options?.callable_by_subagents).toBe(true) + }, + }) + }) + + test("callable_by_subagents false is preserved (default)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "private-agent": { + description: "Not callable by subagents", + mode: "subagent", + callable_by_subagents: false, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["private-agent"] + expect(agentConfig?.options?.callable_by_subagents).toBe(false) + }, + }) + }) + + test("missing callable_by_subagents defaults to undefined (not callable)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "default-agent": { + description: "Agent without callable_by_subagents", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["default-agent"] + expect(agentConfig?.options?.callable_by_subagents).toBeUndefined() + }, + }) + }) +}) + +describe("two-dimensional delegation config", () => { + test("full delegation config with both dimensions", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "principal-partner": { + description: "Orchestrates complex workflows", + mode: "subagent", + task_budget: 20, + callable_by_subagents: false, + permission: { + task: { + "*": "deny", + "assistant-sonnet": "allow", + "assistant-flash": "allow", + }, + }, + }, + "assistant-sonnet": { + description: "Thorough analysis", + mode: "subagent", + task_budget: 3, + callable_by_subagents: true, + permission: { + task: { + "*": "deny", + "assistant-flash": "allow", + }, + }, + }, + "assistant-flash": { + description: "Fast analytical passes", + mode: "subagent", + task_budget: 1, + callable_by_subagents: true, + permission: { + task: { + "*": "deny", + "assistant-sonnet": "allow", + }, + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + + // Principal-Partner: high budget, not callable + const partnerConfig = config.agent?.["principal-partner"] + expect(partnerConfig?.options?.task_budget).toBe(20) + expect(partnerConfig?.options?.callable_by_subagents).toBe(false) + + // Verify permission rules + const partnerRuleset = PermissionNext.fromConfig(partnerConfig?.permission ?? {}) + expect(PermissionNext.evaluate("task", "assistant-sonnet", partnerRuleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "assistant-flash", partnerRuleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "principal-partner", partnerRuleset).action).toBe("deny") + + // Assistant-Sonnet: lower budget, callable + const sonnetConfig = config.agent?.["assistant-sonnet"] + expect(sonnetConfig?.options?.task_budget).toBe(3) + expect(sonnetConfig?.options?.callable_by_subagents).toBe(true) + + // Assistant-Flash: lowest budget, callable + const flashConfig = config.agent?.["assistant-flash"] + expect(flashConfig?.options?.task_budget).toBe(1) + expect(flashConfig?.options?.callable_by_subagents).toBe(true) + }, + }) + }) +}) + +describe("backwards compatibility", () => { + test("agent without delegation config has defaults (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "legacy-agent": { + description: "Agent without delegation config", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["legacy-agent"] + + // Both should be undefined/falsy = delegation disabled + const taskBudget = (agentConfig?.options?.task_budget as number) ?? 0 + const callable = (agentConfig?.options?.callable_by_subagents as boolean) ?? false + + expect(taskBudget).toBe(0) + expect(callable).toBe(false) + }, + }) + }) + + test("built-in agents should not have delegation config by default", async () => { + await using tmp = await tmpdir({ + git: true, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Get the built-in general agent + const generalAgent = await Agent.get("general") + + // Built-in agents should not have delegation configured + const taskBudget = (generalAgent?.options?.task_budget as number) ?? 0 + const callable = (generalAgent?.options?.callable_by_subagents as boolean) ?? false + + expect(taskBudget).toBe(0) + expect(callable).toBe(false) + }, + }) + }) +}) From 4aaae38347e1ab11363831a45f770bc5a5025edb Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Sat, 10 Jan 2026 17:12:01 -0600 Subject: [PATCH 02/10] fix(task): use strict equality for callable_by_subagents check Use === true instead of truthy coercion to prevent accidental enablement from misconfigured values like "yes" or 1. Co-Authored-By: Claude Opus 4.5 --- packages/opencode/src/tool/task.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 719de86aa9f..f91a9993f25 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -95,7 +95,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { // - task_budget on CALLER: how many calls the caller can make per request // - callable_by_subagents on TARGET: whether target can be called by subagents const callerTaskBudget = (callerAgentInfo?.options?.task_budget as number) ?? 0 - const targetCallable = (targetAgent.options?.callable_by_subagents as boolean) ?? false + const targetCallable = targetAgent.options?.callable_by_subagents === true // Get target's task_budget once (used for session permissions and tool availability) const targetTaskBudget = (targetAgent.options?.task_budget as number) ?? 0 From 08f2f4a1923ee0ae853c3e7ace07b4843d0263fc Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Sat, 10 Jan 2026 21:10:48 -0600 Subject: [PATCH 03/10] fix(task): change budget scope from per-message to per-session The task_budget was incorrectly keyed by (sessionID, messageID), causing the budget counter to reset every turn since each assistant response generates a new messageID. Changed to per-session tracking so all task calls within a delegated session count toward the same budget. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 113 +++++++++++++++++++++++++++++ packages/opencode/src/tool/task.ts | 30 +++----- 2 files changed, 125 insertions(+), 18 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..603941e75b6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OpenCode is an open-source AI-powered coding agent, similar to Claude Code but provider-agnostic. It supports multiple LLM providers (Anthropic, OpenAI, Google, Azure, local models) and features a TUI built with SolidJS, LSP support, and client/server architecture. + +## Development Commands + +```bash +# Install and run development server +bun install +bun dev # Run in packages/opencode directory +bun dev # Run against a specific directory +bun dev . # Run against repo root + +# Type checking +bun run typecheck # Single package +bun turbo typecheck # All packages + +# Testing (per-package, not from root) +cd packages/opencode && bun test + +# Build standalone executable +./packages/opencode/script/build.ts --single +# Output: ./packages/opencode/dist/opencode-/bin/opencode + +# Regenerate SDK after API changes +./script/generate.ts +# Or for JS SDK specifically: +./packages/sdk/js/script/build.ts + +# Web app development +bun run --cwd packages/app dev # http://localhost:5173 + +# Desktop app (requires Tauri/Rust) +bun run --cwd packages/desktop tauri dev # Native + web server +bun run --cwd packages/desktop dev # Web only (port 1420) +bun run --cwd packages/desktop tauri build # Production build +``` + +## Architecture + +**Monorepo Structure** (Bun workspaces + Turbo): + +| Package | Purpose | +|---------|---------| +| `packages/opencode` | Core CLI, server, business logic | +| `packages/app` | Shared web UI components (SolidJS + Vite) | +| `packages/desktop` | Native desktop app (Tauri wrapper) | +| `packages/ui` | Shared component library (Kobalte + Tailwind) | +| `packages/console/app` | Console dashboard (Solid Start) | +| `packages/console/core` | Backend services (Hono + DrizzleORM) | +| `packages/sdk/js` | JavaScript SDK | +| `packages/plugin` | Plugin system API | + +**Key Directories in `packages/opencode/src`**: +- `cli/cmd/tui/` - Terminal UI (SolidJS + opentui) +- `agent/` - Agent logic and state +- `provider/` - AI provider implementations +- `server/` - Server mode +- `mcp/` - Model Context Protocol integration +- `lsp/` - Language Server Protocol support + +**Default branch**: `dev` + +## Code Style + +- Keep logic in single functions unless reusable +- Avoid destructuring: use `obj.a` instead of `const { a } = obj` +- Avoid `try/catch` - prefer `.catch()` +- Avoid `else` statements +- Avoid `any` type +- Avoid `let` - use immutable patterns +- Prefer single-word variable names when descriptive +- Use Bun APIs (e.g., `Bun.file()`) when applicable + +## Built-in Agents + +- **build** - Default agent with full access for development +- **plan** - Read-only agent for analysis (denies edits, asks before bash) +- **general** - Subagent for complex tasks, invoked with `@general` + +Switch agents with `Tab` key in TUI. + +## Debugging + +```bash +# Debug with inspector +bun run --inspect=ws://localhost:6499/ dev + +# Debug server separately +bun run --inspect=ws://localhost:6499/ ./src/index.ts serve --port 4096 +opencode attach http://localhost:4096 + +# Debug TUI +bun run --inspect=ws://localhost:6499/ --conditions=browser ./src/index.ts + +# Use spawn for breakpoints in server code +bun dev spawn +``` + +Use `--inspect-wait` or `--inspect-brk` for different breakpoint behaviors. + +## PR Guidelines + +- All PRs must reference an existing issue (`Fixes #123`) +- UI/core feature changes require design review with core team +- PR titles follow conventional commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:` +- Optional scope: `feat(app):`, `fix(desktop):` +- Include screenshots/videos for UI changes +- Explain verification steps for logic changes diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index f91a9993f25..764b0345e33 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -13,26 +13,20 @@ import { Config } from "../config/config" import { PermissionNext } from "@/permission/next" import { Instance } from "../project/instance" -// Track task calls per request: Map> -// Budget is per-request (one "work assignment" within a session), resets on new messageID -// Note: State grows with sessions/messages but entries are small. Future optimization: +// Track task calls per session: Map +// Budget is per-session (all calls within the delegated work count toward the limit) +// Note: State grows with sessions but entries are small. Future optimization: // clean up completed sessions via Session lifecycle hooks if memory becomes a concern. -const taskCallState = Instance.state(() => new Map>()) +const taskCallState = Instance.state(() => new Map()) -function getCallCount(sessionID: string, messageID: string): number { - const sessionCounts = taskCallState().get(sessionID) - return sessionCounts?.get(messageID) ?? 0 +function getCallCount(sessionID: string): number { + return taskCallState().get(sessionID) ?? 0 } -function incrementCallCount(sessionID: string, messageID: string): number { +function incrementCallCount(sessionID: string): number { const state = taskCallState() - let sessionCounts = state.get(sessionID) - if (!sessionCounts) { - sessionCounts = new Map() - state.set(sessionID, sessionCounts) - } - const newCount = (sessionCounts.get(messageID) ?? 0) + 1 - sessionCounts.set(messageID, newCount) + const newCount = (state.get(sessionID) ?? 0) + 1 + state.set(sessionID, newCount) return newCount } @@ -130,8 +124,8 @@ export const TaskTool = Tool.define("task", async (ctx) => { ) } - // Check 3: Budget not exhausted for this request (messageID) - const currentCount = getCallCount(ctx.sessionID, ctx.messageID) + // Check 3: Budget not exhausted for this session + const currentCount = getCallCount(ctx.sessionID) if (currentCount >= callerTaskBudget) { throw new Error( `Task budget exhausted (${currentCount}/${callerTaskBudget} calls). ` + @@ -140,7 +134,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { } // Increment count after passing all checks (including ownership above) - incrementCallCount(ctx.sessionID, ctx.messageID) + incrementCallCount(ctx.sessionID) } const session = await iife(async () => { From 08416ce09b8eea3446639da74f140876de255c15 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Mon, 19 Jan 2026 19:31:10 -0600 Subject: [PATCH 04/10] refactor(task): promote task_budget and callable_by_subagents to first-class config fields - Add explicit Zod schema definitions for task_budget and callable_by_subagents - Add fields to Agent.Info runtime type with mapping from config - Update task.ts to read from top-level agent fields instead of options - Update tests to use neutral terminology and check top-level fields This enables JSON Schema validation and editor autocomplete for these config options. --- packages/opencode/src/agent/agent.ts | 74 ++-------- packages/opencode/src/config/config.ts | 130 ++++++++---------- packages/opencode/src/tool/task.ts | 17 +-- .../opencode/test/task-delegation.test.ts | 86 ++++++------ 4 files changed, 123 insertions(+), 184 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 2b44308f130..70c828abf1a 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,8 @@ 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(), + callable_by_subagents: z.boolean().optional(), }) .meta({ ref: "Agent", @@ -54,16 +52,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 +72,6 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", - plan_enter: "allow", }), user, ), @@ -91,14 +85,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 +126,6 @@ export namespace Agent { read: "allow", external_directory: { [Truncate.DIR]: "allow", - [Truncate.GLOB]: "allow", }, }), user, @@ -220,6 +208,8 @@ 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.callable_by_subagents = value.callable_by_subagents ?? item.callable_by_subagents item.options = mergeDeep(item.options, value.options ?? {}) item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) } @@ -227,16 +217,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 +245,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 +253,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 +282,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/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cba8..f6d3c0fb4f6 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 = {} @@ -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 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, @@ -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,20 @@ 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).", + ), + callable_by_subagents: z + .boolean() + .optional() + .describe("Whether this agent can be called by other subagents (default: false)"), + options: z.record(z.string(), z.any()).optional(), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format") @@ -599,6 +596,8 @@ export namespace Config { "top_p", "mode", "hidden", + "task_budget", + "callable_by_subagents", "color", "steps", "maxSteps", @@ -656,23 +655,13 @@ export namespace Config { session_list: z.string().optional().default("l").describe("List all sessions"), session_timeline: z.string().optional().default("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("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() @@ -960,7 +949,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() @@ -1135,7 +1124,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 +1193,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 764b0345e33..abe86ed3cff 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -75,9 +75,6 @@ export const TaskTool = Tool.define("task", async (ctx) => { 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`) - // Check if target agent has task permission configured - const hasTaskPermission = targetAgent.permission.some((rule) => rule.permission === "task") - // Get caller's session to check if this is a subagent calling const callerSession = await Session.get(ctx.sessionID) const isSubagent = callerSession.parentID !== undefined @@ -88,11 +85,11 @@ export const TaskTool = Tool.define("task", async (ctx) => { // Get config values: // - task_budget on CALLER: how many calls the caller can make per request // - callable_by_subagents on TARGET: whether target can be called by subagents - const callerTaskBudget = (callerAgentInfo?.options?.task_budget as number) ?? 0 - const targetCallable = targetAgent.options?.callable_by_subagents === true + const callerTaskBudget = callerAgentInfo?.task_budget ?? 0 + const targetCallable = targetAgent.callable_by_subagents === true // Get target's task_budget once (used for session permissions and tool availability) - const targetTaskBudget = (targetAgent.options?.task_budget as number) ?? 0 + 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 @@ -152,8 +149,8 @@ export const TaskTool = Tool.define("task", async (ctx) => { { permission: "todoread", pattern: "*", action: "deny" }, ] - // Deny task if: (1) target has no task_budget, OR (2) target has no task permission - if (targetTaskBudget <= 0 || !hasTaskPermission) { + // Only deny task if target agent has no task_budget (cannot delegate further) + if (targetTaskBudget <= 0) { sessionPermissions.push({ permission: "task", pattern: "*", action: "deny" }) } @@ -227,8 +224,8 @@ export const TaskTool = Tool.define("task", async (ctx) => { tools: { todowrite: false, todoread: false, - // Disable task if: (1) target has no task_budget, OR (2) target has no task permission - ...(targetTaskBudget <= 0 || !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 index e0ccc470d85..16c82bea007 100644 --- a/packages/opencode/test/task-delegation.test.ts +++ b/packages/opencode/test/task-delegation.test.ts @@ -6,13 +6,13 @@ import { PermissionNext } from "../src/permission/next" import { tmpdir } from "./fixture/fixture" describe("task_budget configuration (caller)", () => { - test("task_budget is preserved in agent.options from config", async () => { + test("task_budget is preserved from config", async () => { await using tmp = await tmpdir({ git: true, config: { agent: { - "principal-partner": { - description: "Orchestrator with high budget", + orchestrator: { + description: "Agent with high task budget", mode: "subagent", task_budget: 20, }, @@ -23,8 +23,8 @@ describe("task_budget configuration (caller)", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const agentConfig = config.agent?.["principal-partner"] - expect(agentConfig?.options?.task_budget).toBe(20) + const agentConfig = config.agent?.["orchestrator"] + expect(agentConfig?.task_budget).toBe(20) }, }) }) @@ -47,7 +47,7 @@ describe("task_budget configuration (caller)", () => { fn: async () => { const config = await Config.get() const agentConfig = config.agent?.["disabled-agent"] - expect(agentConfig?.options?.task_budget).toBe(0) + expect(agentConfig?.task_budget).toBe(0) }, }) }) @@ -69,7 +69,7 @@ describe("task_budget configuration (caller)", () => { fn: async () => { const config = await Config.get() const agentConfig = config.agent?.["default-agent"] - expect(agentConfig?.options?.task_budget).toBeUndefined() + expect(agentConfig?.task_budget).toBeUndefined() }, }) }) @@ -81,8 +81,8 @@ describe("callable_by_subagents configuration (target)", () => { git: true, config: { agent: { - "assistant-sonnet": { - description: "Callable assistant", + "callable-worker": { + description: "Worker that can be called by other subagents", mode: "subagent", callable_by_subagents: true, }, @@ -93,8 +93,8 @@ describe("callable_by_subagents configuration (target)", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const agentConfig = config.agent?.["assistant-sonnet"] - expect(agentConfig?.options?.callable_by_subagents).toBe(true) + const agentConfig = config.agent?.["callable-worker"] + expect(agentConfig?.callable_by_subagents).toBe(true) }, }) }) @@ -117,7 +117,7 @@ describe("callable_by_subagents configuration (target)", () => { fn: async () => { const config = await Config.get() const agentConfig = config.agent?.["private-agent"] - expect(agentConfig?.options?.callable_by_subagents).toBe(false) + expect(agentConfig?.callable_by_subagents).toBe(false) }, }) }) @@ -139,7 +139,7 @@ describe("callable_by_subagents configuration (target)", () => { fn: async () => { const config = await Config.get() const agentConfig = config.agent?.["default-agent"] - expect(agentConfig?.options?.callable_by_subagents).toBeUndefined() + expect(agentConfig?.callable_by_subagents).toBeUndefined() }, }) }) @@ -151,40 +151,40 @@ describe("two-dimensional delegation config", () => { git: true, config: { agent: { - "principal-partner": { - description: "Orchestrates complex workflows", + orchestrator: { + description: "Coordinates other subagents", mode: "subagent", task_budget: 20, callable_by_subagents: false, permission: { task: { "*": "deny", - "assistant-sonnet": "allow", - "assistant-flash": "allow", + "worker-a": "allow", + "worker-b": "allow", }, }, }, - "assistant-sonnet": { - description: "Thorough analysis", + "worker-a": { + description: "Worker with medium budget", mode: "subagent", task_budget: 3, callable_by_subagents: true, permission: { task: { "*": "deny", - "assistant-flash": "allow", + "worker-b": "allow", }, }, }, - "assistant-flash": { - description: "Fast analytical passes", + "worker-b": { + description: "Worker with minimal budget", mode: "subagent", task_budget: 1, callable_by_subagents: true, permission: { task: { "*": "deny", - "assistant-sonnet": "allow", + "worker-a": "allow", }, }, }, @@ -196,26 +196,26 @@ describe("two-dimensional delegation config", () => { fn: async () => { const config = await Config.get() - // Principal-Partner: high budget, not callable - const partnerConfig = config.agent?.["principal-partner"] - expect(partnerConfig?.options?.task_budget).toBe(20) - expect(partnerConfig?.options?.callable_by_subagents).toBe(false) + // Orchestrator: high budget, not callable by others + const orchestratorConfig = config.agent?.["orchestrator"] + expect(orchestratorConfig?.task_budget).toBe(20) + expect(orchestratorConfig?.callable_by_subagents).toBe(false) // Verify permission rules - const partnerRuleset = PermissionNext.fromConfig(partnerConfig?.permission ?? {}) - expect(PermissionNext.evaluate("task", "assistant-sonnet", partnerRuleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "assistant-flash", partnerRuleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "principal-partner", partnerRuleset).action).toBe("deny") + 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") - // Assistant-Sonnet: lower budget, callable - const sonnetConfig = config.agent?.["assistant-sonnet"] - expect(sonnetConfig?.options?.task_budget).toBe(3) - expect(sonnetConfig?.options?.callable_by_subagents).toBe(true) + // Worker-A: medium budget, callable by others + const workerAConfig = config.agent?.["worker-a"] + expect(workerAConfig?.task_budget).toBe(3) + expect(workerAConfig?.callable_by_subagents).toBe(true) - // Assistant-Flash: lowest budget, callable - const flashConfig = config.agent?.["assistant-flash"] - expect(flashConfig?.options?.task_budget).toBe(1) - expect(flashConfig?.options?.callable_by_subagents).toBe(true) + // Worker-B: minimal budget, callable by others + const workerBConfig = config.agent?.["worker-b"] + expect(workerBConfig?.task_budget).toBe(1) + expect(workerBConfig?.callable_by_subagents).toBe(true) }, }) }) @@ -241,8 +241,8 @@ describe("backwards compatibility", () => { const agentConfig = config.agent?.["legacy-agent"] // Both should be undefined/falsy = delegation disabled - const taskBudget = (agentConfig?.options?.task_budget as number) ?? 0 - const callable = (agentConfig?.options?.callable_by_subagents as boolean) ?? false + const taskBudget = (agentConfig?.task_budget as number) ?? 0 + const callable = (agentConfig?.callable_by_subagents as boolean) ?? false expect(taskBudget).toBe(0) expect(callable).toBe(false) @@ -261,8 +261,8 @@ describe("backwards compatibility", () => { const generalAgent = await Agent.get("general") // Built-in agents should not have delegation configured - const taskBudget = (generalAgent?.options?.task_budget as number) ?? 0 - const callable = (generalAgent?.options?.callable_by_subagents as boolean) ?? false + const taskBudget = generalAgent?.task_budget ?? 0 + const callable = generalAgent?.callable_by_subagents ?? false expect(taskBudget).toBe(0) expect(callable).toBe(false) From 428cf642e8f49c707101b147d7264b94e49f67bf Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Tue, 20 Jan 2026 16:49:09 -0600 Subject: [PATCH 05/10] refactor(task): remove callable_by_subagents flag, use permissions-only approach Removes the callable_by_subagents flag in favor of using the existing permission system for all delegation control. This change improves workflow flexibility by enabling additive (grant-based) configuration instead of subtractive (flag + denies) configuration. Rationale: - Existing permission system already provides per-caller granularity - Additive permissions support iterative workflow development better - Simpler for selective delegation (add one allow vs open flag + block others) - Less configuration complexity for experimental/evolving agentic workflows Breaking change: Users previously setting callable_by_subagents must now use permission rules to control which agents can be tasked by subagents. Changes: - config.ts: Remove callable_by_subagents from Agent Zod schema and knownKeys - agent.ts: Remove callable_by_subagents from Agent.Info type and mapping - task.ts: Remove callable_by_subagents check (Check 2) - Tests: Remove 3 tests for callable_by_subagents, update remaining tests Tests: 6/6 passing Typecheck: Clean Co-Authored-By: Claude Sonnet 4.5 --- packages/opencode/src/agent/agent.ts | 2 - packages/opencode/src/config/config.ts | 5 - packages/opencode/src/tool/task.ts | 14 +-- .../opencode/test/task-delegation.test.ts | 92 ++----------------- 4 files changed, 8 insertions(+), 105 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 70c828abf1a..105894c0883 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -36,7 +36,6 @@ export namespace Agent { options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), task_budget: z.number().int().nonnegative().optional(), - callable_by_subagents: z.boolean().optional(), }) .meta({ ref: "Agent", @@ -209,7 +208,6 @@ export namespace Agent { item.name = value.name ?? item.name item.steps = value.steps ?? item.steps item.task_budget = value.task_budget ?? item.task_budget - item.callable_by_subagents = value.callable_by_subagents ?? item.callable_by_subagents item.options = mergeDeep(item.options, value.options ?? {}) item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f6d3c0fb4f6..fc6f7bb0298 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -566,10 +566,6 @@ export namespace Config { .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).", ), - callable_by_subagents: z - .boolean() - .optional() - .describe("Whether this agent can be called by other subagents (default: false)"), options: z.record(z.string(), z.any()).optional(), color: z .string() @@ -597,7 +593,6 @@ export namespace Config { "mode", "hidden", "task_budget", - "callable_by_subagents", "color", "steps", "maxSteps", diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index abe86ed3cff..287a922e539 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -83,10 +83,8 @@ export const TaskTool = Tool.define("task", async (ctx) => { const callerAgentInfo = ctx.agent ? await Agent.get(ctx.agent) : undefined // Get config values: - // - task_budget on CALLER: how many calls the caller can make per request - // - callable_by_subagents on TARGET: whether target can be called by subagents + // - task_budget on CALLER: how many calls the caller can make per session const callerTaskBudget = callerAgentInfo?.task_budget ?? 0 - const targetCallable = targetAgent.callable_by_subagents === true // Get target's task_budget once (used for session permissions and tool availability) const targetTaskBudget = targetAgent.task_budget ?? 0 @@ -113,15 +111,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { ) } - // Check 2: Target must be callable by subagents - if (!targetCallable) { - throw new Error( - `Target "${params.subagent_type}" is not callable by subagents. ` + - `Set callable_by_subagents: true on the target agent to enable.`, - ) - } - - // Check 3: Budget not exhausted for this session + // Check 2: Budget not exhausted for this session const currentCount = getCallCount(ctx.sessionID) if (currentCount >= callerTaskBudget) { throw new Error( diff --git a/packages/opencode/test/task-delegation.test.ts b/packages/opencode/test/task-delegation.test.ts index 16c82bea007..b562f0fee06 100644 --- a/packages/opencode/test/task-delegation.test.ts +++ b/packages/opencode/test/task-delegation.test.ts @@ -75,78 +75,8 @@ describe("task_budget configuration (caller)", () => { }) }) -describe("callable_by_subagents configuration (target)", () => { - test("callable_by_subagents true is preserved", async () => { - await using tmp = await tmpdir({ - git: true, - config: { - agent: { - "callable-worker": { - description: "Worker that can be called by other subagents", - mode: "subagent", - callable_by_subagents: true, - }, - }, - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - const agentConfig = config.agent?.["callable-worker"] - expect(agentConfig?.callable_by_subagents).toBe(true) - }, - }) - }) - - test("callable_by_subagents false is preserved (default)", async () => { - await using tmp = await tmpdir({ - git: true, - config: { - agent: { - "private-agent": { - description: "Not callable by subagents", - mode: "subagent", - callable_by_subagents: false, - }, - }, - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - const agentConfig = config.agent?.["private-agent"] - expect(agentConfig?.callable_by_subagents).toBe(false) - }, - }) - }) - - test("missing callable_by_subagents defaults to undefined (not callable)", async () => { - await using tmp = await tmpdir({ - git: true, - config: { - agent: { - "default-agent": { - description: "Agent without callable_by_subagents", - mode: "subagent", - }, - }, - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - const agentConfig = config.agent?.["default-agent"] - expect(agentConfig?.callable_by_subagents).toBeUndefined() - }, - }) - }) -}) - -describe("two-dimensional delegation config", () => { - test("full delegation config with both dimensions", async () => { +describe("task_budget with permissions config", () => { + test("task_budget with permission rules for selective delegation", async () => { await using tmp = await tmpdir({ git: true, config: { @@ -155,7 +85,6 @@ describe("two-dimensional delegation config", () => { description: "Coordinates other subagents", mode: "subagent", task_budget: 20, - callable_by_subagents: false, permission: { task: { "*": "deny", @@ -168,7 +97,6 @@ describe("two-dimensional delegation config", () => { description: "Worker with medium budget", mode: "subagent", task_budget: 3, - callable_by_subagents: true, permission: { task: { "*": "deny", @@ -180,7 +108,6 @@ describe("two-dimensional delegation config", () => { description: "Worker with minimal budget", mode: "subagent", task_budget: 1, - callable_by_subagents: true, permission: { task: { "*": "deny", @@ -196,10 +123,9 @@ describe("two-dimensional delegation config", () => { fn: async () => { const config = await Config.get() - // Orchestrator: high budget, not callable by others + // Orchestrator: high budget const orchestratorConfig = config.agent?.["orchestrator"] expect(orchestratorConfig?.task_budget).toBe(20) - expect(orchestratorConfig?.callable_by_subagents).toBe(false) // Verify permission rules const orchestratorRuleset = PermissionNext.fromConfig(orchestratorConfig?.permission ?? {}) @@ -207,15 +133,13 @@ describe("two-dimensional delegation config", () => { expect(PermissionNext.evaluate("task", "worker-b", orchestratorRuleset).action).toBe("allow") expect(PermissionNext.evaluate("task", "orchestrator", orchestratorRuleset).action).toBe("deny") - // Worker-A: medium budget, callable by others + // Worker-A: medium budget const workerAConfig = config.agent?.["worker-a"] expect(workerAConfig?.task_budget).toBe(3) - expect(workerAConfig?.callable_by_subagents).toBe(true) - // Worker-B: minimal budget, callable by others + // Worker-B: minimal budget const workerBConfig = config.agent?.["worker-b"] expect(workerBConfig?.task_budget).toBe(1) - expect(workerBConfig?.callable_by_subagents).toBe(true) }, }) }) @@ -240,12 +164,10 @@ describe("backwards compatibility", () => { const config = await Config.get() const agentConfig = config.agent?.["legacy-agent"] - // Both should be undefined/falsy = delegation disabled + // Should be undefined/falsy = delegation disabled const taskBudget = (agentConfig?.task_budget as number) ?? 0 - const callable = (agentConfig?.callable_by_subagents as boolean) ?? false expect(taskBudget).toBe(0) - expect(callable).toBe(false) }, }) }) @@ -262,10 +184,8 @@ describe("backwards compatibility", () => { // Built-in agents should not have delegation configured const taskBudget = generalAgent?.task_budget ?? 0 - const callable = generalAgent?.callable_by_subagents ?? false expect(taskBudget).toBe(0) - expect(callable).toBe(false) }, }) }) From 892d4642d46ce1785e4cc653fb395c700bbfb26e Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Tue, 20 Jan 2026 18:32:44 -0600 Subject: [PATCH 06/10] fix(task): enforce permission checks for subagent-to-subagent delegation Fixes a critical bug where bypassAgentCheck flag (set when user invokes agents with @ or when prompt resolution creates agent parts) was propagating to Task tool calls made BY subagents, causing permission rules to be ignored. Root cause: - When Task tool creates a subagent session, resolvePromptParts() may create "agent" type parts if prompt contains unresolved {file:...} references - This triggers bypassAgentCheck=true for the entire subagent session - All subsequent Task calls by that subagent bypass permission checks Fix: - Move isSubagent check before permission check - Always enforce permissions when caller is a subagent, even if bypassAgentCheck is set - Preserves OpenCode's intended behavior: user @ invocation can bypass, but subagent-to-subagent delegation always checks permissions Impact: - Subagent permission.task rules now work correctly - User @ invocation bypass still works (OpenCode behavior preserved) - Fixes reported issue: assistant-sonnet could task any agent despite permission rules denying it Tests: 6/6 passing Typecheck: Clean Co-Authored-By: Claude Sonnet 4.5 --- packages/opencode/src/tool/task.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 287a922e539..9ff1f4fd71e 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -59,8 +59,13 @@ export const TaskTool = Tool.define("task", async (ctx) => { async execute(params: z.infer, 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], @@ -75,10 +80,6 @@ export const TaskTool = Tool.define("task", async (ctx) => { const targetAgent = await Agent.get(params.subagent_type) if (!targetAgent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) - // Get caller's session to check if this is a subagent calling - const callerSession = await Session.get(ctx.sessionID) - const isSubagent = callerSession.parentID !== undefined - // Get caller agent info for budget check (ctx.agent is just the name) const callerAgentInfo = ctx.agent ? await Agent.get(ctx.agent) : undefined From 9961885716f41bf73bea51afc0b3116c9c51b706 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Thu, 22 Jan 2026 02:14:25 -0600 Subject: [PATCH 07/10] feat(tui): add hierarchical session navigation for subagent sessions - Add session_child_down keybind (down) to navigate into child sessions - Add session_root keybind (escape) to jump directly to root session - Fix sibling cycling to only cycle sessions at same hierarchy level - Fix Task component clickability for text-only subagent returns (use sessionId) - Rewrite Header component for subagent sessions with 3-row layout: - Row 1: Clickable breadcrumb trail with dynamic width-based truncation - Row 2: Divider + token/cost/version stats - Row 3: Adaptive navigation hints (Parent/Child(Ln) notation) - Breadcrumb shows agent names for child sessions, full title for root - Root session header unchanged for familiar UX --- .../src/cli/cmd/tui/routes/session/header.tsx | 219 +++++++++++++++++- .../src/cli/cmd/tui/routes/session/index.tsx | 80 ++++++- packages/opencode/src/config/config.ts | 6 +- packages/sdk/js/src/v2/gen/types.gen.ts | 8 + 4 files changed, 302 insertions(+), 11 deletions(-) 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..b9e8e0c2531 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,118 @@ 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() +<<<<<<< HEAD const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) +======= + const dimensions = useTerminalDimensions() + const [hover, setHover] = createSignal<"parent" | "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]] } + }) +>>>>>>> d727ffefb (feat(tui): add hierarchical session navigation for subagent sessions) return ( @@ -82,6 +190,7 @@ export function Header() { > +<<<<<<< HEAD @@ -123,11 +232,119 @@ export function Header() { Next {keybind.print("session_child_cycle")} +======= + {/* 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} + > + + {upLabel()} {keybind.print("session_parent")} + + + + + 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")} + + + +>>>>>>> d727ffefb (feat(tui): add hierarchical session navigation for subagent sessions) +<<<<<<< HEAD +======= + {/* Root session: unchanged */} + +>>>>>>> d727ffefb (feat(tui): add hierarchical session navigation for subagent sessions) <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..3c69e942583 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -118,6 +118,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 +300,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 +912,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 +940,17 @@ 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() + }, + }, ]) const revertInfo = createMemo(() => session()?.revert) @@ -1783,7 +1847,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 +1869,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 fc6f7bb0298..6e89b9da5cb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -770,9 +770,11 @@ 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"), 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"), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 38a52b325ad..6532624589a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1309,6 +1309,14 @@ export type KeybindsConfig = { * Go to parent session */ session_parent?: string + /** + * Go to first child session + */ + session_child_down?: string + /** + * Go to root session + */ + session_root?: string /** * Suspend terminal */ From 9f805e3efa9e2b437c26134153525d2c5b7b5e97 Mon Sep 17 00:00:00 2001 From: Sean Smith <TheLastQuery@gmail.com> Date: Thu, 22 Jan 2026 02:22:03 -0600 Subject: [PATCH 08/10] feat(tui): add Root navigation hint for deep subagent sessions Shows 'Root ctrl+x esc' hint when at depth 2+ (L2 or below), allowing users to jump directly to root session. --- .../src/cli/cmd/tui/routes/session/header.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 b9e8e0c2531..9dfcfd0f321 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -134,7 +134,7 @@ export function Header() { const narrow = createMemo(() => dimensions().width < 80) ======= const dimensions = useTerminalDimensions() - const [hover, setHover] = createSignal<"parent" | "prev" | "next" | "down" | "breadcrumb" | null>(null) + const [hover, setHover] = createSignal<"parent" | "root" | "prev" | "next" | "down" | "breadcrumb" | null>(null) const [hoverBreadcrumbIdx, setHoverBreadcrumbIdx] = createSignal<number | null>(null) // Calculate breadcrumb text for a set of segments @@ -300,6 +300,18 @@ export function Header() { </text> </box> </Show> + <Show when={depth() >= 2}> + <box + onMouseOver={() => setHover("root")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.root")} + backgroundColor={hover() === "root" ? theme.backgroundElement : theme.backgroundPanel} + > + <text fg={theme.text}> + Root <span style={{ fg: theme.textMuted }}>{keybind.print("session_root")}</span> + </text> + </box> + </Show> <Show when={canCycleSiblings()}> <box onMouseOver={() => setHover("next")} From 78e365c6716420cb686bcc55b83574f159c47716 Mon Sep 17 00:00:00 2001 From: Sean Smith <TheLastQuery@gmail.com> Date: Fri, 23 Jan 2026 21:00:20 -0600 Subject: [PATCH 09/10] feat(tui): add session tree dialog with visual hierarchy and status indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DialogSessionTree component with tree visualization (box-drawing chars) - Add session_child_list keybind (<leader>s) to open session tree dialog - Move status_view keybind from <leader>s to <leader>i (collision resolution) - Show status indicators: current (●), awaiting permission (◉), busy (spinner) - Extract agent names from session titles (@agent pattern or first word) - Use Locale.time() for timestamps (respects user OS locale) Related: #6183 --- .../routes/session/dialog-session-tree.tsx | 185 ++++++++++++++++++ .../src/cli/cmd/tui/routes/session/header.tsx | 57 +----- .../src/cli/cmd/tui/routes/session/index.tsx | 10 + packages/opencode/src/config/config.ts | 3 +- 4 files changed, 199 insertions(+), 56 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/dialog-session-tree.tsx 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<typeof useSync>, + 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 <text fg={theme.primary}>●</text> + } + + // Permission awaiting indicator + const permission = sync.data.permission[session.id] + if (permission?.length) { + return <text fg={theme.warning}>◉</text> + } + + // Busy session indicator (spinner) + const status = sync.data.session_status?.[session.id] + if (status?.type === "busy") { + if (animationsEnabled) { + return <spinner frames={spinnerFrames} interval={80} color={theme.primary} /> + } + return <text fg={theme.textMuted}>[⋯]</text> + } + + 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 ( + <DialogSelect + title="Session Tree" + options={options()} + current={currentSessionID()} + onSelect={(option) => { + 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 9dfcfd0f321..0345a58b306 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -128,12 +128,8 @@ export function Header() { const { theme } = useTheme() const keybind = useKeybind() const command = useCommandDialog() -<<<<<<< HEAD - const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) -======= - const dimensions = useTerminalDimensions() const [hover, setHover] = createSignal<"parent" | "root" | "prev" | "next" | "down" | "breadcrumb" | null>(null) const [hoverBreadcrumbIdx, setHoverBreadcrumbIdx] = createSignal<number | null>(null) @@ -173,7 +169,6 @@ export function Header() { // Fallback: root + last segment return { truncated: true, segments: [path[0], path[path.length - 1]] } }) ->>>>>>> d727ffefb (feat(tui): add hierarchical session navigation for subagent sessions) return ( <box flexShrink={0}> @@ -190,49 +185,6 @@ export function Header() { > <Switch> <Match when={session()?.parentID}> -<<<<<<< HEAD - <box flexDirection="column" gap={1}> - <box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}> - <text fg={theme.text}> - <b>Subagent session</b> - </text> - <box flexDirection="row" gap={1} flexShrink={0}> - <ContextInfo context={context} cost={cost} /> - <text fg={theme.textMuted}>v{Installation.VERSION}</text> - </box> - </box> - <box flexDirection="row" gap={2}> - <box - onMouseOver={() => setHover("parent")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.parent")} - backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} - > - <text fg={theme.text}> - Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span> - </text> - </box> - <box - onMouseOver={() => setHover("prev")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.previous")} - backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} - > - <text fg={theme.text}> - Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span> - </text> - </box> - <box - onMouseOver={() => setHover("next")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.next")} - backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} - > - <text fg={theme.text}> - Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span> - </text> - </box> -======= {/* Subagent session: 3-row layout */} <box flexDirection="column" gap={0}> {/* Row 1: Breadcrumb trail */} @@ -346,17 +298,12 @@ export function Header() { </text> </box> </Show> ->>>>>>> d727ffefb (feat(tui): add hierarchical session navigation for subagent sessions) </box> </box> </Match> <Match when={true}> -<<<<<<< HEAD - <box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}> -======= - {/* Root session: unchanged */} - <box flexDirection="row" justifyContent="space-between" gap={1}> ->>>>>>> d727ffefb (feat(tui): add hierarchical session navigation for subagent sessions) + {/* Root session: responsive layout */} + <box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}> <Title session={session} /> <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 3c69e942583..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) @@ -951,6 +952,15 @@ export function Session() { dialog.clear() }, }, + { + title: "Session tree", + value: "session.tree", + keybind: "session_child_list", + category: "Session", + onSelect: (dialog) => { + dialog.replace(() => <DialogSessionTree />) + }, + }, ]) const revertInfo = createMemo(() => session()?.revert) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6e89b9da5cb..7c4a5d9d5da 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -644,7 +644,7 @@ 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"), @@ -775,6 +775,7 @@ export namespace Config { 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"), From 9043f9707ee07fc5ad8a5204c2a7435905c7a97f Mon Sep 17 00:00:00 2001 From: Sean Smith <TheLastQuery@gmail.com> Date: Sun, 25 Jan 2026 02:28:22 -0600 Subject: [PATCH 10/10] feat(task): add level_limit to prevent infinite delegation depth Adds global level_limit configuration to cap subagent session tree depth. Complements existing task_budget (horizontal limit) with vertical depth limit for complete loop prevention. - Add level_limit to experimental config schema (default: 5) - Add getSessionDepth() helper to calculate session tree depth - Add depth check before task delegation (Check 3) - Add 3 unit tests for level_limit configuration - Regenerate SDK types with level_limit field Related to PR #7756 (subagent delegation) --- packages/opencode/src/config/config.ts | 9 +++ packages/opencode/src/tool/task.ts | 29 +++++++++ .../opencode/test/task-delegation.test.ts | 52 +++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 64 ++++++++----------- 4 files changed, 117 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7c4a5d9d5da..5ef9b69cbbb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1073,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(), }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 9ff1f4fd71e..50618034628 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -30,6 +30,23 @@ function incrementCallCount(sessionID: string): number { 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"), prompt: z.string().describe("The task for the agent to perform"), @@ -121,6 +138,18 @@ export const TaskTool = Tool.define("task", async (ctx) => { ) } + // 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.` + ) + } + } + // Increment count after passing all checks (including ownership above) incrementCallCount(ctx.sessionID) } diff --git a/packages/opencode/test/task-delegation.test.ts b/packages/opencode/test/task-delegation.test.ts index b562f0fee06..35084a9810f 100644 --- a/packages/opencode/test/task-delegation.test.ts +++ b/packages/opencode/test/task-delegation.test.ts @@ -190,3 +190,55 @@ describe("backwards compatibility", () => { }) }) }) + +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 6532624589a..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,11 +1274,11 @@ 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 /** @@ -1317,6 +1293,10 @@ export type KeybindsConfig = { * Go to root session */ session_root?: string + /** + * Open session tree dialog + */ + session_child_list?: string /** * Suspend terminal */ @@ -1410,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 } @@ -1437,6 +1421,7 @@ export type AgentConfig = { | "subagent" | "primary" | "all" + | number | { [key: string]: unknown } @@ -1554,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 } @@ -1598,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 } @@ -1704,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 @@ -1831,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 } } @@ -2135,6 +2124,7 @@ export type Agent = { [key: string]: unknown } steps?: number + task_budget?: number } export type LspStatus = {