refactor: remove legacy tools format, use permission only

BREAKING: Requires OpenCode 1.1.1+

- Remove supportsNewPermissionSystem/usesLegacyToolsSystem checks
- Simplify permission-compat.ts to permission format only
- Unify explore/librarian deny lists: write, edit, task, sisyphus_task, call_omo_agent
- Add sisyphus_task to oracle deny list
- Update agent-tool-restrictions.ts with correct per-agent restrictions
- Clean config-handler.ts conditional version checks
- Update tests for simplified API
This commit is contained in:
justsisyphus 2026-01-16 17:11:34 +09:00
parent ede9abceb3
commit 83cbc56709
21 changed files with 505 additions and 347 deletions

View File

@ -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.

View File

@ -102,6 +102,7 @@ export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
"write",
"edit",
"task",
"sisyphus_task",
])
const base = {

View File

@ -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 = `<Role>
@ -99,26 +98,14 @@ export function createSisyphusJuniorAgentWithOverrides(
const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS)
let toolsConfig: Record<string, unknown> = {}
if (supportsNewPermissionSystem()) {
const userPermission = (override?.permission ?? {}) as Record<string, string>
const basePermission = (baseRestrictions as { permission: Record<string, string> }).permission
const merged: Record<string, string> = { ...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<string, boolean> }).tools
const merged: Record<string, boolean> = { ...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<string, PermissionValue>
const basePermission = baseRestrictions.permission
const merged: Record<string, PermissionValue> = { ...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) {

View File

@ -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)) {

View File

@ -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,

View File

@ -291,37 +291,27 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
LspCodeActionResolve: false,
};
type AgentWithPermission = { permission?: Record<string, unknown> };
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<string, unknown> }).tools = {
...(agentResult["Prometheus (Planner)"] as { tools?: Record<string, unknown> }).tools,
call_omo_agent: false,
};
const agent = agentResult["Prometheus (Planner)"] as AgentWithPermission;
agent.permission = { ...agent.permission, call_omo_agent: "deny" };
}
config.permission = {

View File

@ -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<string, PermissionValue> = {
write: "deny",
edit: "deny",
task: "deny",
sisyphus_task: "deny",
call_omo_agent: "deny",
}
const AGENT_RESTRICTIONS: Record<string, Record<string, PermissionValue>> = {
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<string, PermissionValue> {
return AGENT_RESTRICTIONS[agentName] ?? {}
}
export function hasAgentToolRestrictions(agentName: string): boolean {
const restrictions = AGENT_RESTRICTIONS[agentName]
return restrictions !== undefined && Object.keys(restrictions).length > 0
}

View File

@ -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"

View File

@ -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")
})
})
})

View File

@ -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 {

View File

@ -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<string, boolean> }).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<string, boolean> }).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)
})
})
})

View File

@ -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<string, boolean>
}
export interface NewPermissionFormat {
export interface PermissionFormat {
permission: Record<string, PermissionValue>
}
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<string, boolean>
): Record<string, PermissionValue> {
@ -104,40 +54,23 @@ export function migrateToolsToPermission(
)
}
export function migratePermissionToTools(
permission: Record<string, PermissionValue>
): Record<string, boolean> {
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<string, unknown>
): Record<string, unknown> {
const result = { ...config }
if (supportsNewPermissionSystem()) {
if (result.tools && typeof result.tools === "object") {
const existingPermission =
(result.permission as Record<string, PermissionValue>) || {}
const migratedPermission = migrateToolsToPermission(
result.tools as Record<string, boolean>
)
result.permission = { ...migratedPermission, ...existingPermission }
delete result.tools
}
} else {
if (result.permission && typeof result.permission === "object") {
const existingTools = (result.tools as Record<string, boolean>) || {}
const migratedTools = migratePermissionToTools(
result.permission as Record<string, PermissionValue>
)
result.tools = { ...migratedTools, ...existingTools }
delete result.permission
}
if (result.tools && typeof result.tools === "object") {
const existingPermission =
(result.permission as Record<string, PermissionValue>) || {}
const migratedPermission = migrateToolsToPermission(
result.tools as Record<string, boolean>
)
result.permission = { ...migratedPermission, ...existingPermission }
delete result.tools
}
return result

View File

@ -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,
},

View File

@ -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<string, ToolDefinition> = {
lsp_goto_definition,
lsp_find_references,
lsp_symbols,
lsp_diagnostics,
lsp_prepare_rename,
lsp_rename,

View File

@ -509,6 +509,37 @@ export class LSPClient {
await new Promise((r) => setTimeout(r, 1000))
}
async definition(filePath: string, line: number, character: number): Promise<unknown> {
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<unknown> {
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<unknown> {
const absPath = resolve(filePath)
await this.openFile(absPath)
return this.send("textDocument/documentSymbol", {
textDocument: { uri: pathToFileURL(absPath).href },
})
}
async workspaceSymbols(query: string): Promise<unknown> {
return this.send("workspace/symbol", { query })
}
async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
const absPath = resolve(filePath)
const uri = pathToFileURL(absPath).href

View File

@ -1,5 +1,34 @@
import type { LSPServerConfig } from "./types"
export const SYMBOL_KIND_MAP: Record<number, string> = {
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<number, string> = {
1: "error",
2: "warning",
@ -7,6 +36,8 @@ export const SEVERITY_MAP: Record<number, string> = {
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<string, string> = {

View File

@ -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"

View File

@ -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.",

View File

@ -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

View File

@ -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<T>(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

View File

@ -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,