refactor: wave 1 - extract leaf modules, rename catch-all files, split index.ts hooks

- Split 25+ index.ts files into hook.ts + extracted modules
- Rename all catch-all utils.ts/helpers.ts to domain-specific names
- Split src/tools/lsp/ into ~15 focused modules
- Split src/tools/delegate-task/ into ~18 focused modules
- Separate shared types from implementation
- 155 files changed, 60+ new files created
- All typecheck clean, 61 tests pass
This commit is contained in:
YeonGyu-Kim
2026-02-08 13:57:26 +09:00
parent 7a15c1f3f9
commit df03300211
156 changed files with 7280 additions and 6771 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { CLI_LANGUAGES } from "./constants"
import { runSg } from "./cli"
import { formatSearchResult, formatReplaceResult } from "./utils"
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
import type { CliLanguage } from "./types"
async function showOutputToUser(context: unknown, output: string): Promise<void> {
@@ -0,0 +1,61 @@
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, ParentContext } from "./executor-types"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDetailedError } from "./error-formatting"
export async function executeBackgroundContinuation(
args: DelegateTaskArgs,
ctx: ToolContextWithMetadata,
executorCtx: ExecutorContext,
parentContext: ParentContext
): Promise<string> {
const { manager } = executorCtx
try {
const task = await manager.resume({
sessionId: args.session_id!,
prompt: args.prompt,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
})
const bgContMeta = {
title: `Continue: ${task.description}`,
metadata: {
prompt: args.prompt,
agent: task.agent,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: task.sessionID,
command: args.command,
},
}
await ctx.metadata?.(bgContMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, bgContMeta)
}
return `Background task continued.
Task ID: ${task.id}
Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
Agent continues with full previous context preserved.
Use \`background_output\` with task_id="${task.id}" to check progress.
<task_metadata>
session_id: ${task.sessionID}
</task_metadata>`
} catch (error) {
return formatDetailedError(error, {
operation: "Continue background task",
args,
sessionID: args.session_id,
})
}
}
@@ -0,0 +1,87 @@
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, ParentContext } from "./executor-types"
import { getTimingConfig } from "./timing"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDetailedError } from "./error-formatting"
export async function executeBackgroundTask(
args: DelegateTaskArgs,
ctx: ToolContextWithMetadata,
executorCtx: ExecutorContext,
parentContext: ParentContext,
agentToUse: string,
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
systemContent: string | undefined
): Promise<string> {
const { manager } = executorCtx
try {
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: agentToUse,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
model: categoryModel,
skills: args.load_skills.length > 0 ? args.load_skills : undefined,
skillContent: systemContent,
category: args.category,
})
// OpenCode TUI's `Task` tool UI calculates toolcalls by looking up
// `props.metadata.sessionId` and then counting tool parts in that session.
// BackgroundManager.launch() returns immediately (pending) before the session exists,
// so we must wait briefly for the session to be created to set metadata correctly.
const timing = getTimingConfig()
const waitStart = Date.now()
let sessionId = task.sessionID
while (!sessionId && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
if (ctx.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
}
await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS))
const updated = manager.getTask(task.id)
sessionId = updated?.sessionID
}
const unstableMeta = {
title: args.description,
metadata: {
prompt: args.prompt,
agent: task.agent,
category: args.category,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: sessionId ?? "pending",
command: args.command,
},
}
await ctx.metadata?.(unstableMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, unstableMeta)
}
return `Background task launched.
Task ID: ${task.id}
Description: ${task.description}
Agent: ${task.agent}${args.category ? ` (category: ${args.category})` : ""}
Status: ${task.status}
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
<task_metadata>
session_id: ${sessionId}
</task_metadata>`
} catch (error) {
return formatDetailedError(error, {
operation: "Launch background task",
args,
agent: agentToUse,
category: args.category,
})
}
}
@@ -0,0 +1,165 @@
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import type { DelegateTaskArgs } from "./types"
import type { ExecutorContext } from "./executor-types"
import { DEFAULT_CATEGORIES } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { resolveCategoryConfig } from "./categories"
import { parseModelString } from "./model-string-parser"
import { fetchAvailableModels } from "../../shared/model-availability"
import { readConnectedProvidersCache } from "../../shared/connected-providers-cache"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { resolveModelPipeline } from "../../shared"
export interface CategoryResolutionResult {
agentToUse: string
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
categoryPromptAppend: string | undefined
modelInfo: ModelFallbackInfo | undefined
actualModel: string | undefined
isUnstableAgent: boolean
error?: string
}
export async function resolveCategoryExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
inheritedModel: string | undefined,
systemDefaultModel: string | undefined
): Promise<CategoryResolutionResult> {
const { client, userCategories, sisyphusJuniorModel } = executorCtx
const connectedProviders = readConnectedProvidersCache()
const availableModels = await fetchAvailableModels(client, {
connectedProviders: connectedProviders ?? undefined,
})
const resolved = resolveCategoryConfig(args.category!, {
userCategories,
inheritedModel,
systemDefaultModel,
availableModels,
})
if (!resolved) {
return {
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
error: `Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`,
}
}
const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category!]
let actualModel: string | undefined
let modelInfo: ModelFallbackInfo | undefined
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
const overrideModel = sisyphusJuniorModel
const explicitCategoryModel = userCategories?.[args.category!]?.model
if (!requirement) {
// Precedence: explicit category model > sisyphus-junior default > category resolved model
// This keeps `sisyphus-junior.model` useful as a global default while allowing
// per-category overrides via `categories[category].model`.
actualModel = explicitCategoryModel ?? overrideModel ?? resolved.model
if (actualModel) {
modelInfo = explicitCategoryModel || overrideModel
? { model: actualModel, type: "user-defined", source: "override" }
: { model: actualModel, type: "system-default", source: "system-default" }
}
} else {
const resolution = resolveModelPipeline({
intent: {
userModel: explicitCategoryModel ?? overrideModel,
categoryDefaultModel: resolved.model,
},
constraints: { availableModels },
policy: {
fallbackChain: requirement.fallbackChain,
systemDefaultModel,
},
})
if (resolution) {
const { model: resolvedModel, provenance, variant: resolvedVariant } = resolution
actualModel = resolvedModel
if (!parseModelString(actualModel)) {
return {
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
error: `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`,
}
}
let type: "user-defined" | "inherited" | "category-default" | "system-default"
const source = provenance
switch (provenance) {
case "override":
type = "user-defined"
break
case "category-default":
case "provider-fallback":
type = "category-default"
break
case "system-default":
type = "system-default"
break
}
modelInfo = { model: actualModel, type, source }
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category!]?.variant ?? resolvedVariant ?? resolved.config.variant
categoryModel = parsedModel
? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel)
: undefined
}
}
if (!categoryModel && actualModel) {
const parsedModel = parseModelString(actualModel)
categoryModel = parsedModel ?? undefined
}
const categoryPromptAppend = resolved.promptAppend || undefined
if (!categoryModel && !actualModel) {
const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories })
return {
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
error: `Model not configured for category "${args.category}".
Configure in one of:
1. OpenCode: Set "model" in opencode.json
2. Oh-My-OpenCode: Set category model in oh-my-opencode.json
3. Provider: Connect a provider with available models
Current category: ${args.category}
Available categories: ${categoryNames.join(", ")}`,
}
}
const unstableModel = actualModel?.toLowerCase()
const isUnstableAgent = resolved.config.is_unstable_agent === true || (unstableModel ? unstableModel.includes("gemini") || unstableModel.includes("minimax") : false)
return {
agentToUse: SISYPHUS_JUNIOR_AGENT,
categoryModel,
categoryPromptAppend,
modelInfo,
actualModel,
isUnstableAgent,
}
}
@@ -0,0 +1,51 @@
import type { DelegateTaskArgs } from "./types"
/**
* Context for error formatting.
*/
export interface ErrorContext {
operation: string
args?: DelegateTaskArgs
sessionID?: string
agent?: string
category?: string
}
/**
* Format an error with detailed context for debugging.
*/
export function formatDetailedError(error: unknown, ctx: ErrorContext): string {
const message = error instanceof Error ? error.message : String(error)
const stack = error instanceof Error ? error.stack : undefined
const lines: string[] = [`${ctx.operation} failed`, "", `**Error**: ${message}`]
if (ctx.sessionID) {
lines.push(`**Session ID**: ${ctx.sessionID}`)
}
if (ctx.agent) {
lines.push(`**Agent**: ${ctx.agent}${ctx.category ? ` (category: ${ctx.category})` : ""}`)
}
if (ctx.args) {
lines.push("", "**Arguments**:")
lines.push(`- description: "${ctx.args.description}"`)
lines.push(`- category: ${ctx.args.category ?? "(none)"}`)
lines.push(`- subagent_type: ${ctx.args.subagent_type ?? "(none)"}`)
lines.push(`- run_in_background: ${ctx.args.run_in_background}`)
lines.push(`- load_skills: [${ctx.args.load_skills?.join(", ") ?? ""}]`)
if (ctx.args.session_id) {
lines.push(`- session_id: ${ctx.args.session_id}`)
}
}
if (stack) {
lines.push("", "**Stack Trace**:")
lines.push("```")
lines.push(stack.split("\n").slice(0, 10).join("\n"))
lines.push("```")
}
return lines.join("\n")
}
+33
View File
@@ -0,0 +1,33 @@
import type { BackgroundManager } from "../../features/background-agent"
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
import type { OpencodeClient } from "./types"
export interface ExecutorContext {
manager: BackgroundManager
client: OpencodeClient
directory: string
userCategories?: CategoriesConfig
gitMasterConfig?: GitMasterConfig
sisyphusJuniorModel?: string
browserProvider?: BrowserAutomationProvider
onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
}
export interface ParentContext {
sessionID: string
messageID: string
agent?: string
model?: { providerID: string; modelID: string; variant?: string }
}
export interface SessionMessage {
info?: {
role?: string
time?: { created?: number }
agent?: string
model?: { providerID: string; modelID: string }
modelID?: string
providerID?: string
}
parts?: Array<{ type?: string; text?: string }>
}
File diff suppressed because it is too large Load Diff
-101
View File
@@ -1,101 +0,0 @@
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
import type { DelegateTaskArgs } from "./types"
/**
* Parse a model string in "provider/model" format.
*/
export function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
const parts = model.split("/")
if (parts.length >= 2) {
return { providerID: parts[0], modelID: parts.slice(1).join("/") }
}
return undefined
}
/**
* Get the message directory for a session, checking both direct and nested paths.
*/
export function getMessageDir(sessionID: string): string | null {
if (!sessionID.startsWith("ses_")) return null
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
/**
* Format a duration between two dates as a human-readable string.
*/
export function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`
if (minutes > 0) return `${minutes}m ${seconds % 60}s`
return `${seconds}s`
}
/**
* Context for error formatting.
*/
export interface ErrorContext {
operation: string
args?: DelegateTaskArgs
sessionID?: string
agent?: string
category?: string
}
/**
* Format an error with detailed context for debugging.
*/
export function formatDetailedError(error: unknown, ctx: ErrorContext): string {
const message = error instanceof Error ? error.message : String(error)
const stack = error instanceof Error ? error.stack : undefined
const lines: string[] = [
`${ctx.operation} failed`,
"",
`**Error**: ${message}`,
]
if (ctx.sessionID) {
lines.push(`**Session ID**: ${ctx.sessionID}`)
}
if (ctx.agent) {
lines.push(`**Agent**: ${ctx.agent}${ctx.category ? ` (category: ${ctx.category})` : ""}`)
}
if (ctx.args) {
lines.push("", "**Arguments**:")
lines.push(`- description: "${ctx.args.description}"`)
lines.push(`- category: ${ctx.args.category ?? "(none)"}`)
lines.push(`- subagent_type: ${ctx.args.subagent_type ?? "(none)"}`)
lines.push(`- run_in_background: ${ctx.args.run_in_background}`)
lines.push(`- load_skills: [${ctx.args.load_skills?.join(", ") ?? ""}]`)
if (ctx.args.session_id) {
lines.push(`- session_id: ${ctx.args.session_id}`)
}
}
if (stack) {
lines.push("", "**Stack Trace**:")
lines.push("```")
lines.push(stack.split("\n").slice(0, 10).join("\n"))
lines.push("```")
}
return lines.join("\n")
}
@@ -0,0 +1,10 @@
/**
* Parse a model string in "provider/model" format.
*/
export function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
const parts = model.split("/")
if (parts.length >= 2) {
return { providerID: parts[0], modelID: parts.slice(1).join("/") }
}
return undefined
}
@@ -0,0 +1,38 @@
import type { ToolContextWithMetadata } from "./types"
import type { ParentContext } from "./executor-types"
import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { log, getMessageDir } from "../../shared"
export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContext {
const messageDir = getMessageDir(ctx.sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
const sessionAgent = getSessionAgent(ctx.sessionID)
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
log("[task] parentAgent resolution", {
sessionID: ctx.sessionID,
messageDir,
ctxAgent: ctx.agent,
sessionAgent,
firstMessageAgent,
prevMessageAgent: prevMessage?.agent,
resolvedParentAgent: parentAgent,
})
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
? {
providerID: prevMessage.model.providerID,
modelID: prevMessage.model.modelID,
...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}),
}
: undefined
return {
sessionID: ctx.sessionID,
messageID: ctx.messageID,
agent: parentAgent,
model: parentModel,
}
}
@@ -0,0 +1 @@
export const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior"
@@ -0,0 +1,21 @@
import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
import { discoverSkills } from "../../features/opencode-skill-loader"
export async function resolveSkillContent(
skills: string[],
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set<string> }
): Promise<{ content: string | undefined; error: string | null }> {
if (skills.length === 0) {
return { content: undefined, error: null }
}
const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options)
if (notFound.length > 0) {
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
const available = allSkills.map(s => s.name).join(", ")
return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
}
return { content: Array.from(resolved.values()).join("\n\n"), error: null }
}
@@ -0,0 +1,87 @@
import type { DelegateTaskArgs } from "./types"
import type { ExecutorContext } from "./executor-types"
import { isPlanAgent } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
export async function resolveSubagentExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
parentAgent: string | undefined,
categoryExamples: string
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string } | undefined; error?: string }> {
const { client } = executorCtx
if (!args.subagent_type?.trim()) {
return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` }
}
const agentName = args.subagent_type.trim()
if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) {
return {
agentToUse: "",
categoryModel: undefined,
error: `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. Use category parameter instead (e.g., ${categoryExamples}).
Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`,
}
}
if (isPlanAgent(agentName) && isPlanAgent(parentAgent)) {
return {
agentToUse: "",
categoryModel: undefined,
error: `You are prometheus. You cannot delegate to prometheus via task.
Create the work plan directly - that's your job as the planning agent.`,
}
}
let agentToUse = agentName
let categoryModel: { providerID: string; modelID: string } | undefined
try {
const agentsResult = await client.app.agents()
type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all"; model?: { providerID: string; modelID: string } }
const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[]
const callableAgents = agents.filter((a) => a.mode !== "primary")
const matchedAgent = callableAgents.find(
(agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
)
if (!matchedAgent) {
const isPrimaryAgent = agents
.filter((a) => a.mode === "primary")
.find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase())
if (isPrimaryAgent) {
return {
agentToUse: "",
categoryModel: undefined,
error: `Cannot call primary agent "${isPrimaryAgent.name}" via task. Primary agents are top-level orchestrators.`,
}
}
const availableAgents = callableAgents
.map((a) => a.name)
.sort()
.join(", ")
return {
agentToUse: "",
categoryModel: undefined,
error: `Unknown agent: "${agentToUse}". Available agents: ${availableAgents}`,
}
}
agentToUse = matchedAgent.name
if (matchedAgent.model) {
categoryModel = matchedAgent.model
}
} catch {
// Proceed anyway - session.prompt will fail with clearer error if agent doesn't exist
}
return { agentToUse, categoryModel }
}
@@ -0,0 +1,154 @@
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, SessionMessage } from "./executor-types"
import { getTimingConfig } from "./timing"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { getAgentToolRestrictions, getMessageDir } from "../../shared"
import { findNearestMessageWithFields } from "../../features/hook-message-injector"
import { formatDuration } from "./time-formatter"
export async function executeSyncContinuation(
args: DelegateTaskArgs,
ctx: ToolContextWithMetadata,
executorCtx: ExecutorContext
): Promise<string> {
const { client } = executorCtx
const toastManager = getTaskToastManager()
const taskId = `resume_sync_${args.session_id!.slice(0, 8)}`
const startTime = new Date()
if (toastManager) {
toastManager.addTask({
id: taskId,
description: args.description,
agent: "continue",
isBackground: false,
})
}
const syncContMeta = {
title: `Continue: ${args.description}`,
metadata: {
prompt: args.prompt,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: args.session_id,
sync: true,
command: args.command,
},
}
await ctx.metadata?.(syncContMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, syncContMeta)
}
try {
let resumeAgent: string | undefined
let resumeModel: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await client.session.messages({ path: { id: args.session_id! } })
const messages = (messagesResp.data ?? []) as SessionMessage[]
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
resumeAgent = info.agent
resumeModel = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
break
}
}
} catch {
const resumeMessageDir = getMessageDir(args.session_id!)
const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null
resumeAgent = resumeMessage?.agent
resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
: undefined
}
await (client.session as any).promptAsync({
path: { id: args.session_id! },
body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}),
tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: args.prompt }],
},
})
} catch (promptError) {
if (toastManager) {
toastManager.removeTask(taskId)
}
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
return `Failed to send continuation prompt: ${errorMessage}\n\nSession ID: ${args.session_id}`
}
const timing = getTimingConfig()
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
while (Date.now() - pollStart < 60000) {
await new Promise(resolve => setTimeout(resolve, timing.POLL_INTERVAL_MS))
const elapsed = Date.now() - pollStart
if (elapsed < timing.SESSION_CONTINUATION_STABILITY_MS) continue
const messagesCheck = await client.session.messages({ path: { id: args.session_id! } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= timing.STABILITY_POLLS_REQUIRED) break
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
const messagesResult = await client.session.messages({
path: { id: args.session_id! },
})
if (messagesResult.error) {
if (toastManager) {
toastManager.removeTask(taskId)
}
return `Error fetching result: ${messagesResult.error}\n\nSession ID: ${args.session_id}`
}
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
const assistantMessages = messages
.filter((m) => m.info?.role === "assistant")
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
const lastMessage = assistantMessages[0]
if (toastManager) {
toastManager.removeTask(taskId)
}
if (!lastMessage) {
return `No assistant response found.\n\nSession ID: ${args.session_id}`
}
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
const duration = formatDuration(startTime)
return `Task continued and completed in ${duration}.
---
${textContent || "(No text output)"}
<task_metadata>
session_id: ${args.session_id}
</task_metadata>`
}
@@ -0,0 +1,59 @@
import type { DelegateTaskArgs, OpencodeClient } from "./types"
import { isPlanAgent } from "./constants"
import { promptWithModelSuggestionRetry } from "../../shared"
import { formatDetailedError } from "./error-formatting"
export async function sendSyncPrompt(
client: OpencodeClient,
input: {
sessionID: string
agentToUse: string
args: DelegateTaskArgs
systemContent: string | undefined
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
toastManager: { removeTask: (id: string) => void } | null | undefined
taskId: string | undefined
}
): Promise<string | null> {
try {
const allowTask = isPlanAgent(input.agentToUse)
await promptWithModelSuggestionRetry(client, {
path: { id: input.sessionID },
body: {
agent: input.agentToUse,
system: input.systemContent,
tools: {
task: allowTask,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: input.args.prompt }],
...(input.categoryModel ? { model: { providerID: input.categoryModel.providerID, modelID: input.categoryModel.modelID } } : {}),
...(input.categoryModel?.variant ? { variant: input.categoryModel.variant } : {}),
},
})
} catch (promptError) {
if (input.toastManager && input.taskId !== undefined) {
input.toastManager.removeTask(input.taskId)
}
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
return formatDetailedError(new Error(`Agent "${input.agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`), {
operation: "Send prompt to agent",
args: input.args,
sessionID: input.sessionID,
agent: input.agentToUse,
category: input.args.category,
})
}
return formatDetailedError(promptError, {
operation: "Send prompt",
args: input.args,
sessionID: input.sessionID,
agent: input.agentToUse,
category: input.args.category,
})
}
return null
}
@@ -0,0 +1,31 @@
import type { OpencodeClient } from "./types"
import type { SessionMessage } from "./executor-types"
export async function fetchSyncResult(
client: OpencodeClient,
sessionID: string
): Promise<{ ok: true; textContent: string } | { ok: false; error: string }> {
const messagesResult = await client.session.messages({
path: { id: sessionID },
})
if ((messagesResult as { error?: unknown }).error) {
return { ok: false, error: `Error fetching result: ${(messagesResult as { error: unknown }).error}\n\nSession ID: ${sessionID}` }
}
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
const assistantMessages = messages
.filter((m) => m.info?.role === "assistant")
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
const lastMessage = assistantMessages[0]
if (!lastMessage) {
return { ok: false, error: `No assistant response found.\n\nSession ID: ${sessionID}` }
}
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
return { ok: true, textContent }
}
@@ -0,0 +1,30 @@
import type { OpencodeClient } from "./types"
export async function createSyncSession(
client: OpencodeClient,
input: { parentSessionID: string; agentToUse: string; description: string; defaultDirectory: string }
): Promise<{ ok: true; sessionID: string; parentDirectory: string } | { ok: false; error: string }> {
const parentSession = client.session.get
? await client.session.get({ path: { id: input.parentSessionID } }).catch(() => null)
: null
const parentDirectory = parentSession?.data?.directory ?? input.defaultDirectory
const createResult = await client.session.create({
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agentToUse} subagent)`,
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
query: {
directory: parentDirectory,
},
})
if (createResult.error) {
return { ok: false, error: `Failed to create session: ${createResult.error}` }
}
return { ok: true, sessionID: createResult.data.id, parentDirectory }
}
@@ -0,0 +1,80 @@
import type { ToolContextWithMetadata, OpencodeClient } from "./types"
import { getTimingConfig } from "./timing"
import { log } from "../../shared"
export async function pollSyncSession(
ctx: ToolContextWithMetadata,
client: OpencodeClient,
input: {
sessionID: string
agentToUse: string
toastManager: { removeTask: (id: string) => void } | null | undefined
taskId: string | undefined
}
): Promise<string | null> {
const syncTiming = getTimingConfig()
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
let pollCount = 0
log("[task] Starting poll loop", { sessionID: input.sessionID, agentToUse: input.agentToUse })
while (Date.now() - pollStart < syncTiming.MAX_POLL_TIME_MS) {
if (ctx.abort?.aborted) {
log("[task] Aborted by user", { sessionID: input.sessionID })
if (input.toastManager && input.taskId) input.toastManager.removeTask(input.taskId)
return `Task aborted.\n\nSession ID: ${input.sessionID}`
}
await new Promise(resolve => setTimeout(resolve, syncTiming.POLL_INTERVAL_MS))
pollCount++
const statusResult = await client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const sessionStatus = allStatuses[input.sessionID]
if (pollCount % 10 === 0) {
log("[task] Poll status", {
sessionID: input.sessionID,
pollCount,
elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s",
sessionStatus: sessionStatus?.type ?? "not_in_status",
stablePolls,
lastMsgCount,
})
}
if (sessionStatus && sessionStatus.type !== "idle") {
stablePolls = 0
lastMsgCount = 0
continue
}
const elapsed = Date.now() - pollStart
if (elapsed < syncTiming.MIN_STABILITY_TIME_MS) {
continue
}
const messagesCheck = await client.session.messages({ path: { id: input.sessionID } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= syncTiming.STABILITY_POLLS_REQUIRED) {
log("[task] Poll complete - messages stable", { sessionID: input.sessionID, pollCount, currentMsgCount })
break
}
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
if (Date.now() - pollStart >= syncTiming.MAX_POLL_TIME_MS) {
log("[task] Poll timeout reached", { sessionID: input.sessionID, pollCount, lastMsgCount, stablePolls })
}
return null
}
+154
View File
@@ -0,0 +1,154 @@
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, ParentContext } from "./executor-types"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared"
import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
import { createSyncSession } from "./sync-session-creator"
import { sendSyncPrompt } from "./sync-prompt-sender"
import { pollSyncSession } from "./sync-session-poller"
import { fetchSyncResult } from "./sync-result-fetcher"
export async function executeSyncTask(
args: DelegateTaskArgs,
ctx: ToolContextWithMetadata,
executorCtx: ExecutorContext,
parentContext: ParentContext,
agentToUse: string,
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
systemContent: string | undefined,
modelInfo?: ModelFallbackInfo
): Promise<string> {
const { client, directory, onSyncSessionCreated } = executorCtx
const toastManager = getTaskToastManager()
let taskId: string | undefined
let syncSessionID: string | undefined
try {
const createSessionResult = await createSyncSession(client, {
parentSessionID: parentContext.sessionID,
agentToUse,
description: args.description,
defaultDirectory: directory,
})
if (!createSessionResult.ok) {
return createSessionResult.error
}
const sessionID = createSessionResult.sessionID
syncSessionID = sessionID
subagentSessions.add(sessionID)
if (onSyncSessionCreated) {
log("[task] Invoking onSyncSessionCreated callback", { sessionID, parentID: parentContext.sessionID })
await onSyncSessionCreated({
sessionID,
parentID: parentContext.sessionID,
title: args.description,
}).catch((err) => {
log("[task] onSyncSessionCreated callback failed", { error: String(err) })
})
await new Promise(r => setTimeout(r, 200))
}
taskId = `sync_${sessionID.slice(0, 8)}`
const startTime = new Date()
if (toastManager) {
toastManager.addTask({
id: taskId,
description: args.description,
agent: agentToUse,
isBackground: false,
category: args.category,
skills: args.load_skills,
modelInfo,
})
}
const syncTaskMeta = {
title: args.description,
metadata: {
prompt: args.prompt,
agent: agentToUse,
category: args.category,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: sessionID,
sync: true,
command: args.command,
},
}
await ctx.metadata?.(syncTaskMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, syncTaskMeta)
}
const promptError = await sendSyncPrompt(client, {
sessionID,
agentToUse,
args,
systemContent,
categoryModel,
toastManager,
taskId,
})
if (promptError) {
return promptError
}
const pollError = await pollSyncSession(ctx, client, {
sessionID,
agentToUse,
toastManager,
taskId,
})
if (pollError) {
return pollError
}
const result = await fetchSyncResult(client, sessionID)
if (!result.ok) {
return result.error
}
const duration = formatDuration(startTime)
if (toastManager) {
toastManager.removeTask(taskId)
}
subagentSessions.delete(sessionID)
return `Task completed in ${duration}.
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
---
${result.textContent || "(No text output)"}
<task_metadata>
session_id: ${sessionID}
</task_metadata>`
} catch (error) {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)
}
if (syncSessionID) {
subagentSessions.delete(syncSessionID)
}
return formatDetailedError(error, {
operation: "Execute task",
args,
sessionID: syncSessionID,
agent: agentToUse,
category: args.category,
})
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Format a duration between two dates as a human-readable string.
*/
export function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`
if (minutes > 0) return `${minutes}m ${seconds % 60}s`
return `${seconds}s`
}
@@ -0,0 +1,158 @@
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, ParentContext, SessionMessage } from "./executor-types"
import { getTimingConfig } from "./timing"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
export async function executeUnstableAgentTask(
args: DelegateTaskArgs,
ctx: ToolContextWithMetadata,
executorCtx: ExecutorContext,
parentContext: ParentContext,
agentToUse: string,
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
systemContent: string | undefined,
actualModel: string | undefined
): Promise<string> {
const { manager, client } = executorCtx
try {
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: agentToUse,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
model: categoryModel,
skills: args.load_skills.length > 0 ? args.load_skills : undefined,
skillContent: systemContent,
category: args.category,
})
const timing = getTimingConfig()
const waitStart = Date.now()
let sessionID = task.sessionID
while (!sessionID && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
if (ctx.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
}
await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS))
const updated = manager.getTask(task.id)
sessionID = updated?.sessionID
}
if (!sessionID) {
return formatDetailedError(new Error(`Task failed to start within timeout (30s). Task ID: ${task.id}, Status: ${task.status}`), {
operation: "Launch monitored background task",
args,
agent: agentToUse,
category: args.category,
})
}
const bgTaskMeta = {
title: args.description,
metadata: {
prompt: args.prompt,
agent: agentToUse,
category: args.category,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: sessionID,
command: args.command,
},
}
await ctx.metadata?.(bgTaskMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, bgTaskMeta)
}
const startTime = new Date()
const timingCfg = getTimingConfig()
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) {
if (ctx.abort?.aborted) {
return `Task aborted (was running in background mode).\n\nSession ID: ${sessionID}`
}
await new Promise(resolve => setTimeout(resolve, timingCfg.POLL_INTERVAL_MS))
const statusResult = await client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const sessionStatus = allStatuses[sessionID]
if (sessionStatus && sessionStatus.type !== "idle") {
stablePolls = 0
lastMsgCount = 0
continue
}
if (Date.now() - pollStart < timingCfg.MIN_STABILITY_TIME_MS) continue
const messagesCheck = await client.session.messages({ path: { id: sessionID } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= timingCfg.STABILITY_POLLS_REQUIRED) break
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
const messagesResult = await client.session.messages({ path: { id: sessionID } })
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
const assistantMessages = messages
.filter((m) => m.info?.role === "assistant")
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
const lastMessage = assistantMessages[0]
if (!lastMessage) {
return `No assistant response found (task ran in background mode).\n\nSession ID: ${sessionID}`
}
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
const duration = formatDuration(startTime)
return `SUPERVISED TASK COMPLETED SUCCESSFULLY
IMPORTANT: This model (${actualModel}) is marked as unstable/experimental.
Your run_in_background=false was automatically converted to background mode for reliability monitoring.
Duration: ${duration}
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
MONITORING INSTRUCTIONS:
- The task was monitored and completed successfully
- If you observe this agent behaving erratically in future calls, actively monitor its progress
- Use background_cancel(task_id="...") to abort if the agent seems stuck or producing garbage output
- Do NOT retry automatically if you see this message - the task already succeeded
---
RESULT:
${textContent || "(No text output)"}
<task_metadata>
session_id: ${sessionID}
</task_metadata>`
} catch (error) {
return formatDetailedError(error, {
operation: "Launch monitored background task",
args,
agent: agentToUse,
category: args.category,
})
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { runRgFiles } from "./cli"
import { resolveGrepCliWithAutoInstall } from "./constants"
import { formatGlobResult } from "./utils"
import { formatGlobResult } from "./result-formatter"
export function createGlobTools(ctx: PluginInput): Record<string, ToolDefinition> {
const glob: ToolDefinition = tool({
+1 -1
View File
@@ -1,7 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { runRg } from "./cli"
import { formatGrepResult } from "./utils"
import { formatGrepResult } from "./result-formatter"
export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
const grep: ToolDefinition = tool({
+1 -1
View File
@@ -1,4 +1,4 @@
import { interactive_bash } from "./tools"
import { startBackgroundCheck } from "./utils"
import { startBackgroundCheck } from "./tmux-path-resolver"
export { interactive_bash, startBackgroundCheck }
+1 -1
View File
@@ -1,6 +1,6 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
import { getCachedTmuxPath } from "./utils"
import { getCachedTmuxPath } from "./tmux-path-resolver"
/**
* Quote-aware command tokenizer with escape handling
+3 -803
View File
@@ -1,803 +1,3 @@
import { spawn as bunSpawn, type Subprocess } from "bun"
import { spawn as nodeSpawn, spawnSync, type ChildProcess } from "node:child_process"
import { Readable, Writable } from "node:stream"
import { existsSync, readFileSync, statSync } from "fs"
import { extname, resolve } from "path"
import { pathToFileURL } from "node:url"
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
type MessageConnection,
} from "vscode-jsonrpc/node"
import { getLanguageId } from "./config"
import type { Diagnostic, ResolvedServer } from "./types"
import { log } from "../../shared/logger"
// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+
function shouldUseNodeSpawn(): boolean {
return process.platform === "win32"
}
// Prevents segfaults when libuv gets a non-existent cwd (oven-sh/bun#25798)
export function validateCwd(cwd: string): { valid: boolean; error?: string } {
try {
if (!existsSync(cwd)) {
return { valid: false, error: `Working directory does not exist: ${cwd}` }
}
const stats = statSync(cwd)
if (!stats.isDirectory()) {
return { valid: false, error: `Path is not a directory: ${cwd}` }
}
return { valid: true }
} catch (err) {
return { valid: false, error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})` }
}
}
function isBinaryAvailableOnWindows(command: string): boolean {
if (process.platform !== "win32") return true
if (command.includes("/") || command.includes("\\")) {
return existsSync(command)
}
try {
const result = spawnSync("where", [command], {
shell: true,
windowsHide: true,
timeout: 5000,
})
return result.status === 0
} catch {
return true
}
}
interface StreamReader {
read(): Promise<{ done: boolean; value: Uint8Array | undefined }>
}
// Bridges Bun Subprocess and Node.js ChildProcess under a common API
interface UnifiedProcess {
stdin: { write(chunk: Uint8Array | string): void }
stdout: { getReader(): StreamReader }
stderr: { getReader(): StreamReader }
exitCode: number | null
exited: Promise<number>
kill(signal?: string): void
}
function wrapNodeProcess(proc: ChildProcess): UnifiedProcess {
let resolveExited: (code: number) => void
let exitCode: number | null = null
const exitedPromise = new Promise<number>((resolve) => {
resolveExited = resolve
})
proc.on("exit", (code) => {
exitCode = code ?? 1
resolveExited(exitCode)
})
proc.on("error", () => {
if (exitCode === null) {
exitCode = 1
resolveExited(1)
}
})
const createStreamReader = (nodeStream: NodeJS.ReadableStream | null): StreamReader => {
const chunks: Uint8Array[] = []
let streamEnded = false
type ReadResult = { done: boolean; value: Uint8Array | undefined }
let waitingResolve: ((result: ReadResult) => void) | null = null
if (nodeStream) {
nodeStream.on("data", (chunk: Buffer) => {
const uint8 = new Uint8Array(chunk)
if (waitingResolve) {
const resolve = waitingResolve
waitingResolve = null
resolve({ done: false, value: uint8 })
} else {
chunks.push(uint8)
}
})
nodeStream.on("end", () => {
streamEnded = true
if (waitingResolve) {
const resolve = waitingResolve
waitingResolve = null
resolve({ done: true, value: undefined })
}
})
nodeStream.on("error", () => {
streamEnded = true
if (waitingResolve) {
const resolve = waitingResolve
waitingResolve = null
resolve({ done: true, value: undefined })
}
})
} else {
streamEnded = true
}
return {
read(): Promise<ReadResult> {
return new Promise((resolve) => {
if (chunks.length > 0) {
resolve({ done: false, value: chunks.shift()! })
} else if (streamEnded) {
resolve({ done: true, value: undefined })
} else {
waitingResolve = resolve
}
})
},
}
}
return {
stdin: {
write(chunk: Uint8Array | string) {
if (proc.stdin) {
proc.stdin.write(chunk)
}
},
},
stdout: {
getReader: () => createStreamReader(proc.stdout),
},
stderr: {
getReader: () => createStreamReader(proc.stderr),
},
get exitCode() {
return exitCode
},
exited: exitedPromise,
kill(signal?: string) {
try {
if (signal === "SIGKILL") {
proc.kill("SIGKILL")
} else {
proc.kill()
}
} catch {}
},
}
}
function spawnProcess(
command: string[],
options: { cwd: string; env: Record<string, string | undefined> }
): UnifiedProcess {
const cwdValidation = validateCwd(options.cwd)
if (!cwdValidation.valid) {
throw new Error(`[LSP] ${cwdValidation.error}`)
}
if (shouldUseNodeSpawn()) {
const [cmd, ...args] = command
if (!isBinaryAvailableOnWindows(cmd)) {
throw new Error(
`[LSP] Binary '${cmd}' not found on Windows. ` +
`Ensure the LSP server is installed and available in PATH. ` +
`For npm packages, try: npm install -g ${cmd}`
)
}
log("[LSP] Using Node.js child_process on Windows to avoid Bun spawn segfault")
const proc = nodeSpawn(cmd, args, {
cwd: options.cwd,
env: options.env as NodeJS.ProcessEnv,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
shell: true,
})
return wrapNodeProcess(proc)
}
const proc = bunSpawn(command, {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
cwd: options.cwd,
env: options.env,
})
return proc as unknown as UnifiedProcess
}
interface ManagedClient {
client: LSPClient
lastUsedAt: number
refCount: number
initPromise?: Promise<void>
isInitializing: boolean
}
class LSPServerManager {
private static instance: LSPServerManager
private clients = new Map<string, ManagedClient>()
private cleanupInterval: ReturnType<typeof setInterval> | null = null
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
private constructor() {
this.startCleanupTimer()
this.registerProcessCleanup()
}
private registerProcessCleanup(): void {
// Synchronous cleanup for 'exit' event (cannot await)
const syncCleanup = () => {
for (const [, managed] of this.clients) {
try {
// Fire-and-forget during sync exit - process is terminating
void managed.client.stop().catch(() => {})
} catch {}
}
this.clients.clear()
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
// Async cleanup for signal handlers - properly await all stops
const asyncCleanup = async () => {
const stopPromises: Promise<void>[] = []
for (const [, managed] of this.clients) {
stopPromises.push(managed.client.stop().catch(() => {}))
}
await Promise.allSettled(stopPromises)
this.clients.clear()
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
process.on("exit", syncCleanup)
// Don't call process.exit() here - let other handlers complete their cleanup first
// The background-agent manager handles the final exit call
// Use async handlers to properly await LSP subprocess cleanup
process.on("SIGINT", () => void asyncCleanup().catch(() => {}))
process.on("SIGTERM", () => void asyncCleanup().catch(() => {}))
if (process.platform === "win32") {
process.on("SIGBREAK", () => void asyncCleanup().catch(() => {}))
}
}
static getInstance(): LSPServerManager {
if (!LSPServerManager.instance) {
LSPServerManager.instance = new LSPServerManager()
}
return LSPServerManager.instance
}
private getKey(root: string, serverId: string): string {
return `${root}::${serverId}`
}
private startCleanupTimer(): void {
if (this.cleanupInterval) return
this.cleanupInterval = setInterval(() => {
this.cleanupIdleClients()
}, 60000)
}
private cleanupIdleClients(): void {
const now = Date.now()
for (const [key, managed] of this.clients) {
if (managed.refCount === 0 && now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
managed.client.stop()
this.clients.delete(key)
}
}
}
async getClient(root: string, server: ResolvedServer): Promise<LSPClient> {
const key = this.getKey(root, server.id)
let managed = this.clients.get(key)
if (managed) {
if (managed.initPromise) {
await managed.initPromise
}
if (managed.client.isAlive()) {
managed.refCount++
managed.lastUsedAt = Date.now()
return managed.client
}
await managed.client.stop()
this.clients.delete(key)
}
const client = new LSPClient(root, server)
const initPromise = (async () => {
await client.start()
await client.initialize()
})()
this.clients.set(key, {
client,
lastUsedAt: Date.now(),
refCount: 1,
initPromise,
isInitializing: true,
})
await initPromise
const m = this.clients.get(key)
if (m) {
m.initPromise = undefined
m.isInitializing = false
}
return client
}
warmupClient(root: string, server: ResolvedServer): void {
const key = this.getKey(root, server.id)
if (this.clients.has(key)) return
const client = new LSPClient(root, server)
const initPromise = (async () => {
await client.start()
await client.initialize()
})()
this.clients.set(key, {
client,
lastUsedAt: Date.now(),
refCount: 0,
initPromise,
isInitializing: true,
})
initPromise.then(() => {
const m = this.clients.get(key)
if (m) {
m.initPromise = undefined
m.isInitializing = false
}
})
}
releaseClient(root: string, serverId: string): void {
const key = this.getKey(root, serverId)
const managed = this.clients.get(key)
if (managed && managed.refCount > 0) {
managed.refCount--
managed.lastUsedAt = Date.now()
}
}
isServerInitializing(root: string, serverId: string): boolean {
const key = this.getKey(root, serverId)
const managed = this.clients.get(key)
return managed?.isInitializing ?? false
}
async stopAll(): Promise<void> {
for (const [, managed] of this.clients) {
await managed.client.stop()
}
this.clients.clear()
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
async cleanupTempDirectoryClients(): Promise<void> {
const keysToRemove: string[] = []
for (const [key, managed] of this.clients.entries()) {
const isTempDir = key.startsWith("/tmp/") || key.startsWith("/var/folders/")
const isIdle = managed.refCount === 0
if (isTempDir && isIdle) {
keysToRemove.push(key)
}
}
for (const key of keysToRemove) {
const managed = this.clients.get(key)
if (managed) {
this.clients.delete(key)
try {
await managed.client.stop()
} catch {}
}
}
}
}
export const lspManager = LSPServerManager.getInstance()
export class LSPClient {
private proc: UnifiedProcess | null = null
private connection: MessageConnection | null = null
private openedFiles = new Set<string>()
private documentVersions = new Map<string, number>()
private lastSyncedText = new Map<string, string>()
private stderrBuffer: string[] = []
private processExited = false
private diagnosticsStore = new Map<string, Diagnostic[]>()
private readonly REQUEST_TIMEOUT = 15000
constructor(
private root: string,
private server: ResolvedServer
) {}
async start(): Promise<void> {
this.proc = spawnProcess(this.server.command, {
cwd: this.root,
env: {
...process.env,
...this.server.env,
},
})
if (!this.proc) {
throw new Error(`Failed to spawn LSP server: ${this.server.command.join(" ")}`)
}
this.startStderrReading()
await new Promise((resolve) => setTimeout(resolve, 100))
if (this.proc.exitCode !== null) {
const stderr = this.stderrBuffer.join("\n")
throw new Error(
`LSP server exited immediately with code ${this.proc.exitCode}` + (stderr ? `\nstderr: ${stderr}` : "")
)
}
const stdoutReader = this.proc.stdout.getReader()
const nodeReadable = new Readable({
async read() {
try {
const { done, value } = await stdoutReader.read()
if (done || !value) {
this.push(null)
} else {
this.push(Buffer.from(value))
}
} catch {
this.push(null)
}
},
})
const stdin = this.proc.stdin
const nodeWritable = new Writable({
write(chunk, _encoding, callback) {
try {
stdin.write(chunk)
callback()
} catch (err) {
callback(err as Error)
}
},
})
this.connection = createMessageConnection(
new StreamMessageReader(nodeReadable),
new StreamMessageWriter(nodeWritable)
)
this.connection.onNotification("textDocument/publishDiagnostics", (params: { uri?: string; diagnostics?: Diagnostic[] }) => {
if (params.uri) {
this.diagnosticsStore.set(params.uri, params.diagnostics ?? [])
}
})
this.connection.onRequest("workspace/configuration", (params: { items?: Array<{ section?: string }> }) => {
const items = params?.items ?? []
return items.map((item) => {
if (item.section === "json") return { validate: { enable: true } }
return {}
})
})
this.connection.onRequest("client/registerCapability", () => null)
this.connection.onRequest("window/workDoneProgress/create", () => null)
this.connection.onClose(() => {
this.processExited = true
})
this.connection.onError((error) => {
log("LSP connection error:", error)
})
this.connection.listen()
}
private startStderrReading(): void {
if (!this.proc) return
const reader = this.proc.stderr.getReader()
const read = async () => {
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
this.stderrBuffer.push(text)
if (this.stderrBuffer.length > 100) {
this.stderrBuffer.shift()
}
}
} catch {}
}
read()
}
private async sendRequest<T>(method: string, params?: unknown): Promise<T> {
if (!this.connection) throw new Error("LSP client not started")
if (this.processExited || (this.proc && this.proc.exitCode !== null)) {
const stderr = this.stderrBuffer.slice(-10).join("\n")
throw new Error(`LSP server already exited (code: ${this.proc?.exitCode})` + (stderr ? `\nstderr: ${stderr}` : ""))
}
let timeoutId: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
const stderr = this.stderrBuffer.slice(-5).join("\n")
reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : "")))
}, this.REQUEST_TIMEOUT)
})
const requestPromise = this.connection.sendRequest(method, params) as Promise<T>
try {
const result = await Promise.race([requestPromise, timeoutPromise])
clearTimeout(timeoutId!)
return result
} catch (error) {
clearTimeout(timeoutId!)
throw error
}
}
private sendNotification(method: string, params?: unknown): void {
if (!this.connection) return
if (this.processExited || (this.proc && this.proc.exitCode !== null)) return
this.connection.sendNotification(method, params)
}
async initialize(): Promise<void> {
const rootUri = pathToFileURL(this.root).href
await this.sendRequest("initialize", {
processId: process.pid,
rootUri,
rootPath: this.root,
workspaceFolders: [{ uri: rootUri, name: "workspace" }],
capabilities: {
textDocument: {
hover: { contentFormat: ["markdown", "plaintext"] },
definition: { linkSupport: true },
references: {},
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
publishDiagnostics: {},
rename: {
prepareSupport: true,
prepareSupportDefaultBehavior: 1,
honorsChangeAnnotations: true,
},
codeAction: {
codeActionLiteralSupport: {
codeActionKind: {
valueSet: [
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports",
"source.fixAll",
],
},
},
isPreferredSupport: true,
disabledSupport: true,
dataSupport: true,
resolveSupport: {
properties: ["edit", "command"],
},
},
},
workspace: {
symbol: {},
workspaceFolders: true,
configuration: true,
applyEdit: true,
workspaceEdit: {
documentChanges: true,
},
},
},
...this.server.initialization,
})
this.sendNotification("initialized")
this.sendNotification("workspace/didChangeConfiguration", {
settings: { json: { validate: { enable: true } } },
})
await new Promise((r) => setTimeout(r, 300))
}
async openFile(filePath: string): Promise<void> {
const absPath = resolve(filePath)
const uri = pathToFileURL(absPath).href
const text = readFileSync(absPath, "utf-8")
if (!this.openedFiles.has(absPath)) {
const ext = extname(absPath)
const languageId = getLanguageId(ext)
const version = 1
this.sendNotification("textDocument/didOpen", {
textDocument: {
uri,
languageId,
version,
text,
},
})
this.openedFiles.add(absPath)
this.documentVersions.set(uri, version)
this.lastSyncedText.set(uri, text)
await new Promise((r) => setTimeout(r, 1000))
return
}
const prevText = this.lastSyncedText.get(uri)
if (prevText === text) {
return
}
const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1
this.documentVersions.set(uri, nextVersion)
this.lastSyncedText.set(uri, text)
this.sendNotification("textDocument/didChange", {
textDocument: { uri, version: nextVersion },
contentChanges: [{ text }],
})
// Some servers update diagnostics only after save
this.sendNotification("textDocument/didSave", {
textDocument: { uri },
text,
})
}
async definition(filePath: string, line: number, character: number): Promise<unknown> {
const absPath = resolve(filePath)
await this.openFile(absPath)
return this.sendRequest("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.sendRequest("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.sendRequest("textDocument/documentSymbol", {
textDocument: { uri: pathToFileURL(absPath).href },
})
}
async workspaceSymbols(query: string): Promise<unknown> {
return this.sendRequest("workspace/symbol", { query })
}
async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
const absPath = resolve(filePath)
const uri = pathToFileURL(absPath).href
await this.openFile(absPath)
await new Promise((r) => setTimeout(r, 500))
try {
const result = await this.sendRequest<{ items?: Diagnostic[] }>("textDocument/diagnostic", {
textDocument: { uri },
})
if (result && typeof result === "object" && "items" in result) {
return result as { items: Diagnostic[] }
}
} catch {}
return { items: this.diagnosticsStore.get(uri) ?? [] }
}
async prepareRename(filePath: string, line: number, character: number): Promise<unknown> {
const absPath = resolve(filePath)
await this.openFile(absPath)
return this.sendRequest("textDocument/prepareRename", {
textDocument: { uri: pathToFileURL(absPath).href },
position: { line: line - 1, character },
})
}
async rename(filePath: string, line: number, character: number, newName: string): Promise<unknown> {
const absPath = resolve(filePath)
await this.openFile(absPath)
return this.sendRequest("textDocument/rename", {
textDocument: { uri: pathToFileURL(absPath).href },
position: { line: line - 1, character },
newName,
})
}
isAlive(): boolean {
return this.proc !== null && !this.processExited && this.proc.exitCode === null
}
async stop(): Promise<void> {
if (this.connection) {
try {
this.sendNotification("shutdown", {})
this.sendNotification("exit")
} catch {}
this.connection.dispose()
this.connection = null
}
const proc = this.proc
if (proc) {
this.proc = null
let exitedBeforeTimeout = false
try {
proc.kill()
// Wait for exit with timeout to prevent indefinite hang
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, 5000)
})
await Promise.race([
proc.exited.then(() => { exitedBeforeTimeout = true }).finally(() => timeoutId && clearTimeout(timeoutId)),
timeoutPromise,
])
if (!exitedBeforeTimeout) {
log("[LSPClient] Process did not exit within timeout, escalating to SIGKILL")
try {
proc.kill("SIGKILL")
// Wait briefly for SIGKILL to take effect
await Promise.race([
proc.exited,
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
])
} catch {}
}
} catch {}
}
this.processExited = true
this.diagnosticsStore.clear()
}
}
export { validateCwd } from "./lsp-process"
export { lspManager } from "./lsp-server"
export { LSPClient } from "./lsp-client"
+3 -289
View File
@@ -1,289 +1,3 @@
import { existsSync, readFileSync } from "fs"
import { join } from "path"
import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from "./constants"
import type { ResolvedServer, ServerLookupResult } from "./types"
import { getOpenCodeConfigDir, getDataDir } from "../../shared"
interface LspEntry {
disabled?: boolean
command?: string[]
extensions?: string[]
priority?: number
env?: Record<string, string>
initialization?: Record<string, unknown>
}
interface ConfigJson {
lsp?: Record<string, LspEntry>
}
type ConfigSource = "project" | "user" | "opencode"
interface ServerWithSource extends ResolvedServer {
source: ConfigSource
}
function loadJsonFile<T>(path: string): T | null {
if (!existsSync(path)) return null
try {
return JSON.parse(readFileSync(path, "utf-8")) as T
} catch {
return null
}
}
function getConfigPaths(): { project: string; user: string; opencode: string } {
const cwd = process.cwd()
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
return {
project: join(cwd, ".opencode", "oh-my-opencode.json"),
user: join(configDir, "oh-my-opencode.json"),
opencode: join(configDir, "opencode.json"),
}
}
function loadAllConfigs(): Map<ConfigSource, ConfigJson> {
const paths = getConfigPaths()
const configs = new Map<ConfigSource, ConfigJson>()
const project = loadJsonFile<ConfigJson>(paths.project)
if (project) configs.set("project", project)
const user = loadJsonFile<ConfigJson>(paths.user)
if (user) configs.set("user", user)
const opencode = loadJsonFile<ConfigJson>(paths.opencode)
if (opencode) configs.set("opencode", opencode)
return configs
}
function getMergedServers(): ServerWithSource[] {
const configs = loadAllConfigs()
const servers: ServerWithSource[] = []
const disabled = new Set<string>()
const seen = new Set<string>()
const sources: ConfigSource[] = ["project", "user", "opencode"]
for (const source of sources) {
const config = configs.get(source)
if (!config?.lsp) continue
for (const [id, entry] of Object.entries(config.lsp)) {
if (entry.disabled) {
disabled.add(id)
continue
}
if (seen.has(id)) continue
if (!entry.command || !entry.extensions) continue
servers.push({
id,
command: entry.command,
extensions: entry.extensions,
priority: entry.priority ?? 0,
env: entry.env,
initialization: entry.initialization,
source,
})
seen.add(id)
}
}
for (const [id, config] of Object.entries(BUILTIN_SERVERS)) {
if (disabled.has(id) || seen.has(id)) continue
servers.push({
id,
command: config.command,
extensions: config.extensions,
priority: -100,
source: "opencode",
})
}
return servers.sort((a, b) => {
if (a.source !== b.source) {
const order: Record<ConfigSource, number> = { project: 0, user: 1, opencode: 2 }
return order[a.source] - order[b.source]
}
return b.priority - a.priority
})
}
export function findServerForExtension(ext: string): ServerLookupResult {
const servers = getMergedServers()
for (const server of servers) {
if (server.extensions.includes(ext) && isServerInstalled(server.command)) {
return {
status: "found",
server: {
id: server.id,
command: server.command,
extensions: server.extensions,
priority: server.priority,
env: server.env,
initialization: server.initialization,
},
}
}
}
for (const server of servers) {
if (server.extensions.includes(ext)) {
const installHint =
LSP_INSTALL_HINTS[server.id] || `Install '${server.command[0]}' and ensure it's in your PATH`
return {
status: "not_installed",
server: {
id: server.id,
command: server.command,
extensions: server.extensions,
},
installHint,
}
}
}
const availableServers = [...new Set(servers.map((s) => s.id))]
return {
status: "not_configured",
extension: ext,
availableServers,
}
}
export function getLanguageId(ext: string): string {
return EXT_TO_LANG[ext] || "plaintext"
}
export function isServerInstalled(command: string[]): boolean {
if (command.length === 0) return false
const cmd = command[0]
// Support absolute paths (e.g., C:\Users\...\server.exe or /usr/local/bin/server)
if (cmd.includes("/") || cmd.includes("\\")) {
if (existsSync(cmd)) return true
}
const isWindows = process.platform === "win32"
let exts = [""]
if (isWindows) {
const pathExt = process.env.PATHEXT || ""
if (pathExt) {
const systemExts = pathExt.split(";").filter(Boolean)
exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])]
} else {
exts = ["", ".exe", ".cmd", ".bat", ".ps1"]
}
}
let pathEnv = process.env.PATH || ""
if (isWindows && !pathEnv) {
pathEnv = process.env.Path || ""
}
const pathSeparator = isWindows ? ";" : ":"
const paths = pathEnv.split(pathSeparator)
for (const p of paths) {
for (const suffix of exts) {
if (existsSync(join(p, cmd + suffix))) {
return true
}
}
}
const cwd = process.cwd()
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const dataDir = join(getDataDir(), "opencode")
const additionalBases = [
join(cwd, "node_modules", ".bin"),
join(configDir, "bin"),
join(configDir, "node_modules", ".bin"),
join(dataDir, "bin"),
]
for (const base of additionalBases) {
for (const suffix of exts) {
if (existsSync(join(base, cmd + suffix))) {
return true
}
}
}
// Runtime wrappers (bun/node) are always available in oh-my-opencode context
if (cmd === "bun" || cmd === "node") {
return true
}
return false
}
export function getAllServers(): Array<{
id: string
installed: boolean
extensions: string[]
disabled: boolean
source: string
priority: number
}> {
const configs = loadAllConfigs()
const servers = getMergedServers()
const disabled = new Set<string>()
for (const config of configs.values()) {
if (!config.lsp) continue
for (const [id, entry] of Object.entries(config.lsp)) {
if (entry.disabled) disabled.add(id)
}
}
const result: Array<{
id: string
installed: boolean
extensions: string[]
disabled: boolean
source: string
priority: number
}> = []
const seen = new Set<string>()
for (const server of servers) {
if (seen.has(server.id)) continue
result.push({
id: server.id,
installed: isServerInstalled(server.command),
extensions: server.extensions,
disabled: false,
source: server.source,
priority: server.priority,
})
seen.add(server.id)
}
for (const id of disabled) {
if (seen.has(id)) continue
const builtin = BUILTIN_SERVERS[id]
result.push({
id,
installed: builtin ? isServerInstalled(builtin.command) : false,
extensions: builtin?.extensions || [],
disabled: true,
source: "disabled",
priority: 0,
})
}
return result
}
export function getConfigPaths_(): { project: string; user: string; opencode: string } {
return getConfigPaths()
}
export { findServerForExtension, getAllServers, getConfigPaths_ } from "./server-resolution"
export { getLanguageId } from "./language-config"
export { isServerInstalled } from "./server-installation"
+2 -386
View File
@@ -1,390 +1,6 @@
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",
3: "information",
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> = {
typescript: "npm install -g typescript-language-server typescript",
deno: "Install Deno from https://deno.land",
vue: "npm install -g @vue/language-server",
eslint: "npm install -g vscode-langservers-extracted",
oxlint: "npm install -g oxlint",
biome: "npm install -g @biomejs/biome",
gopls: "go install golang.org/x/tools/gopls@latest",
"ruby-lsp": "gem install ruby-lsp",
basedpyright: "pip install basedpyright",
pyright: "pip install pyright",
ty: "pip install ty",
ruff: "pip install ruff",
"elixir-ls": "See https://github.com/elixir-lsp/elixir-ls",
zls: "See https://github.com/zigtools/zls",
csharp: "dotnet tool install -g csharp-ls",
fsharp: "dotnet tool install -g fsautocomplete",
"sourcekit-lsp": "Included with Xcode or Swift toolchain",
rust: "rustup component add rust-analyzer",
clangd: "See https://clangd.llvm.org/installation",
svelte: "npm install -g svelte-language-server",
astro: "npm install -g @astrojs/language-server",
"bash-ls": "npm install -g bash-language-server",
jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls",
"yaml-ls": "npm install -g yaml-language-server",
"lua-ls": "See https://github.com/LuaLS/lua-language-server",
php: "npm install -g intelephense",
dart: "Included with Dart SDK",
"terraform-ls": "See https://github.com/hashicorp/terraform-ls",
terraform: "See https://github.com/hashicorp/terraform-ls",
prisma: "npm install -g prisma",
"ocaml-lsp": "opam install ocaml-lsp-server",
texlab: "See https://github.com/latex-lsp/texlab",
dockerfile: "npm install -g dockerfile-language-server-nodejs",
gleam: "See https://gleam.run/getting-started/installing/",
"clojure-lsp": "See https://clojure-lsp.io/installation/",
nixd: "nix profile install nixpkgs#nixd",
tinymist: "See https://github.com/Myriad-Dreamin/tinymist",
"haskell-language-server": "ghcup install hls",
bash: "npm install -g bash-language-server",
"kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp",
}
// Synced with OpenCode's server.ts
// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/server.ts
export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
typescript: {
command: ["typescript-language-server", "--stdio"],
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
},
deno: {
command: ["deno", "lsp"],
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
},
vue: {
command: ["vue-language-server", "--stdio"],
extensions: [".vue"],
},
eslint: {
command: ["vscode-eslint-language-server", "--stdio"],
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
},
oxlint: {
command: ["oxlint", "--lsp"],
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"],
},
biome: {
command: ["biome", "lsp-proxy", "--stdio"],
extensions: [
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
".json", ".jsonc", ".vue", ".astro", ".svelte", ".css", ".graphql", ".gql", ".html",
],
},
gopls: {
command: ["gopls"],
extensions: [".go"],
},
"ruby-lsp": {
command: ["rubocop", "--lsp"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
},
basedpyright: {
command: ["basedpyright-langserver", "--stdio"],
extensions: [".py", ".pyi"],
},
pyright: {
command: ["pyright-langserver", "--stdio"],
extensions: [".py", ".pyi"],
},
ty: {
command: ["ty", "server"],
extensions: [".py", ".pyi"],
},
ruff: {
command: ["ruff", "server"],
extensions: [".py", ".pyi"],
},
"elixir-ls": {
command: ["elixir-ls"],
extensions: [".ex", ".exs"],
},
zls: {
command: ["zls"],
extensions: [".zig", ".zon"],
},
csharp: {
command: ["csharp-ls"],
extensions: [".cs"],
},
fsharp: {
command: ["fsautocomplete"],
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
},
"sourcekit-lsp": {
command: ["sourcekit-lsp"],
extensions: [".swift", ".objc", ".objcpp"],
},
rust: {
command: ["rust-analyzer"],
extensions: [".rs"],
},
clangd: {
command: ["clangd", "--background-index", "--clang-tidy"],
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
},
svelte: {
command: ["svelteserver", "--stdio"],
extensions: [".svelte"],
},
astro: {
command: ["astro-ls", "--stdio"],
extensions: [".astro"],
},
bash: {
command: ["bash-language-server", "start"],
extensions: [".sh", ".bash", ".zsh", ".ksh"],
},
// Keep legacy alias for backward compatibility
"bash-ls": {
command: ["bash-language-server", "start"],
extensions: [".sh", ".bash", ".zsh", ".ksh"],
},
jdtls: {
command: ["jdtls"],
extensions: [".java"],
},
"yaml-ls": {
command: ["yaml-language-server", "--stdio"],
extensions: [".yaml", ".yml"],
},
"lua-ls": {
command: ["lua-language-server"],
extensions: [".lua"],
},
php: {
command: ["intelephense", "--stdio"],
extensions: [".php"],
},
dart: {
command: ["dart", "language-server", "--lsp"],
extensions: [".dart"],
},
terraform: {
command: ["terraform-ls", "serve"],
extensions: [".tf", ".tfvars"],
},
// Legacy alias for backward compatibility
"terraform-ls": {
command: ["terraform-ls", "serve"],
extensions: [".tf", ".tfvars"],
},
prisma: {
command: ["prisma", "language-server"],
extensions: [".prisma"],
},
"ocaml-lsp": {
command: ["ocamllsp"],
extensions: [".ml", ".mli"],
},
texlab: {
command: ["texlab"],
extensions: [".tex", ".bib"],
},
dockerfile: {
command: ["docker-langserver", "--stdio"],
extensions: [".dockerfile"],
},
gleam: {
command: ["gleam", "lsp"],
extensions: [".gleam"],
},
"clojure-lsp": {
command: ["clojure-lsp", "listen"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
},
nixd: {
command: ["nixd"],
extensions: [".nix"],
},
tinymist: {
command: ["tinymist"],
extensions: [".typ", ".typc"],
},
"haskell-language-server": {
command: ["haskell-language-server-wrapper", "--lsp"],
extensions: [".hs", ".lhs"],
},
"kotlin-ls": {
command: ["kotlin-lsp"],
extensions: [".kt", ".kts"],
},
}
// Synced with OpenCode's language.ts
// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/language.ts
export const EXT_TO_LANG: Record<string, string> = {
".abap": "abap",
".bat": "bat",
".bib": "bibtex",
".bibtex": "bibtex",
".clj": "clojure",
".cljs": "clojure",
".cljc": "clojure",
".edn": "clojure",
".coffee": "coffeescript",
".c": "c",
".cpp": "cpp",
".cxx": "cpp",
".cc": "cpp",
".c++": "cpp",
".cs": "csharp",
".css": "css",
".d": "d",
".pas": "pascal",
".pascal": "pascal",
".diff": "diff",
".patch": "diff",
".dart": "dart",
".dockerfile": "dockerfile",
".ex": "elixir",
".exs": "elixir",
".erl": "erlang",
".hrl": "erlang",
".fs": "fsharp",
".fsi": "fsharp",
".fsx": "fsharp",
".fsscript": "fsharp",
".gitcommit": "git-commit",
".gitrebase": "git-rebase",
".go": "go",
".groovy": "groovy",
".gleam": "gleam",
".hbs": "handlebars",
".handlebars": "handlebars",
".hs": "haskell",
".html": "html",
".htm": "html",
".ini": "ini",
".java": "java",
".js": "javascript",
".jsx": "javascriptreact",
".json": "json",
".jsonc": "jsonc",
".tex": "latex",
".latex": "latex",
".less": "less",
".lua": "lua",
".makefile": "makefile",
makefile: "makefile",
".md": "markdown",
".markdown": "markdown",
".m": "objective-c",
".mm": "objective-cpp",
".pl": "perl",
".pm": "perl",
".pm6": "perl6",
".php": "php",
".ps1": "powershell",
".psm1": "powershell",
".pug": "jade",
".jade": "jade",
".py": "python",
".pyi": "python",
".r": "r",
".cshtml": "razor",
".razor": "razor",
".rb": "ruby",
".rake": "ruby",
".gemspec": "ruby",
".ru": "ruby",
".erb": "erb",
".html.erb": "erb",
".js.erb": "erb",
".css.erb": "erb",
".json.erb": "erb",
".rs": "rust",
".scss": "scss",
".sass": "sass",
".scala": "scala",
".shader": "shaderlab",
".sh": "shellscript",
".bash": "shellscript",
".zsh": "shellscript",
".ksh": "shellscript",
".sql": "sql",
".svelte": "svelte",
".swift": "swift",
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".mtsx": "typescriptreact",
".ctsx": "typescriptreact",
".xml": "xml",
".xsl": "xsl",
".yaml": "yaml",
".yml": "yaml",
".mjs": "javascript",
".cjs": "javascript",
".vue": "vue",
".zig": "zig",
".zon": "zig",
".astro": "astro",
".ml": "ocaml",
".mli": "ocaml",
".tf": "terraform",
".tfvars": "terraform-vars",
".hcl": "hcl",
".nix": "nix",
".typ": "typst",
".typc": "typst",
".ets": "typescript",
".lhs": "haskell",
".kt": "kotlin",
".kts": "kotlin",
".prisma": "prisma",
// Additional extensions not in OpenCode
".h": "c",
".hpp": "cpp",
".hh": "cpp",
".hxx": "cpp",
".h++": "cpp",
".objc": "objective-c",
".objcpp": "objective-cpp",
".fish": "fish",
".graphql": "graphql",
".gql": "graphql",
}
export { SYMBOL_KIND_MAP, SEVERITY_MAP, EXT_TO_LANG } from "./language-mappings"
export { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./server-definitions"
+53
View File
@@ -0,0 +1,53 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { DEFAULT_MAX_DIAGNOSTICS } from "./constants"
import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters"
import { withLspClient } from "./lsp-client-wrapper"
import type { Diagnostic } from "./types"
export const lsp_diagnostics: ToolDefinition = tool({
description: "Get errors, warnings, hints from language server BEFORE running build.",
args: {
filePath: tool.schema.string(),
severity: tool.schema
.enum(["error", "warning", "information", "hint", "all"])
.optional()
.describe("Filter by severity level"),
},
execute: async (args, context) => {
try {
const result = await withLspClient(args.filePath, async (client) => {
return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null
})
let diagnostics: Diagnostic[] = []
if (result) {
if (Array.isArray(result)) {
diagnostics = result
} else if (result.items) {
diagnostics = result.items
}
}
diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity)
if (diagnostics.length === 0) {
const output = "No diagnostics found"
return output
}
const total = diagnostics.length
const truncated = total > DEFAULT_MAX_DIAGNOSTICS
const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics
const lines = limited.map(formatDiagnostic)
if (truncated) {
lines.unshift(`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`)
}
const output = lines.join("\n")
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
throw new Error(output)
}
},
})
+43
View File
@@ -0,0 +1,43 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { DEFAULT_MAX_REFERENCES } from "./constants"
import { formatLocation } from "./lsp-formatters"
import { withLspClient } from "./lsp-client-wrapper"
import type { Location } from "./types"
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
}
},
})
+42
View File
@@ -0,0 +1,42 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { formatLocation } from "./lsp-formatters"
import { withLspClient } from "./lsp-client-wrapper"
import type { Location, LocationLink } from "./types"
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
}
},
})
+3 -1
View File
@@ -2,6 +2,8 @@ export * from "./types"
export * from "./constants"
export * from "./config"
export * from "./client"
export * from "./utils"
export * from "./lsp-client-wrapper"
export * from "./lsp-formatters"
export * from "./workspace-edit"
// NOTE: lsp_servers removed - duplicates OpenCode's built-in LspServers
export { lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename } from "./tools"
+5
View File
@@ -0,0 +1,5 @@
import { EXT_TO_LANG } from "./constants"
export function getLanguageId(ext: string): string {
return EXT_TO_LANG[ext] || "plaintext"
}
+171
View File
@@ -0,0 +1,171 @@
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",
3: "information",
4: "hint",
}
// Synced with OpenCode's language.ts
// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/language.ts
export const EXT_TO_LANG: Record<string, string> = {
".abap": "abap",
".bat": "bat",
".bib": "bibtex",
".bibtex": "bibtex",
".clj": "clojure",
".cljs": "clojure",
".cljc": "clojure",
".edn": "clojure",
".coffee": "coffeescript",
".c": "c",
".cpp": "cpp",
".cxx": "cpp",
".cc": "cpp",
".c++": "cpp",
".cs": "csharp",
".css": "css",
".d": "d",
".pas": "pascal",
".pascal": "pascal",
".diff": "diff",
".patch": "diff",
".dart": "dart",
".dockerfile": "dockerfile",
".ex": "elixir",
".exs": "elixir",
".erl": "erlang",
".hrl": "erlang",
".fs": "fsharp",
".fsi": "fsharp",
".fsx": "fsharp",
".fsscript": "fsharp",
".gitcommit": "git-commit",
".gitrebase": "git-rebase",
".go": "go",
".groovy": "groovy",
".gleam": "gleam",
".hbs": "handlebars",
".handlebars": "handlebars",
".hs": "haskell",
".html": "html",
".htm": "html",
".ini": "ini",
".java": "java",
".js": "javascript",
".jsx": "javascriptreact",
".json": "json",
".jsonc": "jsonc",
".tex": "latex",
".latex": "latex",
".less": "less",
".lua": "lua",
".makefile": "makefile",
makefile: "makefile",
".md": "markdown",
".markdown": "markdown",
".m": "objective-c",
".mm": "objective-cpp",
".pl": "perl",
".pm": "perl",
".pm6": "perl6",
".php": "php",
".ps1": "powershell",
".psm1": "powershell",
".pug": "jade",
".jade": "jade",
".py": "python",
".pyi": "python",
".r": "r",
".cshtml": "razor",
".razor": "razor",
".rb": "ruby",
".rake": "ruby",
".gemspec": "ruby",
".ru": "ruby",
".erb": "erb",
".html.erb": "erb",
".js.erb": "erb",
".css.erb": "erb",
".json.erb": "erb",
".rs": "rust",
".scss": "scss",
".sass": "sass",
".scala": "scala",
".shader": "shaderlab",
".sh": "shellscript",
".bash": "shellscript",
".zsh": "shellscript",
".ksh": "shellscript",
".sql": "sql",
".svelte": "svelte",
".swift": "swift",
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".mtsx": "typescriptreact",
".ctsx": "typescriptreact",
".xml": "xml",
".xsl": "xsl",
".yaml": "yaml",
".yml": "yaml",
".mjs": "javascript",
".cjs": "javascript",
".vue": "vue",
".zig": "zig",
".zon": "zig",
".astro": "astro",
".ml": "ocaml",
".mli": "ocaml",
".tf": "terraform",
".tfvars": "terraform-vars",
".hcl": "hcl",
".nix": "nix",
".typ": "typst",
".typc": "typst",
".ets": "typescript",
".lhs": "haskell",
".kt": "kotlin",
".kts": "kotlin",
".prisma": "prisma",
// Additional extensions not in OpenCode
".h": "c",
".hpp": "cpp",
".hh": "cpp",
".hxx": "cpp",
".h++": "cpp",
".objc": "objective-c",
".objcpp": "objective-cpp",
".fish": "fish",
".graphql": "graphql",
".gql": "graphql",
}
+66
View File
@@ -0,0 +1,66 @@
import { pathToFileURL } from "node:url"
import { LSPClientTransport } from "./lsp-client-transport"
export class LSPClientConnection extends LSPClientTransport {
async initialize(): Promise<void> {
const rootUri = pathToFileURL(this.root).href
await this.sendRequest("initialize", {
processId: process.pid,
rootUri,
rootPath: this.root,
workspaceFolders: [{ uri: rootUri, name: "workspace" }],
capabilities: {
textDocument: {
hover: { contentFormat: ["markdown", "plaintext"] },
definition: { linkSupport: true },
references: {},
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
publishDiagnostics: {},
rename: {
prepareSupport: true,
prepareSupportDefaultBehavior: 1,
honorsChangeAnnotations: true,
},
codeAction: {
codeActionLiteralSupport: {
codeActionKind: {
valueSet: [
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports",
"source.fixAll",
],
},
},
isPreferredSupport: true,
disabledSupport: true,
dataSupport: true,
resolveSupport: {
properties: ["edit", "command"],
},
},
},
workspace: {
symbol: {},
workspaceFolders: true,
configuration: true,
applyEdit: true,
workspaceEdit: {
documentChanges: true,
},
},
},
...this.server.initialization,
})
this.sendNotification("initialized")
this.sendNotification("workspace/didChangeConfiguration", {
settings: { json: { validate: { enable: true } } },
})
await new Promise((r) => setTimeout(r, 300))
}
}
+194
View File
@@ -0,0 +1,194 @@
import { Readable, Writable } from "node:stream"
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
type MessageConnection,
} from "vscode-jsonrpc/node"
import type { Diagnostic, ResolvedServer } from "./types"
import { spawnProcess, type UnifiedProcess } from "./lsp-process"
import { log } from "../../shared/logger"
export class LSPClientTransport {
protected proc: UnifiedProcess | null = null
protected connection: MessageConnection | null = null
protected readonly stderrBuffer: string[] = []
protected processExited = false
protected readonly diagnosticsStore = new Map<string, Diagnostic[]>()
protected readonly REQUEST_TIMEOUT = 15000
constructor(protected root: string, protected server: ResolvedServer) {}
async start(): Promise<void> {
this.proc = spawnProcess(this.server.command, {
cwd: this.root,
env: {
...process.env,
...this.server.env,
},
})
if (!this.proc) {
throw new Error(`Failed to spawn LSP server: ${this.server.command.join(" ")}`)
}
this.startStderrReading()
await new Promise((resolve) => setTimeout(resolve, 100))
if (this.proc.exitCode !== null) {
const stderr = this.stderrBuffer.join("\n")
throw new Error(`LSP server exited immediately with code ${this.proc.exitCode}` + (stderr ? `\nstderr: ${stderr}` : ""))
}
const stdoutReader = this.proc.stdout.getReader()
const nodeReadable = new Readable({
async read() {
try {
const { done, value } = await stdoutReader.read()
if (done || !value) {
this.push(null)
} else {
this.push(Buffer.from(value))
}
} catch {
this.push(null)
}
},
})
const stdin = this.proc.stdin
const nodeWritable = new Writable({
write(chunk, _encoding, callback) {
try {
stdin.write(chunk)
callback()
} catch (err) {
callback(err as Error)
}
},
})
this.connection = createMessageConnection(new StreamMessageReader(nodeReadable), new StreamMessageWriter(nodeWritable))
this.connection.onNotification("textDocument/publishDiagnostics", (params: { uri?: string; diagnostics?: Diagnostic[] }) => {
if (params.uri) {
this.diagnosticsStore.set(params.uri, params.diagnostics ?? [])
}
})
this.connection.onRequest("workspace/configuration", (params: { items?: Array<{ section?: string }> }) => {
const items = params?.items ?? []
return items.map((item) => {
if (item.section === "json") return { validate: { enable: true } }
return {}
})
})
this.connection.onRequest("client/registerCapability", () => null)
this.connection.onRequest("window/workDoneProgress/create", () => null)
this.connection.onClose(() => {
this.processExited = true
})
this.connection.onError((error) => {
log("LSP connection error:", error)
})
this.connection.listen()
}
protected startStderrReading(): void {
if (!this.proc) return
const reader = this.proc.stderr.getReader()
const read = async () => {
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
this.stderrBuffer.push(text)
if (this.stderrBuffer.length > 100) {
this.stderrBuffer.shift()
}
}
} catch {}
}
read()
}
protected async sendRequest<T>(method: string, params?: unknown): Promise<T> {
if (!this.connection) throw new Error("LSP client not started")
if (this.processExited || (this.proc && this.proc.exitCode !== null)) {
const stderr = this.stderrBuffer.slice(-10).join("\n")
throw new Error(`LSP server already exited (code: ${this.proc?.exitCode})` + (stderr ? `\nstderr: ${stderr}` : ""))
}
let timeoutId: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
const stderr = this.stderrBuffer.slice(-5).join("\n")
reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : "")))
}, this.REQUEST_TIMEOUT)
})
const requestPromise = this.connection.sendRequest(method, params) as Promise<T>
try {
const result = await Promise.race([requestPromise, timeoutPromise])
clearTimeout(timeoutId!)
return result
} catch (error) {
clearTimeout(timeoutId!)
throw error
}
}
protected sendNotification(method: string, params?: unknown): void {
if (!this.connection) return
if (this.processExited || (this.proc && this.proc.exitCode !== null)) return
this.connection.sendNotification(method, params)
}
isAlive(): boolean {
return this.proc !== null && !this.processExited && this.proc.exitCode === null
}
async stop(): Promise<void> {
if (this.connection) {
try {
this.sendNotification("shutdown", {})
this.sendNotification("exit")
} catch {}
this.connection.dispose()
this.connection = null
}
const proc = this.proc
if (proc) {
this.proc = null
let exitedBeforeTimeout = false
try {
proc.kill()
// Wait for exit with timeout to prevent indefinite hang
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, 5000)
})
await Promise.race([
proc.exited.then(() => {
exitedBeforeTimeout = true
}).finally(() => timeoutId && clearTimeout(timeoutId)),
timeoutPromise,
])
if (!exitedBeforeTimeout) {
log("[LSPClient] Process did not exit within timeout, escalating to SIGKILL")
try {
proc.kill("SIGKILL")
// Wait briefly for SIGKILL to take effect
await Promise.race([proc.exited, new Promise<void>((resolve) => setTimeout(resolve, 1000))])
} catch {}
}
} catch {}
}
this.processExited = true
this.diagnosticsStore.clear()
}
}
+100
View File
@@ -0,0 +1,100 @@
import { extname, resolve } from "path"
import { fileURLToPath } from "node:url"
import { existsSync } from "fs"
import { LSPClient, lspManager } from "./client"
import { findServerForExtension } from "./config"
import type { ServerLookupResult } from "./types"
export function findWorkspaceRoot(filePath: string): string {
let dir = resolve(filePath)
if (!existsSync(dir) || !require("fs").statSync(dir).isDirectory()) {
dir = require("path").dirname(dir)
}
const markers = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"]
let prevDir = ""
while (dir !== prevDir) {
for (const marker of markers) {
if (existsSync(require("path").join(dir, marker))) {
return dir
}
}
prevDir = dir
dir = require("path").dirname(dir)
}
return require("path").dirname(resolve(filePath))
}
export function uriToPath(uri: string): string {
return fileURLToPath(uri)
}
export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
if (result.status === "not_installed") {
const { server, installHint } = result
return [
`LSP server '${server.id}' is configured but NOT INSTALLED.`,
``,
`Command not found: ${server.command[0]}`,
``,
`To install:`,
` ${installHint}`,
``,
`Supported extensions: ${server.extensions.join(", ")}`,
``,
`After installation, the server will be available automatically.`,
`Run 'LspServers' tool to verify installation status.`,
].join("\n")
}
return [
`No LSP server configured for extension: ${result.extension}`,
``,
`Available servers: ${result.availableServers.slice(0, 10).join(", ")}${result.availableServers.length > 10 ? "..." : ""}`,
``,
`To add a custom server, configure 'lsp' in oh-my-opencode.json:`,
` {`,
` "lsp": {`,
` "my-server": {`,
` "command": ["my-lsp", "--stdio"],`,
` "extensions": ["${result.extension}"]`,
` }`,
` }`,
` }`,
].join("\n")
}
export async function withLspClient<T>(filePath: string, fn: (client: LSPClient) => Promise<T>): Promise<T> {
const absPath = resolve(filePath)
const ext = extname(absPath)
const result = findServerForExtension(ext)
if (result.status !== "found") {
throw new Error(formatServerLookupError(result))
}
const server = result.server
const root = findWorkspaceRoot(absPath)
const client = await lspManager.getClient(root, server)
try {
return await fn(client)
} catch (e) {
if (e instanceof Error && e.message.includes("timeout")) {
const isInitializing = lspManager.isServerInitializing(root, server.id)
if (isInitializing) {
throw new Error(
`LSP server is still initializing. Please retry in a few seconds. ` +
`Original error: ${e.message}`
)
}
}
throw e
} finally {
lspManager.releaseClient(root, server.id)
}
}
+129
View File
@@ -0,0 +1,129 @@
import { readFileSync } from "fs"
import { extname, resolve } from "path"
import { pathToFileURL } from "node:url"
import { getLanguageId } from "./config"
import { LSPClientConnection } from "./lsp-client-connection"
import type { Diagnostic } from "./types"
export class LSPClient extends LSPClientConnection {
private openedFiles = new Set<string>()
private documentVersions = new Map<string, number>()
private lastSyncedText = new Map<string, string>()
async openFile(filePath: string): Promise<void> {
const absPath = resolve(filePath)
const uri = pathToFileURL(absPath).href
const text = readFileSync(absPath, "utf-8")
if (!this.openedFiles.has(absPath)) {
const ext = extname(absPath)
const languageId = getLanguageId(ext)
const version = 1
this.sendNotification("textDocument/didOpen", {
textDocument: {
uri,
languageId,
version,
text,
},
})
this.openedFiles.add(absPath)
this.documentVersions.set(uri, version)
this.lastSyncedText.set(uri, text)
await new Promise((r) => setTimeout(r, 1000))
return
}
const prevText = this.lastSyncedText.get(uri)
if (prevText === text) {
return
}
const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1
this.documentVersions.set(uri, nextVersion)
this.lastSyncedText.set(uri, text)
this.sendNotification("textDocument/didChange", {
textDocument: { uri, version: nextVersion },
contentChanges: [{ text }],
})
// Some servers update diagnostics only after save
this.sendNotification("textDocument/didSave", {
textDocument: { uri },
text,
})
}
async definition(filePath: string, line: number, character: number): Promise<unknown> {
const absPath = resolve(filePath)
await this.openFile(absPath)
return this.sendRequest("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.sendRequest("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.sendRequest("textDocument/documentSymbol", {
textDocument: { uri: pathToFileURL(absPath).href },
})
}
async workspaceSymbols(query: string): Promise<unknown> {
return this.sendRequest("workspace/symbol", { query })
}
async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
const absPath = resolve(filePath)
const uri = pathToFileURL(absPath).href
await this.openFile(absPath)
await new Promise((r) => setTimeout(r, 500))
try {
const result = await this.sendRequest<{ items?: Diagnostic[] }>("textDocument/diagnostic", {
textDocument: { uri },
})
if (result && typeof result === "object" && "items" in result) {
return result as { items: Diagnostic[] }
}
} catch {}
return { items: this.diagnosticsStore.get(uri) ?? [] }
}
async prepareRename(filePath: string, line: number, character: number): Promise<unknown> {
const absPath = resolve(filePath)
await this.openFile(absPath)
return this.sendRequest("textDocument/prepareRename", {
textDocument: { uri: pathToFileURL(absPath).href },
position: { line: line - 1, character },
})
}
async rename(filePath: string, line: number, character: number, newName: string): Promise<unknown> {
const absPath = resolve(filePath)
await this.openFile(absPath)
return this.sendRequest("textDocument/rename", {
textDocument: { uri: pathToFileURL(absPath).href },
position: { line: line - 1, character },
newName,
})
}
}
+193
View File
@@ -0,0 +1,193 @@
import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants"
import { uriToPath } from "./lsp-client-wrapper"
import type {
Diagnostic,
DocumentSymbol,
Location,
LocationLink,
PrepareRenameDefaultBehavior,
PrepareRenameResult,
Range,
SymbolInfo,
TextEdit,
WorkspaceEdit,
} from "./types"
import type { ApplyResult } from "./workspace-edit"
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
const char = diag.range.start.character
const source = diag.source ? `[${diag.source}]` : ""
const code = diag.code ? ` (${diag.code})` : ""
return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`
}
export function filterDiagnosticsBySeverity(
diagnostics: Diagnostic[],
severityFilter?: "error" | "warning" | "information" | "hint" | "all"
): Diagnostic[] {
if (!severityFilter || severityFilter === "all") {
return diagnostics
}
const severityMap: Record<string, number> = {
error: 1,
warning: 2,
information: 3,
hint: 4,
}
const targetSeverity = severityMap[severityFilter]
return diagnostics.filter((d) => d.severity === targetSeverity)
}
export function formatPrepareRenameResult(
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null
): string {
if (!result) return "Cannot rename at this position"
// Case 1: { defaultBehavior: boolean }
if ("defaultBehavior" in result) {
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"
}
// Case 2: { range: Range, placeholder?: string }
if ("range" in result && result.range) {
const startLine = result.range.start.line + 1
const startChar = result.range.start.character
const endLine = result.range.end.line + 1
const endChar = result.range.end.character
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
}
// Case 3: Range directly (has start/end but no range property)
if ("start" in result && "end" in result) {
const startLine = result.start.line + 1
const startChar = result.start.character
const endLine = result.end.line + 1
const endChar = result.end.character
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`
}
return "Cannot rename at this position"
}
export function formatTextEdit(edit: TextEdit): string {
const startLine = edit.range.start.line + 1
const startChar = edit.range.start.character
const endLine = edit.range.end.line + 1
const endChar = edit.range.end.character
const rangeStr = `${startLine}:${startChar}-${endLine}:${endChar}`
const preview = edit.newText.length > 50 ? edit.newText.substring(0, 50) + "..." : edit.newText
return ` ${rangeStr}: "${preview}"`
}
export function formatWorkspaceEdit(edit: WorkspaceEdit | null): string {
if (!edit) return "No changes"
const lines: string[] = []
if (edit.changes) {
for (const [uri, edits] of Object.entries(edit.changes)) {
const filePath = uriToPath(uri)
lines.push(`File: ${filePath}`)
for (const textEdit of edits) {
lines.push(formatTextEdit(textEdit))
}
}
}
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if ("kind" in change) {
if (change.kind === "create") {
lines.push(`Create: ${change.uri}`)
} else if (change.kind === "rename") {
lines.push(`Rename: ${change.oldUri} -> ${change.newUri}`)
} else if (change.kind === "delete") {
lines.push(`Delete: ${change.uri}`)
}
} else {
const filePath = uriToPath(change.textDocument.uri)
lines.push(`File: ${filePath}`)
for (const textEdit of change.edits) {
lines.push(formatTextEdit(textEdit))
}
}
}
}
if (lines.length === 0) return "No changes"
return lines.join("\n")
}
export function formatApplyResult(result: ApplyResult): string {
const lines: string[] = []
if (result.success) {
lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`)
for (const file of result.filesModified) {
lines.push(` - ${file}`)
}
} else {
lines.push("Failed to apply some changes:")
for (const err of result.errors) {
lines.push(` Error: ${err}`)
}
if (result.filesModified.length > 0) {
lines.push(`Successfully modified: ${result.filesModified.join(", ")}`)
}
}
return lines.join("\n")
}
+186
View File
@@ -0,0 +1,186 @@
import { spawn as bunSpawn } from "bun"
import { spawn as nodeSpawn, spawnSync, type ChildProcess } from "node:child_process"
import { existsSync, statSync } from "fs"
import { log } from "../../shared/logger"
// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+
function shouldUseNodeSpawn(): boolean {
return process.platform === "win32"
}
// Prevents segfaults when libuv gets a non-existent cwd (oven-sh/bun#25798)
export function validateCwd(cwd: string): { valid: boolean; error?: string } {
try {
if (!existsSync(cwd)) {
return { valid: false, error: `Working directory does not exist: ${cwd}` }
}
const stats = statSync(cwd)
if (!stats.isDirectory()) {
return { valid: false, error: `Path is not a directory: ${cwd}` }
}
return { valid: true }
} catch (err) {
return { valid: false, error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})` }
}
}
function isBinaryAvailableOnWindows(command: string): boolean {
if (process.platform !== "win32") return true
if (command.includes("/") || command.includes("\\")) {
return existsSync(command)
}
try {
const result = spawnSync("where", [command], {
shell: true,
windowsHide: true,
timeout: 5000,
})
return result.status === 0
} catch {
return true
}
}
interface StreamReader {
read(): Promise<{ done: boolean; value: Uint8Array | undefined }>
}
// Bridges Bun Subprocess and Node.js ChildProcess under a common API
export interface UnifiedProcess {
stdin: { write(chunk: Uint8Array | string): void }
stdout: { getReader(): StreamReader }
stderr: { getReader(): StreamReader }
exitCode: number | null
exited: Promise<number>
kill(signal?: string): void
}
function wrapNodeProcess(proc: ChildProcess): UnifiedProcess {
let resolveExited: (code: number) => void
let exitCode: number | null = null
const exitedPromise = new Promise<number>((resolve) => {
resolveExited = resolve
})
proc.on("exit", (code) => {
exitCode = code ?? 1
resolveExited(exitCode)
})
proc.on("error", () => {
if (exitCode === null) {
exitCode = 1
resolveExited(1)
}
})
const createStreamReader = (nodeStream: NodeJS.ReadableStream | null): StreamReader => {
const chunks: Uint8Array[] = []
let streamEnded = false
type ReadResult = { done: boolean; value: Uint8Array | undefined }
let waitingResolve: ((result: ReadResult) => void) | null = null
if (nodeStream) {
nodeStream.on("data", (chunk: Buffer) => {
const uint8 = new Uint8Array(chunk)
if (waitingResolve) {
const resolve = waitingResolve
waitingResolve = null
resolve({ done: false, value: uint8 })
} else {
chunks.push(uint8)
}
})
nodeStream.on("end", () => {
streamEnded = true
if (waitingResolve) {
const resolve = waitingResolve
waitingResolve = null
resolve({ done: true, value: undefined })
}
})
nodeStream.on("error", () => {
streamEnded = true
if (waitingResolve) {
const resolve = waitingResolve
waitingResolve = null
resolve({ done: true, value: undefined })
}
})
} else {
streamEnded = true
}
return {
read(): Promise<ReadResult> {
return new Promise((resolve) => {
if (chunks.length > 0) {
resolve({ done: false, value: chunks.shift()! })
} else if (streamEnded) {
resolve({ done: true, value: undefined })
} else {
waitingResolve = resolve
}
})
},
}
}
return {
stdin: {
write(chunk: Uint8Array | string) {
if (proc.stdin) {
proc.stdin.write(chunk)
}
},
},
stdout: {
getReader: () => createStreamReader(proc.stdout),
},
stderr: {
getReader: () => createStreamReader(proc.stderr),
},
get exitCode() {
return exitCode
},
exited: exitedPromise,
kill(signal?: string) {
try {
if (signal === "SIGKILL") {
proc.kill("SIGKILL")
} else {
proc.kill()
}
} catch {}
},
}
}
export function spawnProcess(
command: string[],
options: { cwd: string; env: Record<string, string | undefined> }
): UnifiedProcess {
const cwdValidation = validateCwd(options.cwd)
if (!cwdValidation.valid) {
throw new Error(`[LSP] ${cwdValidation.error}`)
}
if (shouldUseNodeSpawn()) {
const [cmd, ...args] = command
if (!isBinaryAvailableOnWindows(cmd)) {
throw new Error(
`[LSP] Binary '${cmd}' not found on Windows. ` +
`Ensure the LSP server is installed and available in PATH. ` +
`For npm packages, try: npm install -g ${cmd}`
)
}
log("[LSP] Using Node.js child_process on Windows to avoid Bun spawn segfault")
const proc = nodeSpawn(cmd, args, {
cwd: options.cwd,
env: options.env as NodeJS.ProcessEnv,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
shell: true,
})
return wrapNodeProcess(proc)
}
const proc = bunSpawn(command, {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
cwd: options.cwd,
env: options.env,
})
return proc as unknown as UnifiedProcess
}
+197
View File
@@ -0,0 +1,197 @@
import type { ResolvedServer } from "./types"
import { LSPClient } from "./lsp-client"
interface ManagedClient {
client: LSPClient
lastUsedAt: number
refCount: number
initPromise?: Promise<void>
isInitializing: boolean
}
class LSPServerManager {
private static instance: LSPServerManager
private clients = new Map<string, ManagedClient>()
private cleanupInterval: ReturnType<typeof setInterval> | null = null
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
private constructor() {
this.startCleanupTimer()
this.registerProcessCleanup()
}
private registerProcessCleanup(): void {
// Synchronous cleanup for 'exit' event (cannot await)
const syncCleanup = () => {
for (const [, managed] of this.clients) {
try {
// Fire-and-forget during sync exit - process is terminating
void managed.client.stop().catch(() => {})
} catch {}
}
this.clients.clear()
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
// Async cleanup for signal handlers - properly await all stops
const asyncCleanup = async () => {
const stopPromises: Promise<void>[] = []
for (const [, managed] of this.clients) {
stopPromises.push(managed.client.stop().catch(() => {}))
}
await Promise.allSettled(stopPromises)
this.clients.clear()
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
process.on("exit", syncCleanup)
// Don't call process.exit() here; other handlers (background-agent manager) handle final exit.
process.on("SIGINT", () => void asyncCleanup().catch(() => {}))
process.on("SIGTERM", () => void asyncCleanup().catch(() => {}))
if (process.platform === "win32") {
process.on("SIGBREAK", () => void asyncCleanup().catch(() => {}))
}
}
static getInstance(): LSPServerManager {
if (!LSPServerManager.instance) {
LSPServerManager.instance = new LSPServerManager()
}
return LSPServerManager.instance
}
private getKey(root: string, serverId: string): string {
return `${root}::${serverId}`
}
private startCleanupTimer(): void {
if (this.cleanupInterval) return
this.cleanupInterval = setInterval(() => {
this.cleanupIdleClients()
}, 60000)
}
private cleanupIdleClients(): void {
const now = Date.now()
for (const [key, managed] of this.clients) {
if (managed.refCount === 0 && now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
managed.client.stop()
this.clients.delete(key)
}
}
}
async getClient(root: string, server: ResolvedServer): Promise<LSPClient> {
const key = this.getKey(root, server.id)
let managed = this.clients.get(key)
if (managed) {
if (managed.initPromise) {
await managed.initPromise
}
if (managed.client.isAlive()) {
managed.refCount++
managed.lastUsedAt = Date.now()
return managed.client
}
await managed.client.stop()
this.clients.delete(key)
}
const client = new LSPClient(root, server)
const initPromise = (async () => {
await client.start()
await client.initialize()
})()
this.clients.set(key, {
client,
lastUsedAt: Date.now(),
refCount: 1,
initPromise,
isInitializing: true,
})
await initPromise
const m = this.clients.get(key)
if (m) {
m.initPromise = undefined
m.isInitializing = false
}
return client
}
warmupClient(root: string, server: ResolvedServer): void {
const key = this.getKey(root, server.id)
if (this.clients.has(key)) return
const client = new LSPClient(root, server)
const initPromise = (async () => {
await client.start()
await client.initialize()
})()
this.clients.set(key, {
client,
lastUsedAt: Date.now(),
refCount: 0,
initPromise,
isInitializing: true,
})
initPromise.then(() => {
const m = this.clients.get(key)
if (m) {
m.initPromise = undefined
m.isInitializing = false
}
})
}
releaseClient(root: string, serverId: string): void {
const key = this.getKey(root, serverId)
const managed = this.clients.get(key)
if (managed && managed.refCount > 0) {
managed.refCount--
managed.lastUsedAt = Date.now()
}
}
isServerInitializing(root: string, serverId: string): boolean {
const key = this.getKey(root, serverId)
const managed = this.clients.get(key)
return managed?.isInitializing ?? false
}
async stopAll(): Promise<void> {
for (const [, managed] of this.clients) {
await managed.client.stop()
}
this.clients.clear()
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
async cleanupTempDirectoryClients(): Promise<void> {
const keysToRemove: string[] = []
for (const [key, managed] of this.clients.entries()) {
const isTempDir = key.startsWith("/tmp/") || key.startsWith("/var/folders/")
const isIdle = managed.refCount === 0
if (isTempDir && isIdle) {
keysToRemove.push(key)
}
}
for (const key of keysToRemove) {
const managed = this.clients.get(key)
if (managed) {
this.clients.delete(key)
try {
await managed.client.stop()
} catch {}
}
}
}
}
export const lspManager = LSPServerManager.getInstance()
+53
View File
@@ -0,0 +1,53 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { formatApplyResult, formatPrepareRenameResult } from "./lsp-formatters"
import { withLspClient } from "./lsp-client-wrapper"
import { applyWorkspaceEdit } from "./workspace-edit"
import type { PrepareRenameDefaultBehavior, PrepareRenameResult, WorkspaceEdit } from "./types"
export const lsp_prepare_rename: ToolDefinition = tool({
description: "Check if rename is valid. Use BEFORE lsp_rename.",
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.prepareRename(args.filePath, args.line, args.character)) as
| PrepareRenameResult
| PrepareRenameDefaultBehavior
| null
})
const output = formatPrepareRenameResult(result)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
}
},
})
export const lsp_rename: ToolDefinition = tool({
description: "Rename symbol across entire workspace. APPLIES changes to all files.",
args: {
filePath: tool.schema.string(),
line: tool.schema.number().min(1).describe("1-based"),
character: tool.schema.number().min(0).describe("0-based"),
newName: tool.schema.string().describe("New symbol name"),
},
execute: async (args, context) => {
try {
const edit = await withLspClient(args.filePath, async (client) => {
return (await client.rename(args.filePath, args.line, args.character, args.newName)) as WorkspaceEdit | null
})
const result = applyWorkspaceEdit(edit)
const output = formatApplyResult(result)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
}
},
})
+115
View File
@@ -0,0 +1,115 @@
import { existsSync, readFileSync } from "fs"
import { join } from "path"
import { BUILTIN_SERVERS } from "./constants"
import type { ResolvedServer } from "./types"
import { getOpenCodeConfigDir } from "../../shared"
interface LspEntry {
disabled?: boolean
command?: string[]
extensions?: string[]
priority?: number
env?: Record<string, string>
initialization?: Record<string, unknown>
}
interface ConfigJson {
lsp?: Record<string, LspEntry>
}
type ConfigSource = "project" | "user" | "opencode"
interface ServerWithSource extends ResolvedServer {
source: ConfigSource
}
function loadJsonFile<T>(path: string): T | null {
if (!existsSync(path)) return null
try {
return JSON.parse(readFileSync(path, "utf-8")) as T
} catch {
return null
}
}
export function getConfigPaths(): { project: string; user: string; opencode: string } {
const cwd = process.cwd()
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
return {
project: join(cwd, ".opencode", "oh-my-opencode.json"),
user: join(configDir, "oh-my-opencode.json"),
opencode: join(configDir, "opencode.json"),
}
}
export function loadAllConfigs(): Map<ConfigSource, ConfigJson> {
const paths = getConfigPaths()
const configs = new Map<ConfigSource, ConfigJson>()
const project = loadJsonFile<ConfigJson>(paths.project)
if (project) configs.set("project", project)
const user = loadJsonFile<ConfigJson>(paths.user)
if (user) configs.set("user", user)
const opencode = loadJsonFile<ConfigJson>(paths.opencode)
if (opencode) configs.set("opencode", opencode)
return configs
}
export function getMergedServers(): ServerWithSource[] {
const configs = loadAllConfigs()
const servers: ServerWithSource[] = []
const disabled = new Set<string>()
const seen = new Set<string>()
const sources: ConfigSource[] = ["project", "user", "opencode"]
for (const source of sources) {
const config = configs.get(source)
if (!config?.lsp) continue
for (const [id, entry] of Object.entries(config.lsp)) {
if (entry.disabled) {
disabled.add(id)
continue
}
if (seen.has(id)) continue
if (!entry.command || !entry.extensions) continue
servers.push({
id,
command: entry.command,
extensions: entry.extensions,
priority: entry.priority ?? 0,
env: entry.env,
initialization: entry.initialization,
source,
})
seen.add(id)
}
}
for (const [id, config] of Object.entries(BUILTIN_SERVERS)) {
if (disabled.has(id) || seen.has(id)) continue
servers.push({
id,
command: config.command,
extensions: config.extensions,
priority: -100,
source: "opencode",
})
}
return servers.sort((a, b) => {
if (a.source !== b.source) {
const order: Record<ConfigSource, number> = { project: 0, user: 1, opencode: 2 }
return order[a.source] - order[b.source]
}
return b.priority - a.priority
})
}
+91
View File
@@ -0,0 +1,91 @@
import type { LSPServerConfig } from "./types"
export const LSP_INSTALL_HINTS: Record<string, string> = {
typescript: "npm install -g typescript-language-server typescript",
deno: "Install Deno from https://deno.land",
vue: "npm install -g @vue/language-server",
eslint: "npm install -g vscode-langservers-extracted",
oxlint: "npm install -g oxlint",
biome: "npm install -g @biomejs/biome",
gopls: "go install golang.org/x/tools/gopls@latest",
"ruby-lsp": "gem install ruby-lsp",
basedpyright: "pip install basedpyright",
pyright: "pip install pyright",
ty: "pip install ty",
ruff: "pip install ruff",
"elixir-ls": "See https://github.com/elixir-lsp/elixir-ls",
zls: "See https://github.com/zigtools/zls",
csharp: "dotnet tool install -g csharp-ls",
fsharp: "dotnet tool install -g fsautocomplete",
"sourcekit-lsp": "Included with Xcode or Swift toolchain",
rust: "rustup component add rust-analyzer",
clangd: "See https://clangd.llvm.org/installation",
svelte: "npm install -g svelte-language-server",
astro: "npm install -g @astrojs/language-server",
"bash-ls": "npm install -g bash-language-server",
jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls",
"yaml-ls": "npm install -g yaml-language-server",
"lua-ls": "See https://github.com/LuaLS/lua-language-server",
php: "npm install -g intelephense",
dart: "Included with Dart SDK",
"terraform-ls": "See https://github.com/hashicorp/terraform-ls",
terraform: "See https://github.com/hashicorp/terraform-ls",
prisma: "npm install -g prisma",
"ocaml-lsp": "opam install ocaml-lsp-server",
texlab: "See https://github.com/latex-lsp/texlab",
dockerfile: "npm install -g dockerfile-language-server-nodejs",
gleam: "See https://gleam.run/getting-started/installing/",
"clojure-lsp": "See https://clojure-lsp.io/installation/",
nixd: "nix profile install nixpkgs#nixd",
tinymist: "See https://github.com/Myriad-Dreamin/tinymist",
"haskell-language-server": "ghcup install hls",
bash: "npm install -g bash-language-server",
"kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp",
}
// Synced with OpenCode's server.ts
// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/server.ts
export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"] },
deno: { command: ["deno", "lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"] },
vue: { command: ["vue-language-server", "--stdio"], extensions: [".vue"] },
eslint: { command: ["vscode-eslint-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"] },
oxlint: { command: ["oxlint", "--lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"] },
biome: { command: ["biome", "lsp-proxy", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".json", ".jsonc", ".vue", ".astro", ".svelte", ".css", ".graphql", ".gql", ".html"] },
gopls: { command: ["gopls"], extensions: [".go"] },
"ruby-lsp": { command: ["rubocop", "--lsp"], extensions: [".rb", ".rake", ".gemspec", ".ru"] },
basedpyright: { command: ["basedpyright-langserver", "--stdio"], extensions: [".py", ".pyi"] },
pyright: { command: ["pyright-langserver", "--stdio"], extensions: [".py", ".pyi"] },
ty: { command: ["ty", "server"], extensions: [".py", ".pyi"] },
ruff: { command: ["ruff", "server"], extensions: [".py", ".pyi"] },
"elixir-ls": { command: ["elixir-ls"], extensions: [".ex", ".exs"] },
zls: { command: ["zls"], extensions: [".zig", ".zon"] },
csharp: { command: ["csharp-ls"], extensions: [".cs"] },
fsharp: { command: ["fsautocomplete"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"] },
"sourcekit-lsp": { command: ["sourcekit-lsp"], extensions: [".swift", ".objc", ".objcpp"] },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
clangd: { command: ["clangd", "--background-index", "--clang-tidy"], extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"] },
svelte: { command: ["svelteserver", "--stdio"], extensions: [".svelte"] },
astro: { command: ["astro-ls", "--stdio"], extensions: [".astro"] },
bash: { command: ["bash-language-server", "start"], extensions: [".sh", ".bash", ".zsh", ".ksh"] },
// Keep legacy alias for backward compatibility
"bash-ls": { command: ["bash-language-server", "start"], extensions: [".sh", ".bash", ".zsh", ".ksh"] },
jdtls: { command: ["jdtls"], extensions: [".java"] },
"yaml-ls": { command: ["yaml-language-server", "--stdio"], extensions: [".yaml", ".yml"] },
"lua-ls": { command: ["lua-language-server"], extensions: [".lua"] },
php: { command: ["intelephense", "--stdio"], extensions: [".php"] },
dart: { command: ["dart", "language-server", "--lsp"], extensions: [".dart"] },
terraform: { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] },
// Legacy alias for backward compatibility
"terraform-ls": { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] },
prisma: { command: ["prisma", "language-server"], extensions: [".prisma"] },
"ocaml-lsp": { command: ["ocamllsp"], extensions: [".ml", ".mli"] },
texlab: { command: ["texlab"], extensions: [".tex", ".bib"] },
dockerfile: { command: ["docker-langserver", "--stdio"], extensions: [".dockerfile"] },
gleam: { command: ["gleam", "lsp"], extensions: [".gleam"] },
"clojure-lsp": { command: ["clojure-lsp", "listen"], extensions: [".clj", ".cljs", ".cljc", ".edn"] },
nixd: { command: ["nixd"], extensions: [".nix"] },
tinymist: { command: ["tinymist"], extensions: [".typ", ".typc"] },
"haskell-language-server": { command: ["haskell-language-server-wrapper", "--lsp"], extensions: [".hs", ".lhs"] },
"kotlin-ls": { command: ["kotlin-lsp"], extensions: [".kt", ".kts"] },
}
+69
View File
@@ -0,0 +1,69 @@
import { existsSync } from "fs"
import { join } from "path"
import { getOpenCodeConfigDir, getDataDir } from "../../shared"
export function isServerInstalled(command: string[]): boolean {
if (command.length === 0) return false
const cmd = command[0]
// Support absolute paths (e.g., C:\Users\...\server.exe or /usr/local/bin/server)
if (cmd.includes("/") || cmd.includes("\\")) {
if (existsSync(cmd)) return true
}
const isWindows = process.platform === "win32"
let exts = [""]
if (isWindows) {
const pathExt = process.env.PATHEXT || ""
if (pathExt) {
const systemExts = pathExt.split(";").filter(Boolean)
exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])]
} else {
exts = ["", ".exe", ".cmd", ".bat", ".ps1"]
}
}
let pathEnv = process.env.PATH || ""
if (isWindows && !pathEnv) {
pathEnv = process.env.Path || ""
}
const pathSeparator = isWindows ? ";" : ":"
const paths = pathEnv.split(pathSeparator)
for (const p of paths) {
for (const suffix of exts) {
if (existsSync(join(p, cmd + suffix))) {
return true
}
}
}
const cwd = process.cwd()
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const dataDir = join(getDataDir(), "opencode")
const additionalBases = [
join(cwd, "node_modules", ".bin"),
join(configDir, "bin"),
join(configDir, "node_modules", ".bin"),
join(dataDir, "bin"),
]
for (const base of additionalBases) {
for (const suffix of exts) {
if (existsSync(join(base, cmd + suffix))) {
return true
}
}
}
// Runtime wrappers (bun/node) are always available in oh-my-opencode context
if (cmd === "bun" || cmd === "node") {
return true
}
return false
}
+109
View File
@@ -0,0 +1,109 @@
import { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./constants"
import { getConfigPaths, getMergedServers, loadAllConfigs } from "./server-config-loader"
import { isServerInstalled } from "./server-installation"
import type { ServerLookupResult } from "./types"
export function findServerForExtension(ext: string): ServerLookupResult {
const servers = getMergedServers()
for (const server of servers) {
if (server.extensions.includes(ext) && isServerInstalled(server.command)) {
return {
status: "found",
server: {
id: server.id,
command: server.command,
extensions: server.extensions,
priority: server.priority,
env: server.env,
initialization: server.initialization,
},
}
}
}
for (const server of servers) {
if (server.extensions.includes(ext)) {
const installHint = LSP_INSTALL_HINTS[server.id] || `Install '${server.command[0]}' and ensure it's in your PATH`
return {
status: "not_installed",
server: {
id: server.id,
command: server.command,
extensions: server.extensions,
},
installHint,
}
}
}
const availableServers = [...new Set(servers.map((s) => s.id))]
return {
status: "not_configured",
extension: ext,
availableServers,
}
}
export function getAllServers(): Array<{
id: string
installed: boolean
extensions: string[]
disabled: boolean
source: string
priority: number
}> {
const configs = loadAllConfigs()
const servers = getMergedServers()
const disabled = new Set<string>()
for (const config of configs.values()) {
if (!config.lsp) continue
for (const [id, entry] of Object.entries(config.lsp)) {
if (entry.disabled) disabled.add(id)
}
}
const result: Array<{
id: string
installed: boolean
extensions: string[]
disabled: boolean
source: string
priority: number
}> = []
const seen = new Set<string>()
for (const server of servers) {
if (seen.has(server.id)) continue
result.push({
id: server.id,
installed: isServerInstalled(server.command),
extensions: server.extensions,
disabled: false,
source: server.source,
priority: server.priority,
})
seen.add(server.id)
}
for (const id of disabled) {
if (seen.has(id)) continue
const builtin = BUILTIN_SERVERS[id]
result.push({
id,
installed: builtin ? isServerInstalled(builtin.command) : false,
extensions: builtin?.extensions || [],
disabled: true,
source: "disabled",
priority: 0,
})
}
return result
}
export function getConfigPaths_(): { project: string; user: string; opencode: string } {
return getConfigPaths()
}
+76
View File
@@ -0,0 +1,76 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { DEFAULT_MAX_SYMBOLS } from "./constants"
import { formatDocumentSymbol, formatSymbolInfo } from "./lsp-formatters"
import { withLspClient } from "./lsp-client-wrapper"
import type { DocumentSymbol, SymbolInfo } from "./types"
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)}`
}
},
})
+5 -261
View File
@@ -1,261 +1,5 @@
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,
applyWorkspaceEdit,
formatApplyResult,
} from "./utils"
import type {
Location,
LocationLink,
DocumentSymbol,
SymbolInfo,
Diagnostic,
PrepareRenameResult,
PrepareRenameDefaultBehavior,
WorkspaceEdit,
} from "./types"
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.",
args: {
filePath: tool.schema.string(),
severity: tool.schema
.enum(["error", "warning", "information", "hint", "all"])
.optional()
.describe("Filter by severity level"),
},
execute: async (args, context) => {
try {
const result = await withLspClient(args.filePath, async (client) => {
return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null
})
let diagnostics: Diagnostic[] = []
if (result) {
if (Array.isArray(result)) {
diagnostics = result
} else if (result.items) {
diagnostics = result.items
}
}
diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity)
if (diagnostics.length === 0) {
const output = "No diagnostics found"
return output
}
const total = diagnostics.length
const truncated = total > DEFAULT_MAX_DIAGNOSTICS
const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics
const lines = limited.map(formatDiagnostic)
if (truncated) {
lines.unshift(`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`)
}
const output = lines.join("\n")
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
throw new Error(output)
}
},
})
export const lsp_prepare_rename: ToolDefinition = tool({
description: "Check if rename is valid. Use BEFORE lsp_rename.",
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.prepareRename(args.filePath, args.line, args.character)) as
| PrepareRenameResult
| PrepareRenameDefaultBehavior
| null
})
const output = formatPrepareRenameResult(result)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
}
},
})
export const lsp_rename: ToolDefinition = tool({
description: "Rename symbol across entire workspace. APPLIES changes to all files.",
args: {
filePath: tool.schema.string(),
line: tool.schema.number().min(1).describe("1-based"),
character: tool.schema.number().min(0).describe("0-based"),
newName: tool.schema.string().describe("New symbol name"),
},
execute: async (args, context) => {
try {
const edit = await withLspClient(args.filePath, async (client) => {
return (await client.rename(args.filePath, args.line, args.character, args.newName)) as WorkspaceEdit | null
})
const result = applyWorkspaceEdit(edit)
const output = formatApplyResult(result)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
}
},
})
export { lsp_goto_definition } from "./goto-definition-tool"
export { lsp_find_references } from "./find-references-tool"
export { lsp_symbols } from "./symbols-tool"
export { lsp_diagnostics } from "./diagnostics-tool"
export { lsp_prepare_rename, lsp_rename } from "./rename-tools"
+1 -1
View File
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
import { join } from "path"
import os from "os"
import { findWorkspaceRoot } from "./utils"
import { findWorkspaceRoot } from "./lsp-client-wrapper"
describe("lsp utils", () => {
describe("findWorkspaceRoot", () => {
-406
View File
@@ -1,406 +0,0 @@
import { extname, resolve } from "path"
import { fileURLToPath } from "node:url"
import { existsSync, readFileSync, writeFileSync } from "fs"
import { LSPClient, lspManager } from "./client"
import { findServerForExtension } from "./config"
import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants"
import type {
Location,
LocationLink,
DocumentSymbol,
SymbolInfo,
Diagnostic,
PrepareRenameResult,
PrepareRenameDefaultBehavior,
Range,
WorkspaceEdit,
TextEdit,
ServerLookupResult,
} from "./types"
export function findWorkspaceRoot(filePath: string): string {
let dir = resolve(filePath)
if (!existsSync(dir) || !require("fs").statSync(dir).isDirectory()) {
dir = require("path").dirname(dir)
}
const markers = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"]
let prevDir = ""
while (dir !== prevDir) {
for (const marker of markers) {
if (existsSync(require("path").join(dir, marker))) {
return dir
}
}
prevDir = dir
dir = require("path").dirname(dir)
}
return require("path").dirname(resolve(filePath))
}
export function uriToPath(uri: string): string {
return fileURLToPath(uri)
}
export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
if (result.status === "not_installed") {
const { server, installHint } = result
return [
`LSP server '${server.id}' is configured but NOT INSTALLED.`,
``,
`Command not found: ${server.command[0]}`,
``,
`To install:`,
` ${installHint}`,
``,
`Supported extensions: ${server.extensions.join(", ")}`,
``,
`After installation, the server will be available automatically.`,
`Run 'LspServers' tool to verify installation status.`,
].join("\n")
}
return [
`No LSP server configured for extension: ${result.extension}`,
``,
`Available servers: ${result.availableServers.slice(0, 10).join(", ")}${result.availableServers.length > 10 ? "..." : ""}`,
``,
`To add a custom server, configure 'lsp' in oh-my-opencode.json:`,
` {`,
` "lsp": {`,
` "my-server": {`,
` "command": ["my-lsp", "--stdio"],`,
` "extensions": ["${result.extension}"]`,
` }`,
` }`,
].join("\n")
}
export async function withLspClient<T>(filePath: string, fn: (client: LSPClient) => Promise<T>): Promise<T> {
const absPath = resolve(filePath)
const ext = extname(absPath)
const result = findServerForExtension(ext)
if (result.status !== "found") {
throw new Error(formatServerLookupError(result))
}
const server = result.server
const root = findWorkspaceRoot(absPath)
const client = await lspManager.getClient(root, server)
try {
return await fn(client)
} catch (e) {
if (e instanceof Error && e.message.includes("timeout")) {
const isInitializing = lspManager.isServerInitializing(root, server.id)
if (isInitializing) {
throw new Error(
`LSP server is still initializing. Please retry in a few seconds. ` +
`Original error: ${e.message}`
)
}
}
throw e
} finally {
lspManager.releaseClient(root, server.id)
}
}
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
const char = diag.range.start.character
const source = diag.source ? `[${diag.source}]` : ""
const code = diag.code ? ` (${diag.code})` : ""
return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`
}
export function filterDiagnosticsBySeverity(
diagnostics: Diagnostic[],
severityFilter?: "error" | "warning" | "information" | "hint" | "all"
): Diagnostic[] {
if (!severityFilter || severityFilter === "all") {
return diagnostics
}
const severityMap: Record<string, number> = {
error: 1,
warning: 2,
information: 3,
hint: 4,
}
const targetSeverity = severityMap[severityFilter]
return diagnostics.filter((d) => d.severity === targetSeverity)
}
export function formatPrepareRenameResult(
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null
): string {
if (!result) return "Cannot rename at this position"
// Case 1: { defaultBehavior: boolean }
if ("defaultBehavior" in result) {
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"
}
// Case 2: { range: Range, placeholder?: string }
if ("range" in result && result.range) {
const startLine = result.range.start.line + 1
const startChar = result.range.start.character
const endLine = result.range.end.line + 1
const endChar = result.range.end.character
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
}
// Case 3: Range directly (has start/end but no range property)
if ("start" in result && "end" in result) {
const startLine = result.start.line + 1
const startChar = result.start.character
const endLine = result.end.line + 1
const endChar = result.end.character
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`
}
return "Cannot rename at this position"
}
export function formatTextEdit(edit: TextEdit): string {
const startLine = edit.range.start.line + 1
const startChar = edit.range.start.character
const endLine = edit.range.end.line + 1
const endChar = edit.range.end.character
const rangeStr = `${startLine}:${startChar}-${endLine}:${endChar}`
const preview = edit.newText.length > 50 ? edit.newText.substring(0, 50) + "..." : edit.newText
return ` ${rangeStr}: "${preview}"`
}
export function formatWorkspaceEdit(edit: WorkspaceEdit | null): string {
if (!edit) return "No changes"
const lines: string[] = []
if (edit.changes) {
for (const [uri, edits] of Object.entries(edit.changes)) {
const filePath = uriToPath(uri)
lines.push(`File: ${filePath}`)
for (const textEdit of edits) {
lines.push(formatTextEdit(textEdit))
}
}
}
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if ("kind" in change) {
if (change.kind === "create") {
lines.push(`Create: ${change.uri}`)
} else if (change.kind === "rename") {
lines.push(`Rename: ${change.oldUri} -> ${change.newUri}`)
} else if (change.kind === "delete") {
lines.push(`Delete: ${change.uri}`)
}
} else {
const filePath = uriToPath(change.textDocument.uri)
lines.push(`File: ${filePath}`)
for (const textEdit of change.edits) {
lines.push(formatTextEdit(textEdit))
}
}
}
}
if (lines.length === 0) return "No changes"
return lines.join("\n")
}
export interface ApplyResult {
success: boolean
filesModified: string[]
totalEdits: number
errors: string[]
}
function applyTextEditsToFile(filePath: string, edits: TextEdit[]): { success: boolean; editCount: number; error?: string } {
try {
let content = readFileSync(filePath, "utf-8")
const lines = content.split("\n")
const sortedEdits = [...edits].sort((a, b) => {
if (b.range.start.line !== a.range.start.line) {
return b.range.start.line - a.range.start.line
}
return b.range.start.character - a.range.start.character
})
for (const edit of sortedEdits) {
const startLine = edit.range.start.line
const startChar = edit.range.start.character
const endLine = edit.range.end.line
const endChar = edit.range.end.character
if (startLine === endLine) {
const line = lines[startLine] || ""
lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar)
} else {
const firstLine = lines[startLine] || ""
const lastLine = lines[endLine] || ""
const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar)
lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n"))
}
}
writeFileSync(filePath, lines.join("\n"), "utf-8")
return { success: true, editCount: edits.length }
} catch (err) {
return { success: false, editCount: 0, error: err instanceof Error ? err.message : String(err) }
}
}
export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
if (!edit) {
return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] }
}
const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] }
if (edit.changes) {
for (const [uri, edits] of Object.entries(edit.changes)) {
const filePath = uriToPath(uri)
const applyResult = applyTextEditsToFile(filePath, edits)
if (applyResult.success) {
result.filesModified.push(filePath)
result.totalEdits += applyResult.editCount
} else {
result.success = false
result.errors.push(`${filePath}: ${applyResult.error}`)
}
}
}
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if ("kind" in change) {
if (change.kind === "create") {
try {
const filePath = uriToPath(change.uri)
writeFileSync(filePath, "", "utf-8")
result.filesModified.push(filePath)
} catch (err) {
result.success = false
result.errors.push(`Create ${change.uri}: ${err}`)
}
} else if (change.kind === "rename") {
try {
const oldPath = uriToPath(change.oldUri)
const newPath = uriToPath(change.newUri)
const content = readFileSync(oldPath, "utf-8")
writeFileSync(newPath, content, "utf-8")
require("fs").unlinkSync(oldPath)
result.filesModified.push(newPath)
} catch (err) {
result.success = false
result.errors.push(`Rename ${change.oldUri}: ${err}`)
}
} else if (change.kind === "delete") {
try {
const filePath = uriToPath(change.uri)
require("fs").unlinkSync(filePath)
result.filesModified.push(filePath)
} catch (err) {
result.success = false
result.errors.push(`Delete ${change.uri}: ${err}`)
}
}
} else {
const filePath = uriToPath(change.textDocument.uri)
const applyResult = applyTextEditsToFile(filePath, change.edits)
if (applyResult.success) {
result.filesModified.push(filePath)
result.totalEdits += applyResult.editCount
} else {
result.success = false
result.errors.push(`${filePath}: ${applyResult.error}`)
}
}
}
}
return result
}
export function formatApplyResult(result: ApplyResult): string {
const lines: string[] = []
if (result.success) {
lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`)
for (const file of result.filesModified) {
lines.push(` - ${file}`)
}
} else {
lines.push("Failed to apply some changes:")
for (const err of result.errors) {
lines.push(` Error: ${err}`)
}
if (result.filesModified.length > 0) {
lines.push(`Successfully modified: ${result.filesModified.join(", ")}`)
}
}
return lines.join("\n")
}
+121
View File
@@ -0,0 +1,121 @@
import { readFileSync, writeFileSync } from "fs"
import { uriToPath } from "./lsp-client-wrapper"
import type { TextEdit, WorkspaceEdit } from "./types"
export interface ApplyResult {
success: boolean
filesModified: string[]
totalEdits: number
errors: string[]
}
function applyTextEditsToFile(filePath: string, edits: TextEdit[]): { success: boolean; editCount: number; error?: string } {
try {
let content = readFileSync(filePath, "utf-8")
const lines = content.split("\n")
const sortedEdits = [...edits].sort((a, b) => {
if (b.range.start.line !== a.range.start.line) {
return b.range.start.line - a.range.start.line
}
return b.range.start.character - a.range.start.character
})
for (const edit of sortedEdits) {
const startLine = edit.range.start.line
const startChar = edit.range.start.character
const endLine = edit.range.end.line
const endChar = edit.range.end.character
if (startLine === endLine) {
const line = lines[startLine] || ""
lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar)
} else {
const firstLine = lines[startLine] || ""
const lastLine = lines[endLine] || ""
const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar)
lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n"))
}
}
writeFileSync(filePath, lines.join("\n"), "utf-8")
return { success: true, editCount: edits.length }
} catch (err) {
return { success: false, editCount: 0, error: err instanceof Error ? err.message : String(err) }
}
}
export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
if (!edit) {
return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] }
}
const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] }
if (edit.changes) {
for (const [uri, edits] of Object.entries(edit.changes)) {
const filePath = uriToPath(uri)
const applyResult = applyTextEditsToFile(filePath, edits)
if (applyResult.success) {
result.filesModified.push(filePath)
result.totalEdits += applyResult.editCount
} else {
result.success = false
result.errors.push(`${filePath}: ${applyResult.error}`)
}
}
}
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if ("kind" in change) {
if (change.kind === "create") {
try {
const filePath = uriToPath(change.uri)
writeFileSync(filePath, "", "utf-8")
result.filesModified.push(filePath)
} catch (err) {
result.success = false
result.errors.push(`Create ${change.uri}: ${err}`)
}
} else if (change.kind === "rename") {
try {
const oldPath = uriToPath(change.oldUri)
const newPath = uriToPath(change.newUri)
const content = readFileSync(oldPath, "utf-8")
writeFileSync(newPath, content, "utf-8")
require("fs").unlinkSync(oldPath)
result.filesModified.push(newPath)
} catch (err) {
result.success = false
result.errors.push(`Rename ${change.oldUri}: ${err}`)
}
} else if (change.kind === "delete") {
try {
const filePath = uriToPath(change.uri)
require("fs").unlinkSync(filePath)
result.filesModified.push(filePath)
} catch (err) {
result.success = false
result.errors.push(`Delete ${change.uri}: ${err}`)
}
}
} else {
const filePath = uriToPath(change.textDocument.uri)
const applyResult = applyTextEditsToFile(filePath, change.edits)
if (applyResult.success) {
result.filesModified.push(filePath)
result.totalEdits += applyResult.editCount
} else {
result.success = false
result.errors.push(`${filePath}: ${applyResult.error}`)
}
}
}
}
return result
}
+1 -1
View File
@@ -14,7 +14,7 @@ import {
formatSessionMessages,
formatSearchResults,
searchInSession,
} from "./utils"
} from "./session-formatter"
import type { SessionListArgs, SessionReadArgs, SessionSearchArgs, SessionInfoArgs, SearchResult } from "./types"
const SEARCH_TIMEOUT_MS = 60_000
+1 -1
View File
@@ -6,7 +6,7 @@ import {
formatSearchResults,
filterSessionsByDate,
searchInSession,
} from "./utils"
} from "./session-formatter"
import type { SessionInfo, SessionMessage, SearchResult } from "./types"
describe("session-manager utils", () => {