opencode-agent-skill 15.1.0 → 15.1.2

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