opencode-agent-skill 15.0.0 → 15.1.0

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.
package/README.md CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  **Package:** <code>opencode-agent-skill</code>
6
6
  **Host chính:** Pi Agent
7
- **Phiên bản package hiện tại:** <code>15.0.0</code>
7
+ **Phiên bản package hiện tại:** <code>15.1.0</code>
8
8
  **Nhánh phát triển hiện tại:** V15.19 Finalization Hardening
9
- **Stable npm hiện tại:** <code>15.0.0</code>
9
+ **Stable npm hiện tại:** <code>15.1.0</code>
10
10
  **Runtime:** Node.js 22.19+
11
11
  **License:** MIT
12
12
 
@@ -1431,15 +1431,15 @@ npm pack
1431
1431
  Package version hiện tại trên nhánh <code>main</code> là:
1432
1432
 
1433
1433
  ~~~text
1434
- 15.0.0-beta.13
1434
+ 15.1.0
1435
1435
  ~~~
1436
1436
 
1437
- Stable npm public hiện vẫn là <code>14.4.0</code>. V15.3 DEEP Speed giảm exploration trùng lặp ở long-horizon bằng diagnosis deduplication và bounded specialist exploration, nhưng giữ nguyên plan/verifier/integration gates. V15.4 ACP-safe child runtime ngăn UES spawn lại ACP/Zed entrypoint; child specialist luôn được chạy bằng Pi CLI thật. V15.5 Per-Leaf Turbo cho phép leaf task nhỏ trong DEEP plan tự xuống FAST, giữ failure delta task-local, và tái sử dụng context theo root namespace + workspace fingerprint để giảm retry/rebuild latency. V15.6 Fast Planning đặt soft-steer/hard-idle budget riêng cho architect/plan-checker, recovery từ warm context thay vì scan lại, và lease/cleanup sandbox orphan an toàn sau crash/Stop. V15.7 Lightweight Sandbox Cleanup thu hồi worktree theo trace ngay khi /ues-run kết thúc, dọn metadata mồ côi, giảm legacy grace xuống 30 phút và cung cấp /ues-clean để dọn stale artifacts an toàn mà không đụng sandbox đang active. V15.8 Plan Gate Recovery bổ sung soft-steer + bounded recovery cho plan-checker và quét cả detached physical sandbox folders mà Git worktree registry đã quên. V15.9 Runtime Artifact Isolation loại .ues-traces/.ues-cache/.ues-services/.ues-work và runtime state khác khỏi task delta, write-scope conflict và sandbox integration, nhưng vẫn giữ safety gate cho source/config thật như apps/mobile/package.json. V15.10 Adaptive Stability Runtime thay hard-timeout tuyệt đối bằng activity-aware bounded deadlines, giữ absolute cap chống treo, salvage plan JSON đã validate từ partial output, phát graph trước prose, và role-bound context cho read-only planner trong task high-risk mà không giảm evidence budget của executor/verifier. V15.1 bắt đầu bằng deterministic <code>/ues-run</code> admission và Managed Background Services để model yếu không thể bỏ qua controller và không bị treo bởi dev server foreground.
1437
+ Stable npm public hiện vẫn là <code>15.1.0</code>. V15.3 DEEP Speed giảm exploration trùng lặp ở long-horizon bằng diagnosis deduplication và bounded specialist exploration, nhưng giữ nguyên plan/verifier/integration gates. V15.4 ACP-safe child runtime ngăn UES spawn lại ACP/Zed entrypoint; child specialist luôn được chạy bằng Pi CLI thật. V15.5 Per-Leaf Turbo cho phép leaf task nhỏ trong DEEP plan tự xuống FAST, giữ failure delta task-local, và tái sử dụng context theo root namespace + workspace fingerprint để giảm retry/rebuild latency. V15.6 Fast Planning đặt soft-steer/hard-idle budget riêng cho architect/plan-checker, recovery từ warm context thay vì scan lại, và lease/cleanup sandbox orphan an toàn sau crash/Stop. V15.7 Lightweight Sandbox Cleanup thu hồi worktree theo trace ngay khi /ues-run kết thúc, dọn metadata mồ côi, giảm legacy grace xuống 30 phút và cung cấp /ues-clean để dọn stale artifacts an toàn mà không đụng sandbox đang active. V15.8 Plan Gate Recovery bổ sung soft-steer + bounded recovery cho plan-checker và quét cả detached physical sandbox folders mà Git worktree registry đã quên. V15.9 Runtime Artifact Isolation loại .ues-traces/.ues-cache/.ues-services/.ues-work và runtime state khác khỏi task delta, write-scope conflict và sandbox integration, nhưng vẫn giữ safety gate cho source/config thật như apps/mobile/package.json. V15.10 Adaptive Stability Runtime thay hard-timeout tuyệt đối bằng activity-aware bounded deadlines, giữ absolute cap chống treo, salvage plan JSON đã validate từ partial output, phát graph trước prose, và role-bound context cho read-only planner trong task high-risk mà không giảm evidence budget của executor/verifier. V15.1 bắt đầu bằng deterministic <code>/ues-run</code> admission và Managed Background Services để model yếu không thể bỏ qua controller và không bị treo bởi dev server foreground.
1438
1438
 
1439
1439
  Test file packed với Pi:
1440
1440
 
1441
1441
  ~~~cmd
1442
- pi install .\opencode-agent-skill-14.4.0.tgz
1442
+ pi install .\opencode-agent-skill-15.1.0.tgz
1443
1443
  pi list
1444
1444
  ~~~
1445
1445
 
@@ -9,6 +9,7 @@ const SOURCE_EXT = new Set([".js",".jsx",".mjs",".cjs",".ts",".tsx",".py",".java
9
9
  const SKIP = new Set([".git","node_modules",".next","dist","build","coverage",".venv","venv","target","Pods","DerivedData",".gradle",".ues-cache",".ues-work",".ues-sandboxes",".ues-traces",".ues-learning",".ues-dashboard",".ues-memory",".ues-evals",".ues-services"])
10
10
  const AFFECTED_TEST_CACHE = new Map()
11
11
  const AFFECTED_TEST_INFLIGHT = new Map()
12
+ const TEST_CONTENT_CACHE = new Map()
12
13
 
13
14
  function git(root, args) {
14
15
  return spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 })
@@ -47,6 +48,37 @@ async function walk(root, options = {}) {
47
48
  return rows
48
49
  }
49
50
 
51
+ async function projectFiles(root, options = {}) {
52
+ const indexed = git(root, ["ls-files", "-co", "--exclude-standard", "-z"])
53
+ if (indexed.status === 0) {
54
+ const files = String(indexed.stdout || "")
55
+ .split("\0")
56
+ .map((value) => normalize(value))
57
+ .filter(Boolean)
58
+ .filter((relative) => !relative.split("/").some((part) => SKIP.has(part)))
59
+ .map((relative) => path.resolve(root, relative))
60
+ return { files: [...new Set(files)], source: "git-index" }
61
+ }
62
+ return { files: await walk(root, options), source: "filesystem-walk" }
63
+ }
64
+
65
+ async function cachedTestContent(file, info) {
66
+ if (!info?.size || info.size > 512 * 1024) return ""
67
+ const key = path.resolve(file)
68
+ const signature = String(info.size) + ":" + String(Math.trunc(Number(info.mtimeMs || 0)))
69
+ const cached = TEST_CONTENT_CACHE.get(key)
70
+ if (cached?.signature === signature) return cached.content
71
+ const content = await readFile(key, "utf8").catch(() => "")
72
+ TEST_CONTENT_CACHE.delete(key)
73
+ TEST_CONTENT_CACHE.set(key, { signature, content })
74
+ while (TEST_CONTENT_CACHE.size > 1200) {
75
+ const oldest = TEST_CONTENT_CACHE.keys().next().value
76
+ if (!oldest) break
77
+ TEST_CONTENT_CACHE.delete(oldest)
78
+ }
79
+ return content
80
+ }
81
+
50
82
  export function gitChangedFiles(root = process.cwd()) {
51
83
  root = path.resolve(root)
52
84
  const inside = git(root, ["rev-parse", "--is-inside-work-tree"])
@@ -190,7 +222,8 @@ export async function resolveAffectedTests(root = process.cwd(), options = {}) {
190
222
  }
191
223
 
192
224
  const compute = async () => {
193
- const files = await walk(root, options)
225
+ const inventory = await projectFiles(root, options)
226
+ const files = inventory.files
194
227
  const tests = files
195
228
  .map((file) => normalize(path.relative(root, file)))
196
229
  .filter((file) => TEST_RE.test(file))
@@ -198,9 +231,7 @@ export async function resolveAffectedTests(root = process.cwd(), options = {}) {
198
231
  const ranked = []
199
232
  for (const testFile of tests) {
200
233
  const info = await stat(path.join(root, testFile)).catch(() => null)
201
- const content = info?.size && info.size <= 512 * 1024
202
- ? await readFile(path.join(root, testFile), "utf8").catch(() => "")
203
- : ""
234
+ const content = await cachedTestContent(path.join(root, testFile), info)
204
235
  let best = { score: 0, reasons: [], changedFile: null }
205
236
  for (const changedFile of changed) {
206
237
  const current = scoreTest(testFile, changedFile, content)
@@ -232,6 +263,7 @@ export async function resolveAffectedTests(root = process.cwd(), options = {}) {
232
263
  suggestedCommands,
233
264
  truncated: ranked.length > selected.length,
234
265
  cacheHit: false,
266
+ inventorySource: inventory.source,
235
267
  }
236
268
  }
237
269
 
@@ -257,4 +289,5 @@ export async function resolveAffectedTests(root = process.cwd(), options = {}) {
257
289
  export function clearAffectedTestCache() {
258
290
  AFFECTED_TEST_CACHE.clear()
259
291
  AFFECTED_TEST_INFLIGHT.clear()
292
+ TEST_CONTENT_CACHE.clear()
260
293
  }
@@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"
3
3
  import path from "node:path"
4
4
  import { querySemanticIndex } from "../semantic-index.mjs"
5
5
  import { anchoredLines, applyAnchoredEdits } from "./edit-anchor.mjs"
6
- import { diagnoseCode, lspProviderStatus } from "./lsp-provider.mjs"
6
+ import { diagnoseCode, lspOperation, lspProviderStatus, LSP_OPERATIONS } from "./lsp-provider.mjs"
7
7
 
8
8
  const AST_COMMANDS = ["ast-grep", "sg"]
9
9
  const commandCache = new Map()
@@ -40,6 +40,7 @@ export function probeCodeIntelligence(file = "") {
40
40
  semanticIndex: true,
41
41
  astProvider,
42
42
  lsp: lspProviderStatus(file),
43
+ lspOperations: [...LSP_OPERATIONS],
43
44
  }
44
45
  }
45
46
 
@@ -92,4 +93,4 @@ export async function searchCodeIntelligence(root, query, options = {}) {
92
93
  }
93
94
  }
94
95
 
95
- export { diagnoseCode, lspProviderStatus } from "./lsp-provider.mjs"
96
+ export { diagnoseCode, lspOperation, lspProviderStatus, LSP_OPERATIONS } from "./lsp-provider.mjs"
@@ -1,7 +1,7 @@
1
1
  import { spawn, spawnSync } from "node:child_process"
2
2
  import { lstat, readFile, realpath } from "node:fs/promises"
3
3
  import path from "node:path"
4
- import { pathToFileURL } from "node:url"
4
+ import { fileURLToPath, pathToFileURL } from "node:url"
5
5
 
6
6
  const PROVIDERS = [
7
7
  { 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,6 +11,17 @@ const PROVIDERS = [
11
11
  { id: "clangd", extensions: new Set([".c", ".cc", ".cpp", ".cxx", ".h", ".hpp"]), commands: [["clangd", []]], languageId: (ext) => [".c", ".h"].includes(ext) ? "c" : "cpp" },
12
12
  ]
13
13
 
14
+ export const LSP_OPERATIONS = Object.freeze([
15
+ "diagnostics",
16
+ "definition",
17
+ "references",
18
+ "symbols",
19
+ "hover",
20
+ "rename-preview",
21
+ "incoming-calls",
22
+ "outgoing-calls",
23
+ ])
24
+
14
25
  const commandCache = new Map()
15
26
  const COMMAND_TTL_MS = 60_000
16
27
 
@@ -31,11 +42,11 @@ function inside(root, target) {
31
42
  async function safeFile(root, relative) {
32
43
  const base = await realpath(path.resolve(root)).catch(() => path.resolve(root))
33
44
  const requested = path.resolve(base, String(relative || ""))
34
- if (!inside(base, requested)) throw new Error("diagnostic path escapes workspace root")
45
+ if (!inside(base, requested)) throw new Error("LSP path escapes workspace root")
35
46
  const info = await lstat(requested)
36
- if (!info.isFile()) throw new Error("diagnostic path is not a file")
47
+ if (!info.isFile()) throw new Error("LSP path is not a file")
37
48
  const actual = await realpath(requested)
38
- if (!inside(base, actual)) throw new Error("diagnostic symlink escapes workspace root")
49
+ if (!inside(base, actual)) throw new Error("LSP symlink escapes workspace root")
39
50
  return { base, file: actual, info }
40
51
  }
41
52
 
@@ -59,7 +70,13 @@ export function lspProviderStatus(file = "") {
59
70
  const selected = provider.commands.find(([command]) => commandExists(command)) || null
60
71
  return { id: provider.id, available: Boolean(selected), command: selected?.[0] || null, extensions: [...provider.extensions] }
61
72
  })
62
- return { schemaVersion: 1, extension: ext, available: providers.some((row) => row.available), providers }
73
+ return {
74
+ schemaVersion: 2,
75
+ extension: ext,
76
+ available: providers.some((row) => row.available),
77
+ operations: [...LSP_OPERATIONS],
78
+ providers,
79
+ }
63
80
  }
64
81
 
65
82
  function encodeMessage(payload) {
@@ -67,6 +84,23 @@ function encodeMessage(payload) {
67
84
  return Buffer.concat([Buffer.from("Content-Length: " + body.length + "\r\n\r\n", "ascii"), body])
68
85
  }
69
86
 
87
+ function relativeUri(base, uri) {
88
+ try {
89
+ if (!String(uri || "").startsWith("file:")) return String(uri || "")
90
+ const file = fileURLToPath(uri)
91
+ if (!inside(base, file)) return String(uri || "")
92
+ return path.relative(base, file).replaceAll("\\", "/")
93
+ } catch {
94
+ return String(uri || "")
95
+ }
96
+ }
97
+
98
+ function position(options = {}) {
99
+ const line = Math.max(1, Math.trunc(Number(options.line || 1)))
100
+ const character = Math.max(1, Math.trunc(Number(options.character || 1)))
101
+ return { line: line - 1, character: character - 1 }
102
+ }
103
+
70
104
  function sanitizeDiagnostic(value = {}) {
71
105
  return {
72
106
  range: value.range || null,
@@ -77,73 +111,214 @@ function sanitizeDiagnostic(value = {}) {
77
111
  }
78
112
  }
79
113
 
80
- export async function diagnoseCode(root, relative, options = {}) {
114
+ function sanitizeLocations(base, value, limit = 120) {
115
+ const list = Array.isArray(value) ? value : value ? [value] : []
116
+ return list.slice(0, limit).map((item) => {
117
+ if (item?.targetUri) {
118
+ return {
119
+ file: relativeUri(base, item.targetUri),
120
+ range: item.targetRange || null,
121
+ selectionRange: item.targetSelectionRange || null,
122
+ originSelectionRange: item.originSelectionRange || null,
123
+ }
124
+ }
125
+ return {
126
+ file: relativeUri(base, item?.uri),
127
+ range: item?.range || null,
128
+ }
129
+ })
130
+ }
131
+
132
+ function flattenSymbols(base, values, output, parent = null, limit = 160) {
133
+ for (const item of Array.isArray(values) ? values : []) {
134
+ if (output.length >= limit) break
135
+ output.push({
136
+ name: String(item?.name || "").slice(0, 300),
137
+ detail: item?.detail == null ? null : String(item.detail).slice(0, 500),
138
+ kind: item?.kind ?? null,
139
+ containerName: item?.containerName || parent,
140
+ file: item?.location?.uri ? relativeUri(base, item.location.uri) : null,
141
+ range: item?.range || item?.location?.range || null,
142
+ selectionRange: item?.selectionRange || null,
143
+ })
144
+ if (Array.isArray(item?.children)) flattenSymbols(base, item.children, output, item?.name || parent, limit)
145
+ }
146
+ return output
147
+ }
148
+
149
+ function hoverText(contents) {
150
+ if (contents == null) return ""
151
+ if (typeof contents === "string") return contents
152
+ if (Array.isArray(contents)) {
153
+ return contents.map((item) => typeof item === "string" ? item : item?.value || "").filter(Boolean).join("\n")
154
+ }
155
+ if (typeof contents === "object") return String(contents.value || "")
156
+ return String(contents)
157
+ }
158
+
159
+ function sanitizeWorkspaceEdit(base, edit, limit = 240) {
160
+ const rows = []
161
+ for (const [uri, edits] of Object.entries(edit?.changes || {})) {
162
+ for (const item of Array.isArray(edits) ? edits : []) {
163
+ rows.push({ file: relativeUri(base, uri), range: item.range || null, newText: String(item.newText || "").slice(0, 4000) })
164
+ if (rows.length >= limit) return { edits: rows, truncated: true }
165
+ }
166
+ }
167
+ for (const change of Array.isArray(edit?.documentChanges) ? edit.documentChanges : []) {
168
+ const uri = change?.textDocument?.uri
169
+ for (const item of Array.isArray(change?.edits) ? change.edits : []) {
170
+ rows.push({ file: relativeUri(base, uri), range: item.range || null, newText: String(item.newText || "").slice(0, 4000) })
171
+ if (rows.length >= limit) return { edits: rows, truncated: true }
172
+ }
173
+ }
174
+ return { edits: rows, truncated: false }
175
+ }
176
+
177
+ function sanitizeHierarchyItem(base, item) {
178
+ if (!item) return null
179
+ return {
180
+ name: String(item.name || "").slice(0, 300),
181
+ detail: item.detail == null ? null : String(item.detail).slice(0, 500),
182
+ kind: item.kind ?? null,
183
+ file: relativeUri(base, item.uri),
184
+ range: item.range || null,
185
+ selectionRange: item.selectionRange || null,
186
+ _raw: item,
187
+ }
188
+ }
189
+
190
+ function sanitizeHierarchyCalls(base, values, direction, limit = 120) {
191
+ return (Array.isArray(values) ? values : []).slice(0, limit).map((call) => {
192
+ const item = direction === "incoming" ? call.from : call.to
193
+ const clean = sanitizeHierarchyItem(base, item)
194
+ if (clean) delete clean._raw
195
+ return {
196
+ item: clean,
197
+ ranges: direction === "incoming" ? (call.fromRanges || []) : (call.fromRanges || []),
198
+ }
199
+ })
200
+ }
201
+
202
+ async function withLspSession(root, relative, options, operation) {
81
203
  const target = await safeFile(root, relative)
82
204
  const provider = providerFor(target.file)
83
205
  const relativeFile = path.relative(target.base, target.file).replaceAll("\\", "/")
84
206
  if (!provider?.command) {
85
- return { schemaVersion: 1, file: relativeFile, available: false, provider: provider?.id || null, diagnostics: [], reason: provider ? "lsp-command-unavailable" : "unsupported-extension" }
207
+ return {
208
+ schemaVersion: 2,
209
+ file: relativeFile,
210
+ available: false,
211
+ provider: provider?.id || null,
212
+ operation: options.operation || null,
213
+ reason: provider ? "lsp-command-unavailable" : "unsupported-extension",
214
+ result: null,
215
+ }
86
216
  }
87
217
 
88
218
  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)")
219
+ if (target.info.size > maxBytes) throw new Error("LSP file exceeds limit (" + target.info.size + " > " + maxBytes + " bytes)")
90
220
  const source = await readFile(target.file, "utf8")
91
221
  const uri = pathToFileURL(target.file).href
92
222
  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)))
223
+ const timeoutMs = Math.max(500, Math.min(15_000, Number(options.timeoutMs || 5000)))
95
224
 
96
225
  return await new Promise((resolve) => {
97
- const child = spawn(provider.command, provider.args, { cwd: target.base, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, shell: false })
226
+ const child = spawn(provider.command, provider.args, {
227
+ cwd: target.base,
228
+ stdio: ["pipe", "pipe", "pipe"],
229
+ windowsHide: true,
230
+ shell: false,
231
+ })
98
232
  let buffer = Buffer.alloc(0)
99
233
  let stderr = ""
100
234
  let settled = false
101
- let initialized = false
102
- let diagnostics = []
235
+ let nextId = 1
236
+ const pending = new Map()
237
+ const notificationWaiters = new Map()
238
+ const recentNotifications = new Map()
103
239
  let timer = null
104
240
 
105
- const finish = (reason, error = null) => {
241
+ const finish = (reason, result = null, error = null) => {
106
242
  if (settled) return
107
243
  settled = true
108
244
  if (timer) clearTimeout(timer)
245
+ for (const waiter of pending.values()) waiter.reject(new Error("LSP session settled: " + reason))
246
+ pending.clear()
109
247
  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
- }
248
+ if (child.stdin.writable) child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
114
249
  } catch {}
115
250
  setTimeout(() => { try { child.kill() } catch {} }, 50).unref?.()
116
251
  resolve({
117
- schemaVersion: 1,
252
+ schemaVersion: 2,
118
253
  file: relativeFile,
119
254
  available: true,
120
255
  provider: provider.id,
121
256
  command: provider.command,
122
- diagnostics: diagnostics.slice(0, maxDiagnostics),
257
+ operation: options.operation || null,
123
258
  reason,
259
+ result,
124
260
  stderr: stderr.trim().slice(0, 2000) || null,
125
- error: error ? String(error instanceof Error ? error.message : error).slice(0, 1000) : null,
261
+ error: error ? String(error instanceof Error ? error.message : error).slice(0, 1200) : null,
126
262
  })
127
263
  }
128
264
 
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
- }
265
+ const send = (payload) => {
266
+ if (settled || !child.stdin.writable) throw new Error("LSP stdin unavailable")
267
+ child.stdin.write(encodeMessage(payload))
268
+ }
269
+
270
+ const request = (method, params) => new Promise((res, rej) => {
271
+ const id = nextId++
272
+ pending.set(id, { resolve: res, reject: rej, method })
273
+ try { send({ jsonrpc: "2.0", id, method, params }) }
274
+ catch (error) { pending.delete(id); rej(error) }
275
+ })
276
+
277
+ const notify = (method, params) => send({ jsonrpc: "2.0", method, params })
278
+
279
+ const waitForNotification = (method) => {
280
+ if (recentNotifications.has(method)) {
281
+ const value = recentNotifications.get(method)
282
+ recentNotifications.delete(method)
283
+ return Promise.resolve(value)
284
+ }
285
+ return new Promise((res) => {
286
+ const list = notificationWaiters.get(method) || []
287
+ list.push(res)
288
+ notificationWaiters.set(method, list)
289
+ })
290
+ }
291
+
292
+ const dispatch = (message) => {
293
+ if (message?.id != null && pending.has(message.id)) {
294
+ const waiter = pending.get(message.id)
295
+ pending.delete(message.id)
296
+ if (message.error) waiter.reject(new Error(String(message.error.message || "LSP request failed")))
297
+ else waiter.resolve(message.result)
142
298
  return
143
299
  }
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")
300
+ if (message?.method) {
301
+ // Language servers may issue configuration/capability requests. Reply
302
+ // deterministically so the server does not stall waiting on a client UI.
303
+ if (message.id != null) {
304
+ try {
305
+ const result = message.method === "workspace/configuration"
306
+ ? (Array.isArray(message.params?.items) ? message.params.items.map(() => null) : [])
307
+ : null
308
+ send({ jsonrpc: "2.0", id: message.id, result })
309
+ } catch {}
310
+ }
311
+ const waiters = notificationWaiters.get(message.method) || []
312
+ if (waiters.length) {
313
+ notificationWaiters.delete(message.method)
314
+ for (const waiter of waiters) waiter(message.params)
315
+ } else {
316
+ recentNotifications.set(message.method, message.params)
317
+ if (recentNotifications.size > 16) {
318
+ const oldest = recentNotifications.keys().next().value
319
+ if (oldest) recentNotifications.delete(oldest)
320
+ }
321
+ }
147
322
  }
148
323
  }
149
324
 
@@ -159,31 +334,130 @@ export async function diagnoseCode(root, relative, options = {}) {
159
334
  if (buffer.length < bodyStart + length) return
160
335
  const body = buffer.subarray(bodyStart, bodyStart + length).toString("utf8")
161
336
  buffer = buffer.subarray(bodyStart + length)
162
- try { handle(JSON.parse(body)) } catch {}
337
+ try { dispatch(JSON.parse(body)) } catch {}
163
338
  }
164
339
  }
165
340
 
166
341
  child.stdout.on("data", (chunk) => { buffer = Buffer.concat([buffer, Buffer.from(chunk)]); parse() })
167
342
  child.stderr.on("data", (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk.toString() })
168
- child.on("error", (error) => finish("spawn-error", error))
343
+ child.on("error", (error) => finish("spawn-error", null, error))
169
344
  child.on("close", (code) => { if (!settled) finish("server-exit:" + (code ?? "unknown")) })
170
345
 
171
- try {
172
- child.stdin.write(encodeMessage({
173
- jsonrpc: "2.0",
174
- id: 1,
175
- method: "initialize",
176
- params: {
346
+ timer = setTimeout(() => finish("timeout"), timeoutMs)
347
+ timer.unref?.()
348
+
349
+ ;(async () => {
350
+ try {
351
+ await request("initialize", {
177
352
  processId: process.pid,
178
353
  rootUri,
179
- capabilities: { textDocument: { publishDiagnostics: { relatedInformation: true } } },
354
+ capabilities: {
355
+ textDocument: {
356
+ publishDiagnostics: { relatedInformation: true },
357
+ definition: { linkSupport: true },
358
+ references: {},
359
+ documentSymbol: { hierarchicalDocumentSymbolSupport: true },
360
+ hover: { contentFormat: ["markdown", "plaintext"] },
361
+ rename: { prepareSupport: true },
362
+ callHierarchy: {},
363
+ },
364
+ workspace: { workspaceFolders: true, applyEdit: false },
365
+ },
180
366
  workspaceFolders: [{ uri: rootUri, name: path.basename(target.base) || "workspace" }],
181
- },
182
- }))
183
- } catch (error) {
184
- finish("initialize-write-failed", error)
367
+ })
368
+ notify("initialized", {})
369
+ notify("textDocument/didOpen", {
370
+ textDocument: { uri, languageId: provider.languageId, version: 1, text: source },
371
+ })
372
+ const result = await operation({
373
+ request,
374
+ notify,
375
+ waitForNotification,
376
+ uri,
377
+ base: target.base,
378
+ file: relativeFile,
379
+ })
380
+ finish("ok", result)
381
+ } catch (error) {
382
+ finish("request-error", null, error)
383
+ }
384
+ })()
385
+ })
386
+ }
387
+
388
+ export async function lspOperation(root, relative, operation, options = {}) {
389
+ const op = String(operation || "")
390
+ if (!LSP_OPERATIONS.includes(op)) throw new Error("unsupported LSP operation: " + op)
391
+ const maxResults = Math.max(1, Math.min(500, Number(options.maxResults || 120)))
392
+ return withLspSession(root, relative, { ...options, operation: op }, async (session) => {
393
+ const pos = position(options)
394
+ const textDocument = { uri: session.uri }
395
+
396
+ if (op === "diagnostics") {
397
+ const params = await session.waitForNotification("textDocument/publishDiagnostics")
398
+ const values = Array.isArray(params?.diagnostics) ? params.diagnostics : []
399
+ return { diagnostics: values.slice(0, maxResults).map(sanitizeDiagnostic) }
185
400
  }
186
- timer = setTimeout(() => finish("timeout"), timeoutMs)
187
- timer.unref?.()
401
+ if (op === "definition") {
402
+ const result = await session.request("textDocument/definition", { textDocument, position: pos })
403
+ return { locations: sanitizeLocations(session.base, result, maxResults) }
404
+ }
405
+ if (op === "references") {
406
+ const result = await session.request("textDocument/references", {
407
+ textDocument,
408
+ position: pos,
409
+ context: { includeDeclaration: options.includeDeclaration !== false },
410
+ })
411
+ return { locations: sanitizeLocations(session.base, result, maxResults) }
412
+ }
413
+ if (op === "symbols") {
414
+ const result = await session.request("textDocument/documentSymbol", { textDocument })
415
+ return { symbols: flattenSymbols(session.base, result, [], null, maxResults) }
416
+ }
417
+ if (op === "hover") {
418
+ const result = await session.request("textDocument/hover", { textDocument, position: pos })
419
+ return {
420
+ range: result?.range || null,
421
+ markdown: hoverText(result?.contents).slice(0, Number(options.maxHoverChars || 12000)),
422
+ }
423
+ }
424
+ if (op === "rename-preview") {
425
+ const newName = String(options.newName || "").trim()
426
+ if (!newName) throw new Error("rename-preview requires newName")
427
+ let prepared = null
428
+ try {
429
+ prepared = await session.request("textDocument/prepareRename", { textDocument, position: pos })
430
+ } catch {}
431
+ const result = await session.request("textDocument/rename", { textDocument, position: pos, newName })
432
+ return {
433
+ prepared: prepared || null,
434
+ newName,
435
+ ...sanitizeWorkspaceEdit(session.base, result, maxResults),
436
+ applied: false,
437
+ }
438
+ }
439
+ if (op === "incoming-calls" || op === "outgoing-calls") {
440
+ const prepared = await session.request("textDocument/prepareCallHierarchy", { textDocument, position: pos })
441
+ const item = Array.isArray(prepared) ? prepared[0] : prepared
442
+ if (!item) return { root: null, calls: [] }
443
+ const method = op === "incoming-calls" ? "callHierarchy/incomingCalls" : "callHierarchy/outgoingCalls"
444
+ const result = await session.request(method, { item })
445
+ const rootItem = sanitizeHierarchyItem(session.base, item)
446
+ if (rootItem) delete rootItem._raw
447
+ return {
448
+ root: rootItem,
449
+ calls: sanitizeHierarchyCalls(session.base, result, op === "incoming-calls" ? "incoming" : "outgoing", maxResults),
450
+ }
451
+ }
452
+ return null
188
453
  })
189
454
  }
455
+
456
+ export async function diagnoseCode(root, relative, options = {}) {
457
+ const result = await lspOperation(root, relative, "diagnostics", options)
458
+ return {
459
+ ...result,
460
+ diagnostics: result?.result?.diagnostics || [],
461
+ result: undefined,
462
+ }
463
+ }