diff --git a/src/agents/librarian.ts b/src/agents/librarian.ts index 27b6a652..0742ad45 100644 --- a/src/agents/librarian.ts +++ b/src/agents/librarian.ts @@ -1,5 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentPromptMetadata } from "./types" +import { createAgentToolRestrictions } from "../shared/permission-compat" const DEFAULT_MODEL = "opencode/glm-4.7-free" @@ -21,13 +22,21 @@ export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = { } export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig { + const restrictions = createAgentToolRestrictions([ + "write", + "edit", + "task", + "sisyphus_task", + "call_omo_agent", + ]) + return { description: "Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.", mode: "subagent" as const, model, temperature: 0.1, - tools: { write: false, edit: false, background_task: false }, + ...restrictions, prompt: `# THE LIBRARIAN You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent. diff --git a/src/agents/oracle.ts b/src/agents/oracle.ts index db3814cb..6ed37953 100644 --- a/src/agents/oracle.ts +++ b/src/agents/oracle.ts @@ -102,6 +102,7 @@ export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig { "write", "edit", "task", + "sisyphus_task", ]) const base = { diff --git a/src/agents/sisyphus-junior.ts b/src/agents/sisyphus-junior.ts index 690b3eeb..c7793f1e 100644 --- a/src/agents/sisyphus-junior.ts +++ b/src/agents/sisyphus-junior.ts @@ -3,8 +3,7 @@ import { isGptModel } from "./types" import type { AgentOverrideConfig, CategoryConfig } from "../config/schema" import { createAgentToolRestrictions, - migrateAgentConfig, - supportsNewPermissionSystem, + type PermissionValue, } from "../shared/permission-compat" const SISYPHUS_JUNIOR_PROMPT = ` @@ -99,26 +98,14 @@ export function createSisyphusJuniorAgentWithOverrides( const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) - let toolsConfig: Record = {} - if (supportsNewPermissionSystem()) { - const userPermission = (override?.permission ?? {}) as Record - const basePermission = (baseRestrictions as { permission: Record }).permission - const merged: Record = { ...userPermission } - for (const tool of BLOCKED_TOOLS) { - merged[tool] = "deny" - } - merged.call_omo_agent = "allow" - toolsConfig = { permission: { ...merged, ...basePermission } } - } else { - const userTools = override?.tools ?? {} - const baseTools = (baseRestrictions as { tools: Record }).tools - const merged: Record = { ...userTools } - for (const tool of BLOCKED_TOOLS) { - merged[tool] = false - } - merged.call_omo_agent = true - toolsConfig = { tools: { ...merged, ...baseTools } } + const userPermission = (override?.permission ?? {}) as Record + const basePermission = baseRestrictions.permission + const merged: Record = { ...userPermission } + for (const tool of BLOCKED_TOOLS) { + merged[tool] = "deny" } + merged.call_omo_agent = "allow" + const toolsConfig = { permission: { ...merged, ...basePermission } } const base: AgentConfig = { description: override?.description ?? @@ -153,10 +140,18 @@ export function createSisyphusJuniorAgent( const prompt = buildSisyphusJuniorPrompt(promptAppend) const model = categoryConfig.model const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) - const mergedConfig = migrateAgentConfig({ - ...baseRestrictions, - ...(categoryConfig.tools ? { tools: categoryConfig.tools } : {}), - }) + const categoryPermission = categoryConfig.tools + ? Object.fromEntries( + Object.entries(categoryConfig.tools).map(([k, v]) => [ + k, + v ? ("allow" as const) : ("deny" as const), + ]) + ) + : {} + const mergedPermission = { + ...categoryPermission, + ...baseRestrictions.permission, + } const base: AgentConfig = { @@ -167,7 +162,7 @@ export function createSisyphusJuniorAgent( maxTokens: categoryConfig.maxTokens ?? 64000, prompt, color: "#20B2AA", - ...mergedConfig, + permission: mergedPermission, } if (categoryConfig.temperature !== undefined) { diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index fe45b68e..a5907739 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -618,9 +618,7 @@ export function createSisyphusAgent( ? buildDynamicSisyphusPrompt(availableAgents, tools, skills) : buildDynamicSisyphusPrompt([], tools, skills) - // Note: question permission allows agent to ask user questions via OpenCode's QuestionTool - // SDK type doesn't include 'question' yet, but OpenCode runtime supports it - const permission = { question: "allow" } as AgentConfig["permission"] + const permission = { question: "allow", call_omo_agent: "deny" } as AgentConfig["permission"] const base = { description: "Sisyphus - Powerful AI orchestrator from OhMyOpenCode. Plans obsessively with todos, assesses search complexity before exploration, delegates strategically to specialized agents. Uses explore for internal code (parallel-friendly), librarian only for external docs, and always delegates UI work to frontend engineer.", @@ -630,7 +628,6 @@ export function createSisyphusAgent( prompt, color: "#00CED1", permission, - tools: { call_omo_agent: false }, } if (isGptModel(model)) { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 93d39804..39ead85e 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -5,7 +5,7 @@ import type { LaunchInput, ResumeInput, } from "./types" -import { log } from "../../shared/logger" +import { log, getAgentToolRestrictions } from "../../shared" import { ConcurrencyManager } from "./concurrency" import type { BackgroundTaskConfig } from "../../config/schema" @@ -178,6 +178,7 @@ export class BackgroundManager { ...(input.model ? { model: input.model } : {}), system: input.skillContent, tools: { + ...getAgentToolRestrictions(input.agent), task: false, sisyphus_task: false, call_omo_agent: true, @@ -406,6 +407,7 @@ export class BackgroundManager { body: { agent: existingTask.agent, tools: { + ...getAgentToolRestrictions(existingTask.agent), task: false, sisyphus_task: false, call_omo_agent: true, diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index fbd29099..b258c29c 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -291,37 +291,27 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { LspCodeActionResolve: false, }; + type AgentWithPermission = { permission?: Record }; + if (agentResult.librarian) { - agentResult.librarian.tools = { - ...agentResult.librarian.tools, - "grep_app_*": true, - }; + const agent = agentResult.librarian as AgentWithPermission; + agent.permission = { ...agent.permission, "grep_app_*": "allow" }; } if (agentResult["multimodal-looker"]) { - agentResult["multimodal-looker"].tools = { - ...agentResult["multimodal-looker"].tools, - task: false, - look_at: false, - }; + const agent = agentResult["multimodal-looker"] as AgentWithPermission; + agent.permission = { ...agent.permission, task: "deny", look_at: "deny" }; } if (agentResult["orchestrator-sisyphus"]) { - agentResult["orchestrator-sisyphus"].tools = { - ...agentResult["orchestrator-sisyphus"].tools, - task: false, - call_omo_agent: false, - }; + const agent = agentResult["orchestrator-sisyphus"] as AgentWithPermission; + agent.permission = { ...agent.permission, task: "deny", call_omo_agent: "deny" }; } if (agentResult.Sisyphus) { - agentResult.Sisyphus.tools = { - ...agentResult.Sisyphus.tools, - call_omo_agent: false, - }; + const agent = agentResult.Sisyphus as AgentWithPermission; + agent.permission = { ...agent.permission, call_omo_agent: "deny" }; } if (agentResult["Prometheus (Planner)"]) { - (agentResult["Prometheus (Planner)"] as { tools?: Record }).tools = { - ...(agentResult["Prometheus (Planner)"] as { tools?: Record }).tools, - call_omo_agent: false, - }; + const agent = agentResult["Prometheus (Planner)"] as AgentWithPermission; + agent.permission = { ...agent.permission, call_omo_agent: "deny" }; } config.permission = { diff --git a/src/shared/agent-tool-restrictions.ts b/src/shared/agent-tool-restrictions.ts new file mode 100644 index 00000000..7ee7daf3 --- /dev/null +++ b/src/shared/agent-tool-restrictions.ts @@ -0,0 +1,59 @@ +import type { PermissionValue } from "./permission-compat" + +/** + * Agent tool restrictions for session.prompt calls. + * OpenCode SDK's session.prompt `tools` parameter OVERRIDES agent-level permissions. + * This provides complete restriction sets so session.prompt calls include all necessary restrictions. + */ + +const EXPLORATION_AGENT_DENYLIST: Record = { + write: "deny", + edit: "deny", + task: "deny", + sisyphus_task: "deny", + call_omo_agent: "deny", +} + +const AGENT_RESTRICTIONS: Record> = { + explore: EXPLORATION_AGENT_DENYLIST, + + librarian: EXPLORATION_AGENT_DENYLIST, + + oracle: { + write: "deny", + edit: "deny", + task: "deny", + sisyphus_task: "deny", + }, + + "multimodal-looker": { + "*": "deny", + read: "allow", + }, + + "document-writer": { + task: "deny", + sisyphus_task: "deny", + call_omo_agent: "deny", + }, + + "frontend-ui-ux-engineer": { + task: "deny", + sisyphus_task: "deny", + call_omo_agent: "deny", + }, + + "Sisyphus-Junior": { + task: "deny", + sisyphus_task: "deny", + }, +} + +export function getAgentToolRestrictions(agentName: string): Record { + return AGENT_RESTRICTIONS[agentName] ?? {} +} + +export function hasAgentToolRestrictions(agentName: string): boolean { + const restrictions = AGENT_RESTRICTIONS[agentName] + return restrictions !== undefined && Object.keys(restrictions).length > 0 +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 41dd9789..7ee5a321 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -25,3 +25,4 @@ export * from "./agent-variant" export * from "./session-cursor" export * from "./shell-env" export * from "./system-directive" +export * from "./agent-tool-restrictions" diff --git a/src/shared/opencode-version.test.ts b/src/shared/opencode-version.test.ts index 9f7c1f75..d6b419c2 100644 --- a/src/shared/opencode-version.test.ts +++ b/src/shared/opencode-version.test.ts @@ -1,16 +1,14 @@ -import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from "bun:test" -import * as childProcess from "child_process" +import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { parseVersion, compareVersions, isVersionGte, isVersionLt, getOpenCodeVersion, - supportsNewPermissionSystem, - usesLegacyToolsSystem, + isOpenCodeVersionAtLeast, resetVersionCache, setVersionCache, - PERMISSION_BREAKING_VERSION, + MINIMUM_OPENCODE_VERSION, } from "./opencode-version" describe("opencode-version", () => { @@ -163,7 +161,7 @@ describe("opencode-version", () => { }) }) - describe("supportsNewPermissionSystem", () => { + describe("isOpenCodeVersionAtLeast", () => { beforeEach(() => { resetVersionCache() }) @@ -172,34 +170,34 @@ describe("opencode-version", () => { resetVersionCache() }) - test("returns true for v1.1.1", () => { + test("returns true for exact version", () => { // #given version is 1.1.1 setVersionCache("1.1.1") - // #when checking permission system support - const result = supportsNewPermissionSystem() + // #when checking against 1.1.1 + const result = isOpenCodeVersionAtLeast("1.1.1") // #then returns true expect(result).toBe(true) }) - test("returns true for versions above 1.1.1", () => { - // #given version is above 1.1.1 + test("returns true for versions above target", () => { + // #given version is above target setVersionCache("1.2.0") - // #when checking - const result = supportsNewPermissionSystem() + // #when checking against 1.1.1 + const result = isOpenCodeVersionAtLeast("1.1.1") // #then returns true expect(result).toBe(true) }) - test("returns false for versions below 1.1.1", () => { - // #given version is below 1.1.1 + test("returns false for versions below target", () => { + // #given version is below target setVersionCache("1.1.0") - // #when checking - const result = supportsNewPermissionSystem() + // #when checking against 1.1.1 + const result = isOpenCodeVersionAtLeast("1.1.1") // #then returns false expect(result).toBe(false) @@ -210,48 +208,16 @@ describe("opencode-version", () => { setVersionCache(null) // #when checking - const result = supportsNewPermissionSystem() + const result = isOpenCodeVersionAtLeast("1.1.1") // #then returns true (assume newer version) expect(result).toBe(true) }) }) - describe("usesLegacyToolsSystem", () => { - beforeEach(() => { - resetVersionCache() - }) - - afterEach(() => { - resetVersionCache() - }) - - test("returns true for versions below 1.1.1", () => { - // #given version is below 1.1.1 - setVersionCache("1.0.150") - - // #when checking - const result = usesLegacyToolsSystem() - - // #then returns true - expect(result).toBe(true) - }) - - test("returns false for v1.1.1 and above", () => { - // #given version is 1.1.1 - setVersionCache("1.1.1") - - // #when checking - const result = usesLegacyToolsSystem() - - // #then returns false - expect(result).toBe(false) - }) - }) - - describe("PERMISSION_BREAKING_VERSION", () => { + describe("MINIMUM_OPENCODE_VERSION", () => { test("is set to 1.1.1", () => { - expect(PERMISSION_BREAKING_VERSION).toBe("1.1.1") + expect(MINIMUM_OPENCODE_VERSION).toBe("1.1.1") }) }) }) diff --git a/src/shared/opencode-version.ts b/src/shared/opencode-version.ts index c70069ae..fab09552 100644 --- a/src/shared/opencode-version.ts +++ b/src/shared/opencode-version.ts @@ -1,6 +1,10 @@ import { execSync } from "child_process" -export const PERMISSION_BREAKING_VERSION = "1.1.1" +/** + * Minimum OpenCode version required for this plugin. + * This plugin only supports OpenCode 1.1.1+ which uses the permission system. + */ +export const MINIMUM_OPENCODE_VERSION = "1.1.1" const NOT_CACHED = Symbol("NOT_CACHED") let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED @@ -53,14 +57,10 @@ export function getOpenCodeVersion(): string | null { } } -export function supportsNewPermissionSystem(): boolean { - const version = getOpenCodeVersion() - if (!version) return true - return isVersionGte(version, PERMISSION_BREAKING_VERSION) -} - -export function usesLegacyToolsSystem(): boolean { - return !supportsNewPermissionSystem() +export function isOpenCodeVersionAtLeast(version: string): boolean { + const current = getOpenCodeVersion() + if (!current) return true + return isVersionGte(current, version) } export function resetVersionCache(): void { diff --git a/src/shared/permission-compat.test.ts b/src/shared/permission-compat.test.ts index 73b71ac3..91b3d79f 100644 --- a/src/shared/permission-compat.test.ts +++ b/src/shared/permission-compat.test.ts @@ -1,27 +1,15 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { describe, test, expect } from "bun:test" import { createAgentToolRestrictions, createAgentToolAllowlist, migrateToolsToPermission, - migratePermissionToTools, migrateAgentConfig, } from "./permission-compat" -import { setVersionCache, resetVersionCache } from "./opencode-version" describe("permission-compat", () => { - beforeEach(() => { - resetVersionCache() - }) - - afterEach(() => { - resetVersionCache() - }) - describe("createAgentToolRestrictions", () => { - test("returns permission format for v1.1.1+", () => { - // #given version is 1.1.1 - setVersionCache("1.1.1") - + test("returns permission format with deny values", () => { + // #given tools to restrict // #when creating restrictions const result = createAgentToolRestrictions(["write", "edit"]) @@ -31,38 +19,19 @@ describe("permission-compat", () => { }) }) - test("returns tools format for versions below 1.1.1", () => { - // #given version is below 1.1.1 - setVersionCache("1.0.150") - + test("returns empty permission for empty array", () => { + // #given empty tools array // #when creating restrictions - const result = createAgentToolRestrictions(["write", "edit"]) + const result = createAgentToolRestrictions([]) - // #then returns tools format - expect(result).toEqual({ - tools: { write: false, edit: false }, - }) - }) - - test("assumes new format when version unknown", () => { - // #given version is null - setVersionCache(null) - - // #when creating restrictions - const result = createAgentToolRestrictions(["write"]) - - // #then returns permission format (assumes new version) - expect(result).toEqual({ - permission: { write: "deny" }, - }) + // #then returns empty permission + expect(result).toEqual({ permission: {} }) }) }) describe("createAgentToolAllowlist", () => { - test("returns wildcard deny with explicit allow for v1.1.1+", () => { - // #given version is 1.1.1 - setVersionCache("1.1.1") - + test("returns wildcard deny with explicit allow", () => { + // #given tools to allow // #when creating allowlist const result = createAgentToolAllowlist(["read"]) @@ -72,11 +41,9 @@ describe("permission-compat", () => { }) }) - test("returns wildcard deny with multiple allows for v1.1.1+", () => { - // #given version is 1.1.1 - setVersionCache("1.1.1") - - // #when creating allowlist with multiple tools + test("returns wildcard deny with multiple allows", () => { + // #given multiple tools to allow + // #when creating allowlist const result = createAgentToolAllowlist(["read", "glob"]) // #then returns wildcard deny with both allows @@ -84,35 +51,6 @@ describe("permission-compat", () => { permission: { "*": "deny", read: "allow", glob: "allow" }, }) }) - - test("returns explicit deny list for old versions", () => { - // #given version is below 1.1.1 - setVersionCache("1.0.150") - - // #when creating allowlist - const result = createAgentToolAllowlist(["read"]) - - // #then returns tools format with common tools denied except read - expect(result).toHaveProperty("tools") - const tools = (result as { tools: Record }).tools - expect(tools.write).toBe(false) - expect(tools.edit).toBe(false) - expect(tools.bash).toBe(false) - expect(tools.read).toBeUndefined() - }) - - test("excludes allowed tools from legacy deny list", () => { - // #given version is below 1.1.1 - setVersionCache("1.0.150") - - // #when creating allowlist with glob - const result = createAgentToolAllowlist(["read", "glob"]) - - // #then glob is not in deny list - const tools = (result as { tools: Record }).tools - expect(tools.glob).toBeUndefined() - expect(tools.write).toBe(false) - }) }) describe("migrateToolsToPermission", () => { @@ -132,38 +70,9 @@ describe("permission-compat", () => { }) }) - describe("migratePermissionToTools", () => { - test("converts permission to boolean tools", () => { - // #given permission config - const permission = { write: "deny" as const, edit: "allow" as const } - - // #when migrating - const result = migratePermissionToTools(permission) - - // #then converts correctly - expect(result).toEqual({ write: false, edit: true }) - }) - - test("excludes ask values", () => { - // #given permission with ask - const permission = { - write: "deny" as const, - edit: "ask" as const, - bash: "allow" as const, - } - - // #when migrating - const result = migratePermissionToTools(permission) - - // #then ask is excluded - expect(result).toEqual({ write: false, bash: true }) - }) - }) - describe("migrateAgentConfig", () => { - test("migrates tools to permission for v1.1.1+", () => { - // #given v1.1.1 and config with tools - setVersionCache("1.1.1") + test("migrates tools to permission", () => { + // #given config with tools const config = { model: "test", tools: { write: false, edit: false }, @@ -178,25 +87,8 @@ describe("permission-compat", () => { expect(result.model).toBe("test") }) - test("migrates permission to tools for old versions", () => { - // #given old version and config with permission - setVersionCache("1.0.150") - const config = { - model: "test", - permission: { write: "deny" as const, edit: "deny" as const }, - } - - // #when migrating - const result = migrateAgentConfig(config) - - // #then converts to tools - expect(result.permission).toBeUndefined() - expect(result.tools).toEqual({ write: false, edit: false }) - }) - test("preserves other config fields", () => { // #given config with other fields - setVersionCache("1.1.1") const config = { model: "test", temperature: 0.5, @@ -212,5 +104,31 @@ describe("permission-compat", () => { expect(result.temperature).toBe(0.5) expect(result.prompt).toBe("hello") }) + + test("merges existing permission with migrated tools", () => { + // #given config with both tools and permission + const config = { + tools: { write: false }, + permission: { bash: "deny" as const }, + } + + // #when migrating + const result = migrateAgentConfig(config) + + // #then merges permission (existing takes precedence) + expect(result.tools).toBeUndefined() + expect(result.permission).toEqual({ write: "deny", bash: "deny" }) + }) + + test("returns unchanged config if no tools", () => { + // #given config without tools + const config = { model: "test", permission: { edit: "deny" as const } } + + // #when migrating + const result = migrateAgentConfig(config) + + // #then returns unchanged + expect(result).toEqual(config) + }) }) }) diff --git a/src/shared/permission-compat.ts b/src/shared/permission-compat.ts index 74a398b4..f582fd55 100644 --- a/src/shared/permission-compat.ts +++ b/src/shared/permission-compat.ts @@ -1,98 +1,48 @@ -import { supportsNewPermissionSystem } from "./opencode-version" - -export { supportsNewPermissionSystem } +/** + * Permission system utilities for OpenCode 1.1.1+. + * This module only supports the new permission format. + */ export type PermissionValue = "ask" | "allow" | "deny" -export interface LegacyToolsFormat { - tools: Record -} - -export interface NewPermissionFormat { +export interface PermissionFormat { permission: Record } -export type VersionAwareRestrictions = LegacyToolsFormat | NewPermissionFormat - +/** + * Creates tool restrictions that deny specified tools. + */ export function createAgentToolRestrictions( denyTools: string[] -): VersionAwareRestrictions { - if (supportsNewPermissionSystem()) { - return { - permission: Object.fromEntries( - denyTools.map((tool) => [tool, "deny" as const]) - ), - } - } - +): PermissionFormat { return { - tools: Object.fromEntries(denyTools.map((tool) => [tool, false])), + permission: Object.fromEntries( + denyTools.map((tool) => [tool, "deny" as const]) + ), } } -/** - * Common tools that should be denied when using allowlist approach. - * Used for legacy fallback when `*: deny` pattern is not supported. - */ -const COMMON_TOOLS_TO_DENY = [ - "write", - "edit", - "bash", - "task", - "sisyphus_task", - "call_omo_agent", - "webfetch", - "glob", - "grep", - "lsp_diagnostics", - "lsp_prepare_rename", - "lsp_rename", - "ast_grep_search", - "ast_grep_replace", - "session_list", - "session_read", - "session_search", - "session_info", - "background_output", - "background_cancel", - "skill", - "skill_mcp", - "look_at", - "todowrite", - "todoread", - "interactive_bash", -] as const - /** * Creates tool restrictions that ONLY allow specified tools. - * All other tools are denied by default. - * - * Uses `*: deny` pattern for new permission system, - * falls back to explicit deny list for legacy systems. + * All other tools are denied by default using `*: deny` pattern. */ export function createAgentToolAllowlist( allowTools: string[] -): VersionAwareRestrictions { - if (supportsNewPermissionSystem()) { - return { - permission: { - "*": "deny" as const, - ...Object.fromEntries( - allowTools.map((tool) => [tool, "allow" as const]) - ), - }, - } - } - - // Legacy fallback: explicitly deny common tools except allowed ones - const allowSet = new Set(allowTools) - const denyTools = COMMON_TOOLS_TO_DENY.filter((tool) => !allowSet.has(tool)) - +): PermissionFormat { return { - tools: Object.fromEntries(denyTools.map((tool) => [tool, false])), + permission: { + "*": "deny" as const, + ...Object.fromEntries( + allowTools.map((tool) => [tool, "allow" as const]) + ), + }, } } +/** + * Converts legacy tools format to permission format. + * For migrating user configs from older versions. + */ export function migrateToolsToPermission( tools: Record ): Record { @@ -104,40 +54,23 @@ export function migrateToolsToPermission( ) } -export function migratePermissionToTools( - permission: Record -): Record { - return Object.fromEntries( - Object.entries(permission) - .filter(([, value]) => value !== "ask") - .map(([key, value]) => [key, value === "allow"]) - ) -} - +/** + * Migrates agent config from legacy tools format to permission format. + * If config has `tools`, converts to `permission`. + */ export function migrateAgentConfig( config: Record ): Record { const result = { ...config } - if (supportsNewPermissionSystem()) { - if (result.tools && typeof result.tools === "object") { - const existingPermission = - (result.permission as Record) || {} - const migratedPermission = migrateToolsToPermission( - result.tools as Record - ) - result.permission = { ...migratedPermission, ...existingPermission } - delete result.tools - } - } else { - if (result.permission && typeof result.permission === "object") { - const existingTools = (result.tools as Record) || {} - const migratedTools = migratePermissionToTools( - result.permission as Record - ) - result.tools = { ...migratedTools, ...existingTools } - delete result.permission - } + if (result.tools && typeof result.tools === "object") { + const existingPermission = + (result.permission as Record) || {} + const migratedPermission = migrateToolsToPermission( + result.tools as Record + ) + result.permission = { ...migratedPermission, ...existingPermission } + delete result.tools } return result diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index ef92341c..2f06adf8 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants" import type { CallOmoAgentArgs } from "./types" import type { BackgroundManager } from "../../features/background-agent" -import { log } from "../../shared/logger" +import { log, getAgentToolRestrictions } from "../../shared" import { consumeNewMessages } from "../../shared/session-cursor" import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" @@ -188,6 +188,7 @@ async function executeSync( body: { agent: args.subagent_type, tools: { + ...getAgentToolRestrictions(args.subagent_type), task: false, sisyphus_task: false, }, diff --git a/src/tools/index.ts b/src/tools/index.ts index 29a4b54d..aac291f1 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,4 +1,7 @@ import { + lsp_goto_definition, + lsp_find_references, + lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename, @@ -52,6 +55,9 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco } export const builtinTools: Record = { + lsp_goto_definition, + lsp_find_references, + lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename, diff --git a/src/tools/lsp/client.ts b/src/tools/lsp/client.ts index 329afb4a..493f4e35 100644 --- a/src/tools/lsp/client.ts +++ b/src/tools/lsp/client.ts @@ -509,6 +509,37 @@ export class LSPClient { await new Promise((r) => setTimeout(r, 1000)) } + async definition(filePath: string, line: number, character: number): Promise { + const absPath = resolve(filePath) + await this.openFile(absPath) + return this.send("textDocument/definition", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + }) + } + + async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise { + const absPath = resolve(filePath) + await this.openFile(absPath) + return this.send("textDocument/references", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + context: { includeDeclaration }, + }) + } + + async documentSymbols(filePath: string): Promise { + const absPath = resolve(filePath) + await this.openFile(absPath) + return this.send("textDocument/documentSymbol", { + textDocument: { uri: pathToFileURL(absPath).href }, + }) + } + + async workspaceSymbols(query: string): Promise { + return this.send("workspace/symbol", { query }) + } + async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> { const absPath = resolve(filePath) const uri = pathToFileURL(absPath).href diff --git a/src/tools/lsp/constants.ts b/src/tools/lsp/constants.ts index 9bc71835..d5aada38 100644 --- a/src/tools/lsp/constants.ts +++ b/src/tools/lsp/constants.ts @@ -1,5 +1,34 @@ import type { LSPServerConfig } from "./types" +export const SYMBOL_KIND_MAP: Record = { + 1: "File", + 2: "Module", + 3: "Namespace", + 4: "Package", + 5: "Class", + 6: "Method", + 7: "Property", + 8: "Field", + 9: "Constructor", + 10: "Enum", + 11: "Interface", + 12: "Function", + 13: "Variable", + 14: "Constant", + 15: "String", + 16: "Number", + 17: "Boolean", + 18: "Array", + 19: "Object", + 20: "Key", + 21: "Null", + 22: "EnumMember", + 23: "Struct", + 24: "Event", + 25: "Operator", + 26: "TypeParameter", +} + export const SEVERITY_MAP: Record = { 1: "error", 2: "warning", @@ -7,6 +36,8 @@ export const SEVERITY_MAP: Record = { 4: "hint", } +export const DEFAULT_MAX_REFERENCES = 200 +export const DEFAULT_MAX_SYMBOLS = 200 export const DEFAULT_MAX_DIAGNOSTICS = 200 export const LSP_INSTALL_HINTS: Record = { diff --git a/src/tools/lsp/index.ts b/src/tools/lsp/index.ts index bdf15aa1..f149bec3 100644 --- a/src/tools/lsp/index.ts +++ b/src/tools/lsp/index.ts @@ -4,4 +4,4 @@ export * from "./config" export * from "./client" export * from "./utils" // NOTE: lsp_servers removed - duplicates OpenCode's built-in LspServers -export { lsp_diagnostics, lsp_prepare_rename, lsp_rename } from "./tools" +export { lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename } from "./tools" diff --git a/src/tools/lsp/tools.ts b/src/tools/lsp/tools.ts index 2f7657d9..11ace346 100644 --- a/src/tools/lsp/tools.ts +++ b/src/tools/lsp/tools.ts @@ -1,9 +1,14 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { + DEFAULT_MAX_REFERENCES, + DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_DIAGNOSTICS, } from "./constants" import { withLspClient, + formatLocation, + formatDocumentSymbol, + formatSymbolInfo, formatDiagnostic, filterDiagnosticsBySeverity, formatPrepareRenameResult, @@ -11,14 +16,155 @@ import { formatApplyResult, } from "./utils" import type { + Location, + LocationLink, + DocumentSymbol, + SymbolInfo, Diagnostic, PrepareRenameResult, PrepareRenameDefaultBehavior, WorkspaceEdit, } from "./types" -// NOTE: lsp_goto_definition, lsp_find_references, lsp_symbols are removed -// as they duplicate OpenCode's built-in LSP tools (LspGotoDefinition, LspFindReferences, LspDocumentSymbols, LspWorkspaceSymbols) +export const lsp_goto_definition: ToolDefinition = tool({ + description: "Jump to symbol definition. Find WHERE something is defined.", + args: { + filePath: tool.schema.string(), + line: tool.schema.number().min(1).describe("1-based"), + character: tool.schema.number().min(0).describe("0-based"), + }, + execute: async (args, context) => { + try { + const result = await withLspClient(args.filePath, async (client) => { + return (await client.definition(args.filePath, args.line, args.character)) as + | Location + | Location[] + | LocationLink[] + | null + }) + + if (!result) { + const output = "No definition found" + return output + } + + const locations = Array.isArray(result) ? result : [result] + if (locations.length === 0) { + const output = "No definition found" + return output + } + + const output = locations.map(formatLocation).join("\n") + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + return output + } + }, +}) + +export const lsp_find_references: ToolDefinition = tool({ + description: "Find ALL usages/references of a symbol across the entire workspace.", + args: { + filePath: tool.schema.string(), + line: tool.schema.number().min(1).describe("1-based"), + character: tool.schema.number().min(0).describe("0-based"), + includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"), + }, + execute: async (args, context) => { + try { + const result = await withLspClient(args.filePath, async (client) => { + return (await client.references(args.filePath, args.line, args.character, args.includeDeclaration ?? true)) as + | Location[] + | null + }) + + if (!result || result.length === 0) { + const output = "No references found" + return output + } + + const total = result.length + const truncated = total > DEFAULT_MAX_REFERENCES + const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result + const lines = limited.map(formatLocation) + if (truncated) { + lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`) + } + const output = lines.join("\n") + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + return output + } + }, +}) + +export const lsp_symbols: ToolDefinition = tool({ + description: "Get symbols from file (document) or search across workspace. Use scope='document' for file outline, scope='workspace' for project-wide symbol search.", + args: { + filePath: tool.schema.string().describe("File path for LSP context"), + scope: tool.schema.enum(["document", "workspace"]).default("document").describe("'document' for file symbols, 'workspace' for project-wide search"), + query: tool.schema.string().optional().describe("Symbol name to search (required for workspace scope)"), + limit: tool.schema.number().optional().describe("Max results (default 50)"), + }, + execute: async (args, context) => { + try { + const scope = args.scope ?? "document" + + if (scope === "workspace") { + if (!args.query) { + return "Error: 'query' is required for workspace scope" + } + + const result = await withLspClient(args.filePath, async (client) => { + return (await client.workspaceSymbols(args.query!)) as SymbolInfo[] | null + }) + + if (!result || result.length === 0) { + return "No symbols found" + } + + const total = result.length + const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS) + const truncated = total > limit + const limited = result.slice(0, limit) + const lines = limited.map(formatSymbolInfo) + if (truncated) { + lines.unshift(`Found ${total} symbols (showing first ${limit}):`) + } + return lines.join("\n") + } else { + const result = await withLspClient(args.filePath, async (client) => { + return (await client.documentSymbols(args.filePath)) as DocumentSymbol[] | SymbolInfo[] | null + }) + + if (!result || result.length === 0) { + return "No symbols found" + } + + const total = result.length + const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS) + const truncated = total > limit + const limited = truncated ? result.slice(0, limit) : result + + const lines: string[] = [] + if (truncated) { + lines.push(`Found ${total} symbols (showing first ${limit}):`) + } + + if ("range" in limited[0]) { + lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s))) + } else { + lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo)) + } + return lines.join("\n") + } + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}` + } + }, +}) export const lsp_diagnostics: ToolDefinition = tool({ description: "Get errors, warnings, hints from language server BEFORE running build.", diff --git a/src/tools/lsp/types.ts b/src/tools/lsp/types.ts index 2b431193..6a7c1ddf 100644 --- a/src/tools/lsp/types.ts +++ b/src/tools/lsp/types.ts @@ -17,6 +17,33 @@ export interface Range { end: Position } +export interface Location { + uri: string + range: Range +} + +export interface LocationLink { + targetUri: string + targetRange: Range + targetSelectionRange: Range + originSelectionRange?: Range +} + +export interface SymbolInfo { + name: string + kind: number + location: Location + containerName?: string +} + +export interface DocumentSymbol { + name: string + kind: number + range: Range + selectionRange: Range + children?: DocumentSymbol[] +} + export interface Diagnostic { range: Range severity?: number diff --git a/src/tools/lsp/utils.ts b/src/tools/lsp/utils.ts index e54a0680..51087150 100644 --- a/src/tools/lsp/utils.ts +++ b/src/tools/lsp/utils.ts @@ -3,8 +3,12 @@ import { fileURLToPath } from "node:url" import { existsSync, readFileSync, writeFileSync } from "fs" import { LSPClient, lspManager } from "./client" import { findServerForExtension } from "./config" -import { SEVERITY_MAP } from "./constants" +import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants" import type { + Location, + LocationLink, + DocumentSymbol, + SymbolInfo, Diagnostic, PrepareRenameResult, PrepareRenameDefaultBehavior, @@ -106,11 +110,51 @@ export async function withLspClient(filePath: string, fn: (client: LSPClient) } } +export function formatLocation(loc: Location | LocationLink): string { + if ("targetUri" in loc) { + const uri = uriToPath(loc.targetUri) + const line = loc.targetRange.start.line + 1 + const char = loc.targetRange.start.character + return `${uri}:${line}:${char}` + } + + const uri = uriToPath(loc.uri) + const line = loc.range.start.line + 1 + const char = loc.range.start.character + return `${uri}:${line}:${char}` +} + +export function formatSymbolKind(kind: number): string { + return SYMBOL_KIND_MAP[kind] || `Unknown(${kind})` +} + export function formatSeverity(severity: number | undefined): string { if (!severity) return "unknown" return SEVERITY_MAP[severity] || `unknown(${severity})` } +export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string { + const prefix = " ".repeat(indent) + const kind = formatSymbolKind(symbol.kind) + const line = symbol.range.start.line + 1 + let result = `${prefix}${symbol.name} (${kind}) - line ${line}` + + if (symbol.children && symbol.children.length > 0) { + for (const child of symbol.children) { + result += "\n" + formatDocumentSymbol(child, indent + 1) + } + } + + return result +} + +export function formatSymbolInfo(symbol: SymbolInfo): string { + const kind = formatSymbolKind(symbol.kind) + const loc = formatLocation(symbol.location) + const container = symbol.containerName ? ` (in ${symbol.containerName})` : "" + return `${symbol.name} (${kind})${container} - ${loc}` +} + export function formatDiagnostic(diag: Diagnostic): string { const severity = formatSeverity(diag.severity) const line = diag.range.start.line + 1 diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index 612f4394..7f6697cf 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -11,7 +11,7 @@ import { discoverSkills } from "../../features/opencode-skill-loader" import { getTaskToastManager } from "../../features/task-toast-manager" import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state" -import { log } from "../../shared/logger" +import { log, getAgentToolRestrictions } from "../../shared" type OpencodeClient = PluginInput["client"] @@ -322,6 +322,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.` ...(resumeAgent !== undefined ? { agent: resumeAgent } : {}), ...(resumeModel !== undefined ? { model: resumeModel } : {}), tools: { + ...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}), task: false, sisyphus_task: false, call_omo_agent: true,