@mrclrchtr/supi-lsp 11.2.0 → 11.3.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
@@ -64,6 +64,8 @@ A configured route is not a ready server. Features depend on the active server,
64
64
 
65
65
  The [operation-support model](src/config/operation-support.ts) separates server advertisement from SuPi planning limits. A server can advertise an action that SuPi cannot safely turn into a plan.
66
66
 
67
+ Workspace-symbol scopes select contributing routes and bound query completeness; returned data can still contain out-of-scope rows, which consumers must filter. An automatic-policy exclusion does not make a scoped query partial when both its observed path and physical identity are proven outside every requested scope. In-scope exclusions, aliases that may overlap, unresolved paths, and contributing route failures remain partial. Unscoped queries retain all exclusion evidence. Partial metadata sets `exclusionOnly` when policy exclusions are the only gap, with no route failure or inferred-project coverage gap. Explicitly selected excluded files and directories remain inspectable; when a scoped query returns no row inside any scope, warm-up may open files inside those selected scopes so a server that only indexes opened documents can report them. Automatic path rules and target-uniqueness checks are unchanged.
68
+
67
69
  ### Reference-server acceptance
68
70
 
69
71
  The [Code Intelligence acceptance suite](../supi-code-intelligence/__tests__/acceptance/language-baseline-harness.ts) exercises public tools against real servers for TypeScript, JavaScript, Python, Java, Go, Rust, C, and C++. Its [pinned Linux/macOS configuration](../../scripts/language-baseline/pins.json) is separate from the built-in route matrix. Qualification remains pending until both pinned CI jobs pass. Missing test prerequisites are failures, not skipped passes.
@@ -161,6 +163,22 @@ Protocol support is separate from the configured mode. A server may support pull
161
163
 
162
164
  The LSP 3.18 specification adds `Diagnostic.message` markup content, guarded by the client capability `textDocument.diagnostic.markupMessageSupport`; SuPi's validator already accepts plaintext and markdown messages but does not advertise the capability. Other 3.18 features (snippet text edits, inline completion, folding-range refresh, multi-range formatting) are outside the diagnostic surface and are not implemented.
163
165
 
166
+ ### Texlab diagnostic verification
167
+
168
+ Verified binary: **Texlab 5.26.0**. This separate verification does not revise the historical audit above or qualify other Texlab versions. The probe advertised pull support, opened a document with a syntax error, and then corrected and saved it.
169
+
170
+ | Protocol check | Observed result |
171
+ |---|---|
172
+ | Static `diagnosticProvider` | Not advertised |
173
+ | Dynamic `textDocument/diagnostic` registration | None observed |
174
+ | Explicit `textDocument/diagnostic` request | JSON-RPC `-32601` (`method not found`) |
175
+ | `publishDiagnostics` | Publications without a document version |
176
+ | Syntax error → corrected document | Error publication followed by empty publications |
177
+
178
+ Texlab supplies useful diagnostics through ambient push. SuPi shows observed errors as partial evidence and removes them after a correcting publication, but an empty publication does not confirm a completed clean check. `code_health` therefore keeps the file unconfirmed. A `push-settle` timeout ends the bounded observation window; it is not a failed diagnostic request or proof that Texlab is unresponsive. Longer waits, repeated publications, and restarts do not establish completion.
179
+
180
+ The [installed Texlab integration test](__tests__/integration/client.integration.texlab.test.ts) covers routing, the absence of a diagnostic provider/request adapter, and unconfirmed coverage. It runs when `texlab` is installed. See [ADR 0022](../../docs/adr/0022-request-confirmed-lsp-agnostic-diagnostics.md) for the unchanged evidence policy.
181
+
164
182
  ### Diagnostic evidence policy
165
183
 
166
184
  See [`ADR 0022`](../../docs/adr/0022-request-confirmed-lsp-agnostic-diagnostics.md) for the decision record. SuPi uses one shared diagnostic engine with optional internal request adapters. It selects one source for each file:
@@ -185,11 +203,13 @@ Refresh and file collection share synchronization, evidence-revision, deadline,
185
203
 
186
204
  Caller cancellation or deadline stops only that caller's wait. Explicit diagnostic requests use an owned transport lifetime: supersession drops queued jobs and stops future adapter phases but does not cancel an active protocol request. The route stays occupied until actual settlement or connection disposal. The owner timeout attempts protocol cancellation but does not prove that the backend stopped. The owner bound is at least 30 seconds (or a larger collection budget). A result from an obsolete document generation is discarded. Ordinary semantic requests still pass caller cancellation to the protocol transport.
187
205
 
206
+ First document enrollment is admitted between semantic query and refactor-planning attempts on the same current client. Each attempt covers input synchronization, its RPCs, and final acceptance; reads can still run concurrently. A refactor attempt retains admission through `prepareRename`/`rename` or `codeAction`/`resolve` and verified snapshot capture, but releases it before retries and the rename workflow's own cross-file enrollment. FIFO admission prevents later reads from overtaking a waiting first open. At most 32 operations may wait; excess demand fails rather than growing an unbounded queue. Waiting preserves the caller's original signal and absolute deadline, and interrupted operations are removed before they can open a document. Disk content is read after admission. First opens for semantic or refactor demand also discard the caller's original parameters after a known input change; ordinary tracking can still adopt fresh content. Tracked-document updates, closes, workspace changes, and lifecycle invalidation do not wait: they still invalidate active evidence and discard queued request parameters. Closing a file cancels its queued opens, and stopping a client discards its queued work. Admission does not span retries, project warm-up, or whole tool/refactor workflows. Diagnostic transport ownership and confirmation remain separate and unchanged.
207
+
188
208
  If another request first opens a document during an active semantic or diagnostic request, the client can synchronize and repeat the request once. The retry retains the caller's original signal and deadline. Content, workspace, close, and lifecycle changes still reject stale results. A diagnostic retry collects new request evidence; it does not promote an old cache. This retry is not route recovery. A second enrollment during the retry still rejects the result. Semantic failure reasons state whether the retry failed or was exhausted. The bounded `request.retry` debug event records the retry reason and outcome with the public call's opaque operation ID, when supplied. A completed RPC alone does not establish accepted semantic evidence.
189
209
 
190
210
  A read-only semantic request can also retry once after an LSP `ContentModified` error (`-32801`). This retry shares the same one-retry budget as enrollment; it does not add another try. Before this protocol retry, the client waits 100 ms so an immediate retry does not race the same server analysis. The wait is cancellable, respects the original absolute deadline, and does not delay enrollment-only retries. The client keeps the original request parameters and checks changes from the first failed input revision. It runs the full input sync before retry and checks current inputs before it accepts a result. An enrollment is still the only input change that can permit a retry. A content, workspace, close, lifecycle, or retirement change blocks it. Client replacement, caller cancellation, and a deadline also block retry. The post-request check rejects a result if inputs change while that attempt runs. This error-code retry applies only to read-only semantic requests. Diagnostics, rename, code actions, and refactors keep the default policy and do not retry `ContentModified`. See the [LSP 3.17 message-ordering rule](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#messageOrdering), the [retry guard](src/client/client-request-enrollment.ts), and the [client query path](src/client/client.ts).
191
211
 
192
- Diagnostic collection does not close and reopen a document to obtain confirmation. It does not send a no-op `didChange` for that purpose. Refresh retains unchanged documents and their current server state, and resynchronizes changed or invalidated documents. A client restart may reopen tracked documents to restore client state; that is recovery, not diagnostic confirmation.
212
+ Diagnostic collection does not close and reopen a document to obtain confirmation. It does not send a no-op `didChange` for that purpose. Refresh retains unchanged documents and their current server state, and resynchronizes changed or invalidated documents. A client restart may reopen tracked documents to restore client state; that is recovery, not diagnostic confirmation. Recovery skips documents closed or already reopened on the replacement and checks client ownership before reading or restoring failed state.
193
213
 
194
214
  Server readiness follows LSP work-done progress: a created progress token is pending and never blocks readiness; an observed `begin` marks active work and makes the client not ready until its `end` or the bounded per-token timeout.
195
215
 
@@ -233,7 +253,7 @@ Set `code-intelligence.exclude` in project or global SuPi configuration. A proje
233
253
 
234
254
  An old `lsp.exclude` value is a migration error. Move it to `code-intelligence.exclude`; SuPi does not edit personal configuration for you.
235
255
 
236
- An exact semantic request can still route an excluded file when a compatible server is available. This does not add the file to automatic work. Configured diagnostic suppression still applies to diagnostic output.
256
+ An exact semantic request or exact-file health refresh can still route an excluded or external file when a compatible server is available. Explicit refresh can retry that file's failed route without applying automatic-discovery exclusions. Original route and document identities are preserved; display-path canonicalization does not merge alias routes. This does not add the file to automatic work. Configured diagnostic suppression still applies to diagnostic output.
237
257
 
238
258
  Tsconfig and jsconfig filtering applies only to TypeScript and JavaScript-family files (`.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, and `.cjs`). Other-language diagnostics are not filtered by a nearby TypeScript or JavaScript project config. Automatic path exclusions and configured diagnostic suppression still apply. A single-file health result omits the `Tsconfig` coverage line for other languages.
239
259
 
@@ -272,6 +292,23 @@ Server settings use one snapshot for the active workspace runtime:
272
292
  - SuPi validates the JSON object shape, not server-specific setting names or values.
273
293
  - Saved changes apply after reload or restart. A saved value does not prove that the server accepted it.
274
294
 
295
+ TypeScript and JavaScript servers format code-action edits, such as organize imports, with their own defaults (4-space indentation) unless settings say otherwise. SuPi does not infer formatting from Biome, Prettier, or EditorConfig. Both the `typescript-language-server` and native TS 7 backends read `typescript.format` / `javascript.format`:
296
+
297
+ ```json
298
+ {
299
+ "lsp": {
300
+ "servers": {
301
+ "typescript": {
302
+ "settings": {
303
+ "typescript": { "format": { "indentSize": 2, "tabSize": 2, "convertTabsToSpaces": true } },
304
+ "javascript": { "format": { "indentSize": 2, "tabSize": 2, "convertTabsToSpaces": true } }
305
+ }
306
+ }
307
+ }
308
+ }
309
+ }
310
+ ```
311
+
275
312
  File types do not include a leading dot. If `rootMarkers` is omitted, the server uses the session root.
276
313
 
277
314
  Disable one language with `lsp.servers.<language>.enabled: false`. Use `/supi-ci-status` from `@mrclrchtr/supi-code-intelligence` to see detected, running, and missing servers.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-code-runtime",
3
- "version": "11.2.0",
3
+ "version": "11.3.0",
4
4
  "description": "Shared workspace context and capability contracts for code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -77,7 +77,9 @@ export interface SemanticProvider {
77
77
  ): Promise<CodeQueryResult<DocumentCodeSymbol[]>>;
78
78
  /**
79
79
  * Collect workspace symbols from routes that can contribute within the
80
- * optional canonical path scopes.
80
+ * optional path scopes. Completeness is relative to those scopes, but data
81
+ * may include out-of-scope rows; consumers must filter rows themselves.
82
+ * Policy exclusions proven outside every scope do not make the query partial.
81
83
  */
82
84
  workspaceSymbols(
83
85
  query: string,
@@ -3,8 +3,17 @@ export interface CodeQueryPartialMetadata {
3
3
  /** Automatic path-policy evidence removed from a collected result. */
4
4
  readonly automaticPathExclusion?: {
5
5
  readonly excludedCount: number;
6
+ /** Observed policy paths, not canonical navigation paths; retain aliases when presenting exclusions. */
6
7
  readonly examples: readonly string[];
7
8
  };
9
+ /**
10
+ * True when automatic path-policy exclusions are the only reason the result
11
+ * is partial: every contributing provider branch completed and no coverage
12
+ * gap was observed. Excluded rows matched paths the workspace policy treats
13
+ * as out of automatic scope, so the retained rows are the complete in-policy
14
+ * answer.
15
+ */
16
+ readonly exclusionOnly?: boolean;
8
17
  }
9
18
 
10
19
  /**
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "11.2.0",
3
+ "version": "11.3.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,110 @@
1
+ // Session-provided Web tools for SuPi child sessions (Agent Runs and Reviewer Sessions).
2
+ //
3
+ // The loaded supi-web extension answers a synchronous request on the parent event bus.
4
+ // Consumers never depend on supi-web; when it is absent or provides no tools, the
5
+ // request returns undefined and the child simply lacks Web tools.
6
+
7
+ import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
8
+
9
+ /** Web tools that child sessions may receive when the containing session provides them. */
10
+ export const CHILD_WEB_TOOL_NAMES = [
11
+ "web_fetch_md",
12
+ "web_search",
13
+ "web_docs_search",
14
+ "web_docs_fetch",
15
+ ] as const;
16
+
17
+ /** One session-provided Web tool name. */
18
+ export type ChildWebToolName = (typeof CHILD_WEB_TOOL_NAMES)[number];
19
+
20
+ /**
21
+ * Web tools currently callable in the containing session plus a child-only extension
22
+ * factory that registers them. The factory must not capture the parent session; it may
23
+ * capture plain data such as the containing session's configuration directory.
24
+ */
25
+ export interface ChildWebToolProvision {
26
+ readonly toolNames: readonly ChildWebToolName[];
27
+ /** Register the requested subset of `toolNames` in a child session. */
28
+ readonly createFactory: (toolNames: readonly ChildWebToolName[]) => ExtensionFactory;
29
+ }
30
+
31
+ /** Plain containing-session data supplied with a child Web tool request. */
32
+ export interface ChildWebToolsRequestContext {
33
+ /**
34
+ * Working directory whose SuPi configuration governs Web settings such as Firecrawl.
35
+ * Children with a different cwd (for example a frozen Review Workspace) keep this policy.
36
+ */
37
+ readonly configCwd: string;
38
+ }
39
+
40
+ type EventBusLike = Pick<ExtensionAPI["events"], "on" | "emit">;
41
+
42
+ const REQUEST_CHANNEL = "supi:child-web-tools:request";
43
+ const RESPONSE_CHANNEL = "supi:child-web-tools:response";
44
+
45
+ interface ChildWebToolsRequest extends ChildWebToolsRequestContext {
46
+ readonly requestId: string;
47
+ }
48
+
49
+ interface ChildWebToolsResponse {
50
+ readonly requestId: string;
51
+ readonly provision: ChildWebToolProvision;
52
+ }
53
+
54
+ let nextRequestId = 0;
55
+
56
+ /**
57
+ * Answer child Web tool requests on the parent event bus. The callback must be synchronous
58
+ * because Pi's event bus does not await listeners. Returns an unsubscribe function.
59
+ */
60
+ export function provideChildWebTools(
61
+ events: EventBusLike,
62
+ resolve: (context: ChildWebToolsRequestContext) => ChildWebToolProvision | undefined,
63
+ ): () => void {
64
+ return events.on(REQUEST_CHANNEL, (data) => {
65
+ const request = data as Partial<ChildWebToolsRequest> | undefined;
66
+ if (typeof request?.requestId !== "string" || typeof request.configCwd !== "string") return;
67
+ const { requestId, configCwd } = request;
68
+ const provision = resolve({ configCwd });
69
+ if (!provision || provision.toolNames.length === 0) return;
70
+ events.emit(RESPONSE_CHANNEL, { requestId, provision } satisfies ChildWebToolsResponse);
71
+ });
72
+ }
73
+
74
+ /** Capture the Web tools the containing session currently provides, if any. */
75
+ export function requestChildWebTools(
76
+ events: EventBusLike,
77
+ context: ChildWebToolsRequestContext,
78
+ ): ChildWebToolProvision | undefined {
79
+ const requestId = `child-web-${process.pid}-${++nextRequestId}`;
80
+ let provision: ChildWebToolProvision | undefined;
81
+ const unsubscribe = events.on(RESPONSE_CHANNEL, (data) => {
82
+ const response = data as Partial<ChildWebToolsResponse> | undefined;
83
+ if (response?.requestId === requestId && response.provision) provision = response.provision;
84
+ });
85
+ try {
86
+ events.emit(REQUEST_CHANNEL, { requestId, ...context } satisfies ChildWebToolsRequest);
87
+ } finally {
88
+ unsubscribe();
89
+ }
90
+ return provision;
91
+ }
92
+
93
+ /**
94
+ * Return the Web tools callable in a session under Pi's exposure rules: active `direct`
95
+ * tools, plus registered `codemode` and `deferred` tools. `model-only` and `hidden`
96
+ * tools are not callable.
97
+ */
98
+ export function callableChildWebToolNames(
99
+ pi: Pick<ExtensionAPI, "getAllTools" | "getActiveTools">,
100
+ ): ChildWebToolName[] {
101
+ const active = new Set(pi.getActiveTools());
102
+ const exposures = new Map(
103
+ pi.getAllTools().map((tool) => [tool.name, tool.exposure ?? "direct"] as const),
104
+ );
105
+ return CHILD_WEB_TOOL_NAMES.filter((name) => {
106
+ const exposure = exposures.get(name);
107
+ if (exposure === "direct") return active.has(name);
108
+ return exposure === "codemode" || exposure === "deferred";
109
+ });
110
+ }
@@ -1,5 +1,16 @@
1
1
  // supi-core session domain — session utilities and registries.
2
2
 
3
+ export type {
4
+ ChildWebToolName,
5
+ ChildWebToolProvision,
6
+ ChildWebToolsRequestContext,
7
+ } from "./child-web-tools.ts";
8
+ export {
9
+ CHILD_WEB_TOOL_NAMES,
10
+ callableChildWebToolNames,
11
+ provideChildWebTools,
12
+ requestChildWebTools,
13
+ } from "./child-web-tools.ts";
3
14
  export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
4
15
  export type {
5
16
  SessionCapabilitySkill,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-lsp",
3
- "version": "11.2.0",
3
+ "version": "11.3.0",
4
4
  "description": "Language Server Protocol runtime for SuPi code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,8 +38,8 @@
38
38
  "vscode-jsonrpc": "^10.0.0",
39
39
  "vscode-languageserver-protocol": "^3.18.5",
40
40
  "vscode-languageserver-types": "^3.18.5",
41
- "@mrclrchtr/supi-core": "11.2.0",
42
- "@mrclrchtr/supi-code-runtime": "11.2.0"
41
+ "@mrclrchtr/supi-code-runtime": "11.3.0",
42
+ "@mrclrchtr/supi-core": "11.3.0"
43
43
  },
44
44
  "bundledDependencies": [
45
45
  "@mrclrchtr/supi-code-runtime",
@@ -95,10 +95,17 @@ async function collectPushOnlyEvidence(
95
95
  ? partialCodeQuery(diagnostics, TENTATIVE_PUSH_UNAVAILABLE_REASON)
96
96
  : unavailableCodeQuery(TENTATIVE_PUSH_UNAVAILABLE_REASON);
97
97
  }
98
- return incompleteDiagnosticResult(
99
- options.cachedDiagnostics,
100
- push === "released" ? "released" : "timed-out",
101
- );
98
+ if (push === "released") {
99
+ return incompleteDiagnosticResult(options.cachedDiagnostics, "released");
100
+ }
101
+ return options.cachedDiagnostics
102
+ ? partialCodeQuery(
103
+ options.cachedDiagnostics,
104
+ `${TENTATIVE_PUSH_UNAVAILABLE_REASON} Retained cached diagnostics are partial evidence.`,
105
+ )
106
+ : unavailableCodeQuery(
107
+ `${TENTATIVE_PUSH_UNAVAILABLE_REASON} No current publication was observed.`,
108
+ );
102
109
  }
103
110
 
104
111
  /** Collect one request report without using an ambient push as confirmation. */
@@ -47,6 +47,7 @@ import {
47
47
  } from "./client-document-state.ts";
48
48
  import { clearTrackedDocumentState, synchronizeTrackedDocument } from "./client-document-sync.ts";
49
49
  import { getDiagnosticFileState } from "./client-file-state.ts";
50
+ import { SemanticInputAdmission } from "./client-input-admission.ts";
50
51
  import { SemanticInputRequestRetryGuard } from "./client-request-enrollment.ts";
51
52
  import {
52
53
  SemanticInputBarrier,
@@ -103,6 +104,7 @@ export class ClientDiagnostics {
103
104
  /** URIs closed by a lifecycle operation: versioned pushes stay fail-closed. */
104
105
  readonly #closedVersionedBarrier = new Set<string>();
105
106
  readonly #inputBarrier: SemanticInputBarrier;
107
+ #inputAdmission = new SemanticInputAdmission();
106
108
  #evidenceRevision = 0;
107
109
  #nextSynchronizationId = 0;
108
110
  /** One active server refresh plus one coalesced newer demand generation. */
@@ -125,6 +127,60 @@ export class ClientDiagnostics {
125
127
  });
126
128
  }
127
129
 
130
+ /** Keep first enrollment outside a semantic query or refactor-planning attempt. */
131
+ withSemanticRequest<T>(
132
+ run: () => Promise<T>,
133
+ control?: CodeRequestControl,
134
+ uri?: string,
135
+ ): Promise<T> {
136
+ return this.#inputAdmission.request(run, control, uri);
137
+ }
138
+
139
+ /** Read content only after admission; tracked-document changes never wait. */
140
+ async openDocument(
141
+ filePath: string,
142
+ readContent: () => string,
143
+ control?: CodeRequestControl,
144
+ protectInputs = false,
145
+ ): Promise<void> {
146
+ throwIfCodeRequestInterrupted(control);
147
+ const open = () => {
148
+ throwIfCodeRequestInterrupted(control);
149
+ if (!this.host.isOperational()) throw new Error("LSP client is not running.");
150
+ this.didOpen(filePath, readContent());
151
+ };
152
+ if (this.#openDocs.has(fileToUri(filePath))) {
153
+ open();
154
+ return;
155
+ }
156
+ await this.#inputAdmission.enroll(fileToUri(filePath), open, control, protectInputs);
157
+ }
158
+
159
+ /** Restore only still-retained documents; a close or replacement wins over recovery. */
160
+ async restoreDocument(
161
+ filePath: string,
162
+ readContent: () => string,
163
+ isCurrent: () => boolean,
164
+ ): Promise<void> {
165
+ const uri = fileToUri(filePath);
166
+ const admission = this.#inputAdmission;
167
+ const canRestore = () =>
168
+ isCurrent() &&
169
+ this.host.isOperational() &&
170
+ admission === this.#inputAdmission &&
171
+ !this.#closedVersionedBarrier.has(uri) &&
172
+ !this.#openDocs.has(uri);
173
+ if (!canRestore()) return;
174
+ try {
175
+ await this.openDocument(filePath, () => {
176
+ if (!canRestore()) throw new Error("Document restoration was superseded.");
177
+ return readContent();
178
+ });
179
+ } catch {
180
+ if (canRestore()) this.markFailedFile(filePath);
181
+ }
182
+ }
183
+
128
184
  /** Synchronize all open document inputs, rejoining once after a new enrollment. */
129
185
  async synchronizeSemanticInputs(
130
186
  control?: CodeRequestControl,
@@ -222,17 +278,22 @@ export class ClientDiagnostics {
222
278
  this.#inputBarrier.forgetDocumentContent(uri);
223
279
  }
224
280
 
225
- #ensureExplicitDocumentContent(
281
+ async #ensureExplicitDocumentContent(
226
282
  filePath: string,
227
283
  content: string,
228
284
  contentIsAuthoritative: boolean,
229
- ): void {
285
+ control?: CodeRequestControl,
286
+ ): Promise<void> {
230
287
  const uri = fileToUri(filePath);
231
288
  const document = this.#openDocs.get(uri);
232
289
  if (!document) {
233
- // An untracked file needs an initial protocol document before its barrier
234
- // read can verify the manager's content.
235
- this.didOpen(filePath, content);
290
+ // A manager read can predate admission. Re-read disk after the wait;
291
+ // explicit authoritative content retains its existing override semantics.
292
+ await this.openDocument(
293
+ filePath,
294
+ () => (contentIsAuthoritative ? content : readFileSync(filePath, "utf-8")),
295
+ control,
296
+ );
236
297
  return;
237
298
  }
238
299
  // Manager reads are not authoritative: an already tracked document can
@@ -260,6 +321,8 @@ export class ClientDiagnostics {
260
321
  }
261
322
 
262
323
  clear(options: { preserveFailedDocuments?: boolean } = {}): void {
324
+ this.#inputAdmission.dispose();
325
+ this.#inputAdmission = new SemanticInputAdmission();
263
326
  if (options.preserveFailedDocuments) {
264
327
  for (const uri of [...this.#openDocs.keys(), ...this.#diagnosticStore.keys()]) {
265
328
  this.#failedUris.add(uri);
@@ -336,6 +399,7 @@ export class ClientDiagnostics {
336
399
 
337
400
  /** Send a save after a disk change that the input barrier observed. */
338
401
  #noteExternalDiskChange(filePath: string, content: string): void {
402
+ this.#inputAdmission.invalidateRequests();
339
403
  if (!this.host.isOperational()) return;
340
404
  const documentSync = this.host.documentSync();
341
405
  if (!documentSync.save) return;
@@ -368,6 +432,7 @@ export class ClientDiagnostics {
368
432
 
369
433
  didClose(filePath: string): void {
370
434
  const uri = fileToUri(filePath);
435
+ this.#inputAdmission.close(uri);
371
436
  const wasOpen = this.#openDocs.has(uri);
372
437
  const hadState =
373
438
  wasOpen ||
@@ -627,7 +692,12 @@ export class ClientDiagnostics {
627
692
  throwIfCodeRequestInterrupted(control);
628
693
  const uri = fileToUri(filePath);
629
694
  const contentIsAuthoritative = options.contentIsAuthoritative !== false;
630
- this.#ensureExplicitDocumentContent(filePath, content, contentIsAuthoritative);
695
+ try {
696
+ await this.#ensureExplicitDocumentContent(filePath, content, contentIsAuthoritative, control);
697
+ } catch (error) {
698
+ throwIfCodeRequestInterrupted(control);
699
+ return unavailableAfterEnrollmentRetry(error);
700
+ }
631
701
  const contentOverrides = this.#getDiagnosticContentOverrides(
632
702
  uri,
633
703
  content,
@@ -1044,6 +1114,7 @@ export class ClientDiagnostics {
1044
1114
  changeKind: SemanticInputChangeKind = "content",
1045
1115
  ): void {
1046
1116
  this.#inputBarrier.noteInputChange(changeKind);
1117
+ if (changeKind !== "enrollment") this.#inputAdmission.invalidateRequests();
1047
1118
  if (invalidateEvidence) this.#advanceEvidenceRevision(false);
1048
1119
  if (cancelRequests) this.#cancelAllDiagnosticRequests();
1049
1120
  }
@@ -0,0 +1,149 @@
1
+ import {
2
+ type CodeRequestControl,
3
+ CodeRequestDeadlineError,
4
+ throwIfCodeRequestInterrupted,
5
+ } from "@mrclrchtr/supi-code-runtime/api";
6
+
7
+ const MAX_PENDING_INPUT_OPERATIONS = 32;
8
+
9
+ type InputOperationKind = "request" | "enrollment" | "request-enrollment";
10
+
11
+ interface PendingInputOperation {
12
+ readonly uri: string | undefined;
13
+ readonly kind: InputOperationKind;
14
+ start(): void;
15
+ cancel(reason: unknown): void;
16
+ }
17
+
18
+ /**
19
+ * Admit first opens between semantic query and refactor-planning attempts on one client.
20
+ * Consecutive readers run concurrently; FIFO admission prevents either new
21
+ * readers or enrollments from overtaking a waiting operation. This is not
22
+ * diagnostic transport ownership: an interrupted semantic attempt can release
23
+ * its admission even when the server has not yet acknowledged cancellation.
24
+ */
25
+ export class SemanticInputAdmission {
26
+ readonly #pending: PendingInputOperation[] = [];
27
+ #readers = 0;
28
+ #enrolling = false;
29
+ #disposed = false;
30
+
31
+ /** Cover one attempt's synchronization, RPCs and acceptance, never its retry. */
32
+ request<T>(run: () => Promise<T>, control?: CodeRequestControl, uri?: string): Promise<T> {
33
+ return this.#run("request", uri, run, control);
34
+ }
35
+
36
+ /**
37
+ * Commit a first open after earlier attempts leave. Demand with original
38
+ * semantic/refactor parameters opts into input invalidation while waiting.
39
+ */
40
+ enroll(
41
+ uri: string,
42
+ run: () => void,
43
+ control?: CodeRequestControl,
44
+ protectInputs = false,
45
+ ): Promise<void> {
46
+ return this.#run(protectInputs ? "request-enrollment" : "enrollment", uri, run, control);
47
+ }
48
+
49
+ /** A close cancels queued opens and queries for that document, before dispatch. */
50
+ close(uri: string): void {
51
+ const cancelled = this.#pending.filter((pending) => pending.uri === uri);
52
+ for (const pending of cancelled) this.#pending.splice(this.#pending.indexOf(pending), 1);
53
+ for (const pending of cancelled)
54
+ pending.cancel(new Error("Document closed before input admission."));
55
+ }
56
+
57
+ /** Discard queued request parameters after a known non-enrollment input change. */
58
+ invalidateRequests(): void {
59
+ const cancelled = this.#pending.filter((pending) => pending.kind !== "enrollment");
60
+ for (const pending of cancelled) this.#pending.splice(this.#pending.indexOf(pending), 1);
61
+ for (const pending of cancelled)
62
+ pending.cancel(new Error("Semantic inputs changed before request admission."));
63
+ }
64
+
65
+ /** Reject queued work without confusing caller completion with transport settlement. */
66
+ dispose(): void {
67
+ this.#disposed = true;
68
+ for (const pending of [...this.#pending]) {
69
+ pending.cancel(new Error("LSP client stopped before input admission."));
70
+ }
71
+ }
72
+
73
+ #run<T>(
74
+ kind: InputOperationKind,
75
+ uri: string | undefined,
76
+ run: () => T | Promise<T>,
77
+ control: CodeRequestControl | undefined,
78
+ ): Promise<T> {
79
+ throwIfCodeRequestInterrupted(control);
80
+ if (this.#disposed) return Promise.reject(new Error("LSP input admission is disposed."));
81
+ if (this.#pending.length >= MAX_PENDING_INPUT_OPERATIONS) {
82
+ return Promise.reject(new Error("LSP input admission queue is full."));
83
+ }
84
+ return new Promise<T>((resolve, reject) => {
85
+ let timer: ReturnType<typeof setTimeout> | undefined;
86
+ let waiting = true;
87
+ const cleanup = () => {
88
+ if (timer !== undefined) clearTimeout(timer);
89
+ control?.signal?.removeEventListener("abort", abort);
90
+ };
91
+ const operation: PendingInputOperation = {
92
+ uri,
93
+ kind,
94
+ cancel: (reason) => {
95
+ if (!waiting) return;
96
+ waiting = false;
97
+ cleanup();
98
+ const index = this.#pending.indexOf(operation);
99
+ if (index !== -1) this.#pending.splice(index, 1);
100
+ reject(reason);
101
+ this.#drain();
102
+ },
103
+ start: () => {
104
+ waiting = false;
105
+ cleanup();
106
+ if (kind !== "request") this.#enrolling = true;
107
+ else this.#readers++;
108
+ const finish = () => {
109
+ if (kind !== "request") this.#enrolling = false;
110
+ else this.#readers--;
111
+ this.#drain();
112
+ };
113
+ try {
114
+ throwIfCodeRequestInterrupted(control);
115
+ Promise.resolve(run()).then(resolve, reject).finally(finish);
116
+ } catch (error) {
117
+ reject(error);
118
+ finish();
119
+ }
120
+ },
121
+ };
122
+ const abort = () => {
123
+ try {
124
+ throwIfCodeRequestInterrupted(control);
125
+ } catch (error) {
126
+ operation.cancel(error);
127
+ }
128
+ };
129
+ this.#pending.push(operation);
130
+ control?.signal?.addEventListener("abort", abort, { once: true });
131
+ if (control?.deadline !== undefined) {
132
+ timer = setTimeout(
133
+ () => operation.cancel(new CodeRequestDeadlineError()),
134
+ Math.max(0, control.deadline - Date.now()),
135
+ );
136
+ }
137
+ this.#drain();
138
+ });
139
+ }
140
+
141
+ #drain(): void {
142
+ while (!this.#disposed && !this.#enrolling && this.#pending.length > 0) {
143
+ const next = this.#pending[0];
144
+ if (next.kind !== "request" && this.#readers > 0) return;
145
+ this.#pending.shift();
146
+ next.start();
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Whether tsserver placed an open file in a configured project.
3
+ *
4
+ * - `configured`: a `tsconfig.json` / `jsconfig.json` program owns the file.
5
+ * - `inferred`: tsserver created an inferred project for the file alone, so
6
+ * project-wide requests such as `workspace/symbol` do not cover the
7
+ * configured program.
8
+ * - `unknown`: the route is not TypeScript or tsserver could not answer.
9
+ */
10
+ export type ProjectMembership = "configured" | "inferred" | "unknown";
11
+
12
+ /** Build `workspace/executeCommand` params for tsserver `projectInfo`. */
13
+ export function typeScriptProjectInfoParams(uri: string): unknown {
14
+ return {
15
+ command: "typescript.tsserverRequest",
16
+ arguments: [
17
+ "projectInfo",
18
+ { file: uri, needFileNameList: false },
19
+ // Semantic execution target; the project must be loaded to answer.
20
+ { executionTarget: 0, expectsResult: true, isAsync: false },
21
+ ],
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Read project membership from a tsserver `projectInfo` response.
27
+ * tsserver names projects without a config file `/dev/null/<kind>Project<N>*`.
28
+ */
29
+ export function readTypeScriptProjectMembership(response: unknown): ProjectMembership {
30
+ if (!isRecord(response) || response.type !== "response" || response.success !== true) {
31
+ return "unknown";
32
+ }
33
+ const body = response.body;
34
+ if (!isRecord(body) || typeof body.configFileName !== "string") return "unknown";
35
+ return body.configFileName.startsWith("/dev/null/") ? "inferred" : "configured";
36
+ }
37
+
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === "object" && value !== null;
40
+ }