From 5bc8848686d14f02b90e385801d3e217f4027920 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Sat, 10 Jan 2026 17:09:43 -0600 Subject: [PATCH 01/12] 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 8c8cf827aba..1c0083df3c5 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,6 +10,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"), @@ -58,40 +82,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 task_id provided) + // This prevents "wasting" budget on invalid session resume attempts + if (isSubagent && params.task_id) { + const existingSession = await Session.get(params.task_id).catch(() => undefined) + if (existingSession && existingSession.parentID !== ctx.sessionID) { + throw new Error( + `Cannot resume session: not a child of caller session. ` + + `Session "${params.task_id}" is not owned by this caller.`, + ) + } + } + + // Enforce nested delegation controls only for subagent-to-subagent calls + if (isSubagent) { + // Check 1: Caller must have task_budget configured + if (callerTaskBudget <= 0) { + throw new Error( + `Caller has no task budget configured. ` + + `Set task_budget > 0 on the calling agent to enable nested delegation.`, + ) + } + + // Check 2: 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.`, + ) + } - const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") + // 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.`, + ) + } + + // Increment count after passing all checks (including ownership above) + incrementCallCount(ctx.sessionID, ctx.messageID) + } const session = await iife(async () => { if (params.task_id) { const found = await Session.get(params.task_id).catch(() => {}) - if (found) return found + if (found) { + // Ownership already verified above for subagents + return found + } + } + + // Build session permissions + const sessionPermissions: PermissionNext.Rule[] = [ + { permission: "todowrite", pattern: "*", action: "deny" }, + { permission: "todoread", pattern: "*", action: "deny" }, + ] + + // 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, @@ -103,7 +183,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") - const model = agent.model ?? { + const model = targetAgent.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } @@ -132,11 +212,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 cfa91cba56e9035d2a10fe55e24b5cb7a8c74fe5 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Sat, 10 Jan 2026 17:12:01 -0600 Subject: [PATCH 02/12] 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 1c0083df3c5..f399a8921e1 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -99,7 +99,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 6f0ba20d2787a430b3b66dd80960509af1143e93 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Sat, 10 Jan 2026 21:10:48 -0600 Subject: [PATCH 03/12] 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 f399a8921e1..931a9be59b1 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -12,26 +12,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 } @@ -134,8 +128,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). ` + @@ -144,7 +138,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 3b9fe948e6026bd9187f352839475bbfe9323a35 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Mon, 19 Jan 2026 19:31:10 -0600 Subject: [PATCH 04/12] 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 | 59 ++------- packages/opencode/src/config/config.ts | 112 +++++++++--------- packages/opencode/src/tool/task.ts | 17 ++- .../opencode/test/task-delegation.test.ts | 86 +++++++------- 4 files changed, 118 insertions(+), 156 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index c53ca04e238..6bd9e04b8ca 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,12 +1,10 @@ import { Config } from "../config/config" import z from "zod" import { Provider } from "../provider/provider" -import { generateObject, streamObject, type ModelMessage } from "ai" +import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { Truncate } from "../tool/truncation" -import { Auth } from "../auth" -import { ProviderTransform } from "../provider/transform" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -42,6 +40,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", @@ -61,13 +61,11 @@ export namespace Agent { ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), }, question: "deny", - plan_enter: "deny", - plan_exit: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", - "*.env": "ask", - "*.env.*": "ask", + "*.env": "deny", + "*.env.*": "deny", "*.env.example": "allow", }, }) @@ -82,7 +80,6 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", - plan_enter: "allow", }), user, ), @@ -97,14 +94,9 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", - plan_exit: "allow", - external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", - }, edit: { "*": "deny", - [path.join(".opencode", "plans", "*.md")]: "allow", - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", + ".opencode/plan/*.md": "allow", }, }), user, @@ -227,6 +219,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 ?? {})) } @@ -264,20 +258,7 @@ export namespace Agent { } export async function defaultAgent() { - const cfg = await Config.get() - const agents = await state() - - if (cfg.default_agent) { - const agent = agents[cfg.default_agent] - if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`) - if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`) - if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`) - return agent.name - } - - const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) - if (!primaryVisible) throw new Error("no primary visible agent found") - return primaryVisible.name + return state().then((x) => Object.keys(x)[0]) } export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { @@ -289,8 +270,7 @@ export namespace Agent { const system = [PROMPT_GENERATE] await Plugin.trigger("experimental.chat.system.transform", { model }, { system }) const existing = await list() - - const params = { + const result = await generateObject({ experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, metadata: { @@ -316,24 +296,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 141f6156985..48b432f46a7 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -342,20 +342,19 @@ export namespace Config { dot: true, symlink: true, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse command ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load command", { command: item, err }) - return undefined - }) - if (!md) continue + const md = await ConfigMarkdown.parse(item) + if (!md.data) continue + + const name = (() => { + const patterns = ["/.opencode/command/", "/command/"] + const pattern = patterns.find((p) => item.includes(p)) - const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] - const file = rel(item, patterns) ?? path.basename(item) - const name = trim(file) + if (pattern) { + const index = item.indexOf(pattern) + return item.slice(index + pattern.length, -3) + } + return path.basename(item, ".md") + })() const config = { name, @@ -381,20 +380,23 @@ export namespace Config { dot: true, symlink: true, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse agent ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load agent", { agent: item, err }) - return undefined - }) - if (!md) continue - - const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] - const file = rel(item, patterns) ?? path.basename(item) - const agentName = trim(file) + const md = await ConfigMarkdown.parse(item) + if (!md.data) continue + + // Extract relative path from agent folder for nested agents + let agentName = path.basename(item, ".md") + const agentFolderPath = item.includes("/.opencode/agent/") + ? item.split("/.opencode/agent/")[1] + : item.includes("/agent/") + ? item.split("/agent/")[1] + : agentName + ".md" + + // If agent is in a subfolder, include folder path in name + if (agentFolderPath.includes("/")) { + const relativePath = agentFolderPath.replace(".md", "") + const pathParts = relativePath.split("/") + agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1] + } const config = { name: agentName, @@ -419,16 +421,8 @@ export namespace Config { dot: true, symlink: true, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse mode ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load mode", { mode: item, err }) - return undefined - }) - if (!md) continue + const md = await ConfigMarkdown.parse(item) + if (!md.data) continue const config = { name: path.basename(item, ".md"), @@ -527,7 +521,9 @@ export namespace Config { .int() .positive() .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + .describe( + "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", + ), }) .strict() .meta({ @@ -566,7 +562,9 @@ export namespace Config { .int() .positive() .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + .describe( + "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", + ), }) .strict() .meta({ @@ -679,8 +677,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 .union([ z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"), @@ -709,6 +719,8 @@ export namespace Config { "top_p", "mode", "hidden", + "task_budget", + "callable_by_subagents", "color", "steps", "maxSteps", @@ -766,23 +778,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() @@ -1056,7 +1058,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() diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 931a9be59b1..53fe3ed4cf7 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -79,9 +79,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 @@ -92,11 +89,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 task_id provided) // This prevents "wasting" budget on invalid session resume attempts @@ -156,8 +153,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" }) } @@ -210,8 +207,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 0da4325ff08987ce953c36d56cd5cac691cb6628 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Tue, 20 Jan 2026 16:49:09 -0600 Subject: [PATCH 05/12] 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 6bd9e04b8ca..ea75795ad55 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -41,7 +41,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", @@ -220,7 +219,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 48b432f46a7..368c3480a16 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -686,10 +686,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 .union([ @@ -720,7 +716,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 53fe3ed4cf7..206d1d0543a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -87,10 +87,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 @@ -117,15 +115,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 0be3e4d37007cc9af53169e1c5869e6696a4b6ce Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Tue, 20 Jan 2026 18:32:44 -0600 Subject: [PATCH 06/12] 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 206d1d0543a..00d4b772142 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -63,8 +63,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], @@ -79,10 +84,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 8035e9ab783e97ef248c04ad09de82d7c371a066 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Thu, 22 Jan 2026 02:14:25 -0600 Subject: [PATCH 07/12] 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 | 257 +++++++++++++++--- .../src/cli/cmd/tui/routes/session/index.tsx | 84 ++++-- packages/opencode/src/config/config.ts | 9 +- 3 files changed, 291 insertions(+), 59 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 0c5ea9a8572..218018e9a91 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -1,5 +1,5 @@ -import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js" -import { useRouteData } from "@tui/context/route" +import { type Accessor, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { pipe, sumBy } from "remeda" import { useTheme } from "@tui/context/theme" @@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border" import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" +import { Installation } from "@/installation" import { useTerminalDimensions } from "@opentui/solid" const Title = (props: { session: Accessor }) => { @@ -31,6 +32,7 @@ const ContextInfo = (props: { context: Accessor; cost: Acces export function Header() { const route = useRouteData("session") + const { navigate } = useRoute() const sync = useSync() const session = createMemo(() => sync.session.get(route.sessionID)!) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) @@ -59,12 +61,113 @@ export function Header() { return result }) + // Build session path from root to current session + const sessionPath = createMemo(() => { + const path: Session[] = [] + let current: Session | undefined = session() + while (current) { + path.unshift(current) + current = current.parentID ? sync.session.get(current.parentID) : undefined + } + return path + }) + + // Current depth (0 = root, 1 = first child, etc.) + const depth = createMemo(() => sessionPath().length - 1) + + // Direct children of current session (for down navigation availability) + const directChildren = createMemo(() => { + const currentID = session()?.id + if (!currentID) return [] + return sync.data.session.filter((x) => x.parentID === currentID) + }) + + // Siblings at current level (for left/right navigation availability) + const siblings = createMemo(() => { + const currentParentID = session()?.parentID + if (!currentParentID) return [] + return sync.data.session.filter((x) => x.parentID === currentParentID) + }) + + // Navigation availability + const canGoUp = createMemo(() => !!session()?.parentID) + const canGoDown = createMemo(() => directChildren().length > 0) + const canCycleSiblings = createMemo(() => siblings().length > 1) + + // Get display name for a session + const getSessionDisplayName = (s: Session, isRoot: boolean) => { + if (isRoot) { + // Root session: show the title + return s.title || s.id.slice(0, 8) + } + // Child session: extract agent name from title like "Description (@agent-name subagent)" + const match = s.title?.match(/\(@([^)]+?)(?:\s+subagent)?\)/) + if (match) { + // Return just the agent name without @ and "subagent" + return match[1] + } + // Fallback to title or shortened ID + return s.title || s.id.slice(0, 8) + } + + // Get UP navigation label based on depth + const upLabel = createMemo(() => { + const d = depth() + if (d <= 0) return "" // Root has no parent + if (d === 1) return "Parent" // Depth 1 → Root + return `Child(L${d - 1})` // Depth N → Child(L{N-1}) + }) + + // Get DOWN navigation label based on depth + const downLabel = createMemo(() => { + const d = depth() + return `Child(L${d + 1})` // Depth N → Child(L{N+1}) + }) + const { theme } = useTheme() const keybind = useKeybind() const command = useCommandDialog() - const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) + const [hover, setHover] = createSignal<"parent" | "root" | "prev" | "next" | "down" | "breadcrumb" | null>(null) + const [hoverBreadcrumbIdx, setHoverBreadcrumbIdx] = createSignal(null) + + // Calculate breadcrumb text for a set of segments + const calcBreadcrumbLength = (segments: Session[], truncated: boolean) => { + let len = 0 + segments.forEach((s, i) => { + len += getSessionDisplayName(s, !s.parentID).length + if (i < segments.length - 1) { + len += truncated && i === 0 ? 9 : 3 // " > ... > " or " > " + } + }) + return len + } + + // Dynamic breadcrumb truncation based on available width + const breadcrumbSegments = createMemo(() => { + const path = sessionPath() + const availableWidth = dimensions().width - 40 // Reserve ~40 chars for right-side stats + + // Try full path first + const fullLength = calcBreadcrumbLength(path, false) + if (fullLength <= availableWidth || path.length <= 2) { + return { truncated: false, segments: path } + } + + // Truncate: show root + ... + last N segments that fit + // Start with root + last segment, add more if space allows + for (let keepLast = path.length - 1; keepLast >= 1; keepLast--) { + const segments = [path[0], ...path.slice(-keepLast)] + const len = calcBreadcrumbLength(segments, true) + if (len <= availableWidth || keepLast === 1) { + return { truncated: true, segments } + } + } + + // Fallback: root + last segment + return { truncated: true, segments: [path[0], path[path.length - 1]] } + }) return ( @@ -81,49 +184,125 @@ export function Header() { > - - - - Subagent session - - + {/* Subagent session: 3-row layout */} + + {/* Row 1: Breadcrumb trail */} + + + {(segment, index) => ( + <> + { + setHover("breadcrumb") + setHoverBreadcrumbIdx(index()) + }} + onMouseOut={() => { + setHover(null) + setHoverBreadcrumbIdx(null) + }} + onMouseUp={() => { + navigate({ type: "session", sessionID: segment.id }) + }} + backgroundColor={ + hover() === "breadcrumb" && hoverBreadcrumbIdx() === index() + ? theme.backgroundElement + : theme.backgroundPanel + } + > + + + {getSessionDisplayName(segment, !segment.parentID)} + + + + + {/* Show "... >" after root when truncated */} + + {index() === 0 && breadcrumbSegments().truncated ? " > ... >" : " > "} + + + + )} + - - setHover("parent")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.parent")} - backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} - > - - Parent {keybind.print("session_parent")} - - - setHover("prev")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.previous")} - backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} - > - - Prev {keybind.print("session_child_cycle_reverse")} - + + {/* Row 2: Divider + stats */} + + + ──────────────────────────────────────── - setHover("next")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.next")} - backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} - > - - Next {keybind.print("session_child_cycle")} - + + + v{Installation.VERSION} + + {/* Row 3: Navigation hints */} + + + setHover("parent")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.parent")} + backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} + > + + {upLabel()} {keybind.print("session_parent")} + + + + = 2}> + setHover("root")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.root")} + backgroundColor={hover() === "root" ? theme.backgroundElement : theme.backgroundPanel} + > + + Root {keybind.print("session_root")} + + + + + setHover("next")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.next")} + backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} + > + + Next {keybind.print("session_child_cycle")} + + + setHover("prev")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.previous")} + backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} + > + + Prev {keybind.print("session_child_cycle_reverse")} + + + + + setHover("down")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.down")} + backgroundColor={hover() === "down" ? theme.backgroundElement : theme.backgroundPanel} + > + + {downLabel()} {keybind.print("session_child_down")} + + + + - + {/* Root session: responsive layout */} + <ContextInfo context={context} cost={cost} /> </box> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 31401836766..9aa49d64fa7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -127,6 +127,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 [] @@ -315,29 +334,40 @@ export function Session() { const local = useLocal() - function moveFirstChild() { - if (children().length === 1) return - const next = children().find((x) => !!x.parentID) - if (next) { + function moveChild(direction: number) { + // Use siblings for cycling (sessions with same parentID) + const sibs = siblings() + if (sibs.length <= 1) return + let next = sibs.findIndex((x) => x.id === session()?.id) + direction + if (next >= sibs.length) next = 0 + if (next < 0) next = sibs.length - 1 + if (sibs[next]) { navigate({ type: "session", - sessionID: next.id, + sessionID: sibs[next].id, }) } } - function moveChild(direction: number) { - if (children().length === 1) return - - const sessions = children().filter((x) => !!x.parentID) - let next = sessions.findIndex((x) => x.id === session()?.id) + direction + function moveToFirstChild() { + const children = directChildren() + if (children.length === 0) return + navigate({ + type: "session", + sessionID: children[0].id, + }) + } - if (next >= sessions.length) next = 0 - if (next < 0) next = sessions.length - 1 - if (sessions[next]) { + function moveToRoot() { + // Traverse up to find root session (no parentID) + let current = session() + while (current?.parentID) { + current = sync.session.get(current.parentID) + } + if (current && current.id !== session()?.id) { navigate({ type: "session", - sessionID: sessions[next].id, + sessionID: current.id, }) } } @@ -918,6 +948,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", @@ -957,6 +998,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) @@ -1937,7 +1989,7 @@ function Task(props: ToolProps<typeof TaskTool>) { return ( <Switch> - <Match when={props.input.description || props.input.subagent_type}> + <Match when={props.metadata.sessionId}> <BlockTool title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"} onClick={ @@ -1965,7 +2017,7 @@ function Task(props: ToolProps<typeof TaskTool>) { </box> <Show when={props.metadata.sessionId}> <text fg={theme.text}> - {keybind.print("session_child_first")} + {keybind.print("session_child_down")} <span style={{ fg: theme.textMuted }}> view subagents</span> </text> </Show> diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 368c3480a16..f1200070a8d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -893,10 +893,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_first: z.string().optional().default("<leader>down").describe("Go to first child session"), - session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), - session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), - session_parent: z.string().optional().default("up").describe("Go to parent session"), + session_child_cycle: z.string().optional().default("<leader>right").describe("Next sibling session"), + session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous sibling session"), + session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"), + session_child_down: z.string().optional().default("<leader>down").describe("Go to first child session"), + session_root: z.string().optional().default("<leader>escape").describe("Go to root session"), 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 10244127d3cdc701b591885084529154bf1dc240 Mon Sep 17 00:00:00 2001 From: Sean Smith <TheLastQuery@gmail.com> Date: Fri, 23 Jan 2026 21:00:20 -0600 Subject: [PATCH 08/12] 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/index.tsx | 10 + packages/opencode/src/config/config.ts | 3 +- 3 files changed, 197 insertions(+), 1 deletion(-) 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/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 9aa49d64fa7..0c12a0c2093 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -80,6 +80,7 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { DialogSessionTree } from "./dialog-session-tree" addDefaultParsers(parsers.parsers) @@ -1009,6 +1010,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 f1200070a8d..5da93a8af34 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -767,7 +767,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"), @@ -898,6 +898,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 46b306eda74c35bbcfe120d6db4b9d13c4528865 Mon Sep 17 00:00:00 2001 From: Sean Smith <TheLastQuery@gmail.com> Date: Sun, 25 Jan 2026 02:28:22 -0600 Subject: [PATCH 09/12] 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 +++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5da93a8af34..90e528f1279 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1164,6 +1164,15 @@ export namespace Config { .positive() .optional() .describe("Timeout in milliseconds for model context protocol (MCP) requests"), + level_limit: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Maximum depth for subagent session trees. Prevents infinite delegation loops. " + + "Default: 5. Set to 0 to disable (not recommended)." + ), }) .optional(), }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 00d4b772142..a5adc2e6080 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -29,6 +29,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"), @@ -125,6 +142,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) + }, + }) + }) +}) From b087d4ca41d163edfd9b80d0c334c2bccf8a84ba Mon Sep 17 00:00:00 2001 From: Sean Smith <TheLastQuery@gmail.com> Date: Wed, 11 Feb 2026 14:03:56 -0600 Subject: [PATCH 10/12] fix(tui): surface permission prompts from deeply nested subagent sessions --- .../src/cli/cmd/tui/routes/session/footer.tsx | 14 +++++++++++- .../src/cli/cmd/tui/routes/session/index.tsx | 22 +++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8ace2fff372..737ca86e8ff 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -15,7 +15,19 @@ export function Footer() { const lsp = createMemo(() => Object.keys(sync.data.lsp)) const permissions = createMemo(() => { if (route.data.type !== "session") return [] - return sync.data.permission[route.data.sessionID] ?? [] + // Collect permissions from all descendant sessions (full tree) + const rootID = route.data.sessionID + const ids: string[] = [rootID] + const queue = [rootID] + while (queue.length > 0) { + const parentID = queue.pop()! + for (const s of sync.data.session) { + if (s.parentID !== parentID) continue + ids.push(s.id) + queue.push(s.id) + } + } + return ids.flatMap((id) => sync.data.permission[id] ?? []) }) const directory = useDirectory() const connected = useConnected() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 0c12a0c2093..a23d302f2cf 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -148,13 +148,27 @@ export function Session() { .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + // Collect all descendant session IDs (full tree) for permission/question aggregation + const descendants = createMemo(() => { + const rootID = session()?.id + if (!rootID || session()?.parentID) return [] + const ids: string[] = [rootID] + const queue = [rootID] + while (queue.length > 0) { + const parentID = queue.pop()! + for (const s of sync.data.session) { + if (s.parentID !== parentID) continue + ids.push(s.id) + queue.push(s.id) + } + } + return ids + }) const permissions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.permission[x.id] ?? []) + return descendants().flatMap((id) => sync.data.permission[id] ?? []) }) const questions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + return descendants().flatMap((id) => sync.data.question[id] ?? []) }) const pending = createMemo(() => { From 2958123c0e9b8a6ab3432bb6a91d0cdcab365129 Mon Sep 17 00:00:00 2001 From: Sean Smith <TheLastQuery@gmail.com> Date: Fri, 27 Feb 2026 13:41:32 -0600 Subject: [PATCH 11/12] fix(tui): update stale moveFirstChild reference to moveToFirstChild --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a23d302f2cf..8d470d73056 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -959,7 +959,7 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - moveFirstChild() + moveToFirstChild() dialog.clear() }, }, From 25971974c158c5af2d213de2c12306dcd27a610a Mon Sep 17 00:00:00 2001 From: Sean Smith <TheLastQuery@gmail.com> Date: Fri, 27 Feb 2026 14:05:12 -0600 Subject: [PATCH 12/12] =?UTF-8?q?fix(tui):=20apply=20Group=203=20silent=20?= =?UTF-8?q?merge=20fixes=20=E2=80=94=20remove=20stale=20session.child.firs?= =?UTF-8?q?t=20command=20and=20redundant=20Show=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/cli/cmd/tui/routes/session/index.tsx | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) 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 8d470d73056..2a7b4769e8b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -952,17 +952,6 @@ export function Session() { dialog.clear() }, }, - { - title: "Go to child session", - value: "session.child.first", - keybind: "session_child_first", - category: "Session", - hidden: true, - onSelect: (dialog) => { - moveToFirstChild() - dialog.clear() - }, - }, { title: "Go to first child session", value: "session.child.down", @@ -2039,12 +2028,10 @@ function Task(props: ToolProps<typeof TaskTool>) { }} </Show> </box> - <Show when={props.metadata.sessionId}> - <text fg={theme.text}> - {keybind.print("session_child_down")} - <span style={{ fg: theme.textMuted }}> view subagents</span> - </text> - </Show> + <text fg={theme.text}> + {keybind.print("session_child_down")} + <span style={{ fg: theme.textMuted }}> view subagents</span> + </text> </BlockTool> </Match> <Match when={true}>