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:
@@ -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")
|
||||
}
|
||||
@@ -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 }>
|
||||
}
|
||||
+11
-1013
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,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,4 +1,4 @@
|
||||
import { interactive_bash } from "./tools"
|
||||
import { startBackgroundCheck } from "./utils"
|
||||
import { startBackgroundCheck } from "./tmux-path-resolver"
|
||||
|
||||
export { interactive_bash, startBackgroundCheck }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EXT_TO_LANG } from "./constants"
|
||||
|
||||
export function getLanguageId(ext: string): string {
|
||||
return EXT_TO_LANG[ext] || "plaintext"
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"] },
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
formatSearchResults,
|
||||
filterSessionsByDate,
|
||||
searchInSession,
|
||||
} from "./utils"
|
||||
} from "./session-formatter"
|
||||
import type { SessionInfo, SessionMessage, SearchResult } from "./types"
|
||||
|
||||
describe("session-manager utils", () => {
|
||||
|
||||
Reference in New Issue
Block a user