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
@@ -0,0 +1,694 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { spawn } from "node:child_process"
3
+ import { readFile, stat } from "node:fs/promises"
4
+ import path from "node:path"
5
+ import { pathToFileURL } from "node:url"
6
+ import { terminateProcessTree } from "../process-supervisor.mjs"
7
+ import { resolveWindowsCommand } from "../windows-shim.mjs"
8
+
9
+ const DEFAULT_IDLE_TTL_MS = 3 * 60_000
10
+ const DEFAULT_MAX_SERVERS = 4
11
+ const DEFAULT_MAX_PER_WORKSPACE = 2
12
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5_000
13
+ const DEFAULT_STARTUP_TIMEOUT_MS = 12_000
14
+ const DEFAULT_MAX_RESTARTS = 1
15
+ const CONFIG_MAX_BYTES = 512 * 1024
16
+
17
+ const POOL = new Map()
18
+ const METRICS = {
19
+ coldStarts: 0,
20
+ warmHits: 0,
21
+ restarts: 0,
22
+ evictions: 0,
23
+ fallbacks: 0,
24
+ operations: 0,
25
+ operationLatencyMs: 0,
26
+ }
27
+ let sweepTimer = null
28
+
29
+ function bounded(value, fallback, min, max) {
30
+ const number = Number(value)
31
+ if (!Number.isFinite(number)) return fallback
32
+ return Math.max(min, Math.min(max, Math.trunc(number)))
33
+ }
34
+
35
+ function configured(options = {}) {
36
+ return {
37
+ enabled: options.persistent !== false && String(process.env.UES_LSP_PERSISTENT || "1") !== "0",
38
+ idleTtlMs: bounded(options.idleTtlMs ?? process.env.UES_LSP_IDLE_TTL_MS, DEFAULT_IDLE_TTL_MS, 5_000, 60 * 60_000),
39
+ maxServers: bounded(options.maxServers ?? process.env.UES_LSP_MAX_SERVERS, DEFAULT_MAX_SERVERS, 1, 12),
40
+ maxPerWorkspace: bounded(options.maxPerWorkspace ?? process.env.UES_LSP_MAX_PER_WORKSPACE, DEFAULT_MAX_PER_WORKSPACE, 1, 6),
41
+ requestTimeoutMs: bounded(options.timeoutMs ?? process.env.UES_LSP_REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS, 500, 30_000),
42
+ startupTimeoutMs: bounded(options.startupTimeoutMs ?? process.env.UES_LSP_STARTUP_TIMEOUT_MS, DEFAULT_STARTUP_TIMEOUT_MS, 1_000, 60_000),
43
+ maxRestarts: bounded(options.maxRestarts ?? process.env.UES_LSP_MAX_RESTARTS, DEFAULT_MAX_RESTARTS, 0, 2),
44
+ }
45
+ }
46
+
47
+ function errorWithCode(message, code) {
48
+ const error = new Error(message)
49
+ error.code = code
50
+ return error
51
+ }
52
+
53
+ function transientError(error) {
54
+ return new Set([
55
+ "LSP_REQUEST_TIMEOUT",
56
+ "LSP_NOTIFICATION_TIMEOUT",
57
+ "LSP_SESSION_CLOSED",
58
+ "LSP_SERVER_EXIT",
59
+ "LSP_SPAWN_ERROR",
60
+ "LSP_WRITE_ERROR",
61
+ ]).has(error?.code)
62
+ }
63
+
64
+ function encodeMessage(payload) {
65
+ const body = Buffer.from(JSON.stringify(payload), "utf8")
66
+ return Buffer.concat([Buffer.from("Content-Length: " + body.length + "\r\n\r\n", "ascii"), body])
67
+ }
68
+
69
+ function contentHash(value) {
70
+ return createHash("sha256").update(String(value || "")).digest("hex")
71
+ }
72
+
73
+ function providerConfigNames(providerId) {
74
+ const id = String(providerId || "")
75
+ if (id.includes("typescript")) return ["tsconfig.json", "jsconfig.json", "package.json"]
76
+ if (id.includes("pyright") || id.includes("pylsp")) return ["pyrightconfig.json", "pyproject.toml", "setup.cfg", "setup.py", "requirements.txt"]
77
+ if (id === "gopls") return ["go.mod", "go.sum", "go.work"]
78
+ if (id === "rust-analyzer") return ["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "rust-toolchain"]
79
+ if (id === "clangd") return [".clangd", "compile_commands.json", "CMakeLists.txt"]
80
+ return ["package.json", "pyproject.toml", "go.mod", "Cargo.toml"]
81
+ }
82
+
83
+ function inside(base, target) {
84
+ return target === base || target.startsWith(base + path.sep)
85
+ }
86
+
87
+ async function configurationFingerprint(base, file, provider) {
88
+ const names = providerConfigNames(provider?.id)
89
+ const found = []
90
+ let dir = path.dirname(file)
91
+
92
+ while (inside(base, dir)) {
93
+ for (const name of names) {
94
+ const candidate = path.join(dir, name)
95
+ const info = await stat(candidate).catch(() => null)
96
+ if (!info?.isFile()) continue
97
+ found.push({ file: candidate, size: info.size, mtimeMs: Math.trunc(info.mtimeMs) })
98
+ }
99
+ if (dir === base) break
100
+ const parent = path.dirname(dir)
101
+ if (parent === dir || !inside(base, parent)) break
102
+ dir = parent
103
+ }
104
+
105
+ found.sort((a, b) => a.file.localeCompare(b.file))
106
+ const hash = createHash("sha256")
107
+ hash.update(String(provider?.id || "unknown"))
108
+ hash.update("\0")
109
+ hash.update(String(provider?.command || ""))
110
+ hash.update("\0")
111
+ hash.update(JSON.stringify(provider?.initializationOptions || null))
112
+ for (const item of found) {
113
+ hash.update(path.relative(base, item.file).replaceAll("\\", "/"))
114
+ hash.update("\0")
115
+ hash.update(String(item.size))
116
+ hash.update("\0")
117
+ if (item.size <= CONFIG_MAX_BYTES) {
118
+ const content = await readFile(item.file).catch(() => null)
119
+ if (content) hash.update(content)
120
+ else hash.update(String(item.mtimeMs))
121
+ } else {
122
+ hash.update(String(item.mtimeMs))
123
+ }
124
+ hash.update("\0")
125
+ }
126
+ return hash.digest("hex").slice(0, 24)
127
+ }
128
+
129
+ class ManagedLspSession {
130
+ constructor(base, provider, configFingerprint, options) {
131
+ this.id = randomUUID()
132
+ this.base = base
133
+ this.provider = provider
134
+ this.configFingerprint = configFingerprint
135
+ this.options = options
136
+ this.child = null
137
+ this.buffer = Buffer.alloc(0)
138
+ this.stderr = ""
139
+ this.nextId = 1
140
+ this.pending = new Map()
141
+ this.notificationWaiters = new Map()
142
+ this.notifications = new Map()
143
+ this.documents = new Map()
144
+ this.state = "STARTING"
145
+ this.startedAt = Date.now()
146
+ this.readyAt = null
147
+ this.lastUsedAt = Date.now()
148
+ this.requestCount = 0
149
+ this.busy = 0
150
+ this.closed = false
151
+ this.queueTail = Promise.resolve()
152
+ this.coldStartMs = null
153
+ }
154
+
155
+ alive() {
156
+ return Boolean(
157
+ !this.closed &&
158
+ this.child &&
159
+ this.child.exitCode == null &&
160
+ !["CRASHED", "CLOSED", "EVICTING"].includes(this.state),
161
+ )
162
+ }
163
+
164
+ publicStatus() {
165
+ return {
166
+ id: this.id,
167
+ workspace: this.base,
168
+ provider: this.provider.id,
169
+ command: this.provider.command,
170
+ state: this.state,
171
+ pid: this.child?.pid || null,
172
+ busy: this.busy,
173
+ requestCount: this.requestCount,
174
+ startedAt: new Date(this.startedAt).toISOString(),
175
+ lastUsedAt: new Date(this.lastUsedAt).toISOString(),
176
+ idleMs: Math.max(0, Date.now() - this.lastUsedAt),
177
+ configFingerprint: this.configFingerprint,
178
+ documents: this.documents.size,
179
+ coldStartMs: this.coldStartMs,
180
+ }
181
+ }
182
+
183
+ async start() {
184
+ const started = Date.now()
185
+ try {
186
+ const execution = process.platform === "win32"
187
+ ? resolveWindowsCommand(this.provider.command)
188
+ : { executable: this.provider.command, argsPrefix: [] }
189
+ if (!execution?.executable) {
190
+ throw new Error("unable to resolve LSP executable: " + String(this.provider.command || ""))
191
+ }
192
+ this.child = spawn(execution.executable, [...(execution.argsPrefix || []), ...(this.provider.args || [])], {
193
+ cwd: this.base,
194
+ env: this.provider.env && typeof this.provider.env === "object"
195
+ ? { ...process.env, ...Object.fromEntries(Object.entries(this.provider.env).map(([key, value]) => [String(key), String(value)])) }
196
+ : process.env,
197
+ stdio: ["pipe", "pipe", "pipe"],
198
+ windowsHide: true,
199
+ shell: false,
200
+ detached: process.platform !== "win32",
201
+ })
202
+ } catch (error) {
203
+ this.state = "CRASHED"
204
+ throw errorWithCode("LSP spawn failed: " + String(error?.message || error), "LSP_SPAWN_ERROR")
205
+ }
206
+
207
+ this.child.stdout?.on("data", (chunk) => {
208
+ this.buffer = Buffer.concat([this.buffer, Buffer.from(chunk)])
209
+ this.parse()
210
+ })
211
+ this.child.stderr?.on("data", (chunk) => {
212
+ if (this.stderr.length < 64 * 1024) this.stderr += chunk.toString()
213
+ })
214
+ this.child.on("error", (error) => this.crash("LSP spawn error: " + String(error?.message || error), "LSP_SPAWN_ERROR"))
215
+ this.child.on("close", (code) => {
216
+ if (!this.closed && this.state !== "EVICTING") {
217
+ this.crash("LSP server exited: " + String(code ?? "unknown"), "LSP_SERVER_EXIT")
218
+ }
219
+ })
220
+
221
+ const rootUri = pathToFileURL(this.base + path.sep).href
222
+ await this.request("initialize", {
223
+ processId: process.pid,
224
+ rootUri,
225
+ capabilities: {
226
+ textDocument: {
227
+ publishDiagnostics: { relatedInformation: true },
228
+ definition: { linkSupport: true },
229
+ references: {},
230
+ documentSymbol: { hierarchicalDocumentSymbolSupport: true },
231
+ hover: { contentFormat: ["markdown", "plaintext"] },
232
+ rename: { prepareSupport: true },
233
+ callHierarchy: {},
234
+ },
235
+ workspace: { workspaceFolders: true, applyEdit: false },
236
+ },
237
+ workspaceFolders: [{ uri: rootUri, name: path.basename(this.base) || "workspace" }],
238
+ initializationOptions: this.provider.initializationOptions || undefined,
239
+ }, this.options.startupTimeoutMs)
240
+ this.notify("initialized", {})
241
+ this.readyAt = Date.now()
242
+ this.coldStartMs = this.readyAt - started
243
+ this.state = "READY"
244
+ this.lastUsedAt = Date.now()
245
+ return this
246
+ }
247
+
248
+ crash(message, code) {
249
+ if (this.closed) return
250
+ this.state = "CRASHED"
251
+ const error = errorWithCode(message, code)
252
+ for (const waiter of this.pending.values()) {
253
+ clearTimeout(waiter.timer)
254
+ waiter.reject(error)
255
+ }
256
+ this.pending.clear()
257
+ for (const list of this.notificationWaiters.values()) {
258
+ for (const waiter of list) {
259
+ clearTimeout(waiter.timer)
260
+ waiter.reject(error)
261
+ }
262
+ }
263
+ this.notificationWaiters.clear()
264
+ }
265
+
266
+ send(payload) {
267
+ if (!this.alive() || !this.child.stdin?.writable) {
268
+ throw errorWithCode("LSP stdin unavailable", "LSP_SESSION_CLOSED")
269
+ }
270
+ try {
271
+ this.child.stdin.write(encodeMessage(payload))
272
+ } catch (error) {
273
+ throw errorWithCode("LSP write failed: " + String(error?.message || error), "LSP_WRITE_ERROR")
274
+ }
275
+ }
276
+
277
+ request(method, params, timeoutMs = this.options.requestTimeoutMs) {
278
+ return new Promise((resolve, reject) => {
279
+ const id = this.nextId++
280
+ const timer = setTimeout(() => {
281
+ this.pending.delete(id)
282
+ reject(errorWithCode("LSP request timed out: " + method, "LSP_REQUEST_TIMEOUT"))
283
+ }, bounded(timeoutMs, this.options.requestTimeoutMs, 250, 60_000))
284
+ timer.unref?.()
285
+ this.pending.set(id, { resolve, reject, method, timer })
286
+ try {
287
+ this.send({ jsonrpc: "2.0", id, method, params })
288
+ } catch (error) {
289
+ clearTimeout(timer)
290
+ this.pending.delete(id)
291
+ reject(error)
292
+ }
293
+ })
294
+ }
295
+
296
+ notify(method, params) {
297
+ this.send({ jsonrpc: "2.0", method, params })
298
+ }
299
+
300
+ waitForNotification(method, options = {}) {
301
+ const predicate = typeof options.predicate === "function" ? options.predicate : () => true
302
+ const afterAt = Number(options.afterAt || 0)
303
+ const cached = (this.notifications.get(method) || [])
304
+ .slice()
305
+ .reverse()
306
+ .find((item) => item.at >= afterAt && predicate(item.params))
307
+ if (cached) return Promise.resolve(cached.params)
308
+
309
+ return new Promise((resolve, reject) => {
310
+ const timeoutMs = bounded(options.timeoutMs, this.options.requestTimeoutMs, 250, 60_000)
311
+ const timer = setTimeout(() => {
312
+ const list = this.notificationWaiters.get(method) || []
313
+ this.notificationWaiters.set(method, list.filter((item) => item !== waiter))
314
+ reject(errorWithCode("LSP notification timed out: " + method, "LSP_NOTIFICATION_TIMEOUT"))
315
+ }, timeoutMs)
316
+ timer.unref?.()
317
+ const waiter = { resolve, reject, predicate, afterAt, timer }
318
+ const list = this.notificationWaiters.get(method) || []
319
+ list.push(waiter)
320
+ this.notificationWaiters.set(method, list)
321
+ })
322
+ }
323
+
324
+ dispatch(message) {
325
+ if (message?.id != null && this.pending.has(message.id)) {
326
+ const waiter = this.pending.get(message.id)
327
+ this.pending.delete(message.id)
328
+ clearTimeout(waiter.timer)
329
+ if (message.error) {
330
+ const error = errorWithCode(String(message.error.message || "LSP request failed"), "LSP_REQUEST_REJECTED")
331
+ waiter.reject(error)
332
+ } else {
333
+ waiter.resolve(message.result)
334
+ }
335
+ return
336
+ }
337
+
338
+ if (!message?.method) return
339
+ if (message.id != null) {
340
+ try {
341
+ const result = message.method === "workspace/configuration"
342
+ ? (Array.isArray(message.params?.items) ? message.params.items.map(() => null) : [])
343
+ : null
344
+ this.send({ jsonrpc: "2.0", id: message.id, result })
345
+ } catch {}
346
+ return
347
+ }
348
+
349
+ const event = { params: message.params, at: Date.now() }
350
+ const cached = this.notifications.get(message.method) || []
351
+ cached.push(event)
352
+ if (cached.length > 24) cached.splice(0, cached.length - 24)
353
+ this.notifications.set(message.method, cached)
354
+
355
+ const waiters = this.notificationWaiters.get(message.method) || []
356
+ const remaining = []
357
+ for (const waiter of waiters) {
358
+ if (event.at >= waiter.afterAt && waiter.predicate(event.params)) {
359
+ clearTimeout(waiter.timer)
360
+ waiter.resolve(event.params)
361
+ } else {
362
+ remaining.push(waiter)
363
+ }
364
+ }
365
+ if (remaining.length) this.notificationWaiters.set(message.method, remaining)
366
+ else this.notificationWaiters.delete(message.method)
367
+ }
368
+
369
+ parse() {
370
+ while (!this.closed) {
371
+ const headerEnd = this.buffer.indexOf("\r\n\r\n")
372
+ if (headerEnd < 0) return
373
+ const header = this.buffer.subarray(0, headerEnd).toString("ascii")
374
+ const match = header.match(/Content-Length:\s*(\d+)/i)
375
+ if (!match) {
376
+ this.buffer = this.buffer.subarray(headerEnd + 4)
377
+ continue
378
+ }
379
+ const length = Number(match[1])
380
+ const bodyStart = headerEnd + 4
381
+ if (this.buffer.length < bodyStart + length) return
382
+ const body = this.buffer.subarray(bodyStart, bodyStart + length).toString("utf8")
383
+ this.buffer = this.buffer.subarray(bodyStart + length)
384
+ try { this.dispatch(JSON.parse(body)) } catch {}
385
+ }
386
+ }
387
+
388
+ async syncDocument(target, languageId, options = {}) {
389
+ const maxBytes = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options.maxBytes || 1024 * 1024)))
390
+ const info = target.info?.isFile?.() ? target.info : await stat(target.file)
391
+ if (info.size > maxBytes) {
392
+ throw new Error("LSP file exceeds limit (" + info.size + " > " + maxBytes + " bytes)")
393
+ }
394
+ const source = await readFile(target.file, "utf8")
395
+ const hash = contentHash(source)
396
+ const uri = pathToFileURL(target.file).href
397
+ const previous = this.documents.get(uri)
398
+ const syncedAt = Date.now()
399
+ let changed = false
400
+ let opened = false
401
+ let version = previous?.version || 0
402
+
403
+ if (!previous) {
404
+ opened = true
405
+ changed = true
406
+ version = 1
407
+ this.notify("textDocument/didOpen", {
408
+ textDocument: { uri, languageId, version, text: source },
409
+ })
410
+ } else if (previous.hash !== hash) {
411
+ changed = true
412
+ version += 1
413
+ this.notify("textDocument/didChange", {
414
+ textDocument: { uri, version },
415
+ contentChanges: [{ text: source }],
416
+ })
417
+ }
418
+
419
+ this.documents.set(uri, { hash, version, languageId, syncedAt })
420
+ return { uri, sourceHash: hash, version, changed, opened, syncedAt }
421
+ }
422
+
423
+ async runExclusive(fn) {
424
+ const previous = this.queueTail
425
+ let release
426
+ const gate = new Promise((resolve) => { release = resolve })
427
+ this.queueTail = previous.catch(() => {}).then(() => gate)
428
+ await previous.catch(() => {})
429
+ this.busy += 1
430
+ this.state = "BUSY"
431
+ const started = Date.now()
432
+ try {
433
+ const result = await fn()
434
+ this.requestCount += 1
435
+ return { result, durationMs: Date.now() - started }
436
+ } finally {
437
+ this.busy = Math.max(0, this.busy - 1)
438
+ this.lastUsedAt = Date.now()
439
+ if (this.alive()) this.state = "READY"
440
+ release()
441
+ }
442
+ }
443
+
444
+ async stop(reason = "shutdown") {
445
+ if (this.closed) return
446
+ this.state = "EVICTING"
447
+ this.closed = true
448
+ const error = errorWithCode("LSP session closed: " + reason, "LSP_SESSION_CLOSED")
449
+ for (const waiter of this.pending.values()) {
450
+ clearTimeout(waiter.timer)
451
+ waiter.reject(error)
452
+ }
453
+ this.pending.clear()
454
+ for (const list of this.notificationWaiters.values()) {
455
+ for (const waiter of list) {
456
+ clearTimeout(waiter.timer)
457
+ waiter.reject(error)
458
+ }
459
+ }
460
+ this.notificationWaiters.clear()
461
+ try {
462
+ if (this.child?.stdin?.writable) {
463
+ this.child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
464
+ }
465
+ } catch {}
466
+ if (this.child) terminateProcessTree(this.child, { graceMs: 500 })
467
+ this.state = "CLOSED"
468
+ }
469
+ }
470
+
471
+ function sessionKey(base, provider, fingerprint) {
472
+ return [base, provider.id, provider.command, fingerprint].join("\u0000")
473
+ }
474
+
475
+ function sessionsForWorkspace(base) {
476
+ return [...POOL.values()].filter((session) => session.base === base && session.alive())
477
+ }
478
+
479
+ async function removeSession(key, reason) {
480
+ const session = POOL.get(key)
481
+ if (!session) return
482
+ POOL.delete(key)
483
+ await session.stop(reason).catch(() => {})
484
+ }
485
+
486
+ async function evictExpired(options) {
487
+ const now = Date.now()
488
+ const targets = [...POOL.entries()]
489
+ .filter(([, session]) => session.busy === 0 && now - session.lastUsedAt >= options.idleTtlMs)
490
+ for (const [key] of targets) {
491
+ METRICS.evictions += 1
492
+ await removeSession(key, "idle-ttl")
493
+ }
494
+ }
495
+
496
+ async function evictLru(candidates, reason) {
497
+ const idle = candidates
498
+ .filter((session) => session.busy === 0)
499
+ .sort((a, b) => a.lastUsedAt - b.lastUsedAt)
500
+ const victim = idle[0]
501
+ if (!victim) return false
502
+ const entry = [...POOL.entries()].find(([, session]) => session === victim)
503
+ if (!entry) return false
504
+ METRICS.evictions += 1
505
+ await removeSession(entry[0], reason)
506
+ return true
507
+ }
508
+
509
+ async function ensureCapacity(base, options) {
510
+ await evictExpired(options)
511
+
512
+ while (sessionsForWorkspace(base).length >= options.maxPerWorkspace) {
513
+ const evicted = await evictLru(sessionsForWorkspace(base), "workspace-lru")
514
+ if (!evicted) return false
515
+ }
516
+
517
+ while ([...POOL.values()].filter((session) => session.alive()).length >= options.maxServers) {
518
+ const evicted = await evictLru([...POOL.values()], "global-lru")
519
+ if (!evicted) return false
520
+ }
521
+ return true
522
+ }
523
+
524
+ function ensureSweeper() {
525
+ if (sweepTimer) return
526
+ sweepTimer = setInterval(() => {
527
+ const options = configured()
528
+ void evictExpired(options).catch(() => {})
529
+ }, 30_000)
530
+ sweepTimer.unref?.()
531
+ }
532
+
533
+ async function acquire(target, provider, options) {
534
+ ensureSweeper()
535
+ const fingerprint = await configurationFingerprint(target.base, target.file, provider)
536
+ const key = sessionKey(target.base, provider, fingerprint)
537
+ const existing = POOL.get(key)
538
+ if (existing?.alive()) {
539
+ METRICS.warmHits += 1
540
+ existing.lastUsedAt = Date.now()
541
+ return { key, session: existing, poolHit: true }
542
+ }
543
+ if (existing) await removeSession(key, "stale-session")
544
+
545
+ for (const [otherKey, session] of POOL) {
546
+ if (
547
+ session.base === target.base &&
548
+ session.provider.id === provider.id &&
549
+ session.configFingerprint !== fingerprint &&
550
+ session.busy === 0
551
+ ) {
552
+ METRICS.evictions += 1
553
+ await removeSession(otherKey, "config-changed")
554
+ }
555
+ }
556
+
557
+ if (!await ensureCapacity(target.base, options)) return null
558
+ const session = new ManagedLspSession(target.base, provider, fingerprint, options)
559
+ POOL.set(key, session)
560
+ try {
561
+ await session.start()
562
+ METRICS.coldStarts += 1
563
+ return { key, session, poolHit: false }
564
+ } catch (error) {
565
+ POOL.delete(key)
566
+ await session.stop("startup-failed").catch(() => {})
567
+ throw error
568
+ }
569
+ }
570
+
571
+ export async function withManagedLspSession(target, provider, inputOptions = {}, operation) {
572
+ const options = configured(inputOptions)
573
+ if (!options.enabled) {
574
+ METRICS.fallbacks += 1
575
+ return { ok: false, reason: "persistent-disabled", error: null }
576
+ }
577
+
578
+ let attempt = 0
579
+ while (attempt <= options.maxRestarts) {
580
+ let acquired
581
+ try {
582
+ acquired = await acquire(target, provider, options)
583
+ if (!acquired) {
584
+ METRICS.fallbacks += 1
585
+ return { ok: false, reason: "pool-capacity-busy", error: null }
586
+ }
587
+
588
+ const session = acquired.session
589
+ const executed = await session.runExclusive(async () => {
590
+ const documentSync = await session.syncDocument(target, provider.languageId, inputOptions)
591
+ const adapter = {
592
+ request: (method, params) => session.request(method, params, options.requestTimeoutMs),
593
+ notify: (method, params) => session.notify(method, params),
594
+ waitForNotification: (method, waitOptions = {}) => session.waitForNotification(method, {
595
+ timeoutMs: options.requestTimeoutMs,
596
+ ...waitOptions,
597
+ }),
598
+ uri: documentSync.uri,
599
+ base: target.base,
600
+ file: path.relative(target.base, target.file).replaceAll("\\", "/"),
601
+ documentSync,
602
+ }
603
+ return operation(adapter)
604
+ })
605
+
606
+ METRICS.operations += 1
607
+ METRICS.operationLatencyMs += executed.durationMs
608
+ return {
609
+ ok: true,
610
+ result: executed.result,
611
+ meta: {
612
+ persistent: true,
613
+ poolHit: acquired.poolHit,
614
+ warm: acquired.poolHit,
615
+ sessionId: session.id,
616
+ state: session.state,
617
+ coldStartMs: acquired.poolHit ? 0 : session.coldStartMs,
618
+ operationDurationMs: executed.durationMs,
619
+ requestCount: session.requestCount,
620
+ configFingerprint: session.configFingerprint,
621
+ },
622
+ stderr: session.stderr.trim().slice(0, 2000) || null,
623
+ }
624
+ } catch (error) {
625
+ const transient = transientError(error)
626
+ const canRestart = transient && attempt < options.maxRestarts
627
+ if (transient && acquired?.key) {
628
+ await removeSession(acquired.key, canRestart ? "bounded-restart" : "operation-failed")
629
+ }
630
+ if (!canRestart) {
631
+ METRICS.fallbacks += 1
632
+ return {
633
+ ok: false,
634
+ reason: transient ? "managed-lsp-failed" : "managed-lsp-request-rejected",
635
+ error: String(error instanceof Error ? error.message : error).slice(0, 1200),
636
+ errorCode: error?.code || null,
637
+ }
638
+ }
639
+ METRICS.restarts += 1
640
+ attempt += 1
641
+ }
642
+ }
643
+
644
+ METRICS.fallbacks += 1
645
+ return { ok: false, reason: "restart-budget-exhausted", error: null }
646
+ }
647
+
648
+ export function lspPoolStatus(options = {}) {
649
+ const config = configured(options)
650
+ const sessions = [...POOL.values()].filter((session) => session.alive())
651
+ return {
652
+ schemaVersion: 2,
653
+ kind: "ues-managed-lsp-pool",
654
+ enabled: config.enabled,
655
+ limits: {
656
+ maxServers: config.maxServers,
657
+ maxPerWorkspace: config.maxPerWorkspace,
658
+ idleTtlMs: config.idleTtlMs,
659
+ requestTimeoutMs: config.requestTimeoutMs,
660
+ startupTimeoutMs: config.startupTimeoutMs,
661
+ maxRestarts: config.maxRestarts,
662
+ },
663
+ active: sessions.length,
664
+ busy: sessions.filter((session) => session.busy > 0).length,
665
+ metrics: {
666
+ ...METRICS,
667
+ averageOperationLatencyMs: METRICS.operations
668
+ ? Number((METRICS.operationLatencyMs / METRICS.operations).toFixed(2))
669
+ : null,
670
+ },
671
+ sessions: options.includeSessions === false ? [] : sessions.map((session) => session.publicStatus()),
672
+ }
673
+ }
674
+
675
+ export async function shutdownLspPool(root = null) {
676
+ const base = root ? path.resolve(root) : null
677
+ const entries = [...POOL.entries()].filter(([, session]) => !base || session.base === base)
678
+ for (const [key] of entries) await removeSession(key, "shutdown")
679
+ if (!POOL.size && sweepTimer) {
680
+ clearInterval(sweepTimer)
681
+ sweepTimer = null
682
+ }
683
+ return { schemaVersion: 2, stopped: entries.length, remaining: POOL.size }
684
+ }
685
+
686
+ export async function evictIdleLspSessions(options = {}) {
687
+ const before = POOL.size
688
+ await evictExpired(configured(options))
689
+ return { schemaVersion: 2, evicted: Math.max(0, before - POOL.size), remaining: POOL.size }
690
+ }
691
+
692
+ export function resetLspPoolMetrics() {
693
+ for (const key of Object.keys(METRICS)) METRICS[key] = 0
694
+ }