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 { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types" import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "opencode/glm-4.7-free" 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 { export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"task",
"sisyphus_task",
"call_omo_agent",
])
return { return {
description: 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.", "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, mode: "subagent" as const,
model, model,
temperature: 0.1, temperature: 0.1,
tools: { write: false, edit: false, background_task: false }, ...restrictions,
prompt: `# THE LIBRARIAN prompt: `# THE LIBRARIAN
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent. 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", "write",
"edit", "edit",
"task", "task",
"sisyphus_task",
]) ])
const base = { const base = {

View File

@ -3,8 +3,7 @@ import { isGptModel } from "./types"
import type { AgentOverrideConfig, CategoryConfig } from "../config/schema" import type { AgentOverrideConfig, CategoryConfig } from "../config/schema"
import { import {
createAgentToolRestrictions, createAgentToolRestrictions,
migrateAgentConfig, type PermissionValue,
supportsNewPermissionSystem,
} from "../shared/permission-compat" } from "../shared/permission-compat"
const SISYPHUS_JUNIOR_PROMPT = `<Role> const SISYPHUS_JUNIOR_PROMPT = `<Role>
@ -99,26 +98,14 @@ export function createSisyphusJuniorAgentWithOverrides(
const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS)
let toolsConfig: Record<string, unknown> = {} const userPermission = (override?.permission ?? {}) as Record<string, PermissionValue>
if (supportsNewPermissionSystem()) { const basePermission = baseRestrictions.permission
const userPermission = (override?.permission ?? {}) as Record<string, string> const merged: Record<string, PermissionValue> = { ...userPermission }
const basePermission = (baseRestrictions as { permission: Record<string, string> }).permission for (const tool of BLOCKED_TOOLS) {
const merged: Record<string, string> = { ...userPermission } merged[tool] = "deny"
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 } }
} }
merged.call_omo_agent = "allow"
const toolsConfig = { permission: { ...merged, ...basePermission } }
const base: AgentConfig = { const base: AgentConfig = {
description: override?.description ?? description: override?.description ??
@ -153,10 +140,18 @@ export function createSisyphusJuniorAgent(
const prompt = buildSisyphusJuniorPrompt(promptAppend) const prompt = buildSisyphusJuniorPrompt(promptAppend)
const model = categoryConfig.model const model = categoryConfig.model
const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS)
const mergedConfig = migrateAgentConfig({ const categoryPermission = categoryConfig.tools
...baseRestrictions, ? Object.fromEntries(
...(categoryConfig.tools ? { tools: categoryConfig.tools } : {}), Object.entries(categoryConfig.tools).map(([k, v]) => [
}) k,
v ? ("allow" as const) : ("deny" as const),
])
)
: {}
const mergedPermission = {
...categoryPermission,
...baseRestrictions.permission,
}
const base: AgentConfig = { const base: AgentConfig = {
@ -167,7 +162,7 @@ export function createSisyphusJuniorAgent(
maxTokens: categoryConfig.maxTokens ?? 64000, maxTokens: categoryConfig.maxTokens ?? 64000,
prompt, prompt,
color: "#20B2AA", color: "#20B2AA",
...mergedConfig, permission: mergedPermission,
} }
if (categoryConfig.temperature !== undefined) { if (categoryConfig.temperature !== undefined) {

View File

@ -618,9 +618,7 @@ export function createSisyphusAgent(
? buildDynamicSisyphusPrompt(availableAgents, tools, skills) ? buildDynamicSisyphusPrompt(availableAgents, tools, skills)
: buildDynamicSisyphusPrompt([], tools, skills) : buildDynamicSisyphusPrompt([], tools, skills)
// Note: question permission allows agent to ask user questions via OpenCode's QuestionTool const permission = { question: "allow", call_omo_agent: "deny" } as AgentConfig["permission"]
// SDK type doesn't include 'question' yet, but OpenCode runtime supports it
const permission = { question: "allow" } as AgentConfig["permission"]
const base = { const base = {
description: 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.", "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, prompt,
color: "#00CED1", color: "#00CED1",
permission, permission,
tools: { call_omo_agent: false },
} }
if (isGptModel(model)) { if (isGptModel(model)) {

View File

@ -5,7 +5,7 @@ import type {
LaunchInput, LaunchInput,
ResumeInput, ResumeInput,
} from "./types" } from "./types"
import { log } from "../../shared/logger" import { log, getAgentToolRestrictions } from "../../shared"
import { ConcurrencyManager } from "./concurrency" import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig } from "../../config/schema" import type { BackgroundTaskConfig } from "../../config/schema"
@ -178,6 +178,7 @@ export class BackgroundManager {
...(input.model ? { model: input.model } : {}), ...(input.model ? { model: input.model } : {}),
system: input.skillContent, system: input.skillContent,
tools: { tools: {
...getAgentToolRestrictions(input.agent),
task: false, task: false,
sisyphus_task: false, sisyphus_task: false,
call_omo_agent: true, call_omo_agent: true,
@ -406,6 +407,7 @@ export class BackgroundManager {
body: { body: {
agent: existingTask.agent, agent: existingTask.agent,
tools: { tools: {
...getAgentToolRestrictions(existingTask.agent),
task: false, task: false,
sisyphus_task: false, sisyphus_task: false,
call_omo_agent: true, call_omo_agent: true,

View File

@ -291,37 +291,27 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
LspCodeActionResolve: false, LspCodeActionResolve: false,
}; };
type AgentWithPermission = { permission?: Record<string, unknown> };
if (agentResult.librarian) { if (agentResult.librarian) {
agentResult.librarian.tools = { const agent = agentResult.librarian as AgentWithPermission;
...agentResult.librarian.tools, agent.permission = { ...agent.permission, "grep_app_*": "allow" };
"grep_app_*": true,
};
} }
if (agentResult["multimodal-looker"]) { if (agentResult["multimodal-looker"]) {
agentResult["multimodal-looker"].tools = { const agent = agentResult["multimodal-looker"] as AgentWithPermission;
...agentResult["multimodal-looker"].tools, agent.permission = { ...agent.permission, task: "deny", look_at: "deny" };
task: false,
look_at: false,
};
} }
if (agentResult["orchestrator-sisyphus"]) { if (agentResult["orchestrator-sisyphus"]) {
agentResult["orchestrator-sisyphus"].tools = { const agent = agentResult["orchestrator-sisyphus"] as AgentWithPermission;
...agentResult["orchestrator-sisyphus"].tools, agent.permission = { ...agent.permission, task: "deny", call_omo_agent: "deny" };
task: false,
call_omo_agent: false,
};
} }
if (agentResult.Sisyphus) { if (agentResult.Sisyphus) {
agentResult.Sisyphus.tools = { const agent = agentResult.Sisyphus as AgentWithPermission;
...agentResult.Sisyphus.tools, agent.permission = { ...agent.permission, call_omo_agent: "deny" };
call_omo_agent: false,
};
} }
if (agentResult["Prometheus (Planner)"]) { if (agentResult["Prometheus (Planner)"]) {
(agentResult["Prometheus (Planner)"] as { tools?: Record<string, unknown> }).tools = { const agent = agentResult["Prometheus (Planner)"] as AgentWithPermission;
...(agentResult["Prometheus (Planner)"] as { tools?: Record<string, unknown> }).tools, agent.permission = { ...agent.permission, call_omo_agent: "deny" };
call_omo_agent: false,
};
} }
config.permission = { 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 "./session-cursor"
export * from "./shell-env" export * from "./shell-env"
export * from "./system-directive" 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 { describe, test, expect, beforeEach, afterEach } from "bun:test"
import * as childProcess from "child_process"
import { import {
parseVersion, parseVersion,
compareVersions, compareVersions,
isVersionGte, isVersionGte,
isVersionLt, isVersionLt,
getOpenCodeVersion, getOpenCodeVersion,
supportsNewPermissionSystem, isOpenCodeVersionAtLeast,
usesLegacyToolsSystem,
resetVersionCache, resetVersionCache,
setVersionCache, setVersionCache,
PERMISSION_BREAKING_VERSION, MINIMUM_OPENCODE_VERSION,
} from "./opencode-version" } from "./opencode-version"
describe("opencode-version", () => { describe("opencode-version", () => {
@ -163,7 +161,7 @@ describe("opencode-version", () => {
}) })
}) })
describe("supportsNewPermissionSystem", () => { describe("isOpenCodeVersionAtLeast", () => {
beforeEach(() => { beforeEach(() => {
resetVersionCache() resetVersionCache()
}) })
@ -172,34 +170,34 @@ describe("opencode-version", () => {
resetVersionCache() resetVersionCache()
}) })
test("returns true for v1.1.1", () => { test("returns true for exact version", () => {
// #given version is 1.1.1 // #given version is 1.1.1
setVersionCache("1.1.1") setVersionCache("1.1.1")
// #when checking permission system support // #when checking against 1.1.1
const result = supportsNewPermissionSystem() const result = isOpenCodeVersionAtLeast("1.1.1")
// #then returns true // #then returns true
expect(result).toBe(true) expect(result).toBe(true)
}) })
test("returns true for versions above 1.1.1", () => { test("returns true for versions above target", () => {
// #given version is above 1.1.1 // #given version is above target
setVersionCache("1.2.0") setVersionCache("1.2.0")
// #when checking // #when checking against 1.1.1
const result = supportsNewPermissionSystem() const result = isOpenCodeVersionAtLeast("1.1.1")
// #then returns true // #then returns true
expect(result).toBe(true) expect(result).toBe(true)
}) })
test("returns false for versions below 1.1.1", () => { test("returns false for versions below target", () => {
// #given version is below 1.1.1 // #given version is below target
setVersionCache("1.1.0") setVersionCache("1.1.0")
// #when checking // #when checking against 1.1.1
const result = supportsNewPermissionSystem() const result = isOpenCodeVersionAtLeast("1.1.1")
// #then returns false // #then returns false
expect(result).toBe(false) expect(result).toBe(false)
@ -210,48 +208,16 @@ describe("opencode-version", () => {
setVersionCache(null) setVersionCache(null)
// #when checking // #when checking
const result = supportsNewPermissionSystem() const result = isOpenCodeVersionAtLeast("1.1.1")
// #then returns true (assume newer version) // #then returns true (assume newer version)
expect(result).toBe(true) expect(result).toBe(true)
}) })
}) })
describe("usesLegacyToolsSystem", () => { describe("MINIMUM_OPENCODE_VERSION", () => {
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", () => {
test("is set to 1.1.1", () => { 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" 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") const NOT_CACHED = Symbol("NOT_CACHED")
let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED
@ -53,14 +57,10 @@ export function getOpenCodeVersion(): string | null {
} }
} }
export function supportsNewPermissionSystem(): boolean { export function isOpenCodeVersionAtLeast(version: string): boolean {
const version = getOpenCodeVersion() const current = getOpenCodeVersion()
if (!version) return true if (!current) return true
return isVersionGte(version, PERMISSION_BREAKING_VERSION) return isVersionGte(current, version)
}
export function usesLegacyToolsSystem(): boolean {
return !supportsNewPermissionSystem()
} }
export function resetVersionCache(): void { 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 { import {
createAgentToolRestrictions, createAgentToolRestrictions,
createAgentToolAllowlist, createAgentToolAllowlist,
migrateToolsToPermission, migrateToolsToPermission,
migratePermissionToTools,
migrateAgentConfig, migrateAgentConfig,
} from "./permission-compat" } from "./permission-compat"
import { setVersionCache, resetVersionCache } from "./opencode-version"
describe("permission-compat", () => { describe("permission-compat", () => {
beforeEach(() => {
resetVersionCache()
})
afterEach(() => {
resetVersionCache()
})
describe("createAgentToolRestrictions", () => { describe("createAgentToolRestrictions", () => {
test("returns permission format for v1.1.1+", () => { test("returns permission format with deny values", () => {
// #given version is 1.1.1 // #given tools to restrict
setVersionCache("1.1.1")
// #when creating restrictions // #when creating restrictions
const result = createAgentToolRestrictions(["write", "edit"]) const result = createAgentToolRestrictions(["write", "edit"])
@ -31,38 +19,19 @@ describe("permission-compat", () => {
}) })
}) })
test("returns tools format for versions below 1.1.1", () => { test("returns empty permission for empty array", () => {
// #given version is below 1.1.1 // #given empty tools array
setVersionCache("1.0.150")
// #when creating restrictions // #when creating restrictions
const result = createAgentToolRestrictions(["write", "edit"]) const result = createAgentToolRestrictions([])
// #then returns tools format // #then returns empty permission
expect(result).toEqual({ expect(result).toEqual({ permission: {} })
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" },
})
}) })
}) })
describe("createAgentToolAllowlist", () => { describe("createAgentToolAllowlist", () => {
test("returns wildcard deny with explicit allow for v1.1.1+", () => { test("returns wildcard deny with explicit allow", () => {
// #given version is 1.1.1 // #given tools to allow
setVersionCache("1.1.1")
// #when creating allowlist // #when creating allowlist
const result = createAgentToolAllowlist(["read"]) const result = createAgentToolAllowlist(["read"])
@ -72,11 +41,9 @@ describe("permission-compat", () => {
}) })
}) })
test("returns wildcard deny with multiple allows for v1.1.1+", () => { test("returns wildcard deny with multiple allows", () => {
// #given version is 1.1.1 // #given multiple tools to allow
setVersionCache("1.1.1") // #when creating allowlist
// #when creating allowlist with multiple tools
const result = createAgentToolAllowlist(["read", "glob"]) const result = createAgentToolAllowlist(["read", "glob"])
// #then returns wildcard deny with both allows // #then returns wildcard deny with both allows
@ -84,35 +51,6 @@ describe("permission-compat", () => {
permission: { "*": "deny", read: "allow", glob: "allow" }, 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", () => { 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", () => { describe("migrateAgentConfig", () => {
test("migrates tools to permission for v1.1.1+", () => { test("migrates tools to permission", () => {
// #given v1.1.1 and config with tools // #given config with tools
setVersionCache("1.1.1")
const config = { const config = {
model: "test", model: "test",
tools: { write: false, edit: false }, tools: { write: false, edit: false },
@ -178,25 +87,8 @@ describe("permission-compat", () => {
expect(result.model).toBe("test") 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", () => { test("preserves other config fields", () => {
// #given config with other fields // #given config with other fields
setVersionCache("1.1.1")
const config = { const config = {
model: "test", model: "test",
temperature: 0.5, temperature: 0.5,
@ -212,5 +104,31 @@ describe("permission-compat", () => {
expect(result.temperature).toBe(0.5) expect(result.temperature).toBe(0.5)
expect(result.prompt).toBe("hello") 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" /**
* Permission system utilities for OpenCode 1.1.1+.
export { supportsNewPermissionSystem } * This module only supports the new permission format.
*/
export type PermissionValue = "ask" | "allow" | "deny" export type PermissionValue = "ask" | "allow" | "deny"
export interface LegacyToolsFormat { export interface PermissionFormat {
tools: Record<string, boolean>
}
export interface NewPermissionFormat {
permission: Record<string, PermissionValue> permission: Record<string, PermissionValue>
} }
export type VersionAwareRestrictions = LegacyToolsFormat | NewPermissionFormat /**
* Creates tool restrictions that deny specified tools.
*/
export function createAgentToolRestrictions( export function createAgentToolRestrictions(
denyTools: string[] denyTools: string[]
): VersionAwareRestrictions { ): PermissionFormat {
if (supportsNewPermissionSystem()) {
return {
permission: Object.fromEntries(
denyTools.map((tool) => [tool, "deny" as const])
),
}
}
return { 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. * Creates tool restrictions that ONLY allow specified tools.
* All other tools are denied by default. * All other tools are denied by default using `*: deny` pattern.
*
* Uses `*: deny` pattern for new permission system,
* falls back to explicit deny list for legacy systems.
*/ */
export function createAgentToolAllowlist( export function createAgentToolAllowlist(
allowTools: string[] allowTools: string[]
): VersionAwareRestrictions { ): PermissionFormat {
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))
return { 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( export function migrateToolsToPermission(
tools: Record<string, boolean> tools: Record<string, boolean>
): Record<string, PermissionValue> { ): Record<string, PermissionValue> {
@ -104,40 +54,23 @@ export function migrateToolsToPermission(
) )
} }
export function migratePermissionToTools( /**
permission: Record<string, PermissionValue> * Migrates agent config from legacy tools format to permission format.
): Record<string, boolean> { * If config has `tools`, converts to `permission`.
return Object.fromEntries( */
Object.entries(permission)
.filter(([, value]) => value !== "ask")
.map(([key, value]) => [key, value === "allow"])
)
}
export function migrateAgentConfig( export function migrateAgentConfig(
config: Record<string, unknown> config: Record<string, unknown>
): Record<string, unknown> { ): Record<string, unknown> {
const result = { ...config } const result = { ...config }
if (supportsNewPermissionSystem()) { if (result.tools && typeof result.tools === "object") {
if (result.tools && typeof result.tools === "object") { const existingPermission =
const existingPermission = (result.permission as Record<string, PermissionValue>) || {}
(result.permission as Record<string, PermissionValue>) || {} const migratedPermission = migrateToolsToPermission(
const migratedPermission = migrateToolsToPermission( result.tools as Record<string, boolean>
result.tools as Record<string, boolean> )
) result.permission = { ...migratedPermission, ...existingPermission }
result.permission = { ...migratedPermission, ...existingPermission } delete result.tools
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
}
} }
return result return result

View File

@ -4,7 +4,7 @@ import { join } from "node:path"
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants" import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
import type { CallOmoAgentArgs } from "./types" import type { CallOmoAgentArgs } from "./types"
import type { BackgroundManager } from "../../features/background-agent" import type { BackgroundManager } from "../../features/background-agent"
import { log } from "../../shared/logger" import { log, getAgentToolRestrictions } from "../../shared"
import { consumeNewMessages } from "../../shared/session-cursor" import { consumeNewMessages } from "../../shared/session-cursor"
import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state" import { getSessionAgent } from "../../features/claude-code-session-state"
@ -188,6 +188,7 @@ async function executeSync(
body: { body: {
agent: args.subagent_type, agent: args.subagent_type,
tools: { tools: {
...getAgentToolRestrictions(args.subagent_type),
task: false, task: false,
sisyphus_task: false, sisyphus_task: false,
}, },

View File

@ -1,4 +1,7 @@
import { import {
lsp_goto_definition,
lsp_find_references,
lsp_symbols,
lsp_diagnostics, lsp_diagnostics,
lsp_prepare_rename, lsp_prepare_rename,
lsp_rename, lsp_rename,
@ -52,6 +55,9 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco
} }
export const builtinTools: Record<string, ToolDefinition> = { export const builtinTools: Record<string, ToolDefinition> = {
lsp_goto_definition,
lsp_find_references,
lsp_symbols,
lsp_diagnostics, lsp_diagnostics,
lsp_prepare_rename, lsp_prepare_rename,
lsp_rename, lsp_rename,

View File

@ -509,6 +509,37 @@ export class LSPClient {
await new Promise((r) => setTimeout(r, 1000)) 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[] }> { async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
const absPath = resolve(filePath) const absPath = resolve(filePath)
const uri = pathToFileURL(absPath).href const uri = pathToFileURL(absPath).href

View File

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

View File

@ -4,4 +4,4 @@ export * from "./config"
export * from "./client" export * from "./client"
export * from "./utils" export * from "./utils"
// NOTE: lsp_servers removed - duplicates OpenCode's built-in LspServers // 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 { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { import {
DEFAULT_MAX_REFERENCES,
DEFAULT_MAX_SYMBOLS,
DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_DIAGNOSTICS,
} from "./constants" } from "./constants"
import { import {
withLspClient, withLspClient,
formatLocation,
formatDocumentSymbol,
formatSymbolInfo,
formatDiagnostic, formatDiagnostic,
filterDiagnosticsBySeverity, filterDiagnosticsBySeverity,
formatPrepareRenameResult, formatPrepareRenameResult,
@ -11,14 +16,155 @@ import {
formatApplyResult, formatApplyResult,
} from "./utils" } from "./utils"
import type { import type {
Location,
LocationLink,
DocumentSymbol,
SymbolInfo,
Diagnostic, Diagnostic,
PrepareRenameResult, PrepareRenameResult,
PrepareRenameDefaultBehavior, PrepareRenameDefaultBehavior,
WorkspaceEdit, WorkspaceEdit,
} from "./types" } from "./types"
// NOTE: lsp_goto_definition, lsp_find_references, lsp_symbols are removed export const lsp_goto_definition: ToolDefinition = tool({
// as they duplicate OpenCode's built-in LSP tools (LspGotoDefinition, LspFindReferences, LspDocumentSymbols, LspWorkspaceSymbols) 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({ export const lsp_diagnostics: ToolDefinition = tool({
description: "Get errors, warnings, hints from language server BEFORE running build.", description: "Get errors, warnings, hints from language server BEFORE running build.",

View File

@ -17,6 +17,33 @@ export interface Range {
end: Position 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 { export interface Diagnostic {
range: Range range: Range
severity?: number severity?: number

View File

@ -3,8 +3,12 @@ import { fileURLToPath } from "node:url"
import { existsSync, readFileSync, writeFileSync } from "fs" import { existsSync, readFileSync, writeFileSync } from "fs"
import { LSPClient, lspManager } from "./client" import { LSPClient, lspManager } from "./client"
import { findServerForExtension } from "./config" import { findServerForExtension } from "./config"
import { SEVERITY_MAP } from "./constants" import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants"
import type { import type {
Location,
LocationLink,
DocumentSymbol,
SymbolInfo,
Diagnostic, Diagnostic,
PrepareRenameResult, PrepareRenameResult,
PrepareRenameDefaultBehavior, 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 { export function formatSeverity(severity: number | undefined): string {
if (!severity) return "unknown" if (!severity) return "unknown"
return SEVERITY_MAP[severity] || `unknown(${severity})` 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 { export function formatDiagnostic(diag: Diagnostic): string {
const severity = formatSeverity(diag.severity) const severity = formatSeverity(diag.severity)
const line = diag.range.start.line + 1 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 { getTaskToastManager } from "../../features/task-toast-manager"
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state" import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger" import { log, getAgentToolRestrictions } from "../../shared"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@ -322,6 +322,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}), ...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}), ...(resumeModel !== undefined ? { model: resumeModel } : {}),
tools: { tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: false, task: false,
sisyphus_task: false, sisyphus_task: false,
call_omo_agent: true, call_omo_agent: true,