opencode-agent-skill 15.0.0 → 15.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -5
  2. package/docs/V15-MANAGED-RUNTIME.md +18 -0
  3. package/global-config/agents/verifier.md +6 -2
  4. package/lib/affected-tests.mjs +58 -8
  5. package/lib/capability-fabric.mjs +107 -48
  6. package/lib/code-intelligence/index.mjs +4 -15
  7. package/lib/code-intelligence/lsp-pool.mjs +694 -0
  8. package/lib/code-intelligence/lsp-provider.mjs +481 -67
  9. package/lib/compaction-resume-guard.mjs +327 -0
  10. package/lib/completion-auditor.mjs +20 -0
  11. package/lib/context-engine-v11.mjs +20 -7
  12. package/lib/context-manifest.mjs +38 -21
  13. package/lib/decision-policy.mjs +40 -2
  14. package/lib/evidence-store.mjs +121 -31
  15. package/lib/executable-probe.mjs +69 -0
  16. package/lib/execution-contract.mjs +29 -16
  17. package/lib/hierarchical-context.mjs +31 -3
  18. package/lib/learning-engine.mjs +164 -65
  19. package/lib/memory-engine.mjs +348 -98
  20. package/lib/model-config.mjs +86 -7
  21. package/lib/performance-fabric.mjs +354 -107
  22. package/lib/permission-policy.mjs +232 -0
  23. package/lib/pi-rpc-pool.mjs +170 -5
  24. package/lib/provider-recovery.mjs +51 -0
  25. package/lib/repo-graph.mjs +38 -5
  26. package/lib/runtime-events.mjs +31 -10
  27. package/lib/semantic-index.mjs +67 -20
  28. package/lib/skill-compiler.mjs +84 -18
  29. package/lib/subagent-artifacts.mjs +182 -0
  30. package/lib/task-policy.mjs +80 -7
  31. package/lib/trajectory.mjs +25 -1
  32. package/lib/untrusted-output.mjs +106 -0
  33. package/lib/verification-broker.mjs +32 -27
  34. package/lib/workspace-fingerprint.mjs +43 -8
  35. package/lib/workspace-hygiene.mjs +481 -0
  36. package/package.json +8 -6
  37. package/pi/extensions/ues-child-runtime.ts +103 -5
  38. package/pi/extensions/ues.ts +984 -95
  39. package/scripts/benchmark-lsp-pool.mjs +128 -0
  40. package/scripts/check-release-consistency.mjs +16 -4
  41. package/scripts/check-source-integrity.mjs +60 -1
  42. package/scripts/run-test-suite.mjs +143 -0
  43. package/scripts/smoke-package-closure.mjs +4 -0
@@ -1,7 +1,11 @@
1
- import { spawn, spawnSync } from "node:child_process"
1
+ import { spawn } from "node:child_process"
2
+ import { existsSync } from "node:fs"
2
3
  import { lstat, readFile, realpath } from "node:fs/promises"
3
4
  import path from "node:path"
4
- import { pathToFileURL } from "node:url"
5
+ import { commandExists } from "../executable-probe.mjs"
6
+ import { resolveWindowsCommand } from "../windows-shim.mjs"
7
+ import { lspPoolStatus as managedLspPoolStatus, shutdownLspPool as shutdownManagedLspPool, withManagedLspSession } from "./lsp-pool.mjs"
8
+ import { fileURLToPath, pathToFileURL } from "node:url"
5
9
 
6
10
  const PROVIDERS = [
7
11
  { id: "typescript-language-server", extensions: new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]), commands: [["typescript-language-server", ["--stdio"]]], languageId: (ext) => [".ts", ".tsx"].includes(ext) ? "typescript" : "javascript" },
@@ -11,18 +15,16 @@ const PROVIDERS = [
11
15
  { id: "clangd", extensions: new Set([".c", ".cc", ".cpp", ".cxx", ".h", ".hpp"]), commands: [["clangd", []]], languageId: (ext) => [".c", ".h"].includes(ext) ? "c" : "cpp" },
12
16
  ]
13
17
 
14
- const commandCache = new Map()
15
- const COMMAND_TTL_MS = 60_000
16
-
17
- function commandExists(command) {
18
- const now = Date.now()
19
- const cached = commandCache.get(command)
20
- if (cached && now - cached.at < COMMAND_TTL_MS) return cached.available
21
- const finder = process.platform === "win32" ? "where" : "which"
22
- const available = spawnSync(finder, [command], { stdio: "ignore", windowsHide: true }).status === 0
23
- commandCache.set(command, { available, at: now })
24
- return available
25
- }
18
+ export const LSP_OPERATIONS = Object.freeze([
19
+ "diagnostics",
20
+ "definition",
21
+ "references",
22
+ "symbols",
23
+ "hover",
24
+ "rename-preview",
25
+ "incoming-calls",
26
+ "outgoing-calls",
27
+ ])
26
28
 
27
29
  function inside(root, target) {
28
30
  return target === root || target.startsWith(root + path.sep)
@@ -31,26 +33,81 @@ function inside(root, target) {
31
33
  async function safeFile(root, relative) {
32
34
  const base = await realpath(path.resolve(root)).catch(() => path.resolve(root))
33
35
  const requested = path.resolve(base, String(relative || ""))
34
- if (!inside(base, requested)) throw new Error("diagnostic path escapes workspace root")
36
+ if (!inside(base, requested)) throw new Error("LSP path escapes workspace root")
35
37
  const info = await lstat(requested)
36
- if (!info.isFile()) throw new Error("diagnostic path is not a file")
38
+ if (!info.isFile()) throw new Error("LSP path is not a file")
37
39
  const actual = await realpath(requested)
38
- if (!inside(base, actual)) throw new Error("diagnostic symlink escapes workspace root")
40
+ if (!inside(base, actual)) throw new Error("LSP symlink escapes workspace root")
39
41
  return { base, file: actual, info }
40
42
  }
41
43
 
44
+ export function resolveTypeScriptTsserverFallback(options = {}) {
45
+ const explicit = String(options.explicitPath ?? process.env.UES_TYPESCRIPT_TSSERVER_PATH ?? "").trim()
46
+ if (explicit && existsSync(path.resolve(explicit))) return path.resolve(explicit)
47
+
48
+ const platform = String(options.platform || process.platform)
49
+ if (platform !== "win32") return null
50
+
51
+ const execution = options.execution || resolveWindowsCommand("tsserver")
52
+ const candidates = [
53
+ execution?.entry,
54
+ ...(Array.isArray(execution?.argsPrefix) ? execution.argsPrefix : []),
55
+ ].filter(Boolean)
56
+
57
+ for (const value of candidates) {
58
+ const entry = path.resolve(String(value))
59
+ const binDir = path.dirname(entry)
60
+ const packageRoot = path.basename(binDir).toLowerCase() === "bin"
61
+ ? path.dirname(binDir)
62
+ : null
63
+ const preferred = packageRoot ? path.join(packageRoot, "lib", "tsserver.js") : null
64
+ if (preferred && existsSync(preferred)) return preferred
65
+ if (existsSync(entry)) return entry
66
+ }
67
+ return null
68
+ }
69
+
70
+ function providerRuntimeOptions(provider) {
71
+ if (provider?.id !== "typescript-language-server") return {}
72
+ const fallbackPath = resolveTypeScriptTsserverFallback()
73
+ return fallbackPath
74
+ ? { initializationOptions: { tsserver: { fallbackPath } } }
75
+ : {}
76
+ }
77
+
42
78
  function providerFor(file) {
43
79
  const ext = path.extname(file).toLowerCase()
44
80
  for (const provider of PROVIDERS) {
45
81
  if (!provider.extensions.has(ext)) continue
46
82
  for (const [command, args] of provider.commands) {
47
- if (commandExists(command)) return { ...provider, command, args, languageId: provider.languageId(ext) }
83
+ if (commandExists(command)) {
84
+ return {
85
+ ...provider,
86
+ ...providerRuntimeOptions(provider),
87
+ command,
88
+ args,
89
+ languageId: provider.languageId(ext),
90
+ }
91
+ }
48
92
  }
49
93
  return { ...provider, command: null, args: [], languageId: provider.languageId(ext) }
50
94
  }
51
95
  return null
52
96
  }
53
97
 
98
+ export function lspPersistencePolicy(options = {}) {
99
+ if (typeof options.persistent === "boolean") {
100
+ return { enabled: options.persistent, source: "explicit-option" }
101
+ }
102
+ const env = String(process.env.UES_LSP_PERSISTENT || "").trim()
103
+ if (env === "0") return { enabled: false, source: "env-disabled" }
104
+ if (env === "1") return { enabled: true, source: "env-enabled" }
105
+ if (process.env.UES_CHILD_PROCESS === "1") {
106
+ return { enabled: true, source: "pi-child-runtime" }
107
+ }
108
+ return { enabled: false, source: "short-lived-default" }
109
+ }
110
+
54
111
  export function lspProviderStatus(file = "") {
55
112
  const ext = path.extname(String(file || "")).toLowerCase()
56
113
  const providers = PROVIDERS
@@ -59,7 +116,20 @@ export function lspProviderStatus(file = "") {
59
116
  const selected = provider.commands.find(([command]) => commandExists(command)) || null
60
117
  return { id: provider.id, available: Boolean(selected), command: selected?.[0] || null, extensions: [...provider.extensions] }
61
118
  })
62
- return { schemaVersion: 1, extension: ext, available: providers.some((row) => row.available), providers }
119
+ return {
120
+ schemaVersion: 2,
121
+ extension: ext,
122
+ available: providers.some((row) => row.available),
123
+ operations: [...LSP_OPERATIONS],
124
+ persistentPool: {
125
+ ...managedLspPoolStatus({
126
+ includeSessions: false,
127
+ persistent: lspPersistencePolicy().enabled,
128
+ }),
129
+ policy: lspPersistencePolicy(),
130
+ },
131
+ providers,
132
+ }
63
133
  }
64
134
 
65
135
  function encodeMessage(payload) {
@@ -67,6 +137,23 @@ function encodeMessage(payload) {
67
137
  return Buffer.concat([Buffer.from("Content-Length: " + body.length + "\r\n\r\n", "ascii"), body])
68
138
  }
69
139
 
140
+ function relativeUri(base, uri) {
141
+ try {
142
+ if (!String(uri || "").startsWith("file:")) return String(uri || "")
143
+ const file = fileURLToPath(uri)
144
+ if (!inside(base, file)) return String(uri || "")
145
+ return path.relative(base, file).replaceAll("\\", "/")
146
+ } catch {
147
+ return String(uri || "")
148
+ }
149
+ }
150
+
151
+ function position(options = {}) {
152
+ const line = Math.max(1, Math.trunc(Number(options.line || 1)))
153
+ const character = Math.max(1, Math.trunc(Number(options.character || 1)))
154
+ return { line: line - 1, character: character - 1 }
155
+ }
156
+
70
157
  function sanitizeDiagnostic(value = {}) {
71
158
  return {
72
159
  range: value.range || null,
@@ -77,73 +164,232 @@ function sanitizeDiagnostic(value = {}) {
77
164
  }
78
165
  }
79
166
 
80
- export async function diagnoseCode(root, relative, options = {}) {
167
+ function sanitizeLocations(base, value, limit = 120) {
168
+ const list = Array.isArray(value) ? value : value ? [value] : []
169
+ return list.slice(0, limit).map((item) => {
170
+ if (item?.targetUri) {
171
+ return {
172
+ file: relativeUri(base, item.targetUri),
173
+ range: item.targetRange || null,
174
+ selectionRange: item.targetSelectionRange || null,
175
+ originSelectionRange: item.originSelectionRange || null,
176
+ }
177
+ }
178
+ return {
179
+ file: relativeUri(base, item?.uri),
180
+ range: item?.range || null,
181
+ }
182
+ })
183
+ }
184
+
185
+ function flattenSymbols(base, values, output, parent = null, limit = 160) {
186
+ for (const item of Array.isArray(values) ? values : []) {
187
+ if (output.length >= limit) break
188
+ output.push({
189
+ name: String(item?.name || "").slice(0, 300),
190
+ detail: item?.detail == null ? null : String(item.detail).slice(0, 500),
191
+ kind: item?.kind ?? null,
192
+ containerName: item?.containerName || parent,
193
+ file: item?.location?.uri ? relativeUri(base, item.location.uri) : null,
194
+ range: item?.range || item?.location?.range || null,
195
+ selectionRange: item?.selectionRange || null,
196
+ })
197
+ if (Array.isArray(item?.children)) flattenSymbols(base, item.children, output, item?.name || parent, limit)
198
+ }
199
+ return output
200
+ }
201
+
202
+ function hoverText(contents) {
203
+ if (contents == null) return ""
204
+ if (typeof contents === "string") return contents
205
+ if (Array.isArray(contents)) {
206
+ return contents.map((item) => typeof item === "string" ? item : item?.value || "").filter(Boolean).join("\n")
207
+ }
208
+ if (typeof contents === "object") return String(contents.value || "")
209
+ return String(contents)
210
+ }
211
+
212
+ function sanitizeWorkspaceEdit(base, edit, limit = 240) {
213
+ const rows = []
214
+ for (const [uri, edits] of Object.entries(edit?.changes || {})) {
215
+ for (const item of Array.isArray(edits) ? edits : []) {
216
+ rows.push({ file: relativeUri(base, uri), range: item.range || null, newText: String(item.newText || "").slice(0, 4000) })
217
+ if (rows.length >= limit) return { edits: rows, truncated: true }
218
+ }
219
+ }
220
+ for (const change of Array.isArray(edit?.documentChanges) ? edit.documentChanges : []) {
221
+ const uri = change?.textDocument?.uri
222
+ for (const item of Array.isArray(change?.edits) ? change.edits : []) {
223
+ rows.push({ file: relativeUri(base, uri), range: item.range || null, newText: String(item.newText || "").slice(0, 4000) })
224
+ if (rows.length >= limit) return { edits: rows, truncated: true }
225
+ }
226
+ }
227
+ return { edits: rows, truncated: false }
228
+ }
229
+
230
+ function sanitizeHierarchyItem(base, item) {
231
+ if (!item) return null
232
+ return {
233
+ name: String(item.name || "").slice(0, 300),
234
+ detail: item.detail == null ? null : String(item.detail).slice(0, 500),
235
+ kind: item.kind ?? null,
236
+ file: relativeUri(base, item.uri),
237
+ range: item.range || null,
238
+ selectionRange: item.selectionRange || null,
239
+ _raw: item,
240
+ }
241
+ }
242
+
243
+ function sanitizeHierarchyCalls(base, values, direction, limit = 120) {
244
+ return (Array.isArray(values) ? values : []).slice(0, limit).map((call) => {
245
+ const item = direction === "incoming" ? call.from : call.to
246
+ const clean = sanitizeHierarchyItem(base, item)
247
+ if (clean) delete clean._raw
248
+ return {
249
+ item: clean,
250
+ ranges: direction === "incoming" ? (call.fromRanges || []) : (call.fromRanges || []),
251
+ }
252
+ })
253
+ }
254
+
255
+ async function withEphemeralLspSession(root, relative, options, operation) {
81
256
  const target = await safeFile(root, relative)
82
257
  const provider = providerFor(target.file)
83
258
  const relativeFile = path.relative(target.base, target.file).replaceAll("\\", "/")
84
259
  if (!provider?.command) {
85
- return { schemaVersion: 1, file: relativeFile, available: false, provider: provider?.id || null, diagnostics: [], reason: provider ? "lsp-command-unavailable" : "unsupported-extension" }
260
+ return {
261
+ schemaVersion: 2,
262
+ file: relativeFile,
263
+ available: false,
264
+ provider: provider?.id || null,
265
+ operation: options.operation || null,
266
+ reason: provider ? "lsp-command-unavailable" : "unsupported-extension",
267
+ result: null,
268
+ }
86
269
  }
87
270
 
88
271
  const maxBytes = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options.maxBytes || 1024 * 1024)))
89
- if (target.info.size > maxBytes) throw new Error("diagnostic file exceeds limit (" + target.info.size + " > " + maxBytes + " bytes)")
272
+ if (target.info.size > maxBytes) throw new Error("LSP file exceeds limit (" + target.info.size + " > " + maxBytes + " bytes)")
90
273
  const source = await readFile(target.file, "utf8")
91
274
  const uri = pathToFileURL(target.file).href
92
275
  const rootUri = pathToFileURL(target.base + path.sep).href
93
- const timeoutMs = Math.max(500, Math.min(10_000, Number(options.timeoutMs || 3000)))
94
- const maxDiagnostics = Math.max(1, Math.min(500, Number(options.maxDiagnostics || 100)))
276
+ const timeoutMs = Math.max(500, Math.min(15_000, Number(options.timeoutMs || 5000)))
277
+
278
+ const execution = process.platform === "win32"
279
+ ? resolveWindowsCommand(provider.command)
280
+ : { executable: provider.command, argsPrefix: [] }
281
+ if (!execution?.executable) {
282
+ return {
283
+ schemaVersion: 2,
284
+ file: relativeFile,
285
+ available: true,
286
+ provider: provider.id,
287
+ command: provider.command,
288
+ operation: options.operation || null,
289
+ reason: "spawn-error",
290
+ result: null,
291
+ stderr: null,
292
+ error: "unable to resolve LSP executable: " + provider.command,
293
+ }
294
+ }
95
295
 
96
296
  return await new Promise((resolve) => {
97
- const child = spawn(provider.command, provider.args, { cwd: target.base, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, shell: false })
297
+ const child = spawn(execution.executable, [...(execution.argsPrefix || []), ...provider.args], {
298
+ cwd: target.base,
299
+ stdio: ["pipe", "pipe", "pipe"],
300
+ windowsHide: true,
301
+ shell: false,
302
+ })
98
303
  let buffer = Buffer.alloc(0)
99
304
  let stderr = ""
100
305
  let settled = false
101
- let initialized = false
102
- let diagnostics = []
306
+ let nextId = 1
307
+ const pending = new Map()
308
+ const notificationWaiters = new Map()
309
+ const recentNotifications = new Map()
103
310
  let timer = null
104
311
 
105
- const finish = (reason, error = null) => {
312
+ const finish = (reason, result = null, error = null) => {
106
313
  if (settled) return
107
314
  settled = true
108
315
  if (timer) clearTimeout(timer)
316
+ for (const waiter of pending.values()) waiter.reject(new Error("LSP session settled: " + reason))
317
+ pending.clear()
109
318
  try {
110
- if (initialized && child.stdin.writable) {
111
- child.stdin.write(encodeMessage({ jsonrpc: "2.0", id: 2, method: "shutdown", params: null }))
112
- child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
113
- }
319
+ if (child.stdin.writable) child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
114
320
  } catch {}
115
321
  setTimeout(() => { try { child.kill() } catch {} }, 50).unref?.()
116
322
  resolve({
117
- schemaVersion: 1,
323
+ schemaVersion: 2,
118
324
  file: relativeFile,
119
325
  available: true,
120
326
  provider: provider.id,
121
327
  command: provider.command,
122
- diagnostics: diagnostics.slice(0, maxDiagnostics),
328
+ operation: options.operation || null,
123
329
  reason,
330
+ result,
124
331
  stderr: stderr.trim().slice(0, 2000) || null,
125
- error: error ? String(error instanceof Error ? error.message : error).slice(0, 1000) : null,
332
+ error: error ? String(error instanceof Error ? error.message : error).slice(0, 1200) : null,
126
333
  })
127
334
  }
128
335
 
129
- const handle = (message) => {
130
- if (message?.id === 1 && message?.result) {
131
- initialized = true
132
- try {
133
- child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "initialized", params: {} }))
134
- child.stdin.write(encodeMessage({
135
- jsonrpc: "2.0",
136
- method: "textDocument/didOpen",
137
- params: { textDocument: { uri, languageId: provider.languageId, version: 1, text: source } },
138
- }))
139
- } catch (error) {
140
- finish("write-failed", error)
141
- }
336
+ const send = (payload) => {
337
+ if (settled || !child.stdin.writable) throw new Error("LSP stdin unavailable")
338
+ child.stdin.write(encodeMessage(payload))
339
+ }
340
+
341
+ const request = (method, params) => new Promise((res, rej) => {
342
+ const id = nextId++
343
+ pending.set(id, { resolve: res, reject: rej, method })
344
+ try { send({ jsonrpc: "2.0", id, method, params }) }
345
+ catch (error) { pending.delete(id); rej(error) }
346
+ })
347
+
348
+ const notify = (method, params) => send({ jsonrpc: "2.0", method, params })
349
+
350
+ const waitForNotification = (method) => {
351
+ if (recentNotifications.has(method)) {
352
+ const value = recentNotifications.get(method)
353
+ recentNotifications.delete(method)
354
+ return Promise.resolve(value)
355
+ }
356
+ return new Promise((res) => {
357
+ const list = notificationWaiters.get(method) || []
358
+ list.push(res)
359
+ notificationWaiters.set(method, list)
360
+ })
361
+ }
362
+
363
+ const dispatch = (message) => {
364
+ if (message?.id != null && pending.has(message.id)) {
365
+ const waiter = pending.get(message.id)
366
+ pending.delete(message.id)
367
+ if (message.error) waiter.reject(new Error(String(message.error.message || "LSP request failed")))
368
+ else waiter.resolve(message.result)
142
369
  return
143
370
  }
144
- if (message?.method === "textDocument/publishDiagnostics" && message?.params?.uri === uri) {
145
- diagnostics = Array.isArray(message.params.diagnostics) ? message.params.diagnostics.map(sanitizeDiagnostic).slice(0, maxDiagnostics) : []
146
- finish("publishDiagnostics")
371
+ if (message?.method) {
372
+ // Language servers may issue configuration/capability requests. Reply
373
+ // deterministically so the server does not stall waiting on a client UI.
374
+ if (message.id != null) {
375
+ try {
376
+ const result = message.method === "workspace/configuration"
377
+ ? (Array.isArray(message.params?.items) ? message.params.items.map(() => null) : [])
378
+ : null
379
+ send({ jsonrpc: "2.0", id: message.id, result })
380
+ } catch {}
381
+ }
382
+ const waiters = notificationWaiters.get(message.method) || []
383
+ if (waiters.length) {
384
+ notificationWaiters.delete(message.method)
385
+ for (const waiter of waiters) waiter(message.params)
386
+ } else {
387
+ recentNotifications.set(message.method, message.params)
388
+ if (recentNotifications.size > 16) {
389
+ const oldest = recentNotifications.keys().next().value
390
+ if (oldest) recentNotifications.delete(oldest)
391
+ }
392
+ }
147
393
  }
148
394
  }
149
395
 
@@ -159,31 +405,199 @@ export async function diagnoseCode(root, relative, options = {}) {
159
405
  if (buffer.length < bodyStart + length) return
160
406
  const body = buffer.subarray(bodyStart, bodyStart + length).toString("utf8")
161
407
  buffer = buffer.subarray(bodyStart + length)
162
- try { handle(JSON.parse(body)) } catch {}
408
+ try { dispatch(JSON.parse(body)) } catch {}
163
409
  }
164
410
  }
165
411
 
166
412
  child.stdout.on("data", (chunk) => { buffer = Buffer.concat([buffer, Buffer.from(chunk)]); parse() })
167
413
  child.stderr.on("data", (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk.toString() })
168
- child.on("error", (error) => finish("spawn-error", error))
414
+ child.on("error", (error) => finish("spawn-error", null, error))
169
415
  child.on("close", (code) => { if (!settled) finish("server-exit:" + (code ?? "unknown")) })
170
416
 
171
- try {
172
- child.stdin.write(encodeMessage({
173
- jsonrpc: "2.0",
174
- id: 1,
175
- method: "initialize",
176
- params: {
417
+ timer = setTimeout(() => finish("timeout"), timeoutMs)
418
+ timer.unref?.()
419
+
420
+ ;(async () => {
421
+ try {
422
+ await request("initialize", {
177
423
  processId: process.pid,
178
424
  rootUri,
179
- capabilities: { textDocument: { publishDiagnostics: { relatedInformation: true } } },
425
+ capabilities: {
426
+ textDocument: {
427
+ publishDiagnostics: { relatedInformation: true },
428
+ definition: { linkSupport: true },
429
+ references: {},
430
+ documentSymbol: { hierarchicalDocumentSymbolSupport: true },
431
+ hover: { contentFormat: ["markdown", "plaintext"] },
432
+ rename: { prepareSupport: true },
433
+ callHierarchy: {},
434
+ },
435
+ workspace: { workspaceFolders: true, applyEdit: false },
436
+ },
180
437
  workspaceFolders: [{ uri: rootUri, name: path.basename(target.base) || "workspace" }],
181
- },
182
- }))
183
- } catch (error) {
184
- finish("initialize-write-failed", error)
185
- }
186
- timer = setTimeout(() => finish("timeout"), timeoutMs)
187
- timer.unref?.()
438
+ initializationOptions: provider.initializationOptions || undefined,
439
+ })
440
+ notify("initialized", {})
441
+ notify("textDocument/didOpen", {
442
+ textDocument: { uri, languageId: provider.languageId, version: 1, text: source },
443
+ })
444
+ const result = await operation({
445
+ request,
446
+ notify,
447
+ waitForNotification,
448
+ uri,
449
+ base: target.base,
450
+ file: relativeFile,
451
+ })
452
+ finish("ok", result)
453
+ } catch (error) {
454
+ finish("request-error", null, error)
455
+ }
456
+ })()
188
457
  })
189
458
  }
459
+
460
+ export async function lspOperation(root, relative, operation, options = {}) {
461
+ const op = String(operation || "")
462
+ if (!LSP_OPERATIONS.includes(op)) throw new Error("unsupported LSP operation: " + op)
463
+
464
+ const target = await safeFile(root, relative)
465
+ const provider = providerFor(target.file)
466
+ const relativeFile = path.relative(target.base, target.file).replaceAll("\\", "/")
467
+ if (!provider?.command) {
468
+ return {
469
+ schemaVersion: 2,
470
+ file: relativeFile,
471
+ available: false,
472
+ provider: provider?.id || null,
473
+ operation: op,
474
+ reason: provider ? "lsp-command-unavailable" : "unsupported-extension",
475
+ result: null,
476
+ persistent: false,
477
+ }
478
+ }
479
+
480
+ const maxResults = Math.max(1, Math.min(500, Number(options.maxResults || 120)))
481
+ const execute = async (session) => {
482
+ const pos = position(options)
483
+ const textDocument = { uri: session.uri }
484
+
485
+ if (op === "diagnostics") {
486
+ const params = await session.waitForNotification("textDocument/publishDiagnostics", {
487
+ predicate: (value) =>
488
+ (!value?.uri || value.uri === session.uri) &&
489
+ (value?.version == null || Number(value.version) >= Number(session.documentSync?.version || 0)),
490
+ afterAt: session.documentSync?.changed ? session.documentSync.syncedAt : 0,
491
+ })
492
+ const values = Array.isArray(params?.diagnostics) ? params.diagnostics : []
493
+ return { diagnostics: values.slice(0, maxResults).map(sanitizeDiagnostic) }
494
+ }
495
+ if (op === "definition") {
496
+ const result = await session.request("textDocument/definition", { textDocument, position: pos })
497
+ return { locations: sanitizeLocations(session.base, result, maxResults) }
498
+ }
499
+ if (op === "references") {
500
+ const result = await session.request("textDocument/references", {
501
+ textDocument,
502
+ position: pos,
503
+ context: { includeDeclaration: options.includeDeclaration !== false },
504
+ })
505
+ return { locations: sanitizeLocations(session.base, result, maxResults) }
506
+ }
507
+ if (op === "symbols") {
508
+ const result = await session.request("textDocument/documentSymbol", { textDocument })
509
+ return { symbols: flattenSymbols(session.base, result, [], null, maxResults) }
510
+ }
511
+ if (op === "hover") {
512
+ const result = await session.request("textDocument/hover", { textDocument, position: pos })
513
+ return {
514
+ range: result?.range || null,
515
+ markdown: hoverText(result?.contents).slice(0, Number(options.maxHoverChars || 12000)),
516
+ }
517
+ }
518
+ if (op === "rename-preview") {
519
+ const newName = String(options.newName || "").trim()
520
+ if (!newName) throw new Error("rename-preview requires newName")
521
+ let prepared = null
522
+ try {
523
+ prepared = await session.request("textDocument/prepareRename", { textDocument, position: pos })
524
+ } catch {}
525
+ const result = await session.request("textDocument/rename", { textDocument, position: pos, newName })
526
+ return {
527
+ prepared: prepared || null,
528
+ newName,
529
+ ...sanitizeWorkspaceEdit(session.base, result, maxResults),
530
+ applied: false,
531
+ }
532
+ }
533
+ if (op === "incoming-calls" || op === "outgoing-calls") {
534
+ const prepared = await session.request("textDocument/prepareCallHierarchy", { textDocument, position: pos })
535
+ const item = Array.isArray(prepared) ? prepared[0] : prepared
536
+ if (!item) return { root: null, calls: [] }
537
+ const method = op === "incoming-calls" ? "callHierarchy/incomingCalls" : "callHierarchy/outgoingCalls"
538
+ const result = await session.request(method, { item })
539
+ const rootItem = sanitizeHierarchyItem(session.base, item)
540
+ if (rootItem) delete rootItem._raw
541
+ return {
542
+ root: rootItem,
543
+ calls: sanitizeHierarchyCalls(session.base, result, op === "incoming-calls" ? "incoming" : "outgoing", maxResults),
544
+ }
545
+ }
546
+ return null
547
+ }
548
+
549
+ const persistence = lspPersistencePolicy(options)
550
+ const managed = await withManagedLspSession(
551
+ target,
552
+ provider,
553
+ { ...options, persistent: persistence.enabled, operation: op },
554
+ execute,
555
+ ).catch((error) => ({
556
+ ok: false,
557
+ reason: "managed-lsp-exception",
558
+ error: String(error instanceof Error ? error.message : error).slice(0, 1200),
559
+ }))
560
+
561
+ if (managed?.ok) {
562
+ return {
563
+ schemaVersion: 2,
564
+ file: relativeFile,
565
+ available: true,
566
+ provider: provider.id,
567
+ command: provider.command,
568
+ operation: op,
569
+ reason: "ok",
570
+ result: managed.result,
571
+ stderr: managed.stderr || null,
572
+ error: null,
573
+ persistent: true,
574
+ pool: { ...managed.meta, policy: persistence },
575
+ }
576
+ }
577
+
578
+ const fallback = await withEphemeralLspSession(root, relative, { ...options, operation: op }, execute)
579
+ return {
580
+ ...fallback,
581
+ persistent: false,
582
+ fallbackFrom: managed?.reason || "managed-lsp-unavailable",
583
+ managedError: managed?.error || null,
584
+ persistencePolicy: persistence,
585
+ }
586
+ }
587
+
588
+ export function lspPoolStatus(options = {}) {
589
+ return managedLspPoolStatus(options)
590
+ }
591
+
592
+ export async function shutdownLspPool(root = null) {
593
+ return shutdownManagedLspPool(root)
594
+ }
595
+
596
+ export async function diagnoseCode(root, relative, options = {}) {
597
+ const result = await lspOperation(root, relative, "diagnostics", options)
598
+ return {
599
+ ...result,
600
+ diagnostics: result?.result?.diagnostics || [],
601
+ result: undefined,
602
+ }
603
+ }